Skip to content

Commit de93ca3

Browse files
authored
fix(linear): SyncAdapter correctness — TransitionState name→id, paginated idem comment scan, on-or-after delta (post-merge review of #94) (#99)
Post-merge review of #94 found four defects in sdk/integrations/linear/sync.go: TransitionState sent a state NAME straight through as a GraphQL stateId (round-trip broken); AddComment's idempotency scan only checked the first comments page, letting retries duplicate-post once a marker fell off page 1; ListUpdatedSince used gt+second-truncated timestamps, silently dropping issues updated exactly at the watermark; and UpdateFields silently ignored unknown keys and wrongly-typed values instead of rejecting the patch.
1 parent 3d48378 commit de93ca3

2 files changed

Lines changed: 359 additions & 59 deletions

File tree

sdk/integrations/linear/sync.go

Lines changed: 185 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,23 @@ package linear
33
import (
44
"context"
55
"fmt"
6+
"regexp"
67
"strings"
78
"sync"
89
"time"
910

1011
"github.com/qf-studio/studio-sdk/sdk/core"
1112
)
1213

14+
// uuidPattern matches a Linear-style UUID (e.g. workflow state or issue id),
15+
// distinguishing an already-resolved id from a human-readable state name
16+
// passed into TransitionState.
17+
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
18+
19+
func looksLikeUUID(s string) bool {
20+
return uuidPattern.MatchString(s)
21+
}
22+
1323
// Compile-time interface assertion.
1424
var _ core.SyncCapable = (*SyncAdapter)(nil)
1525

@@ -27,13 +37,20 @@ type SyncAdapter struct {
2737

2838
teamIDMu sync.RWMutex
2939
teamIDCache map[string]string
40+
41+
// workflowStateMu/workflowStateCache cache team-key -> (state name ->
42+
// state id) so repeated TransitionState calls with a state name don't
43+
// re-fetch the team's workflow states on every call.
44+
workflowStateMu sync.RWMutex
45+
workflowStateCache map[string]map[string]string
3046
}
3147

3248
// NewSyncAdapter creates a SyncAdapter backed by client.
3349
func NewSyncAdapter(client *Client) *SyncAdapter {
3450
return &SyncAdapter{
35-
client: client,
36-
teamIDCache: make(map[string]string),
51+
client: client,
52+
teamIDCache: make(map[string]string),
53+
workflowStateCache: make(map[string]map[string]string),
3754
}
3855
}
3956

@@ -102,15 +119,18 @@ func priorityRankFromNormalized(p string) int {
102119
}
103120

104121
// ListUpdatedSince returns issues in the team keyed by projectID whose
105-
// updatedAt is strictly greater than since, one page at a time following the
106-
// #85 cursor-following convention (page is the opaque "after" cursor).
122+
// updatedAt is on or after since (inclusive), one page at a time following
123+
// the #85 cursor-following convention (page is the opaque "after" cursor).
124+
// since is formatted with nanosecond precision so an issue updated exactly at
125+
// the watermark is never dropped; callers are expected to dedupe returned
126+
// snapshots by NativeID across successive calls at the same watermark.
107127
func (s *SyncAdapter) ListUpdatedSince(ctx context.Context, projectID string, since time.Time, page core.Cursor) ([]core.IssueSnapshot, core.Cursor, error) {
108128
query := `
109129
query SyncIssuesSince($teamId: String!, $since: DateTimeOrDuration!, $first: Int!, $after: String) {
110130
issues(
111131
filter: {
112132
team: { key: { eq: $teamId } }
113-
updatedAt: { gt: $since }
133+
updatedAt: { gte: $since }
114134
}
115135
first: $first
116136
after: $after
@@ -124,7 +144,7 @@ func (s *SyncAdapter) ListUpdatedSince(ctx context.Context, projectID string, si
124144

125145
variables := map[string]interface{}{
126146
"teamId": projectID,
127-
"since": since.UTC().Format(time.RFC3339),
147+
"since": since.UTC().Format(time.RFC3339Nano),
128148
"first": listIssuesPageSize,
129149
}
130150
if page != "" {
@@ -201,42 +221,59 @@ func (s *SyncAdapter) GetIssue(ctx context.Context, nativeID string) (core.Issue
201221
// UpdateFields applies a partial field patch to nativeID via the Linear
202222
// issueUpdate mutation. Recognized fields keys: "title" (string),
203223
// "description" (string), "labels" ([]string of label names), "priority"
204-
// (a core.Priority* normalized string). Unrecognized keys are ignored.
224+
// (a core.Priority* normalized string). An unrecognized key, or a recognized
225+
// key with the wrong value type, is rejected with an error and no field is
226+
// applied.
205227
func (s *SyncAdapter) UpdateFields(ctx context.Context, nativeID string, fields core.FieldPatch) (core.IssueSnapshot, error) {
228+
for key := range fields {
229+
switch key {
230+
case "title", "description", "priority", "labels":
231+
default:
232+
return core.IssueSnapshot{}, fmt.Errorf("unrecognized field key %q", key)
233+
}
234+
}
235+
206236
variables := map[string]interface{}{"id": nativeID}
207237

208238
if v, ok := fields["title"]; ok {
209-
if str, ok := v.(string); ok {
210-
variables["title"] = str
239+
str, ok := v.(string)
240+
if !ok {
241+
return core.IssueSnapshot{}, fmt.Errorf("field %q must be a string, got %T", "title", v)
211242
}
243+
variables["title"] = str
212244
}
213245
if v, ok := fields["description"]; ok {
214-
if str, ok := v.(string); ok {
215-
variables["description"] = str
246+
str, ok := v.(string)
247+
if !ok {
248+
return core.IssueSnapshot{}, fmt.Errorf("field %q must be a string, got %T", "description", v)
216249
}
250+
variables["description"] = str
217251
}
218252
if v, ok := fields["priority"]; ok {
219-
if str, ok := v.(string); ok {
220-
variables["priority"] = priorityRankFromNormalized(str)
253+
str, ok := v.(string)
254+
if !ok {
255+
return core.IssueSnapshot{}, fmt.Errorf("field %q must be a string, got %T", "priority", v)
221256
}
257+
variables["priority"] = priorityRankFromNormalized(str)
222258
}
223259
if v, ok := fields["labels"]; ok {
224-
names := toStringSlice(v)
225-
if names != nil {
226-
issue, err := s.client.GetIssue(ctx, nativeID)
260+
names, ok := toStringSlice(v)
261+
if !ok {
262+
return core.IssueSnapshot{}, fmt.Errorf("field %q must be a []string, got %T", "labels", v)
263+
}
264+
issue, err := s.client.GetIssue(ctx, nativeID)
265+
if err != nil {
266+
return core.IssueSnapshot{}, fmt.Errorf("failed to resolve team for label update on %s: %w", nativeID, err)
267+
}
268+
labelIDs := make([]string, 0, len(names))
269+
for _, name := range names {
270+
id, err := s.client.GetOrCreateLabel(ctx, issue.Team.Key, name, "#8b949e")
227271
if err != nil {
228-
return core.IssueSnapshot{}, fmt.Errorf("failed to resolve team for label update on %s: %w", nativeID, err)
272+
return core.IssueSnapshot{}, fmt.Errorf("failed to resolve label %q: %w", name, err)
229273
}
230-
labelIDs := make([]string, 0, len(names))
231-
for _, name := range names {
232-
id, err := s.client.GetOrCreateLabel(ctx, issue.Team.Key, name, "#8b949e")
233-
if err != nil {
234-
return core.IssueSnapshot{}, fmt.Errorf("failed to resolve label %q: %w", name, err)
235-
}
236-
labelIDs = append(labelIDs, id)
237-
}
238-
variables["labelIds"] = labelIDs
274+
labelIDs = append(labelIDs, id)
239275
}
276+
variables["labelIds"] = labelIDs
240277
}
241278

242279
if len(variables) == 1 {
@@ -270,9 +307,78 @@ func (s *SyncAdapter) UpdateFields(ctx context.Context, nativeID string, fields
270307
return toSnapshot(result.IssueUpdate.Issue.toIssue()), nil
271308
}
272309

273-
// TransitionState moves nativeID to providerState (a workflow state id).
310+
// TransitionState moves nativeID to providerState, which may be either a
311+
// Linear workflow-state UUID (as returned in core.IssueSnapshot's internal
312+
// representation, or by GetTeamDoneStateID) or a workflow-state NAME (the
313+
// value core.IssueSnapshot.State actually carries). A UUID is passed straight
314+
// through to Client.UpdateIssueState; a name is first resolved to the state's
315+
// id via a per-team workflow-state lookup, since the underlying GraphQL
316+
// mutation only accepts a stateId.
274317
func (s *SyncAdapter) TransitionState(ctx context.Context, nativeID, providerState string) error {
275-
return s.client.UpdateIssueState(ctx, nativeID, providerState)
318+
stateID := providerState
319+
if !looksLikeUUID(providerState) {
320+
issue, err := s.client.GetIssue(ctx, nativeID)
321+
if err != nil {
322+
return fmt.Errorf("failed to resolve issue %s to look up its team: %w", nativeID, err)
323+
}
324+
id, err := s.resolveWorkflowStateID(ctx, issue.Team.Key, providerState)
325+
if err != nil {
326+
return fmt.Errorf("failed to resolve state %q for team %s: %w", providerState, issue.Team.Key, err)
327+
}
328+
stateID = id
329+
}
330+
return s.client.UpdateIssueState(ctx, nativeID, stateID)
331+
}
332+
333+
// resolveWorkflowStateID resolves stateName to its workflow-state id within
334+
// teamKey's team, caching all of the team's states on first lookup.
335+
func (s *SyncAdapter) resolveWorkflowStateID(ctx context.Context, teamKey, stateName string) (string, error) {
336+
s.workflowStateMu.RLock()
337+
if states, ok := s.workflowStateCache[teamKey]; ok {
338+
id, ok := states[stateName]
339+
s.workflowStateMu.RUnlock()
340+
if ok {
341+
return id, nil
342+
}
343+
return "", fmt.Errorf("no workflow state named %q found for team %s", stateName, teamKey)
344+
}
345+
s.workflowStateMu.RUnlock()
346+
347+
query := `
348+
query SyncResolveWorkflowStates($teamKey: String!) {
349+
workflowStates(filter: { team: { key: { eq: $teamKey } } }) {
350+
nodes { id name }
351+
}
352+
}
353+
`
354+
355+
var result struct {
356+
WorkflowStates struct {
357+
Nodes []struct {
358+
ID string `json:"id"`
359+
Name string `json:"name"`
360+
} `json:"nodes"`
361+
} `json:"workflowStates"`
362+
}
363+
364+
if err := s.client.Execute(ctx, query, map[string]interface{}{"teamKey": teamKey}, &result); err != nil {
365+
return "", err
366+
}
367+
368+
states := make(map[string]string, len(result.WorkflowStates.Nodes))
369+
for _, n := range result.WorkflowStates.Nodes {
370+
states[n.Name] = n.ID
371+
}
372+
373+
s.workflowStateMu.Lock()
374+
s.workflowStateCache[teamKey] = states
375+
s.workflowStateMu.Unlock()
376+
377+
id, ok := states[stateName]
378+
if !ok {
379+
return "", fmt.Errorf("no workflow state named %q found for team %s", stateName, teamKey)
380+
}
381+
return id, nil
276382
}
277383

278384
// syncCommentMarker returns the idempotency marker embedded in comment
@@ -281,42 +387,63 @@ func syncCommentMarker(idemKey string) string {
281387
return fmt.Sprintf("<!-- pilot-op:%s -->", idemKey)
282388
}
283389

390+
// syncCommentsPageSize is the GraphQL page size used when scanning an
391+
// issue's comments for a prior idempotency marker in AddComment.
392+
const syncCommentsPageSize = 100
393+
284394
// AddComment posts body as a comment on nativeID, embedding a
285395
// "<!-- pilot-op:{idemKey} -->" marker. If a comment with the same marker
286396
// already exists on the issue, AddComment is a no-op — this makes retried
287-
// syncs idempotent.
397+
// syncs idempotent. The existing-comments scan follows the comments
398+
// connection's pagination cursor to completion, so a marker sitting beyond
399+
// the first page is still found.
288400
func (s *SyncAdapter) AddComment(ctx context.Context, nativeID, body, idemKey string) error {
289401
marker := syncCommentMarker(idemKey)
290402

291403
query := `
292-
query SyncIssueComments($id: String!) {
404+
query SyncIssueComments($id: String!, $first: Int!, $after: String) {
293405
issue(id: $id) {
294-
comments {
406+
comments(first: $first, after: $after) {
295407
nodes { id body }
408+
pageInfo { hasNextPage endCursor }
296409
}
297410
}
298411
}
299412
`
300413

301-
var result struct {
302-
Issue struct {
303-
Comments struct {
304-
Nodes []struct {
305-
ID string `json:"id"`
306-
Body string `json:"body"`
307-
} `json:"nodes"`
308-
} `json:"comments"`
309-
} `json:"issue"`
310-
}
414+
after := ""
415+
for {
416+
variables := map[string]interface{}{"id": nativeID, "first": syncCommentsPageSize}
417+
if after != "" {
418+
variables["after"] = after
419+
}
311420

312-
if err := s.client.Execute(ctx, query, map[string]interface{}{"id": nativeID}, &result); err != nil {
313-
return fmt.Errorf("failed to list comments for %s: %w", nativeID, err)
314-
}
421+
var result struct {
422+
Issue struct {
423+
Comments struct {
424+
Nodes []struct {
425+
ID string `json:"id"`
426+
Body string `json:"body"`
427+
} `json:"nodes"`
428+
PageInfo pageInfo `json:"pageInfo"`
429+
} `json:"comments"`
430+
} `json:"issue"`
431+
}
432+
433+
if err := s.client.Execute(ctx, query, variables, &result); err != nil {
434+
return fmt.Errorf("failed to list comments for %s: %w", nativeID, err)
435+
}
436+
437+
for _, c := range result.Issue.Comments.Nodes {
438+
if strings.Contains(c.Body, marker) {
439+
return nil
440+
}
441+
}
315442

316-
for _, c := range result.Issue.Comments.Nodes {
317-
if strings.Contains(c.Body, marker) {
318-
return nil
443+
if !result.Issue.Comments.PageInfo.HasNextPage {
444+
break
319445
}
446+
after = result.Issue.Comments.PageInfo.EndCursor
320447
}
321448

322449
return s.client.AddComment(ctx, nativeID, body+"\n\n"+marker)
@@ -421,20 +548,23 @@ func (s *SyncAdapter) resolveTeamID(ctx context.Context, teamKey string) (string
421548

422549
// toStringSlice converts a FieldPatch value expected to be a string slice,
423550
// accepting both []string (native Go callers) and []interface{} (values
424-
// that passed through JSON unmarshaling). Returns nil if v is neither.
425-
func toStringSlice(v interface{}) []string {
551+
// that passed through JSON unmarshaling). Returns ok=false if v is neither,
552+
// or if a []interface{} contains a non-string element.
553+
func toStringSlice(v interface{}) (out []string, ok bool) {
426554
switch t := v.(type) {
427555
case []string:
428-
return t
556+
return t, true
429557
case []interface{}:
430-
out := make([]string, 0, len(t))
558+
out = make([]string, 0, len(t))
431559
for _, item := range t {
432-
if str, ok := item.(string); ok {
433-
out = append(out, str)
560+
str, ok := item.(string)
561+
if !ok {
562+
return nil, false
434563
}
564+
out = append(out, str)
435565
}
436-
return out
566+
return out, true
437567
default:
438-
return nil
568+
return nil, false
439569
}
440570
}

0 commit comments

Comments
 (0)