@@ -3,13 +3,23 @@ package linear
33import (
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.
1424var _ 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.
3349func 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.
107127func (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.
205227func (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.
274317func (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.
288400func (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