@fink-andreas/pi-linear-tools 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/linear.js CHANGED
@@ -90,830 +90,2589 @@ const ISSUES_WITH_RELATIONS_QUERY = `
90
90
  }
91
91
  `;
92
92
 
93
+ const PROJECT_DETAILS_QUERY = `
94
+ query ProjectDetails($id: String!, $milestoneLimit: Int!) {
95
+ project(id: $id) {
96
+ id
97
+ name
98
+ description
99
+ content
100
+ color
101
+ icon
102
+ priority
103
+ progress
104
+ health
105
+ startDate
106
+ targetDate
107
+ slugId
108
+ url
109
+ archivedAt
110
+ completedAt
111
+ canceledAt
112
+ status {
113
+ id
114
+ name
115
+ type
116
+ color
117
+ }
118
+ lead {
119
+ id
120
+ name
121
+ displayName
122
+ }
123
+ teams {
124
+ nodes {
125
+ id
126
+ key
127
+ name
128
+ }
129
+ }
130
+ projectMilestones(first: $milestoneLimit) {
131
+ nodes {
132
+ id
133
+ name
134
+ status
135
+ progress
136
+ targetDate
137
+ }
138
+ }
139
+ }
140
+ }
141
+ `;
142
+
143
+ const PROJECTS_LOOKUP_QUERY = `
144
+ query ProjectsLookup($includeArchived: Boolean!) {
145
+ projects(first: 250, includeArchived: $includeArchived) {
146
+ nodes {
147
+ id
148
+ name
149
+ slugId
150
+ archivedAt
151
+ }
152
+ }
153
+ }
154
+ `;
155
+
156
+ const PROJECT_MINIMAL_QUERY = `
157
+ query ProjectMinimal($id: String!) {
158
+ project(id: $id) {
159
+ id
160
+ name
161
+ slugId
162
+ archivedAt
163
+ }
164
+ }
165
+ `;
166
+
167
+ const PROJECT_MILESTONES_QUERY = `
168
+ query ProjectMilestones($id: String!, $first: Int!) {
169
+ project(id: $id) {
170
+ id
171
+ name
172
+ projectMilestones(first: $first) {
173
+ nodes {
174
+ id
175
+ name
176
+ description
177
+ progress
178
+ sortOrder
179
+ targetDate
180
+ status
181
+ }
182
+ }
183
+ }
184
+ }
185
+ `;
186
+
187
+ const TEAM_MINIMAL_QUERY = `
188
+ query TeamMinimal($id: String!) {
189
+ team(id: $id) {
190
+ id
191
+ key
192
+ name
193
+ }
194
+ }
195
+ `;
196
+
197
+ const TEAM_STATES_QUERY = `
198
+ query TeamStates($id: String!, $first: Int!) {
199
+ team(id: $id) {
200
+ id
201
+ key
202
+ name
203
+ states(first: $first) {
204
+ nodes {
205
+ id
206
+ name
207
+ type
208
+ }
209
+ }
210
+ }
211
+ }
212
+ `;
213
+
214
+ const PROJECT_CREATE_MUTATION = `
215
+ mutation ProjectCreate($input: ProjectCreateInput!) {
216
+ projectCreate(input: $input) {
217
+ success
218
+ project {
219
+ id
220
+ name
221
+ }
222
+ }
223
+ }
224
+ `;
225
+
226
+ const PROJECT_UPDATE_MUTATION = `
227
+ mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {
228
+ projectUpdate(id: $id, input: $input) {
229
+ success
230
+ project {
231
+ id
232
+ name
233
+ }
234
+ }
235
+ }
236
+ `;
237
+
238
+ const PROJECT_DELETE_MUTATION = `
239
+ mutation ProjectDelete($id: String!) {
240
+ projectDelete(id: $id) {
241
+ success
242
+ entity {
243
+ id
244
+ name
245
+ }
246
+ }
247
+ }
248
+ `;
249
+
250
+ const PROJECT_ARCHIVE_MUTATION = `
251
+ mutation ProjectArchive($id: String!) {
252
+ projectArchiveResult: projectDelete(id: $id) {
253
+ success
254
+ entity {
255
+ id
256
+ name
257
+ }
258
+ }
259
+ }
260
+ `;
261
+
262
+ const PROJECT_UNARCHIVE_MUTATION = `
263
+ mutation ProjectUnarchive($id: String!) {
264
+ projectUnarchive(id: $id) {
265
+ success
266
+ entity {
267
+ id
268
+ name
269
+ }
270
+ }
271
+ }
272
+ `;
273
+
274
+ const PROJECT_UPDATES_BY_PROJECT_QUERY = `
275
+ query ProjectUpdatesByProject($id: String!, $first: Int!, $includeArchived: Boolean!) {
276
+ project(id: $id) {
277
+ id
278
+ name
279
+ projectUpdates(first: $first, includeArchived: $includeArchived) {
280
+ nodes {
281
+ id
282
+ body
283
+ health
284
+ createdAt
285
+ updatedAt
286
+ archivedAt
287
+ url
288
+ slugId
289
+ isDiffHidden
290
+ isStale
291
+ user {
292
+ id
293
+ name
294
+ displayName
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ `;
301
+
302
+ const PROJECT_UPDATE_DETAILS_QUERY = `
303
+ query ProjectUpdateDetails($id: String!) {
304
+ projectUpdate(id: $id) {
305
+ id
306
+ body
307
+ health
308
+ createdAt
309
+ updatedAt
310
+ archivedAt
311
+ editedAt
312
+ url
313
+ slugId
314
+ isDiffHidden
315
+ isStale
316
+ project {
317
+ id
318
+ name
319
+ }
320
+ user {
321
+ id
322
+ name
323
+ displayName
324
+ }
325
+ }
326
+ }
327
+ `;
328
+
329
+ const PROJECT_UPDATE_CREATE_MUTATION = `
330
+ mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {
331
+ projectUpdateCreate(input: $input) {
332
+ success
333
+ projectUpdate {
334
+ id
335
+ }
336
+ }
337
+ }
338
+ `;
339
+
340
+ const PROJECT_UPDATE_UPDATE_MUTATION = `
341
+ mutation ProjectUpdateUpdate($id: String!, $input: ProjectUpdateUpdateInput!) {
342
+ projectUpdateUpdate(id: $id, input: $input) {
343
+ success
344
+ projectUpdate {
345
+ id
346
+ }
347
+ }
348
+ }
349
+ `;
350
+
351
+ const PROJECT_UPDATE_ARCHIVE_MUTATION = `
352
+ mutation ProjectUpdateArchive($id: String!) {
353
+ projectUpdateArchive(id: $id) {
354
+ success
355
+ entity {
356
+ id
357
+ }
358
+ }
359
+ }
360
+ `;
361
+
362
+ const PROJECT_UPDATE_UNARCHIVE_MUTATION = `
363
+ mutation ProjectUpdateUnarchive($id: String!) {
364
+ projectUpdateUnarchive(id: $id) {
365
+ success
366
+ entity {
367
+ id
368
+ }
369
+ }
370
+ }
371
+ `;
372
+
373
+ const DOCUMENT_DETAILS_QUERY = `
374
+ query DocumentDetails($id: String!) {
375
+ document(id: $id) {
376
+ id
377
+ title
378
+ content
379
+ icon
380
+ color
381
+ slugId
382
+ url
383
+ archivedAt
384
+ createdAt
385
+ updatedAt
386
+ project {
387
+ id
388
+ name
389
+ }
390
+ issue {
391
+ id
392
+ identifier
393
+ title
394
+ }
395
+ }
396
+ }
397
+ `;
398
+
399
+ const DOCUMENT_CREATE_MUTATION = `
400
+ mutation DocumentCreate($input: DocumentCreateInput!) {
401
+ documentCreate(input: $input) {
402
+ success
403
+ document {
404
+ id
405
+ }
406
+ }
407
+ }
408
+ `;
409
+
410
+ const DOCUMENT_UPDATE_MUTATION = `
411
+ mutation DocumentUpdate($id: String!, $input: DocumentUpdateInput!) {
412
+ documentUpdate(id: $id, input: $input) {
413
+ success
414
+ document {
415
+ id
416
+ }
417
+ }
418
+ }
419
+ `;
420
+
421
+ const ISSUE_MINIMAL_QUERY = `
422
+ query IssueMinimal($id: String!) {
423
+ issue(id: $id) {
424
+ id
425
+ identifier
426
+ title
427
+ description
428
+ url
429
+ branchName
430
+ priority
431
+ estimate
432
+ createdAt
433
+ updatedAt
434
+ state {
435
+ id
436
+ name
437
+ type
438
+ }
439
+ team {
440
+ id
441
+ key
442
+ name
443
+ }
444
+ project {
445
+ id
446
+ name
447
+ }
448
+ projectMilestone {
449
+ id
450
+ name
451
+ }
452
+ assignee {
453
+ id
454
+ name
455
+ displayName
456
+ }
457
+ }
458
+ }
459
+ `;
460
+
461
+ const ISSUE_MINIMAL_BY_TEAM_AND_NUMBER_QUERY = `
462
+ query IssueMinimalByTeamAndNumber($teamKey: String!, $number: Float!) {
463
+ issues(first: 1, filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }) {
464
+ nodes {
465
+ id
466
+ identifier
467
+ title
468
+ description
469
+ url
470
+ branchName
471
+ priority
472
+ estimate
473
+ createdAt
474
+ updatedAt
475
+ state {
476
+ id
477
+ name
478
+ type
479
+ }
480
+ team {
481
+ id
482
+ key
483
+ name
484
+ }
485
+ project {
486
+ id
487
+ name
488
+ }
489
+ projectMilestone {
490
+ id
491
+ name
492
+ }
493
+ assignee {
494
+ id
495
+ name
496
+ displayName
497
+ }
498
+ }
499
+ }
500
+ }
501
+ `;
502
+
503
+ const ISSUE_DETAILS_QUERY = `
504
+ query IssueDetails($id: String!) {
505
+ issue(id: $id) {
506
+ id
507
+ identifier
508
+ title
509
+ description
510
+ url
511
+ branchName
512
+ priority
513
+ estimate
514
+ createdAt
515
+ updatedAt
516
+ state {
517
+ id
518
+ name
519
+ color
520
+ type
521
+ }
522
+ team {
523
+ id
524
+ key
525
+ name
526
+ }
527
+ project {
528
+ id
529
+ name
530
+ }
531
+ projectMilestone {
532
+ id
533
+ name
534
+ }
535
+ assignee {
536
+ id
537
+ name
538
+ displayName
539
+ }
540
+ creator {
541
+ id
542
+ name
543
+ displayName
544
+ }
545
+ labels(first: 50) {
546
+ nodes {
547
+ id
548
+ name
549
+ color
550
+ }
551
+ }
552
+ parent {
553
+ id
554
+ identifier
555
+ title
556
+ state {
557
+ id
558
+ name
559
+ color
560
+ }
561
+ }
562
+ children(first: 50) {
563
+ nodes {
564
+ id
565
+ identifier
566
+ title
567
+ state {
568
+ id
569
+ name
570
+ color
571
+ }
572
+ }
573
+ }
574
+ attachments(first: 50) {
575
+ nodes {
576
+ id
577
+ title
578
+ url
579
+ subtitle
580
+ sourceType
581
+ createdAt
582
+ }
583
+ }
584
+ }
585
+ }
586
+ `;
587
+
588
+ const ISSUE_DETAILS_WITH_COMMENTS_QUERY = `
589
+ query IssueDetailsWithComments($id: String!) {
590
+ issue(id: $id) {
591
+ id
592
+ identifier
593
+ title
594
+ description
595
+ url
596
+ branchName
597
+ priority
598
+ estimate
599
+ createdAt
600
+ updatedAt
601
+ state {
602
+ id
603
+ name
604
+ color
605
+ type
606
+ }
607
+ team {
608
+ id
609
+ key
610
+ name
611
+ }
612
+ project {
613
+ id
614
+ name
615
+ }
616
+ projectMilestone {
617
+ id
618
+ name
619
+ }
620
+ assignee {
621
+ id
622
+ name
623
+ displayName
624
+ }
625
+ creator {
626
+ id
627
+ name
628
+ displayName
629
+ }
630
+ labels(first: 50) {
631
+ nodes {
632
+ id
633
+ name
634
+ color
635
+ }
636
+ }
637
+ parent {
638
+ id
639
+ identifier
640
+ title
641
+ state {
642
+ id
643
+ name
644
+ color
645
+ }
646
+ }
647
+ children(first: 50) {
648
+ nodes {
649
+ id
650
+ identifier
651
+ title
652
+ state {
653
+ id
654
+ name
655
+ color
656
+ }
657
+ }
658
+ }
659
+ comments(first: 100) {
660
+ nodes {
661
+ id
662
+ body
663
+ createdAt
664
+ updatedAt
665
+ user {
666
+ id
667
+ name
668
+ displayName
669
+ }
670
+ externalUser {
671
+ id
672
+ name
673
+ displayName
674
+ }
675
+ parent {
676
+ id
677
+ }
678
+ }
679
+ }
680
+ attachments(first: 50) {
681
+ nodes {
682
+ id
683
+ title
684
+ url
685
+ subtitle
686
+ sourceType
687
+ createdAt
688
+ }
689
+ }
690
+ }
691
+ }
692
+ `;
693
+
694
+ const ISSUE_CREATE_MUTATION = `
695
+ mutation IssueCreate($input: IssueCreateInput!) {
696
+ issueCreate(input: $input) {
697
+ success
698
+ issue {
699
+ id
700
+ identifier
701
+ title
702
+ description
703
+ url
704
+ branchName
705
+ priority
706
+ estimate
707
+ createdAt
708
+ updatedAt
709
+ state {
710
+ id
711
+ name
712
+ type
713
+ }
714
+ team {
715
+ id
716
+ key
717
+ name
718
+ }
719
+ project {
720
+ id
721
+ name
722
+ }
723
+ projectMilestone {
724
+ id
725
+ name
726
+ }
727
+ assignee {
728
+ id
729
+ name
730
+ displayName
731
+ }
732
+ }
733
+ }
734
+ }
735
+ `;
736
+
737
+ const ISSUE_UPDATE_MUTATION = `
738
+ mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
739
+ issueUpdate(id: $id, input: $input) {
740
+ success
741
+ issue {
742
+ id
743
+ identifier
744
+ title
745
+ description
746
+ url
747
+ branchName
748
+ priority
749
+ estimate
750
+ createdAt
751
+ updatedAt
752
+ state {
753
+ id
754
+ name
755
+ type
756
+ }
757
+ team {
758
+ id
759
+ key
760
+ name
761
+ }
762
+ project {
763
+ id
764
+ name
765
+ }
766
+ projectMilestone {
767
+ id
768
+ name
769
+ }
770
+ assignee {
771
+ id
772
+ name
773
+ displayName
774
+ }
775
+ }
776
+ }
777
+ }
778
+ `;
779
+
780
+ const ISSUE_DELETE_MUTATION = `
781
+ mutation IssueDelete($id: String!) {
782
+ issueDelete(id: $id) {
783
+ success
784
+ entity {
785
+ id
786
+ identifier
787
+ }
788
+ }
789
+ }
790
+ `;
791
+
792
+ const ISSUE_ACTIVITY_QUERY = `
793
+ query IssueActivity($id: String!, $first: Int!, $includeArchived: Boolean!) {
794
+ issue(id: $id) {
795
+ id
796
+ identifier
797
+ title
798
+ url
799
+ history(first: $first, includeArchived: $includeArchived) {
800
+ nodes {
801
+ id
802
+ createdAt
803
+ updatedAt
804
+ archived
805
+ archivedAt
806
+ autoArchived
807
+ autoClosed
808
+ trashed
809
+ updatedDescription
810
+ fromTitle
811
+ toTitle
812
+ fromPriority
813
+ toPriority
814
+ fromState {
815
+ id
816
+ name
817
+ }
818
+ toState {
819
+ id
820
+ name
821
+ }
822
+ fromAssignee {
823
+ id
824
+ name
825
+ displayName
826
+ }
827
+ toAssignee {
828
+ id
829
+ name
830
+ displayName
831
+ }
832
+ fromProject {
833
+ id
834
+ name
835
+ }
836
+ toProject {
837
+ id
838
+ name
839
+ }
840
+ fromProjectMilestone {
841
+ id
842
+ name
843
+ }
844
+ toProjectMilestone {
845
+ id
846
+ name
847
+ }
848
+ addedLabels {
849
+ id
850
+ name
851
+ }
852
+ removedLabels {
853
+ id
854
+ name
855
+ }
856
+ relationChanges {
857
+ identifier
858
+ type
859
+ }
860
+ attachment {
861
+ id
862
+ title
863
+ url
864
+ }
865
+ actor {
866
+ id
867
+ name
868
+ displayName
869
+ }
870
+ }
871
+ }
872
+ }
873
+ }
874
+ `;
875
+
876
+ /**
877
+ * Execute an optimized GraphQL query using rawRequest
878
+ * Falls back to SDK method if rawRequest is not available (e.g., in tests)
879
+ * @param {LinearClient} client - Linear SDK client
880
+ * @param {string} query - GraphQL query string
881
+ * @param {Object} variables - Query variables
882
+ * @returns {Promise<{data: Object, headers: Headers}>}
883
+ */
884
+ async function executeOptimizedQuery(client, query, variables) {
885
+ // Try rawRequest first (preferred - more efficient)
886
+ if (typeof client.rawRequest === 'function') {
887
+ const response = await client.rawRequest(query, variables);
888
+ return {
889
+ data: response.data,
890
+ headers: response.headers,
891
+ };
892
+ }
893
+
894
+ // Fallback to SDK method for testing/compatibility
895
+ if (typeof client.client?.rawRequest === 'function') {
896
+ const response = await client.client.rawRequest(query, variables);
897
+ return {
898
+ data: response.data,
899
+ headers: response.headers,
900
+ };
901
+ }
902
+
903
+ // Fallback: use SDK's issues() method (less efficient but always available)
904
+ warn('executeOptimizedQuery: rawRequest not available, falling back to SDK method');
905
+ const filter = variables.filter || {};
906
+ const result = await client.issues({
907
+ first: variables.first,
908
+ filter,
909
+ });
910
+
911
+ return {
912
+ data: {
913
+ issues: {
914
+ nodes: result.nodes || [],
915
+ pageInfo: result.pageInfo || { hasNextPage: false },
916
+ },
917
+ },
918
+ headers: new Headers(),
919
+ };
920
+ }
921
+
922
+ function getRawRequest(client) {
923
+ return (
924
+ (typeof client.client?.rawRequest === 'function' ? client.client.rawRequest.bind(client.client) : null) ||
925
+ (typeof client.rawRequest === 'function' ? client.rawRequest.bind(client) : null)
926
+ );
927
+ }
928
+
929
+ async function executeGraphQL(client, query, variables = {}) {
930
+ const rawRequest = getRawRequest(client);
931
+
932
+ if (!rawRequest) {
933
+ throw new Error('GraphQL rawRequest is unavailable on this Linear client');
934
+ }
935
+
936
+ const response = await rawRequest(query, variables);
937
+ updateRateLimitState(response);
938
+ checkRateLimitWarning();
939
+ return response.data;
940
+ }
941
+
942
+ /**
943
+ * Transform raw GraphQL issue data to plain object format
944
+ * Used by optimized queries to avoid SDK lazy loading
945
+ */
946
+ function transformRawIssue(rawIssue) {
947
+ if (!rawIssue) return null;
948
+
949
+ return {
950
+ id: rawIssue.id,
951
+ identifier: rawIssue.identifier,
952
+ title: rawIssue.title,
953
+ description: rawIssue.description,
954
+ url: rawIssue.url,
955
+ branchName: rawIssue.branchName,
956
+ priority: rawIssue.priority,
957
+ estimate: rawIssue.estimate ?? null,
958
+ createdAt: rawIssue.createdAt ?? null,
959
+ updatedAt: rawIssue.updatedAt ?? null,
960
+ state: rawIssue.state ? { id: rawIssue.state.id, name: rawIssue.state.name, type: rawIssue.state.type } : null,
961
+ team: rawIssue.team ? { id: rawIssue.team.id, key: rawIssue.team.key, name: rawIssue.team.name } : null,
962
+ project: rawIssue.project ? { id: rawIssue.project.id, name: rawIssue.project.name } : null,
963
+ projectMilestone: rawIssue.projectMilestone ? { id: rawIssue.projectMilestone.id, name: rawIssue.projectMilestone.name } : null,
964
+ assignee: rawIssue.assignee ? { id: rawIssue.assignee.id, name: rawIssue.assignee.name, displayName: rawIssue.assignee.displayName } : null,
965
+ };
966
+ }
967
+
968
+ function parseIssueIdentifierLookup(lookup) {
969
+ const match = String(lookup || '').trim().match(/^([A-Za-z0-9]+)-(\d+)$/);
970
+ if (!match) {
971
+ return null;
972
+ }
973
+
974
+ return {
975
+ teamKey: match[1].toUpperCase(),
976
+ number: Number.parseInt(match[2], 10),
977
+ };
978
+ }
979
+
980
+ async function fetchIssueMinimalById(client, issueId) {
981
+ if (getRawRequest(client)) {
982
+ try {
983
+ const data = await executeGraphQL(client, ISSUE_MINIMAL_QUERY, { id: issueId });
984
+ const transformed = transformRawIssue(data?.issue ?? null);
985
+ if (transformed) {
986
+ return transformed;
987
+ }
988
+ } catch {
989
+ // Fall back to SDK read below.
990
+ }
991
+ }
992
+
993
+ const sdkIssue = await client.issue?.(issueId);
994
+ return sdkIssue ? transformIssue(sdkIssue) : null;
995
+ }
996
+
997
+ async function fetchIssueMinimalByIdentifier(client, identifier) {
998
+ if (getRawRequest(client)) {
999
+ const parsed = parseIssueIdentifierLookup(identifier);
1000
+ if (parsed) {
1001
+ try {
1002
+ const data = await executeGraphQL(client, ISSUE_MINIMAL_BY_TEAM_AND_NUMBER_QUERY, parsed);
1003
+ const transformed = transformRawIssue(data?.issues?.nodes?.[0] ?? null);
1004
+ if (transformed) {
1005
+ return transformed;
1006
+ }
1007
+ } catch {
1008
+ // Fall back to SDK read below.
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ const sdkIssue = await client.issue?.(identifier);
1014
+ return sdkIssue ? transformIssue(sdkIssue) : null;
1015
+ }
1016
+
1017
+ async function fetchIssueMinimal(client, lookup) {
1018
+ return isLinearId(lookup)
1019
+ ? fetchIssueMinimalById(client, lookup)
1020
+ : fetchIssueMinimalByIdentifier(client, lookup);
1021
+ }
1022
+
1023
+ async function performIssueUpdate(client, issueId, updateInput) {
1024
+ if (getRawRequest(client)) {
1025
+ const payload = await executeGraphQL(client, ISSUE_UPDATE_MUTATION, {
1026
+ id: issueId,
1027
+ input: updateInput,
1028
+ });
1029
+
1030
+ if (!payload?.issueUpdate?.success) {
1031
+ throw new Error('Failed to update issue');
1032
+ }
1033
+
1034
+ return transformRawIssue(payload.issueUpdate.issue ?? null);
1035
+ }
1036
+
1037
+ const sdkIssue = await client.issue(issueId);
1038
+ if (!sdkIssue) {
1039
+ throw new Error(`Issue not found: ${issueId}`);
1040
+ }
1041
+
1042
+ const result = await sdkIssue.update(updateInput);
1043
+ if (!result.success) {
1044
+ throw new Error('Failed to update issue');
1045
+ }
1046
+
1047
+ return transformIssue(result.issue);
1048
+ }
1049
+
1050
+ function transformRawIssueDetails(rawIssue, options = {}) {
1051
+ const { includeComments = true } = options;
1052
+ if (!rawIssue) return null;
1053
+
1054
+ return {
1055
+ identifier: rawIssue.identifier,
1056
+ title: rawIssue.title,
1057
+ description: rawIssue.description ?? null,
1058
+ url: rawIssue.url ?? null,
1059
+ branchName: rawIssue.branchName ?? null,
1060
+ priority: rawIssue.priority ?? null,
1061
+ estimate: rawIssue.estimate ?? null,
1062
+ createdAt: rawIssue.createdAt ?? null,
1063
+ updatedAt: rawIssue.updatedAt ?? null,
1064
+ state: rawIssue.state ? {
1065
+ name: rawIssue.state.name,
1066
+ color: rawIssue.state.color ?? null,
1067
+ type: rawIssue.state.type ?? null,
1068
+ } : null,
1069
+ team: rawIssue.team ? { id: rawIssue.team.id, key: rawIssue.team.key, name: rawIssue.team.name } : null,
1070
+ project: rawIssue.project ? { id: rawIssue.project.id, name: rawIssue.project.name } : null,
1071
+ projectMilestone: rawIssue.projectMilestone ? { id: rawIssue.projectMilestone.id, name: rawIssue.projectMilestone.name } : null,
1072
+ assignee: rawIssue.assignee ? { id: rawIssue.assignee.id, name: rawIssue.assignee.name, displayName: rawIssue.assignee.displayName } : null,
1073
+ creator: rawIssue.creator ? { id: rawIssue.creator.id, name: rawIssue.creator.name, displayName: rawIssue.creator.displayName } : null,
1074
+ labels: (rawIssue.labels?.nodes || []).map((label) => ({
1075
+ id: label.id,
1076
+ name: label.name,
1077
+ color: label.color ?? null,
1078
+ })),
1079
+ parent: rawIssue.parent ? {
1080
+ identifier: rawIssue.parent.identifier,
1081
+ title: rawIssue.parent.title,
1082
+ state: rawIssue.parent.state ? {
1083
+ name: rawIssue.parent.state.name,
1084
+ color: rawIssue.parent.state.color ?? null,
1085
+ } : null,
1086
+ } : null,
1087
+ children: (rawIssue.children?.nodes || []).map((child) => ({
1088
+ identifier: child.identifier,
1089
+ title: child.title,
1090
+ state: child.state ? {
1091
+ name: child.state.name,
1092
+ color: child.state.color ?? null,
1093
+ } : null,
1094
+ })),
1095
+ comments: includeComments
1096
+ ? (rawIssue.comments?.nodes || []).map((comment) => ({
1097
+ id: comment.id,
1098
+ body: comment.body,
1099
+ createdAt: comment.createdAt,
1100
+ updatedAt: comment.updatedAt,
1101
+ user: comment.user ? {
1102
+ name: comment.user.name,
1103
+ displayName: comment.user.displayName,
1104
+ } : null,
1105
+ externalUser: comment.externalUser ? {
1106
+ name: comment.externalUser.name,
1107
+ displayName: comment.externalUser.displayName,
1108
+ } : null,
1109
+ parent: comment.parent ? { id: comment.parent.id } : null,
1110
+ }))
1111
+ : [],
1112
+ attachments: (rawIssue.attachments?.nodes || []).map((attachment) => ({
1113
+ id: attachment.id,
1114
+ title: attachment.title,
1115
+ url: attachment.url,
1116
+ subtitle: attachment.subtitle,
1117
+ sourceType: attachment.sourceType,
1118
+ createdAt: attachment.createdAt,
1119
+ })),
1120
+ };
1121
+ }
1122
+
1123
+ function transformRawProjectMinimal(rawProject) {
1124
+ if (!rawProject) return null;
1125
+
1126
+ return {
1127
+ id: rawProject.id,
1128
+ name: rawProject.name,
1129
+ slugId: rawProject.slugId ?? null,
1130
+ archivedAt: rawProject.archivedAt ?? null,
1131
+ };
1132
+ }
1133
+
1134
+ function transformRawMilestone(rawMilestone, project = null) {
1135
+ if (!rawMilestone) return null;
1136
+
1137
+ return {
1138
+ id: rawMilestone.id,
1139
+ name: rawMilestone.name,
1140
+ description: rawMilestone.description ?? null,
1141
+ progress: rawMilestone.progress ?? null,
1142
+ order: rawMilestone.sortOrder ?? null,
1143
+ targetDate: rawMilestone.targetDate ?? null,
1144
+ status: rawMilestone.status ?? null,
1145
+ project: project ? { id: project.id, name: project.name } : null,
1146
+ };
1147
+ }
1148
+
1149
+ async function fetchProjectMinimal(client, projectId) {
1150
+ // If input looks like a UUID but can't be resolved directly,
1151
+ // it might be a project name that accidentally matches the UUID pattern.
1152
+ // Return null to signal that resolveProjectRef should fall back to name search.
1153
+ if (isLinearId(projectId)) {
1154
+ if (!getRawRequest(client)) {
1155
+ const sdkProject = await client.project?.(projectId);
1156
+ if (sdkProject) {
1157
+ return transformRawProjectMinimal(sdkProject);
1158
+ }
1159
+ // SDK can't resolve it - return null so resolveProjectRef falls back to name lookup
1160
+ return null;
1161
+ }
1162
+
1163
+ const data = await executeGraphQL(client, PROJECT_MINIMAL_QUERY, { id: projectId });
1164
+ if (data?.project) {
1165
+ return transformRawProjectMinimal(data.project);
1166
+ }
1167
+ // GraphQL can't resolve it - return null so resolveProjectRef falls back to name lookup
1168
+ return null;
1169
+ }
1170
+
1171
+ // For non-UUID inputs (names), return null to let resolveProjectRef handle it directly
1172
+ return null;
1173
+ }
1174
+
1175
+ async function fetchTeamMinimal(client, teamId) {
1176
+ if (getRawRequest(client)) {
1177
+ try {
1178
+ const data = await executeGraphQL(client, TEAM_MINIMAL_QUERY, { id: teamId });
1179
+ const team = data?.team;
1180
+ if (team) {
1181
+ return { id: team.id, key: team.key, name: team.name };
1182
+ }
1183
+ } catch {
1184
+ // Fall back to SDK read below.
1185
+ }
1186
+ }
1187
+
1188
+ const sdkTeam = await client.team?.(teamId);
1189
+ return sdkTeam ? { id: sdkTeam.id, key: sdkTeam.key, name: sdkTeam.name } : null;
1190
+ }
1191
+
1192
+ async function fetchTeamStatesByQuery(client, teamId, options = {}) {
1193
+ const { first = 50 } = options;
1194
+
1195
+ if (getRawRequest(client)) {
1196
+ try {
1197
+ const data = await executeGraphQL(client, TEAM_STATES_QUERY, { id: teamId, first });
1198
+ if (data?.team) {
1199
+ return (data.team.states?.nodes || []).map((state) => ({
1200
+ id: state.id,
1201
+ name: state.name,
1202
+ type: state.type,
1203
+ }));
1204
+ }
1205
+ } catch {
1206
+ // Fall back to SDK read below.
1207
+ }
1208
+ }
1209
+
1210
+ const team = await client.team?.(teamId);
1211
+ if (!team) {
1212
+ return null;
1213
+ }
1214
+
1215
+ const result = await team.states();
1216
+ return (result.nodes || []).map((state) => ({
1217
+ id: state.id,
1218
+ name: state.name,
1219
+ type: state.type,
1220
+ }));
1221
+ }
1222
+
1223
+ async function fetchProjectMilestonesByQuery(client, projectId, options = {}) {
1224
+ const { first = 250 } = options;
1225
+
1226
+ if (!getRawRequest(client)) {
1227
+ const project = await client.project?.(projectId);
1228
+ if (!project) {
1229
+ return null;
1230
+ }
1231
+
1232
+ const result = await project.projectMilestones();
1233
+ const nodes = result.nodes || [];
1234
+ return Promise.all(nodes.map(transformMilestone));
1235
+ }
1236
+
1237
+ const data = await executeGraphQL(client, PROJECT_MILESTONES_QUERY, {
1238
+ id: projectId,
1239
+ first,
1240
+ });
1241
+
1242
+ if (!data?.project) {
1243
+ return null;
1244
+ }
1245
+
1246
+ const project = { id: data.project.id, name: data.project.name };
1247
+ return (data.project.projectMilestones?.nodes || []).map((milestone) => transformRawMilestone(milestone, project));
1248
+ }
1249
+
1250
+ // ===== RATE LIMIT TRACKING =====
1251
+
1252
+ /**
1253
+ * Track rate limit status from API responses
1254
+ * Linear API returns headers: X-RateLimit-Requests-Remaining, X-RateLimit-Requests-Reset
1255
+ */
1256
+ const rateLimitState = {
1257
+ remaining: null,
1258
+ resetAt: null,
1259
+ lastWarnAt: 0,
1260
+ };
1261
+
1262
+ /**
1263
+ * Update rate limit state from response headers (internal)
1264
+ * @param {Response} response - Fetch response object
1265
+ */
1266
+ function updateRateLimitState(response) {
1267
+ if (!response) return;
1268
+
1269
+ const headers = response.headers;
1270
+ if (headers) {
1271
+ const remaining = headers.get('X-RateLimit-Requests-Remaining');
1272
+ const resetAt = headers.get('X-RateLimit-Requests-Reset');
1273
+
1274
+ if (remaining !== null) {
1275
+ rateLimitState.remaining = parseInt(remaining, 10);
1276
+ }
1277
+ if (resetAt !== null) {
1278
+ rateLimitState.resetAt = parseInt(resetAt, 10);
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ /**
1284
+ * Get current rate limit status
1285
+ * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null, usagePercent: number|null, shouldWarn: boolean}}
1286
+ */
1287
+ export function getRateLimitStatus() {
1288
+ const result = {
1289
+ remaining: rateLimitState.remaining,
1290
+ resetAt: rateLimitState.resetAt,
1291
+ resetTime: null,
1292
+ usagePercent: null,
1293
+ shouldWarn: false,
1294
+ };
1295
+
1296
+ if (rateLimitState.resetAt) {
1297
+ result.resetTime = new Date(rateLimitState.resetAt).toLocaleTimeString();
1298
+ const remaining = rateLimitState.remaining;
1299
+ if (remaining !== null && remaining <= 1000) {
1300
+ result.usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
1301
+ result.shouldWarn = remaining <= 500;
1302
+ }
1303
+ }
1304
+
1305
+ return result;
1306
+ }
1307
+
1308
+ /**
1309
+ * Check and warn about low rate limit
1310
+ */
1311
+ function checkRateLimitWarning() {
1312
+ const now = Date.now();
1313
+ // Only warn once per 30 seconds to avoid spam
1314
+ if (now - rateLimitState.lastWarnAt < 30000) return;
1315
+
1316
+ const status = getRateLimitStatus();
1317
+ if (status.shouldWarn && status.remaining !== null) {
1318
+ rateLimitState.lastWarnAt = now;
1319
+ warn(`Linear API rate limit running low: ${status.remaining} requests remaining (~${status.usagePercent}% used). Resets at ${status.resetTime}`, {
1320
+ remaining: status.remaining,
1321
+ resetAt: status.resetTime,
1322
+ usagePercent: status.usagePercent,
1323
+ });
1324
+ }
1325
+ }
1326
+
1327
+ // ===== ERROR HANDLING =====
1328
+
1329
+ /**
1330
+ * Check if an error is a Linear SDK error type
1331
+ * @param {Error} error - The error to check
1332
+ * @returns {boolean}
1333
+ */
1334
+ function isLinearError(error) {
1335
+ return error?.constructor?.name?.includes('LinearError') ||
1336
+ error?.name?.includes('LinearError') ||
1337
+ error?.type?.startsWith?.('Ratelimited') ||
1338
+ error?.type?.startsWith?.('Forbidden') ||
1339
+ error?.type?.startsWith?.('Authentication') ||
1340
+ error?.type === 'invalid_request' ||
1341
+ error?.type === 'NetworkError' ||
1342
+ error?.type === 'InternalError';
1343
+ }
1344
+
1345
+ /**
1346
+ * Format a Linear API error into a user-friendly message
1347
+ * @param {Error} error - The original error
1348
+ * @returns {Error|unknown} Formatted error with user-friendly message, or original if unhandled
1349
+ */
1350
+ function formatLinearError(error) {
1351
+ const message = String(error?.message || error || 'Unknown error');
1352
+ const errorType = error?.type || 'Unknown';
1353
+
1354
+ // Rate limit: provide reset time and reduce-frequency hint
1355
+ if (errorType === 'Ratelimited' || message.toLowerCase().includes('rate limit')) {
1356
+ const resetAt = error?.requestsResetAt
1357
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
1358
+ : '1 hour';
1359
+
1360
+ return new Error(
1361
+ `Linear API rate limit exceeded. Please wait before making more requests.\n` +
1362
+ `Rate limit resets at: ${resetAt}\n` +
1363
+ `Hint: Reduce request frequency or wait before retrying.`
1364
+ );
1365
+ }
1366
+
1367
+ // Auth/permission failures: prompt to check credentials
1368
+ if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
1369
+ message.toLowerCase().includes('forbidden') || message.toLowerCase().includes('unauthorized')) {
1370
+ return new Error(
1371
+ `${message}\nHint: Check your Linear API key or OAuth token permissions.`
1372
+ );
1373
+ }
1374
+
1375
+ // Network errors
1376
+ if (errorType === 'NetworkError' || message.toLowerCase().includes('network')) {
1377
+ return new Error(
1378
+ `Network error while communicating with Linear API.\nHint: Check your internet connection and try again.`
1379
+ );
1380
+ }
1381
+
1382
+ // Internal server errors
1383
+ if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
1384
+ return new Error(
1385
+ `Linear API server error (${error?.status || 'unknown'}).\nHint: Linear may be experiencing issues. Try again later.`
1386
+ );
1387
+ }
1388
+
1389
+ // Generic Linear API error
1390
+ if (isLinearError(error)) {
1391
+ return new Error(
1392
+ `Linear API error: ${message}`
1393
+ );
1394
+ }
1395
+
1396
+ // Unknown error - wrap if not already an Error
1397
+ return error instanceof Error ? error : new Error(String(error));
1398
+ }
1399
+
1400
+ /**
1401
+ * Wrap an async function with Linear-specific error handling
1402
+ * @param {Function} fn - Async function to wrap
1403
+ * @param {string} operation - Description of the operation for error messages
1404
+ * @returns {Promise<*>} Result of the wrapped function
1405
+ */
1406
+ async function withLinearErrorHandling(fn, operation = 'Linear API operation') {
1407
+ try {
1408
+ return await fn();
1409
+ } catch (error) {
1410
+ if (isLinearError(error)) {
1411
+ const formatted = formatLinearError(error);
1412
+ debug(`${operation} failed with Linear error`, {
1413
+ originalError: error?.message,
1414
+ errorType: error?.type,
1415
+ formattedMessage: formatted.message,
1416
+ });
1417
+ throw formatted;
1418
+ }
1419
+ throw error;
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * Wrap a handler function with comprehensive error handling for Linear API errors.
1425
+ * This provides user-friendly error messages for rate limits, auth issues, etc.
1426
+ * Use this in the execute() functions of tool handlers.
1427
+ *
1428
+ * @param {Function} fn - Async handler function to wrap
1429
+ * @param {string} operation - Description of the operation for error messages
1430
+ * @returns {Promise<*>} Result of the wrapped function
1431
+ * @example
1432
+ * ```js
1433
+ * export async function executeIssueList(client, params) {
1434
+ * return withHandlerErrorHandling(async () => {
1435
+ * // ... implementation
1436
+ * }, 'executeIssueList');
1437
+ * }
1438
+ * ```
1439
+ */
1440
+ export async function withHandlerErrorHandling(fn, operation = 'Handler') {
1441
+ return withLinearErrorHandling(async () => {
1442
+ try {
1443
+ return await fn();
1444
+ } catch (error) {
1445
+ // Log additional context for unexpected errors
1446
+ debug(`${operation} failed unexpectedly`, {
1447
+ error: error?.message,
1448
+ stack: error?.stack,
1449
+ });
1450
+ throw error;
1451
+ }
1452
+ }, operation);
1453
+ }
1454
+
1455
+ // ===== HELPERS =====
1456
+
1457
+ /**
1458
+ * Check if a value looks like a Linear UUID
1459
+ */
1460
+ function isLinearId(value) {
1461
+ return typeof value === 'string' && /^[0-9a-fA-F-]{16,}$/.test(value);
1462
+ }
1463
+
1464
+ /**
1465
+ * Normalize issue lookup input
1466
+ */
1467
+ function normalizeIssueLookupInput(issue) {
1468
+ const value = String(issue || '').trim();
1469
+ if (!value) throw new Error('Missing required issue identifier');
1470
+
1471
+ const issueUrlMatch = value.match(/\/issue\/([A-Za-z0-9]+-\d+)(?:[/?#]|$)/i);
1472
+ if (issueUrlMatch?.[1]) {
1473
+ return issueUrlMatch[1].toUpperCase();
1474
+ }
1475
+
1476
+ return value;
1477
+ }
1478
+
1479
+ function extractProjectLookupValue(projectRef) {
1480
+ const ref = String(projectRef || '').trim();
1481
+ if (!ref) {
1482
+ return '';
1483
+ }
1484
+
1485
+ const projectUrlMatch = ref.match(/\/project\/([^/?#]+)/i);
1486
+ if (projectUrlMatch?.[1]) {
1487
+ return projectUrlMatch[1];
1488
+ }
1489
+
1490
+ return ref;
1491
+ }
1492
+
1493
+ function getProjectLookupCandidates(projectRef) {
1494
+ const lookupValue = extractProjectLookupValue(projectRef);
1495
+ const candidates = new Set([lookupValue]);
1496
+
1497
+ if (lookupValue.includes('-')) {
1498
+ const slugSuffix = lookupValue.split('-').pop();
1499
+ if (slugSuffix) {
1500
+ candidates.add(slugSuffix);
1501
+ }
1502
+ }
1503
+
1504
+ return Array.from(candidates).filter(Boolean);
1505
+ }
1506
+
1507
+ const PROJECT_UPDATE_HEALTH_VALUES = ['onTrack', 'atRisk', 'offTrack'];
1508
+
1509
+ function normalizePositiveInteger(value, fieldName, defaultValue) {
1510
+ if (value === undefined || value === null) {
1511
+ return defaultValue;
1512
+ }
1513
+
1514
+ const parsed = typeof value === 'number' ? value : Number(String(value).trim());
1515
+ if (!Number.isInteger(parsed) || parsed < 1) {
1516
+ throw new Error(`${fieldName} must be a positive integer`);
1517
+ }
1518
+
1519
+ return parsed;
1520
+ }
1521
+
1522
+ function normalizeProjectUpdateHealth(value) {
1523
+ if (value === undefined) {
1524
+ return undefined;
1525
+ }
1526
+
1527
+ const normalized = String(value).trim();
1528
+ if (!normalized) {
1529
+ throw new Error(`health must be one of: ${PROJECT_UPDATE_HEALTH_VALUES.join(', ')}`);
1530
+ }
1531
+
1532
+ if (!PROJECT_UPDATE_HEALTH_VALUES.includes(normalized)) {
1533
+ throw new Error(`health must be one of: ${PROJECT_UPDATE_HEALTH_VALUES.join(', ')}`);
1534
+ }
1535
+
1536
+ return normalized;
1537
+ }
1538
+
1539
+ function formatPriorityLabel(value) {
1540
+ if (value === undefined || value === null) {
1541
+ return null;
1542
+ }
1543
+
1544
+ const numeric = Number(value);
1545
+ if (!Number.isFinite(numeric)) {
1546
+ return String(value);
1547
+ }
1548
+
1549
+ const priorityNames = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
1550
+ return priorityNames[numeric] || `Priority ${numeric}`;
1551
+ }
1552
+
1553
+ function getUserDisplayName(user) {
1554
+ return user?.displayName || user?.name || 'Unknown';
1555
+ }
1556
+
1557
+ function summarizeIssueHistoryEntry(entry) {
1558
+ if (entry.fromState?.name || entry.toState?.name) {
1559
+ if (entry.fromState?.name && entry.toState?.name) {
1560
+ return `moved state from ${entry.fromState.name} to ${entry.toState.name}`;
1561
+ }
1562
+ if (entry.toState?.name) {
1563
+ return `set state to ${entry.toState.name}`;
1564
+ }
1565
+ return `cleared state ${entry.fromState.name}`;
1566
+ }
1567
+
1568
+ if (entry.fromAssignee || entry.toAssignee) {
1569
+ const fromAssignee = entry.fromAssignee ? getUserDisplayName(entry.fromAssignee) : null;
1570
+ const toAssignee = entry.toAssignee ? getUserDisplayName(entry.toAssignee) : null;
1571
+ if (fromAssignee && toAssignee) {
1572
+ return `reassigned from ${fromAssignee} to ${toAssignee}`;
1573
+ }
1574
+ if (toAssignee) {
1575
+ return `assigned to ${toAssignee}`;
1576
+ }
1577
+ return `unassigned from ${fromAssignee}`;
1578
+ }
1579
+
1580
+ if (entry.fromTitle || entry.toTitle) {
1581
+ if (entry.fromTitle && entry.toTitle) {
1582
+ return `renamed issue from "${entry.fromTitle}" to "${entry.toTitle}"`;
1583
+ }
1584
+ if (entry.toTitle) {
1585
+ return `set title to "${entry.toTitle}"`;
1586
+ }
1587
+ }
1588
+
1589
+ if (entry.fromPriority !== undefined || entry.toPriority !== undefined) {
1590
+ const fromPriority = formatPriorityLabel(entry.fromPriority);
1591
+ const toPriority = formatPriorityLabel(entry.toPriority);
1592
+ if (fromPriority && toPriority) {
1593
+ return `changed priority from ${fromPriority} to ${toPriority}`;
1594
+ }
1595
+ if (toPriority) {
1596
+ return `set priority to ${toPriority}`;
1597
+ }
1598
+ }
1599
+
1600
+ if (entry.fromProject?.name || entry.toProject?.name) {
1601
+ if (entry.fromProject?.name && entry.toProject?.name) {
1602
+ return `moved project from ${entry.fromProject.name} to ${entry.toProject.name}`;
1603
+ }
1604
+ if (entry.toProject?.name) {
1605
+ return `added to project ${entry.toProject.name}`;
1606
+ }
1607
+ return `removed from project ${entry.fromProject.name}`;
1608
+ }
1609
+
1610
+ if (entry.fromProjectMilestone?.name || entry.toProjectMilestone?.name) {
1611
+ if (entry.fromProjectMilestone?.name && entry.toProjectMilestone?.name) {
1612
+ return `moved milestone from ${entry.fromProjectMilestone.name} to ${entry.toProjectMilestone.name}`;
1613
+ }
1614
+ if (entry.toProjectMilestone?.name) {
1615
+ return `set milestone to ${entry.toProjectMilestone.name}`;
1616
+ }
1617
+ return `cleared milestone ${entry.fromProjectMilestone.name}`;
1618
+ }
1619
+
1620
+ if ((entry.addedLabels?.length || 0) > 0 || (entry.removedLabels?.length || 0) > 0) {
1621
+ const labelChanges = [];
1622
+ if ((entry.addedLabels?.length || 0) > 0) {
1623
+ labelChanges.push(`added labels ${entry.addedLabels.map((label) => label.name).join(', ')}`);
1624
+ }
1625
+ if ((entry.removedLabels?.length || 0) > 0) {
1626
+ labelChanges.push(`removed labels ${entry.removedLabels.map((label) => label.name).join(', ')}`);
1627
+ }
1628
+ return labelChanges.join('; ');
1629
+ }
1630
+
1631
+ if ((entry.relationChanges?.length || 0) > 0) {
1632
+ const relationSummary = entry.relationChanges
1633
+ .map((relation) => `${relation.type} ${relation.identifier}`)
1634
+ .join(', ');
1635
+ return `updated relations: ${relationSummary}`;
1636
+ }
1637
+
1638
+ if (entry.updatedDescription) {
1639
+ return 'updated description';
1640
+ }
1641
+
1642
+ if (entry.attachment?.title || entry.attachment?.url) {
1643
+ return `linked attachment ${entry.attachment.title ? `"${entry.attachment.title}"` : entry.attachment.url}`;
1644
+ }
1645
+
1646
+ if (entry.archivedAt || entry.archived === true) {
1647
+ return entry.autoArchived ? 'auto-archived issue' : 'archived issue';
1648
+ }
1649
+
1650
+ if (entry.autoClosed) {
1651
+ return 'auto-closed issue';
1652
+ }
1653
+
1654
+ if (entry.trashed === true) {
1655
+ return 'trashed issue';
1656
+ }
1657
+
1658
+ if (entry.trashed === false) {
1659
+ return 'restored issue from trash';
1660
+ }
1661
+
1662
+ return 'updated issue';
1663
+ }
1664
+
93
1665
  /**
94
- * Execute an optimized GraphQL query using rawRequest
95
- * Falls back to SDK method if rawRequest is not available (e.g., in tests)
96
- * @param {LinearClient} client - Linear SDK client
97
- * @param {string} query - GraphQL query string
98
- * @param {Object} variables - Query variables
99
- * @returns {Promise<{data: Object, headers: Headers}>}
1666
+ * Safely resolve a lazy-loaded relation without triggering unnecessary API calls
1667
+ * Only fetches if the relation is already cached or if we have minimal data
100
1668
  */
101
- async function executeOptimizedQuery(client, query, variables) {
102
- // Try rawRequest first (preferred - more efficient)
103
- if (typeof client.rawRequest === 'function') {
104
- const response = await client.rawRequest(query, variables);
105
- return {
106
- data: response.data,
107
- headers: response.headers,
108
- };
1669
+ async function safeResolveRelation(sdkIssue, relationKey) {
1670
+ try {
1671
+ const relation = sdkIssue[relationKey];
1672
+ if (!relation) return null;
1673
+
1674
+ // If it's a function (lazy loader), check if we can call it safely
1675
+ if (typeof relation === 'function') {
1676
+ const result = await relation().catch(() => null);
1677
+ return result || null;
1678
+ }
1679
+
1680
+ // If it's already resolved, return it
1681
+ return relation;
1682
+ } catch {
1683
+ return null;
109
1684
  }
1685
+ }
110
1686
 
111
- // Fallback to SDK method for testing/compatibility
112
- if (typeof client.client?.rawRequest === 'function') {
113
- const response = await client.client.rawRequest(query, variables);
114
- return {
115
- data: response.data,
116
- headers: response.headers,
117
- };
1687
+ /**
1688
+ * Transform SDK issue object to plain object for consumers
1689
+ * Optimized to minimize API calls by avoiding unnecessary lazy loads
1690
+ */
1691
+ async function transformIssue(sdkIssue) {
1692
+ if (!sdkIssue) return null;
1693
+
1694
+ // If the SDK issue has _data, relations might already be available
1695
+ // Check if we can extract data without making extra calls
1696
+ const hasRelations = sdkIssue.state?.id || sdkIssue.assignee?.id || sdkIssue.project?.id;
1697
+
1698
+ // Only resolve relations if not already available
1699
+ let state = null;
1700
+ let team = null;
1701
+ let project = null;
1702
+ let assignee = null;
1703
+ let projectMilestone = null;
1704
+
1705
+ if (sdkIssue.state?.id) {
1706
+ state = sdkIssue.state;
1707
+ } else if (sdkIssue._data?.state) {
1708
+ state = { id: sdkIssue._data.state?.id, name: sdkIssue._data.state?.name, type: sdkIssue._data.state?.type };
118
1709
  }
119
1710
 
120
- // Fallback: use SDK's issues() method (less efficient but always available)
121
- warn('executeOptimizedQuery: rawRequest not available, falling back to SDK method');
122
- const filter = variables.filter || {};
123
- const result = await client.issues({
124
- first: variables.first,
125
- filter,
126
- });
1711
+ if (sdkIssue.team?.id) {
1712
+ team = sdkIssue.team;
1713
+ } else if (sdkIssue._data?.team) {
1714
+ team = { id: sdkIssue._data.team?.id, key: sdkIssue._data.team?.key, name: sdkIssue._data.team?.name };
1715
+ }
1716
+
1717
+ if (sdkIssue.project?.id) {
1718
+ project = sdkIssue.project;
1719
+ } else if (sdkIssue._data?.project) {
1720
+ project = { id: sdkIssue._data.project?.id, name: sdkIssue._data.project?.name };
1721
+ }
1722
+
1723
+ if (sdkIssue.assignee?.id) {
1724
+ assignee = sdkIssue.assignee;
1725
+ } else if (sdkIssue._data?.assignee) {
1726
+ assignee = { id: sdkIssue._data.assignee?.id, name: sdkIssue._data.assignee?.name, displayName: sdkIssue._data.assignee?.displayName };
1727
+ }
1728
+
1729
+ if (sdkIssue.projectMilestone?.id) {
1730
+ projectMilestone = sdkIssue.projectMilestone;
1731
+ } else if (sdkIssue._data?.projectMilestone) {
1732
+ projectMilestone = { id: sdkIssue._data.projectMilestone?.id, name: sdkIssue._data.projectMilestone?.name };
1733
+ }
1734
+
1735
+ // Only trigger lazy loads if we don't have data from cache
1736
+ const needsLazyLoad = !state && !team && !project && !assignee && !projectMilestone;
1737
+
1738
+ if (needsLazyLoad) {
1739
+ // Use Promise.all with small timeout to avoid blocking
1740
+ const [resolvedState, resolvedTeam, resolvedProject, resolvedAssignee, resolvedMilestone] = await Promise.all([
1741
+ safeResolveRelation(sdkIssue, 'state'),
1742
+ safeResolveRelation(sdkIssue, 'team'),
1743
+ safeResolveRelation(sdkIssue, 'project'),
1744
+ safeResolveRelation(sdkIssue, 'assignee'),
1745
+ safeResolveRelation(sdkIssue, 'projectMilestone'),
1746
+ ]);
1747
+
1748
+ state = state || resolvedState;
1749
+ team = team || resolvedTeam;
1750
+ project = project || resolvedProject;
1751
+ assignee = assignee || resolvedAssignee;
1752
+ projectMilestone = projectMilestone || resolvedMilestone;
1753
+ }
127
1754
 
128
1755
  return {
129
- data: {
130
- issues: {
131
- nodes: result.nodes || [],
132
- pageInfo: result.pageInfo || { hasNextPage: false },
133
- },
134
- },
135
- headers: new Headers(),
1756
+ id: sdkIssue.id,
1757
+ identifier: sdkIssue.identifier,
1758
+ title: sdkIssue.title,
1759
+ description: sdkIssue.description,
1760
+ url: sdkIssue.url,
1761
+ branchName: sdkIssue.branchName,
1762
+ priority: sdkIssue.priority,
1763
+ state: state ? { id: state.id, name: state.name, type: state.type } : null,
1764
+ team: team ? { id: team.id, key: team.key, name: team.name } : null,
1765
+ project: project ? { id: project.id, name: project.name } : null,
1766
+ projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
1767
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
136
1768
  };
137
1769
  }
138
1770
 
139
1771
  /**
140
- * Transform raw GraphQL issue data to plain object format
141
- * Used by optimized queries to avoid SDK lazy loading
1772
+ * Resolve state ID from state input (ID, name, or type)
142
1773
  */
143
- function transformRawIssue(rawIssue) {
144
- if (!rawIssue) return null;
1774
+ function resolveStateIdFromInput(states, stateInput) {
1775
+ if (!stateInput) return null;
1776
+ const target = String(stateInput).trim();
1777
+ if (!target) return null;
145
1778
 
146
- return {
147
- id: rawIssue.id,
148
- identifier: rawIssue.identifier,
149
- title: rawIssue.title,
150
- description: rawIssue.description,
151
- url: rawIssue.url,
152
- branchName: rawIssue.branchName,
153
- priority: rawIssue.priority,
154
- state: rawIssue.state ? { id: rawIssue.state.id, name: rawIssue.state.name, type: rawIssue.state.type } : null,
155
- team: rawIssue.team ? { id: rawIssue.team.id, key: rawIssue.team.key, name: rawIssue.team.name } : null,
156
- project: rawIssue.project ? { id: rawIssue.project.id, name: rawIssue.project.name } : null,
157
- projectMilestone: rawIssue.projectMilestone ? { id: rawIssue.projectMilestone.id, name: rawIssue.projectMilestone.name } : null,
158
- assignee: rawIssue.assignee ? { id: rawIssue.assignee.id, name: rawIssue.assignee.name, displayName: rawIssue.assignee.displayName } : null,
159
- };
1779
+ const byId = states.find((s) => s.id === target);
1780
+ if (byId) return byId.id;
1781
+
1782
+ const lower = target.toLowerCase();
1783
+ const byName = states.find((s) => String(s.name || '').toLowerCase() === lower);
1784
+ if (byName) return byName.id;
1785
+
1786
+ const byType = states.find((s) => String(s.type || '').toLowerCase() === lower);
1787
+ if (byType) return byType.id;
1788
+
1789
+ throw new Error(`State not found in team workflow: ${target}`);
160
1790
  }
161
1791
 
162
- // ===== RATE LIMIT TRACKING =====
1792
+ function resolveProjectMilestoneIdFromInput(milestones, milestoneInput) {
1793
+ const target = String(milestoneInput || '').trim();
1794
+ if (!target) return null;
1795
+
1796
+ const byId = milestones.find((m) => m.id === target);
1797
+ if (byId) return byId.id;
1798
+
1799
+ const lower = target.toLowerCase();
1800
+ const byName = milestones.find((m) => String(m.name || '').toLowerCase() === lower);
1801
+ if (byName) return byName.id;
1802
+
1803
+ throw new Error(`Milestone not found in project: ${target}`);
1804
+ }
163
1805
 
164
1806
  /**
165
- * Track rate limit status from API responses
166
- * Linear API returns headers: X-RateLimit-Requests-Remaining, X-RateLimit-Requests-Reset
1807
+ * Resolve a milestone reference (name or ID) to a milestone object with id and name.
1808
+ * Requires project context to search for milestones by name.
1809
+ * @param {LinearClient} client - Linear SDK client
1810
+ * @param {string} milestoneRef - Milestone name or ID
1811
+ * @param {string} projectId - Project ID to search within
1812
+ * @returns {Promise<{id: string, name: string}>}
167
1813
  */
168
- const rateLimitState = {
169
- remaining: null,
170
- resetAt: null,
171
- lastWarnAt: 0,
172
- };
1814
+ export async function resolveMilestoneRef(client, milestoneRef, projectId) {
1815
+ const ref = String(milestoneRef || '').trim();
1816
+ if (!ref) {
1817
+ throw new Error('Missing milestone reference');
1818
+ }
1819
+
1820
+ // If it's already a Linear ID (UUID format with 16+ hex chars), try to fetch it directly
1821
+ if (/^[0-9a-fA-F-]{16,}$/.test(ref)) {
1822
+ try {
1823
+ const milestone = await client.projectMilestone(ref);
1824
+ if (milestone) {
1825
+ return { id: milestone.id, name: milestone.name };
1826
+ }
1827
+ } catch {
1828
+ // fall through to name lookup
1829
+ }
1830
+ throw new Error(`Milestone not found: ${ref}`);
1831
+ }
1832
+
1833
+ // Search by name in the project's milestones
1834
+ const milestones = await fetchProjectMilestones(client, projectId);
1835
+
1836
+ // Try exact name match first
1837
+ const exactMatch = milestones.find((m) => m.name === ref);
1838
+ if (exactMatch) {
1839
+ return { id: exactMatch.id, name: exactMatch.name };
1840
+ }
1841
+
1842
+ // Try case-insensitive match
1843
+ const lowerRef = ref.toLowerCase();
1844
+ const caseInsensitiveMatch = milestones.find((m) => m.name?.toLowerCase() === lowerRef);
1845
+ if (caseInsensitiveMatch) {
1846
+ return { id: caseInsensitiveMatch.id, name: caseInsensitiveMatch.name };
1847
+ }
1848
+
1849
+ throw new Error(`Milestone not found: ${ref}. Available milestones: ${milestones.map((m) => m.name).join(', ')}`);
1850
+ }
1851
+
1852
+ function normalizeIssueRefList(value) {
1853
+ if (value === undefined || value === null) return [];
1854
+ if (Array.isArray(value)) {
1855
+ return value.map((v) => String(v || '').trim()).filter(Boolean);
1856
+ }
1857
+ return String(value)
1858
+ .split(',')
1859
+ .map((v) => v.trim())
1860
+ .filter(Boolean);
1861
+ }
1862
+
1863
+ // ===== QUERY FUNCTIONS =====
173
1864
 
174
1865
  /**
175
- * Update rate limit state from response headers (internal)
176
- * @param {Response} response - Fetch response object
1866
+ * Fetch the current authenticated viewer
1867
+ * @param {LinearClient} client - Linear SDK client
1868
+ * @returns {Promise<{id: string, name: string}>}
177
1869
  */
178
- function updateRateLimitState(response) {
179
- if (!response) return;
1870
+ export async function fetchViewer(client) {
1871
+ return withLinearErrorHandling(async () => {
1872
+ const cacheKey = getClientCacheKey(client);
1873
+ const cached = getCache(viewerCache, cacheKey);
1874
+ if (cached) return cached;
180
1875
 
181
- const headers = response.headers;
182
- if (headers) {
183
- const remaining = headers.get('X-RateLimit-Requests-Remaining');
184
- const resetAt = headers.get('X-RateLimit-Requests-Reset');
1876
+ const viewer = await client.viewer;
1877
+ const result = {
1878
+ id: viewer.id,
1879
+ name: viewer.name,
1880
+ displayName: viewer.displayName,
1881
+ };
185
1882
 
186
- if (remaining !== null) {
187
- rateLimitState.remaining = parseInt(remaining, 10);
188
- }
189
- if (resetAt !== null) {
190
- rateLimitState.resetAt = parseInt(resetAt, 10);
191
- }
192
- }
1883
+ setCache(viewerCache, cacheKey, result, CACHE_TTL_MS.viewer);
1884
+ return result;
1885
+ }, 'fetchViewer');
193
1886
  }
194
1887
 
1888
+ export const getViewer = fetchViewer;
1889
+
195
1890
  /**
196
- * Get current rate limit status
197
- * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null, usagePercent: number|null, shouldWarn: boolean}}
1891
+ * Fetch issues in specific states, optionally filtered by assignee
1892
+ * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
1893
+ * @param {LinearClient} client - Linear SDK client
1894
+ * @param {string|null} assigneeId - Assignee ID to filter by (null = all assignees)
1895
+ * @param {Array<string>} openStates - List of state names to include
1896
+ * @param {number} limit - Maximum number of issues to fetch
1897
+ * @returns {Promise<{issues: Array, truncated: boolean}>}
198
1898
  */
199
- export function getRateLimitStatus() {
200
- const result = {
201
- remaining: rateLimitState.remaining,
202
- resetAt: rateLimitState.resetAt,
203
- resetTime: null,
204
- usagePercent: null,
205
- shouldWarn: false,
206
- };
1899
+ export async function fetchIssues(client, assigneeId, openStates, limit) {
1900
+ return withLinearErrorHandling(async () => {
1901
+ const filter = {
1902
+ state: { name: { in: openStates } },
1903
+ };
207
1904
 
208
- if (rateLimitState.resetAt) {
209
- result.resetTime = new Date(rateLimitState.resetAt).toLocaleTimeString();
210
- const remaining = rateLimitState.remaining;
211
- if (remaining !== null && remaining <= 1000) {
212
- result.usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
213
- result.shouldWarn = remaining <= 500;
1905
+ if (assigneeId) {
1906
+ filter.assignee = { id: { eq: assigneeId } };
214
1907
  }
215
- }
216
1908
 
217
- return result;
218
- }
1909
+ // Use optimized rawRequest to fetch issues with ALL relations in ONE request
1910
+ const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
1911
+ first: limit,
1912
+ filter,
1913
+ });
219
1914
 
220
- /**
221
- * Check and warn about low rate limit
222
- */
223
- function checkRateLimitWarning() {
224
- const now = Date.now();
225
- // Only warn once per 30 seconds to avoid spam
226
- if (now - rateLimitState.lastWarnAt < 30000) return;
1915
+ const nodes = data?.issues?.nodes || [];
1916
+ const pageInfo = data?.issues?.pageInfo;
1917
+ const hasNextPage = pageInfo?.hasNextPage ?? false;
227
1918
 
228
- const status = getRateLimitStatus();
229
- if (status.shouldWarn && status.remaining !== null) {
230
- rateLimitState.lastWarnAt = now;
231
- warn(`Linear API rate limit running low: ${status.remaining} requests remaining (~${status.usagePercent}% used). Resets at ${status.resetTime}`, {
232
- remaining: status.remaining,
233
- resetAt: status.resetTime,
234
- usagePercent: status.usagePercent,
1919
+ // Transform raw GraphQL response directly - no lazy loading needed
1920
+ const issues = nodes.map(transformRawIssue);
1921
+
1922
+ debug('Fetched issues (optimized)', {
1923
+ issueCount: issues.length,
235
1924
  });
236
- }
237
- }
238
1925
 
239
- // ===== ERROR HANDLING =====
1926
+ const truncated = hasNextPage || nodes.length >= limit;
1927
+ if (truncated) {
1928
+ warn('Issues query may be truncated', {
1929
+ limit,
1930
+ returned: nodes.length,
1931
+ hasNextPage,
1932
+ });
1933
+ }
240
1934
 
241
- /**
242
- * Check if an error is a Linear SDK error type
243
- * @param {Error} error - The error to check
244
- * @returns {boolean}
245
- */
246
- function isLinearError(error) {
247
- return error?.constructor?.name?.includes('LinearError') ||
248
- error?.name?.includes('LinearError') ||
249
- error?.type?.startsWith?.('Ratelimited') ||
250
- error?.type?.startsWith?.('Forbidden') ||
251
- error?.type?.startsWith?.('Authentication') ||
252
- error?.type === 'invalid_request' ||
253
- error?.type === 'NetworkError' ||
254
- error?.type === 'InternalError';
1935
+ return {
1936
+ issues,
1937
+ truncated,
1938
+ };
1939
+ }, 'fetchIssues');
255
1940
  }
256
1941
 
257
1942
  /**
258
- * Format a Linear API error into a user-friendly message
259
- * @param {Error} error - The original error
260
- * @returns {Error|unknown} Formatted error with user-friendly message, or original if unhandled
1943
+ * Fetch issues by project and optional state filter
1944
+ * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
1945
+ * instead of N+1 requests (1 for list + 5 per issue for lazy-loaded relations)
1946
+ * @param {LinearClient} client - Linear SDK client
1947
+ * @param {string} projectId - Project ID to filter by
1948
+ * @param {Array<string>|null} states - List of state names to include (null = all states)
1949
+ * @param {Object} options
1950
+ * @param {string|null} options.assigneeId - Assignee ID to filter by (null = all assignees)
1951
+ * @param {string|null} options.teamId - Team ID to filter by (null = all teams)
1952
+ * @param {number} options.limit - Maximum number of issues to fetch
1953
+ * @returns {Promise<{issues: Array, truncated: boolean}>}
261
1954
  */
262
- function formatLinearError(error) {
263
- const message = String(error?.message || error || 'Unknown error');
264
- const errorType = error?.type || 'Unknown';
1955
+ export async function fetchIssuesByProject(client, projectId, states, options = {}) {
1956
+ return withLinearErrorHandling(async () => {
1957
+ const { assigneeId = null, teamId = null, limit = 20 } = options;
265
1958
 
266
- // Rate limit: provide reset time and reduce-frequency hint
267
- if (errorType === 'Ratelimited' || message.toLowerCase().includes('rate limit')) {
268
- const resetAt = error?.requestsResetAt
269
- ? new Date(error.requestsResetAt).toLocaleTimeString()
270
- : '1 hour';
1959
+ const filter = {
1960
+ project: { id: { eq: projectId } },
1961
+ };
271
1962
 
272
- return new Error(
273
- `Linear API rate limit exceeded. Please wait before making more requests.\n` +
274
- `Rate limit resets at: ${resetAt}\n` +
275
- `Hint: Reduce request frequency or wait before retrying.`
276
- );
277
- }
1963
+ if (states && states.length > 0) {
1964
+ filter.state = { name: { in: states } };
1965
+ }
278
1966
 
279
- // Auth/permission failures: prompt to check credentials
280
- if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
281
- message.toLowerCase().includes('forbidden') || message.toLowerCase().includes('unauthorized')) {
282
- return new Error(
283
- `${message}\nHint: Check your Linear API key or OAuth token permissions.`
284
- );
285
- }
1967
+ if (assigneeId) {
1968
+ filter.assignee = { id: { eq: assigneeId } };
1969
+ }
286
1970
 
287
- // Network errors
288
- if (errorType === 'NetworkError' || message.toLowerCase().includes('network')) {
289
- return new Error(
290
- `Network error while communicating with Linear API.\nHint: Check your internet connection and try again.`
291
- );
292
- }
1971
+ if (teamId) {
1972
+ filter.team = { id: { eq: teamId } };
1973
+ }
293
1974
 
294
- // Internal server errors
295
- if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
296
- return new Error(
297
- `Linear API server error (${error?.status || 'unknown'}).\nHint: Linear may be experiencing issues. Try again later.`
298
- );
299
- }
1975
+ // Use optimized rawRequest to fetch issues with ALL relations in ONE request
1976
+ // This eliminates the N+1 problem where each issue triggered 5 additional API calls
1977
+ const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
1978
+ first: limit,
1979
+ filter,
1980
+ });
300
1981
 
301
- // Generic Linear API error
302
- if (isLinearError(error)) {
303
- return new Error(
304
- `Linear API error: ${message}`
305
- );
306
- }
1982
+ const nodes = data?.issues?.nodes || [];
1983
+ const pageInfo = data?.issues?.pageInfo;
1984
+ const hasNextPage = pageInfo?.hasNextPage ?? false;
307
1985
 
308
- // Unknown error - wrap if not already an Error
309
- return error instanceof Error ? error : new Error(String(error));
310
- }
1986
+ // Transform raw GraphQL response directly - no lazy loading needed
1987
+ const issues = nodes.map(transformRawIssue);
311
1988
 
312
- /**
313
- * Wrap an async function with Linear-specific error handling
314
- * @param {Function} fn - Async function to wrap
315
- * @param {string} operation - Description of the operation for error messages
316
- * @returns {Promise<*>} Result of the wrapped function
317
- */
318
- async function withLinearErrorHandling(fn, operation = 'Linear API operation') {
319
- try {
320
- return await fn();
321
- } catch (error) {
322
- if (isLinearError(error)) {
323
- const formatted = formatLinearError(error);
324
- debug(`${operation} failed with Linear error`, {
325
- originalError: error?.message,
326
- errorType: error?.type,
327
- formattedMessage: formatted.message,
1989
+ debug('Fetched issues by project (optimized)', {
1990
+ projectId,
1991
+ stateCount: states?.length ?? 0,
1992
+ issueCount: issues.length,
1993
+ truncated: hasNextPage,
1994
+ });
1995
+
1996
+ const truncated = hasNextPage || nodes.length >= limit;
1997
+ if (truncated) {
1998
+ warn('Issues query may be truncated', {
1999
+ limit,
2000
+ returned: nodes.length,
2001
+ hasNextPage,
328
2002
  });
329
- throw formatted;
330
2003
  }
331
- throw error;
332
- }
2004
+
2005
+ return {
2006
+ issues,
2007
+ truncated,
2008
+ };
2009
+ }, 'fetchIssuesByProject');
333
2010
  }
334
2011
 
335
2012
  /**
336
- * Wrap a handler function with comprehensive error handling for Linear API errors.
337
- * This provides user-friendly error messages for rate limits, auth issues, etc.
338
- * Use this in the execute() functions of tool handlers.
339
- *
340
- * @param {Function} fn - Async handler function to wrap
341
- * @param {string} operation - Description of the operation for error messages
342
- * @returns {Promise<*>} Result of the wrapped function
343
- * @example
344
- * ```js
345
- * export async function executeIssueList(client, params) {
346
- * return withHandlerErrorHandling(async () => {
347
- * // ... implementation
348
- * }, 'executeIssueList');
349
- * }
350
- * ```
2013
+ * Fetch all accessible projects from Linear API
2014
+ * @param {LinearClient} client - Linear SDK client
2015
+ * @param {{ includeArchived?: boolean }} options - Fetch options
2016
+ * @returns {Promise<Array<{id: string, name: string}>>}
351
2017
  */
352
- export async function withHandlerErrorHandling(fn, operation = 'Handler') {
2018
+ export async function fetchProjects(client, options = {}) {
353
2019
  return withLinearErrorHandling(async () => {
354
- try {
355
- return await fn();
356
- } catch (error) {
357
- // Log additional context for unexpected errors
358
- debug(`${operation} failed unexpectedly`, {
359
- error: error?.message,
360
- stack: error?.stack,
2020
+ const { includeArchived = false, forceGraphql = false } = options;
2021
+ const cacheKey = getClientCacheKey(client);
2022
+ const scopedCacheKey = `${cacheKey}::projects::${includeArchived ? 'all' : 'active'}::${forceGraphql ? 'graphql' : 'sdk'}`;
2023
+ const cached = getCache(projectsCache, scopedCacheKey);
2024
+ if (cached) return cached;
2025
+
2026
+ let nodes = [];
2027
+ if (includeArchived || forceGraphql) {
2028
+ const data = await executeGraphQL(client, PROJECTS_LOOKUP_QUERY, {
2029
+ includeArchived,
361
2030
  });
362
- throw error;
2031
+ nodes = data?.projects?.nodes ?? [];
2032
+ } else {
2033
+ const result = await client.projects();
2034
+ nodes = result.nodes ?? [];
363
2035
  }
364
- }, operation);
365
- }
366
2036
 
367
- // ===== HELPERS =====
2037
+ debug('Fetched Linear projects', {
2038
+ projectCount: nodes.length,
2039
+ includeArchived,
2040
+ projects: nodes.map((p) => ({ id: p.id, name: p.name })),
2041
+ });
368
2042
 
369
- /**
370
- * Check if a value looks like a Linear UUID
371
- */
372
- function isLinearId(value) {
373
- return typeof value === 'string' && /^[0-9a-fA-F-]{16,}$/.test(value);
2043
+ const projects = nodes.map((p) => ({
2044
+ id: p.id,
2045
+ name: p.name,
2046
+ slugId: p.slugId ?? null,
2047
+ archivedAt: p.archivedAt ?? null,
2048
+ }));
2049
+ setCache(projectsCache, scopedCacheKey, projects, CACHE_TTL_MS.projects);
2050
+ return projects;
2051
+ }, 'fetchProjects');
374
2052
  }
375
2053
 
376
- /**
377
- * Normalize issue lookup input
378
- */
379
- function normalizeIssueLookupInput(issue) {
380
- const value = String(issue || '').trim();
381
- if (!value) throw new Error('Missing required issue identifier');
382
- return value;
2054
+ export async function fetchProjectDetails(client, projectRef, options = {}) {
2055
+ return withLinearErrorHandling(async () => {
2056
+ const { milestoneLimit = 10 } = options;
2057
+ const ref = String(projectRef || '').trim();
2058
+ const projectId = isLinearId(ref)
2059
+ ? ref
2060
+ : (await resolveProjectRef(client, ref)).id;
2061
+ const data = await executeGraphQL(client, PROJECT_DETAILS_QUERY, {
2062
+ id: projectId,
2063
+ milestoneLimit,
2064
+ });
2065
+
2066
+ if (!data?.project) {
2067
+ throw new Error(`Project not found: ${projectRef}`);
2068
+ }
2069
+
2070
+ return transformProject(data.project);
2071
+ }, 'fetchProjectDetails');
383
2072
  }
384
2073
 
385
2074
  /**
386
- * Safely resolve a lazy-loaded relation without triggering unnecessary API calls
387
- * Only fetches if the relation is already cached or if we have minimal data
2075
+ * Fetch available workspaces (organization context) from Linear API
2076
+ * @param {LinearClient} client - Linear SDK client
2077
+ * @returns {Promise<Array<{id: string, name: string}>>}
388
2078
  */
389
- async function safeResolveRelation(sdkIssue, relationKey) {
390
- try {
391
- const relation = sdkIssue[relationKey];
392
- if (!relation) return null;
2079
+ export async function fetchWorkspaces(client) {
2080
+ return withLinearErrorHandling(async () => {
2081
+ const viewer = await client.viewer;
2082
+ const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
393
2083
 
394
- // If it's a function (lazy loader), check if we can call it safely
395
- if (typeof relation === 'function') {
396
- const result = await relation().catch(() => null);
397
- return result || null;
2084
+ if (!organization) {
2085
+ debug('No organization available from viewer context');
2086
+ return [];
398
2087
  }
399
2088
 
400
- // If it's already resolved, return it
401
- return relation;
402
- } catch {
403
- return null;
404
- }
2089
+ const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
2090
+
2091
+ debug('Fetched Linear workspace from viewer organization', {
2092
+ workspace,
2093
+ });
2094
+
2095
+ return [workspace];
2096
+ }, 'fetchWorkspaces');
405
2097
  }
406
2098
 
407
2099
  /**
408
- * Transform SDK issue object to plain object for consumers
409
- * Optimized to minimize API calls by avoiding unnecessary lazy loads
2100
+ * Fetch all accessible teams from Linear API
2101
+ * @param {LinearClient} client - Linear SDK client
2102
+ * @returns {Promise<Array<{id: string, key: string, name: string}>>}
410
2103
  */
411
- async function transformIssue(sdkIssue) {
412
- if (!sdkIssue) return null;
2104
+ export async function fetchTeams(client) {
2105
+ return withLinearErrorHandling(async () => {
2106
+ const cacheKey = getClientCacheKey(client);
2107
+ const cached = getCache(teamsCache, cacheKey);
2108
+ if (cached) return cached;
413
2109
 
414
- // If the SDK issue has _data, relations might already be available
415
- // Check if we can extract data without making extra calls
416
- const hasRelations = sdkIssue.state?.id || sdkIssue.assignee?.id || sdkIssue.project?.id;
2110
+ const result = await client.teams();
2111
+ const nodes = result.nodes ?? [];
417
2112
 
418
- // Only resolve relations if not already available
419
- let state = null;
420
- let team = null;
421
- let project = null;
422
- let assignee = null;
423
- let projectMilestone = null;
2113
+ debug('Fetched Linear teams', {
2114
+ teamCount: nodes.length,
2115
+ teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
2116
+ });
424
2117
 
425
- if (sdkIssue.state?.id) {
426
- state = sdkIssue.state;
427
- } else if (sdkIssue._data?.state) {
428
- state = { id: sdkIssue._data.state?.id, name: sdkIssue._data.state?.name, type: sdkIssue._data.state?.type };
429
- }
2118
+ const teams = nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
2119
+ setCache(teamsCache, cacheKey, teams, CACHE_TTL_MS.teams);
2120
+ return teams;
2121
+ }, 'fetchTeams');
2122
+ }
430
2123
 
431
- if (sdkIssue.team?.id) {
432
- team = sdkIssue.team;
433
- } else if (sdkIssue._data?.team) {
434
- team = { id: sdkIssue._data.team?.id, key: sdkIssue._data.team?.key, name: sdkIssue._data.team?.name };
2124
+ /**
2125
+ * Resolve a team reference (key, name, or ID) to a team object
2126
+ * @param {LinearClient} client - Linear SDK client
2127
+ * @param {string} teamRef - Team key, name, or ID
2128
+ * @returns {Promise<{id: string, key: string, name: string}>}
2129
+ */
2130
+ export async function resolveTeamRef(client, teamRef) {
2131
+ const ref = String(teamRef || '').trim();
2132
+ if (!ref) {
2133
+ throw new Error('Missing team reference');
435
2134
  }
436
2135
 
437
- if (sdkIssue.project?.id) {
438
- project = sdkIssue.project;
439
- } else if (sdkIssue._data?.project) {
440
- project = { id: sdkIssue._data.project?.id, name: sdkIssue._data.project?.name };
441
- }
2136
+ // If it looks like a Linear ID (UUID), try a minimal GraphQL lookup first.
2137
+ if (isLinearId(ref)) {
2138
+ try {
2139
+ const direct = await fetchTeamMinimal(client, ref);
2140
+ if (direct) {
2141
+ return direct;
2142
+ }
2143
+ } catch {
2144
+ // fall back to cached/full-team lookup below
2145
+ }
442
2146
 
443
- if (sdkIssue.assignee?.id) {
444
- assignee = sdkIssue.assignee;
445
- } else if (sdkIssue._data?.assignee) {
446
- assignee = { id: sdkIssue._data.assignee?.id, name: sdkIssue._data.assignee?.name, displayName: sdkIssue._data.assignee?.displayName };
2147
+ const teams = await fetchTeams(client);
2148
+ const byId = teams.find((t) => t.id === ref);
2149
+ if (byId) {
2150
+ return byId;
2151
+ }
2152
+ throw new Error(`Team not found with ID: ${ref}`);
447
2153
  }
448
2154
 
449
- if (sdkIssue.projectMilestone?.id) {
450
- projectMilestone = sdkIssue.projectMilestone;
451
- } else if (sdkIssue._data?.projectMilestone) {
452
- projectMilestone = { id: sdkIssue._data.projectMilestone?.id, name: sdkIssue._data.projectMilestone?.name };
453
- }
2155
+ const teams = await fetchTeams(client);
454
2156
 
455
- // Only trigger lazy loads if we don't have data from cache
456
- const needsLazyLoad = !state && !team && !project && !assignee && !projectMilestone;
2157
+ // Try exact key match (e.g., "ENG")
2158
+ const byKey = teams.find((t) => t.key === ref);
2159
+ if (byKey) {
2160
+ return byKey;
2161
+ }
457
2162
 
458
- if (needsLazyLoad) {
459
- // Use Promise.all with small timeout to avoid blocking
460
- const [resolvedState, resolvedTeam, resolvedProject, resolvedAssignee, resolvedMilestone] = await Promise.all([
461
- safeResolveRelation(sdkIssue, 'state'),
462
- safeResolveRelation(sdkIssue, 'team'),
463
- safeResolveRelation(sdkIssue, 'project'),
464
- safeResolveRelation(sdkIssue, 'assignee'),
465
- safeResolveRelation(sdkIssue, 'projectMilestone'),
466
- ]);
2163
+ // Try exact name match
2164
+ const exactName = teams.find((t) => t.name === ref);
2165
+ if (exactName) {
2166
+ return exactName;
2167
+ }
467
2168
 
468
- state = state || resolvedState;
469
- team = team || resolvedTeam;
470
- project = project || resolvedProject;
471
- assignee = assignee || resolvedAssignee;
472
- projectMilestone = projectMilestone || resolvedMilestone;
2169
+ // Try case-insensitive key or name match
2170
+ const lowerRef = ref.toLowerCase();
2171
+ const insensitiveMatch = teams.find(
2172
+ (t) => t.key?.toLowerCase() === lowerRef || t.name?.toLowerCase() === lowerRef
2173
+ );
2174
+ if (insensitiveMatch) {
2175
+ return insensitiveMatch;
473
2176
  }
474
2177
 
475
- return {
476
- id: sdkIssue.id,
477
- identifier: sdkIssue.identifier,
478
- title: sdkIssue.title,
479
- description: sdkIssue.description,
480
- url: sdkIssue.url,
481
- branchName: sdkIssue.branchName,
482
- priority: sdkIssue.priority,
483
- state: state ? { id: state.id, name: state.name, type: state.type } : null,
484
- team: team ? { id: team.id, key: team.key, name: team.name } : null,
485
- project: project ? { id: project.id, name: project.name } : null,
486
- projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
487
- assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
488
- };
2178
+ throw new Error(`Team not found: ${ref}. Available teams: ${teams.map((t) => `${t.key} (${t.name})`).join(', ')}`);
489
2179
  }
490
2180
 
491
2181
  /**
492
- * Resolve state ID from state input (ID, name, or type)
2182
+ * Resolve an issue by ID or identifier
2183
+ * @param {LinearClient} client - Linear SDK client
2184
+ * @param {string} issueRef - Issue identifier (ABC-123) or Linear issue ID
2185
+ * @returns {Promise<Object>} Resolved issue object
493
2186
  */
494
- function resolveStateIdFromInput(states, stateInput) {
495
- if (!stateInput) return null;
496
- const target = String(stateInput).trim();
497
- if (!target) return null;
2187
+ export async function resolveIssue(client, issueRef) {
2188
+ return withLinearErrorHandling(async () => {
2189
+ const lookup = normalizeIssueLookupInput(issueRef);
498
2190
 
499
- const byId = states.find((s) => s.id === target);
500
- if (byId) return byId.id;
2191
+ try {
2192
+ const issue = await fetchIssueMinimal(client, lookup);
2193
+ if (issue) {
2194
+ return issue;
2195
+ }
2196
+ } catch {
2197
+ // Fall through to not-found error below
2198
+ }
501
2199
 
502
- const lower = target.toLowerCase();
503
- const byName = states.find((s) => String(s.name || '').toLowerCase() === lower);
504
- if (byName) return byName.id;
2200
+ throw new Error(`Issue not found: ${lookup}`);
2201
+ }, 'resolveIssue');
2202
+ }
505
2203
 
506
- const byType = states.find((s) => String(s.type || '').toLowerCase() === lower);
507
- if (byType) return byType.id;
2204
+ /**
2205
+ * Get workflow states for a team
2206
+ * @param {LinearClient} client - Linear SDK client
2207
+ * @param {string} teamRef - Team ID or key
2208
+ * @returns {Promise<Array<{id: string, name: string, type: string}>>}
2209
+ */
2210
+ export async function getTeamWorkflowStates(client, teamRef) {
2211
+ return withLinearErrorHandling(async () => {
2212
+ const cacheKey = `${getClientCacheKey(client)}::${teamRef}`;
2213
+ const cached = getCache(teamStatesCache, cacheKey);
2214
+ if (cached) return cached;
508
2215
 
509
- throw new Error(`State not found in team workflow: ${target}`);
2216
+ const mapped = await fetchTeamStatesByQuery(client, teamRef);
2217
+ if (!mapped) {
2218
+ throw new Error(`Team not found: ${teamRef}`);
2219
+ }
2220
+
2221
+ setCache(teamStatesCache, cacheKey, mapped, CACHE_TTL_MS.teamStates);
2222
+ return mapped;
2223
+ }, 'getTeamWorkflowStates');
510
2224
  }
511
2225
 
512
- function resolveProjectMilestoneIdFromInput(milestones, milestoneInput) {
513
- const target = String(milestoneInput || '').trim();
514
- if (!target) return null;
2226
+ /**
2227
+ * Resolve a project reference (name or ID) to a project object
2228
+ * @param {LinearClient} client - Linear SDK client
2229
+ * @param {string} projectRef - Project name or ID
2230
+ * @param {{ includeArchived?: boolean }} options - Lookup options
2231
+ * @returns {Promise<{id: string, name: string}>}
2232
+ */
2233
+ export async function resolveProjectRef(client, projectRef, options = {}) {
2234
+ const ref = String(projectRef || '').trim();
2235
+ const { includeArchived = false } = options;
2236
+ if (!ref) {
2237
+ throw new Error('Missing project reference');
2238
+ }
515
2239
 
516
- const byId = milestones.find((m) => m.id === target);
517
- if (byId) return byId.id;
2240
+ // If it looks like a Linear ID (UUID), try a minimal GraphQL lookup first.
2241
+ if (isLinearId(ref)) {
2242
+ try {
2243
+ const direct = await fetchProjectMinimal(client, ref);
2244
+ if (direct) {
2245
+ return direct;
2246
+ }
2247
+ } catch {
2248
+ // fall back to cached/full-project lookup below
2249
+ }
518
2250
 
519
- const lower = target.toLowerCase();
520
- const byName = milestones.find((m) => String(m.name || '').toLowerCase() === lower);
521
- if (byName) return byName.id;
2251
+ const projectsById = await fetchProjects(client, { includeArchived });
2252
+ const byId = projectsById.find((p) => p.id === ref);
2253
+ if (byId) {
2254
+ return byId;
2255
+ }
2256
+ throw new Error(`Project not found with ID: ${ref}`);
2257
+ }
522
2258
 
523
- throw new Error(`Milestone not found in project: ${target}`);
524
- }
2259
+ const lookupCandidates = getProjectLookupCandidates(ref);
2260
+ const lookupValue = lookupCandidates[0] || ref;
2261
+ const shouldUseGraphqlLookup = lookupValue !== ref;
2262
+ const projects = await fetchProjects(client, {
2263
+ includeArchived,
2264
+ forceGraphql: shouldUseGraphqlLookup,
2265
+ });
525
2266
 
526
- function normalizeIssueRefList(value) {
527
- if (value === undefined || value === null) return [];
528
- if (Array.isArray(value)) {
529
- return value.map((v) => String(v || '').trim()).filter(Boolean);
2267
+ // Try exact name match
2268
+ const exactName = projects.find((p) => p.name === ref);
2269
+ if (exactName) {
2270
+ return exactName;
530
2271
  }
531
- return String(value)
532
- .split(',')
533
- .map((v) => v.trim())
534
- .filter(Boolean);
535
- }
536
2272
 
537
- // ===== QUERY FUNCTIONS =====
2273
+ // Try exact slug match
2274
+ const exactSlug = projects.find((p) => lookupCandidates.includes(p.slugId));
2275
+ if (exactSlug) {
2276
+ return exactSlug;
2277
+ }
538
2278
 
539
- /**
540
- * Fetch the current authenticated viewer
541
- * @param {LinearClient} client - Linear SDK client
542
- * @returns {Promise<{id: string, name: string}>}
543
- */
544
- export async function fetchViewer(client) {
545
- return withLinearErrorHandling(async () => {
546
- const cacheKey = getClientCacheKey(client);
547
- const cached = getCache(viewerCache, cacheKey);
548
- if (cached) return cached;
2279
+ // Try case-insensitive name match
2280
+ const lowerRef = ref.toLowerCase();
2281
+ const insensitiveName = projects.find((p) => p.name?.toLowerCase() === lowerRef);
2282
+ if (insensitiveName) {
2283
+ return insensitiveName;
2284
+ }
549
2285
 
550
- const viewer = await client.viewer;
551
- const result = {
552
- id: viewer.id,
553
- name: viewer.name,
554
- displayName: viewer.displayName,
555
- };
2286
+ // Try case-insensitive slug match
2287
+ const lowerLookupValues = lookupCandidates.map((candidate) => candidate.toLowerCase());
2288
+ const insensitiveSlug = projects.find((p) => p.slugId && lowerLookupValues.includes(p.slugId.toLowerCase()));
2289
+ if (insensitiveSlug) {
2290
+ return insensitiveSlug;
2291
+ }
556
2292
 
557
- setCache(viewerCache, cacheKey, result, CACHE_TTL_MS.viewer);
558
- return result;
559
- }, 'fetchViewer');
2293
+ throw new Error(`Project not found: ${ref}. Available projects: ${projects.map((p) => p.name).join(', ')}`);
560
2294
  }
561
2295
 
562
- export const getViewer = fetchViewer;
563
-
564
- /**
565
- * Fetch issues in specific states, optionally filtered by assignee
566
- * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
567
- * @param {LinearClient} client - Linear SDK client
568
- * @param {string|null} assigneeId - Assignee ID to filter by (null = all assignees)
569
- * @param {Array<string>} openStates - List of state names to include
570
- * @param {number} limit - Maximum number of issues to fetch
571
- * @returns {Promise<{issues: Array, truncated: boolean}>}
572
- */
573
- export async function fetchIssues(client, assigneeId, openStates, limit) {
2296
+ export async function createProject(client, input) {
574
2297
  return withLinearErrorHandling(async () => {
575
- const filter = {
576
- state: { name: { in: openStates } },
577
- };
2298
+ const name = String(input.name || '').trim();
2299
+ if (!name) {
2300
+ throw new Error('Missing required field: name');
2301
+ }
578
2302
 
579
- if (assigneeId) {
580
- filter.assignee = { id: { eq: assigneeId } };
2303
+ const teamIds = Array.isArray(input.teamIds)
2304
+ ? input.teamIds.map((value) => String(value || '').trim()).filter(Boolean)
2305
+ : [];
2306
+
2307
+ if (teamIds.length === 0) {
2308
+ throw new Error('Missing required field: teamIds');
581
2309
  }
582
2310
 
583
- // Use optimized rawRequest to fetch issues with ALL relations in ONE request
584
- const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
585
- first: limit,
586
- filter,
587
- });
2311
+ const createInput = {
2312
+ name,
2313
+ teamIds,
2314
+ };
588
2315
 
589
- const nodes = data?.issues?.nodes || [];
590
- const pageInfo = data?.issues?.pageInfo;
591
- const hasNextPage = pageInfo?.hasNextPage ?? false;
2316
+ for (const field of ['description', 'color', 'icon', 'leadId', 'startDate', 'targetDate']) {
2317
+ if (input[field] !== undefined) {
2318
+ createInput[field] = input[field];
2319
+ }
2320
+ }
592
2321
 
593
- // Transform raw GraphQL response directly - no lazy loading needed
594
- const issues = nodes.map(transformRawIssue);
2322
+ if (input.priority !== undefined) {
2323
+ createInput.priority = input.priority;
2324
+ }
595
2325
 
596
- debug('Fetched issues (optimized)', {
597
- issueCount: issues.length,
2326
+ const payload = await executeGraphQL(client, PROJECT_CREATE_MUTATION, {
2327
+ input: createInput,
598
2328
  });
599
2329
 
600
- const truncated = hasNextPage || nodes.length >= limit;
601
- if (truncated) {
602
- warn('Issues query may be truncated', {
603
- limit,
604
- returned: nodes.length,
605
- hasNextPage,
606
- });
2330
+ if (!payload?.projectCreate?.success || !payload?.projectCreate?.project?.id) {
2331
+ throw new Error('Failed to create project');
607
2332
  }
608
2333
 
609
- return {
610
- issues,
611
- truncated,
612
- };
613
- }, 'fetchIssues');
2334
+ invalidateProjectsCache(client);
2335
+ return fetchProjectDetails(client, payload.projectCreate.project.id);
2336
+ }, 'createProject');
614
2337
  }
615
2338
 
616
- /**
617
- * Fetch issues by project and optional state filter
618
- * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
619
- * instead of N+1 requests (1 for list + 5 per issue for lazy-loaded relations)
620
- * @param {LinearClient} client - Linear SDK client
621
- * @param {string} projectId - Project ID to filter by
622
- * @param {Array<string>|null} states - List of state names to include (null = all states)
623
- * @param {Object} options
624
- * @param {string|null} options.assigneeId - Assignee ID to filter by (null = all assignees)
625
- * @param {string|null} options.teamId - Team ID to filter by (null = all teams)
626
- * @param {number} options.limit - Maximum number of issues to fetch
627
- * @returns {Promise<{issues: Array, truncated: boolean}>}
628
- */
629
- export async function fetchIssuesByProject(client, projectId, states, options = {}) {
2339
+ export async function updateProject(client, projectRef, patch = {}) {
630
2340
  return withLinearErrorHandling(async () => {
631
- const { assigneeId = null, teamId = null, limit = 20 } = options;
2341
+ const resolved = await resolveProjectRef(client, projectRef);
2342
+ const updateInput = {};
632
2343
 
633
- const filter = {
634
- project: { id: { eq: projectId } },
635
- };
2344
+ for (const field of ['name', 'description', 'content', 'color', 'icon', 'startDate', 'targetDate']) {
2345
+ if (patch[field] !== undefined) {
2346
+ updateInput[field] = patch[field];
2347
+ }
2348
+ }
636
2349
 
637
- if (states && states.length > 0) {
638
- filter.state = { name: { in: states } };
2350
+ if (patch.priority !== undefined) {
2351
+ updateInput.priority = patch.priority;
639
2352
  }
640
2353
 
641
- if (assigneeId) {
642
- filter.assignee = { id: { eq: assigneeId } };
2354
+ if (patch.leadId !== undefined) {
2355
+ updateInput.leadId = patch.leadId;
643
2356
  }
644
2357
 
645
- if (teamId) {
646
- filter.team = { id: { eq: teamId } };
2358
+ if (patch.teamIds !== undefined) {
2359
+ updateInput.teamIds = patch.teamIds;
647
2360
  }
648
2361
 
649
- // Use optimized rawRequest to fetch issues with ALL relations in ONE request
650
- // This eliminates the N+1 problem where each issue triggered 5 additional API calls
651
- const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
652
- first: limit,
653
- filter,
2362
+ if (Object.keys(updateInput).length === 0) {
2363
+ throw new Error('No update fields provided');
2364
+ }
2365
+
2366
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_MUTATION, {
2367
+ id: resolved.id,
2368
+ input: updateInput,
654
2369
  });
655
2370
 
656
- const nodes = data?.issues?.nodes || [];
657
- const pageInfo = data?.issues?.pageInfo;
658
- const hasNextPage = pageInfo?.hasNextPage ?? false;
2371
+ if (!payload?.projectUpdate?.success || !payload?.projectUpdate?.project?.id) {
2372
+ throw new Error('Failed to update project');
2373
+ }
659
2374
 
660
- // Transform raw GraphQL response directly - no lazy loading needed
661
- const issues = nodes.map(transformRawIssue);
2375
+ invalidateProjectsCache(client);
2376
+ const project = await fetchProjectDetails(client, payload.projectUpdate.project.id);
662
2377
 
663
- debug('Fetched issues by project (optimized)', {
664
- projectId,
665
- stateCount: states?.length ?? 0,
666
- issueCount: issues.length,
667
- truncated: hasNextPage,
2378
+ return {
2379
+ project,
2380
+ changed: Object.keys(updateInput),
2381
+ };
2382
+ }, 'updateProject');
2383
+ }
2384
+
2385
+ export async function deleteProject(client, projectRef) {
2386
+ return withLinearErrorHandling(async () => {
2387
+ const resolved = await resolveProjectRef(client, projectRef);
2388
+ const payload = await executeGraphQL(client, PROJECT_DELETE_MUTATION, {
2389
+ id: resolved.id,
668
2390
  });
669
2391
 
670
- const truncated = hasNextPage || nodes.length >= limit;
671
- if (truncated) {
672
- warn('Issues query may be truncated', {
673
- limit,
674
- returned: nodes.length,
675
- hasNextPage,
676
- });
2392
+ if (!payload?.projectDelete?.success) {
2393
+ throw new Error('Failed to delete project');
677
2394
  }
678
2395
 
2396
+ invalidateProjectsCache(client);
2397
+
679
2398
  return {
680
- issues,
681
- truncated,
2399
+ success: true,
2400
+ projectId: resolved.id,
2401
+ name: resolved.name,
2402
+ entity: transformProject(payload.projectDelete.entity),
682
2403
  };
683
- }, 'fetchIssuesByProject');
2404
+ }, 'deleteProject');
684
2405
  }
685
2406
 
686
- /**
687
- * Fetch all accessible projects from Linear API
688
- * @param {LinearClient} client - Linear SDK client
689
- * @returns {Promise<Array<{id: string, name: string}>>}
690
- */
691
- export async function fetchProjects(client) {
2407
+ export async function archiveProject(client, projectRef) {
692
2408
  return withLinearErrorHandling(async () => {
693
- const cacheKey = getClientCacheKey(client);
694
- const cached = getCache(projectsCache, cacheKey);
695
- if (cached) return cached;
2409
+ const resolved = await resolveProjectRef(client, projectRef);
2410
+ const payload = await executeGraphQL(client, PROJECT_ARCHIVE_MUTATION, {
2411
+ id: resolved.id,
2412
+ });
696
2413
 
697
- const result = await client.projects();
698
- const nodes = result.nodes ?? [];
2414
+ if (!payload?.projectArchiveResult?.success) {
2415
+ throw new Error('Failed to archive project');
2416
+ }
699
2417
 
700
- debug('Fetched Linear projects', {
701
- projectCount: nodes.length,
702
- projects: nodes.map((p) => ({ id: p.id, name: p.name })),
703
- });
2418
+ invalidateProjectsCache(client);
704
2419
 
705
- const projects = nodes.map(p => ({ id: p.id, name: p.name }));
706
- setCache(projectsCache, cacheKey, projects, CACHE_TTL_MS.projects);
707
- return projects;
708
- }, 'fetchProjects');
2420
+ return {
2421
+ success: true,
2422
+ projectId: resolved.id,
2423
+ name: resolved.name,
2424
+ entity: transformProject(payload.projectArchiveResult.entity),
2425
+ };
2426
+ }, 'archiveProject');
709
2427
  }
710
2428
 
711
- /**
712
- * Fetch available workspaces (organization context) from Linear API
713
- * @param {LinearClient} client - Linear SDK client
714
- * @returns {Promise<Array<{id: string, name: string}>>}
715
- */
716
- export async function fetchWorkspaces(client) {
2429
+ export async function unarchiveProject(client, projectRef) {
717
2430
  return withLinearErrorHandling(async () => {
718
- const viewer = await client.viewer;
719
- const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
2431
+ const ref = String(projectRef || '').trim();
2432
+ const resolved = isLinearId(ref)
2433
+ ? { id: ref, name: null }
2434
+ : await resolveProjectRef(client, ref, { includeArchived: true });
720
2435
 
721
- if (!organization) {
722
- debug('No organization available from viewer context');
723
- return [];
2436
+ const payload = await executeGraphQL(client, PROJECT_UNARCHIVE_MUTATION, {
2437
+ id: resolved.id,
2438
+ });
2439
+
2440
+ if (!payload?.projectUnarchive?.success) {
2441
+ throw new Error('Failed to unarchive project');
724
2442
  }
725
2443
 
726
- const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
2444
+ invalidateProjectsCache(client);
2445
+ const project = await fetchProjectDetails(client, resolved.id);
727
2446
 
728
- debug('Fetched Linear workspace from viewer organization', {
729
- workspace,
2447
+ return {
2448
+ success: true,
2449
+ project,
2450
+ };
2451
+ }, 'unarchiveProject');
2452
+ }
2453
+
2454
+ export async function fetchProjectUpdates(client, projectRef, options = {}) {
2455
+ return withLinearErrorHandling(async () => {
2456
+ const includeArchived = options.includeArchived === true;
2457
+ const limit = normalizePositiveInteger(options.limit, 'limit', 10);
2458
+ const resolved = await resolveProjectRef(client, projectRef, { includeArchived });
2459
+ const data = await executeGraphQL(client, PROJECT_UPDATES_BY_PROJECT_QUERY, {
2460
+ id: resolved.id,
2461
+ first: limit,
2462
+ includeArchived,
730
2463
  });
731
2464
 
732
- return [workspace];
733
- }, 'fetchWorkspaces');
2465
+ const nodes = data?.project?.projectUpdates?.nodes || [];
2466
+ return {
2467
+ project: {
2468
+ id: data?.project?.id || resolved.id,
2469
+ name: data?.project?.name || resolved.name,
2470
+ },
2471
+ updates: nodes.map(transformProjectUpdate),
2472
+ };
2473
+ }, 'fetchProjectUpdates');
734
2474
  }
735
2475
 
736
- /**
737
- * Fetch all accessible teams from Linear API
738
- * @param {LinearClient} client - Linear SDK client
739
- * @returns {Promise<Array<{id: string, key: string, name: string}>>}
740
- */
741
- export async function fetchTeams(client) {
2476
+ export async function fetchProjectUpdateDetails(client, projectUpdateId) {
742
2477
  return withLinearErrorHandling(async () => {
743
- const cacheKey = getClientCacheKey(client);
744
- const cached = getCache(teamsCache, cacheKey);
745
- if (cached) return cached;
2478
+ const id = String(projectUpdateId || '').trim();
2479
+ if (!id) {
2480
+ throw new Error('Missing required field: projectUpdate');
2481
+ }
746
2482
 
747
- const result = await client.teams();
748
- const nodes = result.nodes ?? [];
2483
+ const data = await executeGraphQL(client, PROJECT_UPDATE_DETAILS_QUERY, { id });
749
2484
 
750
- debug('Fetched Linear teams', {
751
- teamCount: nodes.length,
752
- teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
2485
+ if (!data?.projectUpdate) {
2486
+ throw new Error(`Project update not found: ${id}`);
2487
+ }
2488
+
2489
+ return transformProjectUpdate(data.projectUpdate);
2490
+ }, 'fetchProjectUpdateDetails');
2491
+ }
2492
+
2493
+ export async function createProjectUpdate(client, input) {
2494
+ return withLinearErrorHandling(async () => {
2495
+ const projectId = String(input.projectId || '').trim();
2496
+ if (!projectId) {
2497
+ throw new Error('Missing required field: projectId');
2498
+ }
2499
+
2500
+ const createInput = { projectId };
2501
+ if (input.body !== undefined) createInput.body = String(input.body);
2502
+ if (input.health !== undefined) createInput.health = normalizeProjectUpdateHealth(input.health);
2503
+ if (input.isDiffHidden !== undefined) createInput.isDiffHidden = input.isDiffHidden;
2504
+
2505
+ if (createInput.body === undefined && createInput.health === undefined) {
2506
+ throw new Error('At least one of body or health is required');
2507
+ }
2508
+
2509
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_CREATE_MUTATION, {
2510
+ input: createInput,
753
2511
  });
754
2512
 
755
- const teams = nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
756
- setCache(teamsCache, cacheKey, teams, CACHE_TTL_MS.teams);
757
- return teams;
758
- }, 'fetchTeams');
2513
+ if (!payload?.projectUpdateCreate?.success || !payload?.projectUpdateCreate?.projectUpdate?.id) {
2514
+ throw new Error('Failed to create project update');
2515
+ }
2516
+
2517
+ return fetchProjectUpdateDetails(client, payload.projectUpdateCreate.projectUpdate.id);
2518
+ }, 'createProjectUpdate');
759
2519
  }
760
2520
 
761
- /**
762
- * Resolve a team reference (key, name, or ID) to a team object
763
- * @param {LinearClient} client - Linear SDK client
764
- * @param {string} teamRef - Team key, name, or ID
765
- * @returns {Promise<{id: string, key: string, name: string}>}
766
- */
767
- export async function resolveTeamRef(client, teamRef) {
768
- const ref = String(teamRef || '').trim();
769
- if (!ref) {
770
- throw new Error('Missing team reference');
771
- }
2521
+ export async function updateProjectUpdate(client, projectUpdateId, patch = {}) {
2522
+ return withLinearErrorHandling(async () => {
2523
+ const id = String(projectUpdateId || '').trim();
2524
+ if (!id) {
2525
+ throw new Error('Missing required field: projectUpdate');
2526
+ }
2527
+
2528
+ const updateInput = {};
2529
+ if (patch.body !== undefined) updateInput.body = String(patch.body);
2530
+ if (patch.health !== undefined) updateInput.health = normalizeProjectUpdateHealth(patch.health);
2531
+ if (patch.isDiffHidden !== undefined) updateInput.isDiffHidden = patch.isDiffHidden;
2532
+
2533
+ if (Object.keys(updateInput).length === 0) {
2534
+ throw new Error('No update fields provided');
2535
+ }
2536
+
2537
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_UPDATE_MUTATION, {
2538
+ id,
2539
+ input: updateInput,
2540
+ });
772
2541
 
773
- // If it looks like a Linear ID (UUID), try direct lookup first (cheap)
774
- if (isLinearId(ref)) {
775
- try {
776
- const direct = await client.team(ref);
777
- if (direct) {
778
- return { id: direct.id, key: direct.key, name: direct.name };
779
- }
780
- } catch {
781
- // fall back to cached/full-team lookup below
2542
+ if (!payload?.projectUpdateUpdate?.success || !payload?.projectUpdateUpdate?.projectUpdate?.id) {
2543
+ throw new Error('Failed to update project update');
782
2544
  }
783
2545
 
784
- const byId = teams.find((t) => t.id === ref);
785
- if (byId) {
786
- return byId;
2546
+ const projectUpdate = await fetchProjectUpdateDetails(client, payload.projectUpdateUpdate.projectUpdate.id);
2547
+ return {
2548
+ projectUpdate,
2549
+ changed: Object.keys(updateInput),
2550
+ };
2551
+ }, 'updateProjectUpdate');
2552
+ }
2553
+
2554
+ export async function archiveProjectUpdate(client, projectUpdateId) {
2555
+ return withLinearErrorHandling(async () => {
2556
+ const id = String(projectUpdateId || '').trim();
2557
+ if (!id) {
2558
+ throw new Error('Missing required field: projectUpdate');
787
2559
  }
788
- throw new Error(`Team not found with ID: ${ref}`);
789
- }
790
2560
 
791
- const teams = await fetchTeams(client);
2561
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_ARCHIVE_MUTATION, { id });
2562
+ if (!payload?.projectUpdateArchive?.success) {
2563
+ throw new Error('Failed to archive project update');
2564
+ }
792
2565
 
793
- // Try exact key match (e.g., "ENG")
794
- const byKey = teams.find((t) => t.key === ref);
795
- if (byKey) {
796
- return byKey;
797
- }
2566
+ return {
2567
+ success: true,
2568
+ projectUpdateId: id,
2569
+ };
2570
+ }, 'archiveProjectUpdate');
2571
+ }
798
2572
 
799
- // Try exact name match
800
- const exactName = teams.find((t) => t.name === ref);
801
- if (exactName) {
802
- return exactName;
803
- }
2573
+ export async function unarchiveProjectUpdate(client, projectUpdateId) {
2574
+ return withLinearErrorHandling(async () => {
2575
+ const id = String(projectUpdateId || '').trim();
2576
+ if (!id) {
2577
+ throw new Error('Missing required field: projectUpdate');
2578
+ }
804
2579
 
805
- // Try case-insensitive key or name match
806
- const lowerRef = ref.toLowerCase();
807
- const insensitiveMatch = teams.find(
808
- (t) => t.key?.toLowerCase() === lowerRef || t.name?.toLowerCase() === lowerRef
809
- );
810
- if (insensitiveMatch) {
811
- return insensitiveMatch;
812
- }
2580
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_UNARCHIVE_MUTATION, { id });
2581
+ if (!payload?.projectUpdateUnarchive?.success) {
2582
+ throw new Error('Failed to unarchive project update');
2583
+ }
813
2584
 
814
- throw new Error(`Team not found: ${ref}. Available teams: ${teams.map((t) => `${t.key} (${t.name})`).join(', ')}`);
2585
+ const projectUpdate = await fetchProjectUpdateDetails(client, id);
2586
+ return {
2587
+ success: true,
2588
+ projectUpdate,
2589
+ };
2590
+ }, 'unarchiveProjectUpdate');
815
2591
  }
816
2592
 
817
- /**
818
- * Resolve an issue by ID or identifier
819
- * @param {LinearClient} client - Linear SDK client
820
- * @param {string} issueRef - Issue identifier (ABC-123) or Linear issue ID
821
- * @returns {Promise<Object>} Resolved issue object
822
- */
823
- export async function resolveIssue(client, issueRef) {
2593
+ export async function fetchDocumentDetails(client, documentRef) {
824
2594
  return withLinearErrorHandling(async () => {
825
- const lookup = normalizeIssueLookupInput(issueRef);
2595
+ const id = String(documentRef || '').trim();
2596
+ if (!id) {
2597
+ throw new Error('Missing required field: document');
2598
+ }
826
2599
 
827
- // The SDK's client.issue() method accepts both UUIDs and identifiers (ABC-123)
828
- try {
829
- const issue = await client.issue(lookup);
830
- if (issue) {
831
- return transformIssue(issue);
832
- }
833
- } catch (err) {
834
- // Fall through to error
2600
+ const data = await executeGraphQL(client, DOCUMENT_DETAILS_QUERY, { id });
2601
+
2602
+ if (!data?.document) {
2603
+ throw new Error(`Document not found: ${id}`);
835
2604
  }
836
2605
 
837
- throw new Error(`Issue not found: ${lookup}`);
838
- }, 'resolveIssue');
2606
+ return transformDocument(data.document);
2607
+ }, 'fetchDocumentDetails');
839
2608
  }
840
2609
 
841
- /**
842
- * Get workflow states for a team
843
- * @param {LinearClient} client - Linear SDK client
844
- * @param {string} teamRef - Team ID or key
845
- * @returns {Promise<Array<{id: string, name: string, type: string}>>}
846
- */
847
- export async function getTeamWorkflowStates(client, teamRef) {
2610
+ export async function createDocument(client, input = {}) {
848
2611
  return withLinearErrorHandling(async () => {
849
- const cacheKey = `${getClientCacheKey(client)}::${teamRef}`;
850
- const cached = getCache(teamStatesCache, cacheKey);
851
- if (cached) return cached;
2612
+ const title = String(input.title || '').trim();
2613
+ if (!title) {
2614
+ throw new Error('Missing required field: title');
2615
+ }
852
2616
 
853
- const team = await client.team(teamRef);
854
- if (!team) {
855
- throw new Error(`Team not found: ${teamRef}`);
2617
+ const createInput = { title };
2618
+ if (input.projectId !== undefined) createInput.projectId = input.projectId;
2619
+ if (input.issueId !== undefined) createInput.issueId = input.issueId;
2620
+
2621
+ if (!createInput.projectId && !createInput.issueId) {
2622
+ throw new Error('Document create requires either projectId or issueId');
856
2623
  }
857
2624
 
858
- const states = await team.states();
859
- const mapped = (states.nodes || []).map(s => ({
860
- id: s.id,
861
- name: s.name,
862
- type: s.type,
863
- }));
2625
+ for (const field of ['content', 'icon', 'color']) {
2626
+ if (input[field] !== undefined) {
2627
+ createInput[field] = input[field];
2628
+ }
2629
+ }
864
2630
 
865
- setCache(teamStatesCache, cacheKey, mapped, CACHE_TTL_MS.teamStates);
866
- return mapped;
867
- }, 'getTeamWorkflowStates');
2631
+ const payload = await executeGraphQL(client, DOCUMENT_CREATE_MUTATION, {
2632
+ input: createInput,
2633
+ });
2634
+
2635
+ if (!payload?.documentCreate?.success || !payload?.documentCreate?.document?.id) {
2636
+ throw new Error('Failed to create document');
2637
+ }
2638
+
2639
+ return fetchDocumentDetails(client, payload.documentCreate.document.id);
2640
+ }, 'createDocument');
868
2641
  }
869
2642
 
870
- /**
871
- * Resolve a project reference (name or ID) to a project object
872
- * @param {LinearClient} client - Linear SDK client
873
- * @param {string} projectRef - Project name or ID
874
- * @returns {Promise<{id: string, name: string}>}
875
- */
876
- export async function resolveProjectRef(client, projectRef) {
877
- const ref = String(projectRef || '').trim();
878
- if (!ref) {
879
- throw new Error('Missing project reference');
880
- }
2643
+ export async function updateDocument(client, documentRef, patch = {}) {
2644
+ return withLinearErrorHandling(async () => {
2645
+ const id = String(documentRef || '').trim();
2646
+ if (!id) {
2647
+ throw new Error('Missing required field: document');
2648
+ }
881
2649
 
882
- // If it looks like a Linear ID (UUID), try direct lookup first (cheap)
883
- if (isLinearId(ref)) {
884
- try {
885
- const direct = await client.project(ref);
886
- if (direct) {
887
- return { id: direct.id, name: direct.name };
2650
+ const updateInput = {};
2651
+ for (const field of ['title', 'content', 'icon', 'color', 'projectId', 'issueId']) {
2652
+ if (patch[field] !== undefined) {
2653
+ updateInput[field] = patch[field];
888
2654
  }
889
- } catch {
890
- // fall back to cached/full-project lookup below
891
2655
  }
892
2656
 
893
- const projectsById = await fetchProjects(client);
894
- const byId = projectsById.find((p) => p.id === ref);
895
- if (byId) {
896
- return byId;
2657
+ if (Object.keys(updateInput).length === 0) {
2658
+ throw new Error('No update fields provided');
897
2659
  }
898
- throw new Error(`Project not found with ID: ${ref}`);
899
- }
900
2660
 
901
- const projects = await fetchProjects(client);
902
-
903
- // Try exact name match
904
- const exactName = projects.find((p) => p.name === ref);
905
- if (exactName) {
906
- return exactName;
907
- }
2661
+ const payload = await executeGraphQL(client, DOCUMENT_UPDATE_MUTATION, {
2662
+ id,
2663
+ input: updateInput,
2664
+ });
908
2665
 
909
- // Try case-insensitive name match
910
- const lowerRef = ref.toLowerCase();
911
- const insensitiveName = projects.find((p) => p.name?.toLowerCase() === lowerRef);
912
- if (insensitiveName) {
913
- return insensitiveName;
914
- }
2666
+ if (!payload?.documentUpdate?.success || !payload?.documentUpdate?.document?.id) {
2667
+ throw new Error('Failed to update document');
2668
+ }
915
2669
 
916
- throw new Error(`Project not found: ${ref}. Available projects: ${projects.map((p) => p.name).join(', ')}`);
2670
+ const document = await fetchDocumentDetails(client, payload.documentUpdate.document.id);
2671
+ return {
2672
+ document,
2673
+ changed: Object.keys(updateInput),
2674
+ };
2675
+ }, 'updateDocument');
917
2676
  }
918
2677
 
919
2678
  /**
@@ -924,110 +2683,201 @@ export async function resolveProjectRef(client, projectRef) {
924
2683
  * @param {boolean} [options.includeComments=true] - Include comments in response
925
2684
  * @returns {Promise<Object>} Issue details
926
2685
  */
2686
+ async function fetchIssueDetailsViaSdk(client, issueRef, options = {}) {
2687
+ const { includeComments = true } = options;
2688
+
2689
+ const lookup = normalizeIssueLookupInput(issueRef);
2690
+ const sdkIssue = await client.issue(lookup);
2691
+
2692
+ if (!sdkIssue) {
2693
+ throw new Error(`Issue not found: ${lookup}`);
2694
+ }
2695
+
2696
+ const [
2697
+ state,
2698
+ team,
2699
+ project,
2700
+ projectMilestone,
2701
+ assignee,
2702
+ creator,
2703
+ labelsResult,
2704
+ parent,
2705
+ childrenResult,
2706
+ commentsResult,
2707
+ attachmentsResult,
2708
+ ] = await Promise.all([
2709
+ sdkIssue.state?.catch?.(() => null) ?? sdkIssue.state,
2710
+ sdkIssue.team?.catch?.(() => null) ?? sdkIssue.team,
2711
+ sdkIssue.project?.catch?.(() => null) ?? sdkIssue.project,
2712
+ sdkIssue.projectMilestone?.catch?.(() => null) ?? sdkIssue.projectMilestone,
2713
+ sdkIssue.assignee?.catch?.(() => null) ?? sdkIssue.assignee,
2714
+ sdkIssue.creator?.catch?.(() => null) ?? sdkIssue.creator,
2715
+ sdkIssue.labels?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.labels?.() ?? { nodes: [] },
2716
+ sdkIssue.parent?.catch?.(() => null) ?? sdkIssue.parent,
2717
+ sdkIssue.children?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.children?.() ?? { nodes: [] },
2718
+ includeComments ? (sdkIssue.comments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.comments?.() ?? { nodes: [] }) : Promise.resolve({ nodes: [] }),
2719
+ sdkIssue.attachments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.attachments?.() ?? { nodes: [] },
2720
+ ]);
2721
+
2722
+ let transformedParent = null;
2723
+ if (parent) {
2724
+ const parentState = await parent.state?.catch?.(() => null) ?? parent.state;
2725
+ transformedParent = {
2726
+ identifier: parent.identifier,
2727
+ title: parent.title,
2728
+ state: parentState ? { name: parentState.name, color: parentState.color } : null,
2729
+ };
2730
+ }
2731
+
2732
+ const children = (childrenResult.nodes || []).map(c => ({
2733
+ identifier: c.identifier,
2734
+ title: c.title,
2735
+ state: c.state ? { name: c.state.name, color: c.state.color } : null,
2736
+ }));
2737
+
2738
+ const comments = (commentsResult.nodes || []).map(c => ({
2739
+ id: c.id,
2740
+ body: c.body,
2741
+ createdAt: c.createdAt,
2742
+ updatedAt: c.updatedAt,
2743
+ user: c.user ? { name: c.user.name, displayName: c.user.displayName } : null,
2744
+ externalUser: c.externalUser ? { name: c.externalUser.name, displayName: c.externalUser.displayName } : null,
2745
+ parent: c.parent ? { id: c.parent.id } : null,
2746
+ }));
2747
+
2748
+ const attachments = (attachmentsResult.nodes || []).map(a => ({
2749
+ id: a.id,
2750
+ title: a.title,
2751
+ url: a.url,
2752
+ subtitle: a.subtitle,
2753
+ sourceType: a.sourceType,
2754
+ createdAt: a.createdAt,
2755
+ }));
2756
+
2757
+ const labels = (labelsResult.nodes || []).map(l => ({
2758
+ id: l.id,
2759
+ name: l.name,
2760
+ color: l.color,
2761
+ }));
2762
+
2763
+ return {
2764
+ identifier: sdkIssue.identifier,
2765
+ title: sdkIssue.title,
2766
+ description: sdkIssue.description,
2767
+ url: sdkIssue.url,
2768
+ branchName: sdkIssue.branchName,
2769
+ priority: sdkIssue.priority,
2770
+ estimate: sdkIssue.estimate,
2771
+ createdAt: sdkIssue.createdAt,
2772
+ updatedAt: sdkIssue.updatedAt,
2773
+ state: state ? { name: state.name, color: state.color, type: state.type } : null,
2774
+ team: team ? { id: team.id, key: team.key, name: team.name } : null,
2775
+ project: project ? { id: project.id, name: project.name } : null,
2776
+ projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
2777
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
2778
+ creator: creator ? { id: creator.id, name: creator.name, displayName: creator.displayName } : null,
2779
+ labels,
2780
+ parent: transformedParent,
2781
+ children,
2782
+ comments,
2783
+ attachments,
2784
+ };
2785
+ }
2786
+
927
2787
  export async function fetchIssueDetails(client, issueRef, options = {}) {
928
2788
  return withLinearErrorHandling(async () => {
929
2789
  const { includeComments = true } = options;
930
2790
 
931
- // Resolve issue - client.issue() accepts both UUIDs and identifiers
932
- const lookup = normalizeIssueLookupInput(issueRef);
933
- const sdkIssue = await client.issue(lookup);
934
-
935
- if (!sdkIssue) {
936
- throw new Error(`Issue not found: ${lookup}`);
2791
+ if (!getRawRequest(client)) {
2792
+ return fetchIssueDetailsViaSdk(client, issueRef, options);
937
2793
  }
938
2794
 
939
- // Fetch all nested relations in parallel
940
- const [
941
- state,
942
- team,
943
- project,
944
- projectMilestone,
945
- assignee,
946
- creator,
947
- labelsResult,
948
- parent,
949
- childrenResult,
950
- commentsResult,
951
- attachmentsResult,
952
- ] = await Promise.all([
953
- sdkIssue.state?.catch?.(() => null) ?? sdkIssue.state,
954
- sdkIssue.team?.catch?.(() => null) ?? sdkIssue.team,
955
- sdkIssue.project?.catch?.(() => null) ?? sdkIssue.project,
956
- sdkIssue.projectMilestone?.catch?.(() => null) ?? sdkIssue.projectMilestone,
957
- sdkIssue.assignee?.catch?.(() => null) ?? sdkIssue.assignee,
958
- sdkIssue.creator?.catch?.(() => null) ?? sdkIssue.creator,
959
- sdkIssue.labels?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.labels?.() ?? { nodes: [] },
960
- sdkIssue.parent?.catch?.(() => null) ?? sdkIssue.parent,
961
- sdkIssue.children?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.children?.() ?? { nodes: [] },
962
- includeComments ? (sdkIssue.comments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.comments?.() ?? { nodes: [] }) : Promise.resolve({ nodes: [] }),
963
- sdkIssue.attachments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.attachments?.() ?? { nodes: [] },
964
- ]);
2795
+ const lookup = normalizeIssueLookupInput(issueRef);
2796
+ const issueId = isLinearId(lookup)
2797
+ ? lookup
2798
+ : (await resolveIssue(client, lookup)).id;
2799
+ const query = includeComments ? ISSUE_DETAILS_WITH_COMMENTS_QUERY : ISSUE_DETAILS_QUERY;
2800
+ const data = await executeGraphQL(client, query, { id: issueId });
965
2801
 
966
- // Transform parent if exists
967
- let transformedParent = null;
968
- if (parent) {
969
- const parentState = await parent.state?.catch?.(() => null) ?? parent.state;
970
- transformedParent = {
971
- identifier: parent.identifier,
972
- title: parent.title,
973
- state: parentState ? { name: parentState.name, color: parentState.color } : null,
974
- };
2802
+ if (!data?.issue) {
2803
+ throw new Error(`Issue not found: ${lookup}`);
975
2804
  }
976
2805
 
977
- const children = (childrenResult.nodes || []).map(c => ({
978
- identifier: c.identifier,
979
- title: c.title,
980
- state: c.state ? { name: c.state.name, color: c.state.color } : null,
981
- }));
982
-
983
- const comments = (commentsResult.nodes || []).map(c => ({
984
- id: c.id,
985
- body: c.body,
986
- createdAt: c.createdAt,
987
- updatedAt: c.updatedAt,
988
- user: c.user ? { name: c.user.name, displayName: c.user.displayName } : null,
989
- externalUser: c.externalUser ? { name: c.externalUser.name, displayName: c.externalUser.displayName } : null,
990
- parent: c.parent ? { id: c.parent.id } : null,
991
- }));
2806
+ return transformRawIssueDetails(data.issue, { includeComments });
2807
+ }, 'fetchIssueDetails');
2808
+ }
992
2809
 
993
- const attachments = (attachmentsResult.nodes || []).map(a => ({
994
- id: a.id,
995
- title: a.title,
996
- url: a.url,
997
- subtitle: a.subtitle,
998
- sourceType: a.sourceType,
999
- createdAt: a.createdAt,
1000
- }));
2810
+ export async function fetchIssueActivity(client, issueRef, options = {}) {
2811
+ return withLinearErrorHandling(async () => {
2812
+ const limit = normalizePositiveInteger(options.limit, 'limit', 20);
2813
+ const includeArchived = options.includeArchived === true;
2814
+ const resolved = await resolveIssue(client, issueRef);
2815
+ const data = await executeGraphQL(client, ISSUE_ACTIVITY_QUERY, {
2816
+ id: resolved.id,
2817
+ first: limit,
2818
+ includeArchived,
2819
+ });
1001
2820
 
1002
- const labels = (labelsResult.nodes || []).map(l => ({
1003
- id: l.id,
1004
- name: l.name,
1005
- color: l.color,
1006
- }));
2821
+ if (!data?.issue) {
2822
+ throw new Error(`Issue not found: ${issueRef}`);
2823
+ }
1007
2824
 
1008
2825
  return {
1009
- identifier: sdkIssue.identifier,
1010
- title: sdkIssue.title,
1011
- description: sdkIssue.description,
1012
- url: sdkIssue.url,
1013
- branchName: sdkIssue.branchName,
1014
- priority: sdkIssue.priority,
1015
- estimate: sdkIssue.estimate,
1016
- createdAt: sdkIssue.createdAt,
1017
- updatedAt: sdkIssue.updatedAt,
1018
- state: state ? { name: state.name, color: state.color, type: state.type } : null,
1019
- team: team ? { id: team.id, key: team.key, name: team.name } : null,
1020
- project: project ? { id: project.id, name: project.name } : null,
1021
- projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
1022
- assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
1023
- creator: creator ? { id: creator.id, name: creator.name, displayName: creator.displayName } : null,
1024
- labels,
1025
- parent: transformedParent,
1026
- children,
1027
- comments,
1028
- attachments,
2826
+ issue: {
2827
+ id: data.issue.id,
2828
+ identifier: data.issue.identifier,
2829
+ title: data.issue.title,
2830
+ url: data.issue.url ?? null,
2831
+ },
2832
+ activity: (data.issue.history?.nodes || []).map((entry) => ({
2833
+ id: entry.id,
2834
+ createdAt: entry.createdAt ?? null,
2835
+ updatedAt: entry.updatedAt ?? null,
2836
+ actor: entry.actor ? {
2837
+ id: entry.actor.id,
2838
+ name: entry.actor.name,
2839
+ displayName: entry.actor.displayName,
2840
+ } : null,
2841
+ fromState: entry.fromState ? { id: entry.fromState.id, name: entry.fromState.name } : null,
2842
+ toState: entry.toState ? { id: entry.toState.id, name: entry.toState.name } : null,
2843
+ fromAssignee: entry.fromAssignee ? {
2844
+ id: entry.fromAssignee.id,
2845
+ name: entry.fromAssignee.name,
2846
+ displayName: entry.fromAssignee.displayName,
2847
+ } : null,
2848
+ toAssignee: entry.toAssignee ? {
2849
+ id: entry.toAssignee.id,
2850
+ name: entry.toAssignee.name,
2851
+ displayName: entry.toAssignee.displayName,
2852
+ } : null,
2853
+ fromTitle: entry.fromTitle ?? null,
2854
+ toTitle: entry.toTitle ?? null,
2855
+ fromPriority: entry.fromPriority ?? null,
2856
+ toPriority: entry.toPriority ?? null,
2857
+ fromProject: entry.fromProject ? { id: entry.fromProject.id, name: entry.fromProject.name } : null,
2858
+ toProject: entry.toProject ? { id: entry.toProject.id, name: entry.toProject.name } : null,
2859
+ fromProjectMilestone: entry.fromProjectMilestone ? { id: entry.fromProjectMilestone.id, name: entry.fromProjectMilestone.name } : null,
2860
+ toProjectMilestone: entry.toProjectMilestone ? { id: entry.toProjectMilestone.id, name: entry.toProjectMilestone.name } : null,
2861
+ addedLabels: (entry.addedLabels || []).map((label) => ({ id: label.id, name: label.name })),
2862
+ removedLabels: (entry.removedLabels || []).map((label) => ({ id: label.id, name: label.name })),
2863
+ relationChanges: (entry.relationChanges || []).map((relation) => ({
2864
+ identifier: relation.identifier,
2865
+ type: relation.type,
2866
+ })),
2867
+ attachment: entry.attachment ? {
2868
+ id: entry.attachment.id,
2869
+ title: entry.attachment.title,
2870
+ url: entry.attachment.url,
2871
+ } : null,
2872
+ archived: entry.archived ?? null,
2873
+ archivedAt: entry.archivedAt ?? null,
2874
+ autoArchived: entry.autoArchived ?? false,
2875
+ autoClosed: entry.autoClosed ?? false,
2876
+ trashed: entry.trashed ?? null,
2877
+ updatedDescription: entry.updatedDescription ?? false,
2878
+ })),
1029
2879
  };
1030
- }, 'fetchIssueDetails');
2880
+ }, 'fetchIssueActivity');
1031
2881
  }
1032
2882
 
1033
2883
  // ===== MUTATION FUNCTIONS =====
@@ -1041,17 +2891,17 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
1041
2891
  */
1042
2892
  export async function setIssueState(client, issueId, stateId) {
1043
2893
  return withLinearErrorHandling(async () => {
1044
- const issue = await client.issue(issueId);
1045
- if (!issue) {
1046
- throw new Error(`Issue not found: ${issueId}`);
2894
+ const updated = await performIssueUpdate(client, issueId, { stateId });
2895
+ if (updated) {
2896
+ return updated;
1047
2897
  }
1048
2898
 
1049
- const result = await issue.update({ stateId });
1050
- if (!result.success) {
1051
- throw new Error('Failed to update issue state');
2899
+ const refreshed = await fetchIssueMinimalById(client, issueId);
2900
+ if (!refreshed) {
2901
+ throw new Error(`Issue not found: ${issueId}`);
1052
2902
  }
1053
2903
 
1054
- return transformIssue(result.issue);
2904
+ return refreshed;
1055
2905
  }, 'setIssueState');
1056
2906
  }
1057
2907
 
@@ -1101,6 +2951,14 @@ export async function createIssue(client, input) {
1101
2951
  createInput.priority = parsed;
1102
2952
  }
1103
2953
 
2954
+ if (input.estimate !== undefined) {
2955
+ const parsed = Number.parseInt(String(input.estimate), 10);
2956
+ if (Number.isNaN(parsed) || parsed < 0) {
2957
+ throw new Error(`Invalid estimate: ${input.estimate}. Must be a non-negative integer.`);
2958
+ }
2959
+ createInput.estimate = parsed;
2960
+ }
2961
+
1104
2962
  if (input.assigneeId !== undefined) {
1105
2963
  createInput.assigneeId = input.assigneeId;
1106
2964
  }
@@ -1113,33 +2971,55 @@ export async function createIssue(client, input) {
1113
2971
  createInput.stateId = input.stateId;
1114
2972
  }
1115
2973
 
1116
- const result = await client.createIssue(createInput);
2974
+ if (getRawRequest(client)) {
2975
+ const payload = await executeGraphQL(client, ISSUE_CREATE_MUTATION, { input: createInput });
2976
+ if (!payload?.issueCreate?.success) {
2977
+ throw new Error('Failed to create issue');
2978
+ }
1117
2979
 
1118
- if (!result.success) {
1119
- throw new Error('Failed to create issue');
1120
- }
2980
+ const createdIssue = transformRawIssue(payload.issueCreate.issue ?? null);
2981
+ if (createdIssue) {
2982
+ return createdIssue;
2983
+ }
2984
+ } else {
2985
+ const result = await client.createIssue(createInput);
1121
2986
 
1122
- // Prefer official data path: resolve created issue ID then refetch full issue.
1123
- const createdIssueId =
1124
- result.issue?.id
1125
- || result._issue?.id
1126
- || null;
2987
+ if (!result.success) {
2988
+ throw new Error('Failed to create issue');
2989
+ }
1127
2990
 
1128
- if (createdIssueId) {
1129
- try {
1130
- const fullIssue = await client.issue(createdIssueId);
1131
- if (fullIssue) {
1132
- const transformed = await transformIssue(fullIssue);
1133
- return transformed;
2991
+ const createdIssueId =
2992
+ result.issue?.id
2993
+ || result._issue?.id
2994
+ || null;
2995
+
2996
+ if (createdIssueId) {
2997
+ try {
2998
+ const fullIssue = await fetchIssueMinimalById(client, createdIssueId);
2999
+ if (fullIssue) {
3000
+ return fullIssue;
3001
+ }
3002
+ } catch {
3003
+ // continue to fallback
1134
3004
  }
1135
- } catch {
1136
- // continue to fallback
1137
3005
  }
3006
+
3007
+ return {
3008
+ id: createdIssueId,
3009
+ identifier: null,
3010
+ title,
3011
+ description: input.description ?? null,
3012
+ url: null,
3013
+ priority: input.priority ?? null,
3014
+ state: null,
3015
+ team: null,
3016
+ project: null,
3017
+ assignee: null,
3018
+ };
1138
3019
  }
1139
3020
 
1140
- // Minimal fallback when SDK payload does not expose a resolvable issue ID.
1141
3021
  return {
1142
- id: createdIssueId,
3022
+ id: null,
1143
3023
  identifier: null,
1144
3024
  title,
1145
3025
  description: input.description ?? null,
@@ -1211,6 +3091,9 @@ function buildFallbackUpdatedIssue(targetIssue, updateInput) {
1211
3091
  if (Object.prototype.hasOwnProperty.call(updateInput, 'priority')) {
1212
3092
  fallback.priority = updateInput.priority;
1213
3093
  }
3094
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'estimate')) {
3095
+ fallback.estimate = updateInput.estimate;
3096
+ }
1214
3097
 
1215
3098
  if (Object.prototype.hasOwnProperty.call(updateInput, 'assigneeId')) {
1216
3099
  const assigneeId = updateInput.assigneeId;
@@ -1281,6 +3164,15 @@ export async function updateIssue(client, issueRef, patch = {}) {
1281
3164
  updateInput.priority = parsed;
1282
3165
  }
1283
3166
 
3167
+
3168
+ if (patch.estimate !== undefined) {
3169
+ const parsed = Number.parseInt(String(patch.estimate), 10);
3170
+ if (Number.isNaN(parsed) || parsed < 0) {
3171
+ throw new Error(`Invalid estimate: ${patch.estimate}. Must be a non-negative integer.`);
3172
+ }
3173
+ updateInput.estimate = parsed;
3174
+ }
3175
+
1284
3176
  if (patch.state !== undefined) {
1285
3177
  // Need to resolve state ID from team's workflow states
1286
3178
  const team = targetIssue.team;
@@ -1335,14 +3227,7 @@ export async function updateIssue(client, issueRef, patch = {}) {
1335
3227
 
1336
3228
  for (const childRef of parentOfRefs) {
1337
3229
  const childIssue = await resolveIssue(client, childRef);
1338
- const childSdkIssue = await client.issue(childIssue.id);
1339
- if (!childSdkIssue) {
1340
- throw new Error(`Issue not found: ${childRef}`);
1341
- }
1342
- const rel = await childSdkIssue.update({ parentId: targetIssue.id });
1343
- if (!rel.success) {
1344
- throw new Error(`Failed to set parent for issue: ${childRef}`);
1345
- }
3230
+ await performIssueUpdate(client, childIssue.id, { parentId: targetIssue.id });
1346
3231
  }
1347
3232
 
1348
3233
  for (const blockerRef of blockedByRefs) {
@@ -1381,16 +3266,7 @@ export async function updateIssue(client, issueRef, patch = {}) {
1381
3266
  }
1382
3267
 
1383
3268
  if (Object.keys(updateInput).length > 0) {
1384
- // Get fresh issue instance for update
1385
- const sdkIssue = await client.issue(targetIssue.id);
1386
- if (!sdkIssue) {
1387
- throw new Error(`Issue not found: ${targetIssue.id}`);
1388
- }
1389
-
1390
- const result = await sdkIssue.update(updateInput);
1391
- if (!result.success) {
1392
- throw new Error('Failed to update issue');
1393
- }
3269
+ await performIssueUpdate(client, targetIssue.id, updateInput);
1394
3270
  }
1395
3271
 
1396
3272
  for (const relationInput of relationCreates) {
@@ -1405,8 +3281,7 @@ export async function updateIssue(client, issueRef, patch = {}) {
1405
3281
  let updatedIssue = null;
1406
3282
  let usedRateLimitFallback = false;
1407
3283
  try {
1408
- const updatedSdkIssue = await client.issue(targetIssue.id);
1409
- updatedIssue = await transformIssue(updatedSdkIssue);
3284
+ updatedIssue = await fetchIssueMinimalById(client, targetIssue.id);
1410
3285
  } catch (refreshError) {
1411
3286
  const refreshMessage = String(refreshError?.message || refreshError || 'unknown');
1412
3287
  const isRefreshRateLimited = refreshError?.type === 'Ratelimited' || refreshMessage.toLowerCase().includes('rate limit');
@@ -1507,13 +3382,120 @@ async function transformMilestone(sdkMilestone) {
1507
3382
  name: sdkMilestone.name,
1508
3383
  description: sdkMilestone.description,
1509
3384
  progress: sdkMilestone.progress,
1510
- order: sdkMilestone.order,
3385
+ order: sdkMilestone.sortOrder,
1511
3386
  targetDate: sdkMilestone.targetDate,
1512
3387
  status: sdkMilestone.status,
1513
3388
  project: project ? { id: project.id, name: project.name } : null,
1514
3389
  };
1515
3390
  }
1516
3391
 
3392
+ function transformProject(rawProject) {
3393
+ if (!rawProject) return null;
3394
+
3395
+ const milestoneNodes = rawProject.projectMilestones?.nodes || [];
3396
+ const teamNodes = rawProject.teams?.nodes || [];
3397
+
3398
+ return {
3399
+ id: rawProject.id,
3400
+ name: rawProject.name,
3401
+ description: rawProject.description ?? '',
3402
+ content: rawProject.content ?? null,
3403
+ color: rawProject.color ?? null,
3404
+ icon: rawProject.icon ?? null,
3405
+ priority: rawProject.priority ?? null,
3406
+ progress: rawProject.progress ?? null,
3407
+ health: rawProject.health ?? null,
3408
+ startDate: rawProject.startDate ?? null,
3409
+ targetDate: rawProject.targetDate ?? null,
3410
+ slugId: rawProject.slugId ?? null,
3411
+ url: rawProject.url ?? null,
3412
+ archivedAt: rawProject.archivedAt ?? null,
3413
+ completedAt: rawProject.completedAt ?? null,
3414
+ canceledAt: rawProject.canceledAt ?? null,
3415
+ status: rawProject.status ? {
3416
+ id: rawProject.status.id,
3417
+ name: rawProject.status.name,
3418
+ type: rawProject.status.type,
3419
+ color: rawProject.status.color,
3420
+ } : null,
3421
+ lead: rawProject.lead ? {
3422
+ id: rawProject.lead.id,
3423
+ name: rawProject.lead.name,
3424
+ displayName: rawProject.lead.displayName,
3425
+ } : null,
3426
+ teams: teamNodes.map((team) => ({
3427
+ id: team.id,
3428
+ key: team.key,
3429
+ name: team.name,
3430
+ })),
3431
+ projectMilestones: milestoneNodes.map((milestone) => ({
3432
+ id: milestone.id,
3433
+ name: milestone.name,
3434
+ status: milestone.status,
3435
+ progress: milestone.progress ?? null,
3436
+ targetDate: milestone.targetDate ?? null,
3437
+ })),
3438
+ };
3439
+ }
3440
+
3441
+ function transformProjectUpdate(rawUpdate) {
3442
+ if (!rawUpdate) return null;
3443
+
3444
+ return {
3445
+ id: rawUpdate.id,
3446
+ body: rawUpdate.body ?? '',
3447
+ health: rawUpdate.health ?? null,
3448
+ createdAt: rawUpdate.createdAt ?? null,
3449
+ updatedAt: rawUpdate.updatedAt ?? null,
3450
+ archivedAt: rawUpdate.archivedAt ?? null,
3451
+ editedAt: rawUpdate.editedAt ?? null,
3452
+ url: rawUpdate.url ?? null,
3453
+ slugId: rawUpdate.slugId ?? null,
3454
+ isDiffHidden: rawUpdate.isDiffHidden ?? false,
3455
+ isStale: rawUpdate.isStale ?? false,
3456
+ project: rawUpdate.project ? {
3457
+ id: rawUpdate.project.id,
3458
+ name: rawUpdate.project.name,
3459
+ } : null,
3460
+ user: rawUpdate.user ? {
3461
+ id: rawUpdate.user.id,
3462
+ name: rawUpdate.user.name,
3463
+ displayName: rawUpdate.user.displayName,
3464
+ } : null,
3465
+ };
3466
+ }
3467
+
3468
+ function transformDocument(rawDocument) {
3469
+ if (!rawDocument) return null;
3470
+
3471
+ return {
3472
+ id: rawDocument.id,
3473
+ title: rawDocument.title ?? '',
3474
+ content: rawDocument.content ?? '',
3475
+ icon: rawDocument.icon ?? null,
3476
+ color: rawDocument.color ?? null,
3477
+ slugId: rawDocument.slugId ?? null,
3478
+ url: rawDocument.url ?? null,
3479
+ archivedAt: rawDocument.archivedAt ?? null,
3480
+ createdAt: rawDocument.createdAt ?? null,
3481
+ updatedAt: rawDocument.updatedAt ?? null,
3482
+ project: rawDocument.project ? {
3483
+ id: rawDocument.project.id,
3484
+ name: rawDocument.project.name,
3485
+ } : null,
3486
+ issue: rawDocument.issue ? {
3487
+ id: rawDocument.issue.id,
3488
+ identifier: rawDocument.issue.identifier,
3489
+ title: rawDocument.issue.title,
3490
+ } : null,
3491
+ };
3492
+ }
3493
+
3494
+ function invalidateProjectsCache(client) {
3495
+ const cacheKey = getClientCacheKey(client);
3496
+ projectsCache.delete(cacheKey);
3497
+ }
3498
+
1517
3499
  /**
1518
3500
  * Fetch milestones for a project
1519
3501
  * @param {LinearClient} client - Linear SDK client
@@ -1522,16 +3504,11 @@ async function transformMilestone(sdkMilestone) {
1522
3504
  */
1523
3505
  export async function fetchProjectMilestones(client, projectId) {
1524
3506
  return withLinearErrorHandling(async () => {
1525
- const project = await client.project(projectId);
1526
- if (!project) {
3507
+ const milestones = await fetchProjectMilestonesByQuery(client, projectId);
3508
+ if (!milestones) {
1527
3509
  throw new Error(`Project not found: ${projectId}`);
1528
3510
  }
1529
3511
 
1530
- const result = await project.projectMilestones();
1531
- const nodes = result.nodes || [];
1532
-
1533
- const milestones = await Promise.all(nodes.map(transformMilestone));
1534
-
1535
3512
  debug('Fetched project milestones', {
1536
3513
  projectId,
1537
3514
  milestoneCount: milestones.length,
@@ -1585,7 +3562,7 @@ export async function fetchMilestoneDetails(client, milestoneId) {
1585
3562
  name: milestone.name,
1586
3563
  description: milestone.description,
1587
3564
  progress: milestone.progress,
1588
- order: milestone.order,
3565
+ order: milestone.sortOrder,
1589
3566
  targetDate: milestone.targetDate,
1590
3567
  status: milestone.status,
1591
3568
  project: project ? { id: project.id, name: project.name } : null,
@@ -1666,7 +3643,7 @@ export async function createProjectMilestone(client, input) {
1666
3643
  name: created?.name || name,
1667
3644
  description: created?.description ?? input.description ?? null,
1668
3645
  progress: created?.progress ?? 0,
1669
- order: created?.order ?? null,
3646
+ order: created?.sortOrder ?? null,
1670
3647
  targetDate: created?.targetDate ?? input.targetDate ?? null,
1671
3648
  status: created?.status ?? input.status ?? 'backlogged',
1672
3649
  project: null,
@@ -1763,16 +3740,21 @@ export async function deleteIssue(client, issueRef) {
1763
3740
  return withLinearErrorHandling(async () => {
1764
3741
  const targetIssue = await resolveIssue(client, issueRef);
1765
3742
 
1766
- // Get SDK issue instance for delete
1767
- const sdkIssue = await client.issue(targetIssue.id);
1768
- if (!sdkIssue) {
1769
- throw new Error(`Issue not found: ${targetIssue.id}`);
3743
+ let success = false;
3744
+ if (getRawRequest(client)) {
3745
+ const payload = await executeGraphQL(client, ISSUE_DELETE_MUTATION, { id: targetIssue.id });
3746
+ success = payload?.issueDelete?.success === true;
3747
+ } else {
3748
+ const sdkIssue = await client.issue(targetIssue.id);
3749
+ if (!sdkIssue) {
3750
+ throw new Error(`Issue not found: ${targetIssue.id}`);
3751
+ }
3752
+ const result = await sdkIssue.delete();
3753
+ success = result.success;
1770
3754
  }
1771
3755
 
1772
- const result = await sdkIssue.delete();
1773
-
1774
3756
  return {
1775
- success: result.success,
3757
+ success,
1776
3758
  issueId: targetIssue.id,
1777
3759
  identifier: targetIssue.identifier,
1778
3760
  };
@@ -2009,3 +3991,31 @@ export function formatIssueAsMarkdown(issueData, options = {}) {
2009
3991
 
2010
3992
  return lines.join('\n');
2011
3993
  }
3994
+
3995
+ export function formatIssueActivityAsMarkdown(issueData, options = {}) {
3996
+ const { limit } = options;
3997
+ const lines = [`# Activity for ${issueData.issue.identifier}: ${issueData.issue.title}`];
3998
+
3999
+ if (issueData.issue.url) {
4000
+ lines.push('');
4001
+ lines.push(`**URL:** ${issueData.issue.url}`);
4002
+ }
4003
+
4004
+ if (!issueData.activity || issueData.activity.length === 0) {
4005
+ lines.push('');
4006
+ lines.push('No activity entries found.');
4007
+ return lines.join('\n');
4008
+ }
4009
+
4010
+ lines.push('');
4011
+ lines.push(`Showing ${issueData.activity.length}${limit ? ` of up to ${limit}` : ''} activity entries.`);
4012
+ lines.push('');
4013
+
4014
+ for (const entry of issueData.activity) {
4015
+ const actor = entry.actor ? getUserDisplayName(entry.actor) : 'System';
4016
+ const timestamp = entry.createdAt ? formatRelativeTime(entry.createdAt) : 'unknown time';
4017
+ lines.push(`- **${actor}** ${summarizeIssueHistoryEntry(entry)} _(${timestamp})_`);
4018
+ }
4019
+
4020
+ return lines.join('\n');
4021
+ }