@fink-andreas/pi-linear-tools 0.4.1 → 0.4.3

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
@@ -7,6 +7,39 @@
7
7
 
8
8
  import { warn, info, debug } from './logger.js';
9
9
 
10
+ const CACHE_TTL_MS = {
11
+ viewer: 30_000,
12
+ projects: 60_000,
13
+ teams: 60_000,
14
+ teamStates: 60_000,
15
+ };
16
+
17
+ const viewerCache = new Map();
18
+ const projectsCache = new Map();
19
+ const teamsCache = new Map();
20
+ const teamStatesCache = new Map();
21
+
22
+ function getClientCacheKey(client) {
23
+ return client?.apiKey || 'default';
24
+ }
25
+
26
+ function getCache(map, key) {
27
+ const entry = map.get(key);
28
+ if (!entry) return null;
29
+ if (Date.now() > entry.expiresAt) {
30
+ map.delete(key);
31
+ return null;
32
+ }
33
+ return entry.value;
34
+ }
35
+
36
+ function setCache(map, key, value, ttlMs) {
37
+ map.set(key, {
38
+ value,
39
+ expiresAt: Date.now() + ttlMs,
40
+ });
41
+ }
42
+
10
43
  // ===== OPTIMIZED GRAPHQL QUERIES =====
11
44
  // These queries fetch relations upfront to avoid N+1 API calls
12
45
 
@@ -57,6 +90,360 @@ const ISSUES_WITH_RELATIONS_QUERY = `
57
90
  }
58
91
  `;
59
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_CREATE_MUTATION = `
157
+ mutation ProjectCreate($input: ProjectCreateInput!) {
158
+ projectCreate(input: $input) {
159
+ success
160
+ project {
161
+ id
162
+ name
163
+ }
164
+ }
165
+ }
166
+ `;
167
+
168
+ const PROJECT_UPDATE_MUTATION = `
169
+ mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {
170
+ projectUpdate(id: $id, input: $input) {
171
+ success
172
+ project {
173
+ id
174
+ name
175
+ }
176
+ }
177
+ }
178
+ `;
179
+
180
+ const PROJECT_DELETE_MUTATION = `
181
+ mutation ProjectDelete($id: String!) {
182
+ projectDelete(id: $id) {
183
+ success
184
+ entity {
185
+ id
186
+ name
187
+ }
188
+ }
189
+ }
190
+ `;
191
+
192
+ const PROJECT_ARCHIVE_MUTATION = `
193
+ mutation ProjectArchive($id: String!) {
194
+ projectArchiveResult: projectDelete(id: $id) {
195
+ success
196
+ entity {
197
+ id
198
+ name
199
+ }
200
+ }
201
+ }
202
+ `;
203
+
204
+ const PROJECT_UNARCHIVE_MUTATION = `
205
+ mutation ProjectUnarchive($id: String!) {
206
+ projectUnarchive(id: $id) {
207
+ success
208
+ entity {
209
+ id
210
+ name
211
+ }
212
+ }
213
+ }
214
+ `;
215
+
216
+ const PROJECT_UPDATES_BY_PROJECT_QUERY = `
217
+ query ProjectUpdatesByProject($id: String!, $first: Int!, $includeArchived: Boolean!) {
218
+ project(id: $id) {
219
+ id
220
+ name
221
+ projectUpdates(first: $first, includeArchived: $includeArchived) {
222
+ nodes {
223
+ id
224
+ body
225
+ health
226
+ createdAt
227
+ updatedAt
228
+ archivedAt
229
+ url
230
+ slugId
231
+ isDiffHidden
232
+ isStale
233
+ user {
234
+ id
235
+ name
236
+ displayName
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ `;
243
+
244
+ const PROJECT_UPDATE_DETAILS_QUERY = `
245
+ query ProjectUpdateDetails($id: String!) {
246
+ projectUpdate(id: $id) {
247
+ id
248
+ body
249
+ health
250
+ createdAt
251
+ updatedAt
252
+ archivedAt
253
+ editedAt
254
+ url
255
+ slugId
256
+ isDiffHidden
257
+ isStale
258
+ project {
259
+ id
260
+ name
261
+ }
262
+ user {
263
+ id
264
+ name
265
+ displayName
266
+ }
267
+ }
268
+ }
269
+ `;
270
+
271
+ const PROJECT_UPDATE_CREATE_MUTATION = `
272
+ mutation ProjectUpdateCreate($input: ProjectUpdateCreateInput!) {
273
+ projectUpdateCreate(input: $input) {
274
+ success
275
+ projectUpdate {
276
+ id
277
+ }
278
+ }
279
+ }
280
+ `;
281
+
282
+ const PROJECT_UPDATE_UPDATE_MUTATION = `
283
+ mutation ProjectUpdateUpdate($id: String!, $input: ProjectUpdateUpdateInput!) {
284
+ projectUpdateUpdate(id: $id, input: $input) {
285
+ success
286
+ projectUpdate {
287
+ id
288
+ }
289
+ }
290
+ }
291
+ `;
292
+
293
+ const PROJECT_UPDATE_ARCHIVE_MUTATION = `
294
+ mutation ProjectUpdateArchive($id: String!) {
295
+ projectUpdateArchive(id: $id) {
296
+ success
297
+ entity {
298
+ id
299
+ }
300
+ }
301
+ }
302
+ `;
303
+
304
+ const PROJECT_UPDATE_UNARCHIVE_MUTATION = `
305
+ mutation ProjectUpdateUnarchive($id: String!) {
306
+ projectUpdateUnarchive(id: $id) {
307
+ success
308
+ entity {
309
+ id
310
+ }
311
+ }
312
+ }
313
+ `;
314
+
315
+ const DOCUMENT_DETAILS_QUERY = `
316
+ query DocumentDetails($id: String!) {
317
+ document(id: $id) {
318
+ id
319
+ title
320
+ content
321
+ icon
322
+ color
323
+ slugId
324
+ url
325
+ archivedAt
326
+ createdAt
327
+ updatedAt
328
+ project {
329
+ id
330
+ name
331
+ }
332
+ issue {
333
+ id
334
+ identifier
335
+ title
336
+ }
337
+ }
338
+ }
339
+ `;
340
+
341
+ const DOCUMENT_CREATE_MUTATION = `
342
+ mutation DocumentCreate($input: DocumentCreateInput!) {
343
+ documentCreate(input: $input) {
344
+ success
345
+ document {
346
+ id
347
+ }
348
+ }
349
+ }
350
+ `;
351
+
352
+ const DOCUMENT_UPDATE_MUTATION = `
353
+ mutation DocumentUpdate($id: String!, $input: DocumentUpdateInput!) {
354
+ documentUpdate(id: $id, input: $input) {
355
+ success
356
+ document {
357
+ id
358
+ }
359
+ }
360
+ }
361
+ `;
362
+
363
+ const ISSUE_ACTIVITY_QUERY = `
364
+ query IssueActivity($id: String!, $first: Int!, $includeArchived: Boolean!) {
365
+ issue(id: $id) {
366
+ id
367
+ identifier
368
+ title
369
+ url
370
+ history(first: $first, includeArchived: $includeArchived) {
371
+ nodes {
372
+ id
373
+ createdAt
374
+ updatedAt
375
+ archived
376
+ archivedAt
377
+ autoArchived
378
+ autoClosed
379
+ trashed
380
+ updatedDescription
381
+ fromTitle
382
+ toTitle
383
+ fromPriority
384
+ toPriority
385
+ fromState {
386
+ id
387
+ name
388
+ }
389
+ toState {
390
+ id
391
+ name
392
+ }
393
+ fromAssignee {
394
+ id
395
+ name
396
+ displayName
397
+ }
398
+ toAssignee {
399
+ id
400
+ name
401
+ displayName
402
+ }
403
+ fromProject {
404
+ id
405
+ name
406
+ }
407
+ toProject {
408
+ id
409
+ name
410
+ }
411
+ fromProjectMilestone {
412
+ id
413
+ name
414
+ }
415
+ toProjectMilestone {
416
+ id
417
+ name
418
+ }
419
+ addedLabels {
420
+ id
421
+ name
422
+ }
423
+ removedLabels {
424
+ id
425
+ name
426
+ }
427
+ relationChanges {
428
+ identifier
429
+ type
430
+ }
431
+ attachment {
432
+ id
433
+ title
434
+ url
435
+ }
436
+ actor {
437
+ id
438
+ name
439
+ displayName
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+ `;
446
+
60
447
  /**
61
448
  * Execute an optimized GraphQL query using rawRequest
62
449
  * Falls back to SDK method if rawRequest is not available (e.g., in tests)
@@ -103,6 +490,21 @@ async function executeOptimizedQuery(client, query, variables) {
103
490
  };
104
491
  }
105
492
 
493
+ async function executeGraphQL(client, query, variables = {}) {
494
+ const rawRequest =
495
+ (typeof client.client?.rawRequest === 'function' ? client.client.rawRequest.bind(client.client) : null) ||
496
+ (typeof client.rawRequest === 'function' ? client.rawRequest.bind(client) : null);
497
+
498
+ if (!rawRequest) {
499
+ throw new Error('GraphQL rawRequest is unavailable on this Linear client');
500
+ }
501
+
502
+ const response = await rawRequest(query, variables);
503
+ updateRateLimitState(response);
504
+ checkRateLimitWarning();
505
+ return response.data;
506
+ }
507
+
106
508
  /**
107
509
  * Transform raw GraphQL issue data to plain object format
108
510
  * Used by optimized queries to avoid SDK lazy loading
@@ -346,9 +748,201 @@ function isLinearId(value) {
346
748
  function normalizeIssueLookupInput(issue) {
347
749
  const value = String(issue || '').trim();
348
750
  if (!value) throw new Error('Missing required issue identifier');
751
+
752
+ const issueUrlMatch = value.match(/\/issue\/([A-Za-z0-9]+-\d+)(?:[/?#]|$)/i);
753
+ if (issueUrlMatch?.[1]) {
754
+ return issueUrlMatch[1].toUpperCase();
755
+ }
756
+
349
757
  return value;
350
758
  }
351
759
 
760
+ function extractProjectLookupValue(projectRef) {
761
+ const ref = String(projectRef || '').trim();
762
+ if (!ref) {
763
+ return '';
764
+ }
765
+
766
+ const projectUrlMatch = ref.match(/\/project\/([^/?#]+)/i);
767
+ if (projectUrlMatch?.[1]) {
768
+ return projectUrlMatch[1];
769
+ }
770
+
771
+ return ref;
772
+ }
773
+
774
+ function getProjectLookupCandidates(projectRef) {
775
+ const lookupValue = extractProjectLookupValue(projectRef);
776
+ const candidates = new Set([lookupValue]);
777
+
778
+ if (lookupValue.includes('-')) {
779
+ const slugSuffix = lookupValue.split('-').pop();
780
+ if (slugSuffix) {
781
+ candidates.add(slugSuffix);
782
+ }
783
+ }
784
+
785
+ return Array.from(candidates).filter(Boolean);
786
+ }
787
+
788
+ const PROJECT_UPDATE_HEALTH_VALUES = ['onTrack', 'atRisk', 'offTrack'];
789
+
790
+ function normalizePositiveInteger(value, fieldName, defaultValue) {
791
+ if (value === undefined || value === null) {
792
+ return defaultValue;
793
+ }
794
+
795
+ const parsed = typeof value === 'number' ? value : Number(String(value).trim());
796
+ if (!Number.isInteger(parsed) || parsed < 1) {
797
+ throw new Error(`${fieldName} must be a positive integer`);
798
+ }
799
+
800
+ return parsed;
801
+ }
802
+
803
+ function normalizeProjectUpdateHealth(value) {
804
+ if (value === undefined) {
805
+ return undefined;
806
+ }
807
+
808
+ const normalized = String(value).trim();
809
+ if (!normalized) {
810
+ throw new Error(`health must be one of: ${PROJECT_UPDATE_HEALTH_VALUES.join(', ')}`);
811
+ }
812
+
813
+ if (!PROJECT_UPDATE_HEALTH_VALUES.includes(normalized)) {
814
+ throw new Error(`health must be one of: ${PROJECT_UPDATE_HEALTH_VALUES.join(', ')}`);
815
+ }
816
+
817
+ return normalized;
818
+ }
819
+
820
+ function formatPriorityLabel(value) {
821
+ if (value === undefined || value === null) {
822
+ return null;
823
+ }
824
+
825
+ const numeric = Number(value);
826
+ if (!Number.isFinite(numeric)) {
827
+ return String(value);
828
+ }
829
+
830
+ const priorityNames = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
831
+ return priorityNames[numeric] || `Priority ${numeric}`;
832
+ }
833
+
834
+ function getUserDisplayName(user) {
835
+ return user?.displayName || user?.name || 'Unknown';
836
+ }
837
+
838
+ function summarizeIssueHistoryEntry(entry) {
839
+ if (entry.fromState?.name || entry.toState?.name) {
840
+ if (entry.fromState?.name && entry.toState?.name) {
841
+ return `moved state from ${entry.fromState.name} to ${entry.toState.name}`;
842
+ }
843
+ if (entry.toState?.name) {
844
+ return `set state to ${entry.toState.name}`;
845
+ }
846
+ return `cleared state ${entry.fromState.name}`;
847
+ }
848
+
849
+ if (entry.fromAssignee || entry.toAssignee) {
850
+ const fromAssignee = entry.fromAssignee ? getUserDisplayName(entry.fromAssignee) : null;
851
+ const toAssignee = entry.toAssignee ? getUserDisplayName(entry.toAssignee) : null;
852
+ if (fromAssignee && toAssignee) {
853
+ return `reassigned from ${fromAssignee} to ${toAssignee}`;
854
+ }
855
+ if (toAssignee) {
856
+ return `assigned to ${toAssignee}`;
857
+ }
858
+ return `unassigned from ${fromAssignee}`;
859
+ }
860
+
861
+ if (entry.fromTitle || entry.toTitle) {
862
+ if (entry.fromTitle && entry.toTitle) {
863
+ return `renamed issue from "${entry.fromTitle}" to "${entry.toTitle}"`;
864
+ }
865
+ if (entry.toTitle) {
866
+ return `set title to "${entry.toTitle}"`;
867
+ }
868
+ }
869
+
870
+ if (entry.fromPriority !== undefined || entry.toPriority !== undefined) {
871
+ const fromPriority = formatPriorityLabel(entry.fromPriority);
872
+ const toPriority = formatPriorityLabel(entry.toPriority);
873
+ if (fromPriority && toPriority) {
874
+ return `changed priority from ${fromPriority} to ${toPriority}`;
875
+ }
876
+ if (toPriority) {
877
+ return `set priority to ${toPriority}`;
878
+ }
879
+ }
880
+
881
+ if (entry.fromProject?.name || entry.toProject?.name) {
882
+ if (entry.fromProject?.name && entry.toProject?.name) {
883
+ return `moved project from ${entry.fromProject.name} to ${entry.toProject.name}`;
884
+ }
885
+ if (entry.toProject?.name) {
886
+ return `added to project ${entry.toProject.name}`;
887
+ }
888
+ return `removed from project ${entry.fromProject.name}`;
889
+ }
890
+
891
+ if (entry.fromProjectMilestone?.name || entry.toProjectMilestone?.name) {
892
+ if (entry.fromProjectMilestone?.name && entry.toProjectMilestone?.name) {
893
+ return `moved milestone from ${entry.fromProjectMilestone.name} to ${entry.toProjectMilestone.name}`;
894
+ }
895
+ if (entry.toProjectMilestone?.name) {
896
+ return `set milestone to ${entry.toProjectMilestone.name}`;
897
+ }
898
+ return `cleared milestone ${entry.fromProjectMilestone.name}`;
899
+ }
900
+
901
+ if ((entry.addedLabels?.length || 0) > 0 || (entry.removedLabels?.length || 0) > 0) {
902
+ const labelChanges = [];
903
+ if ((entry.addedLabels?.length || 0) > 0) {
904
+ labelChanges.push(`added labels ${entry.addedLabels.map((label) => label.name).join(', ')}`);
905
+ }
906
+ if ((entry.removedLabels?.length || 0) > 0) {
907
+ labelChanges.push(`removed labels ${entry.removedLabels.map((label) => label.name).join(', ')}`);
908
+ }
909
+ return labelChanges.join('; ');
910
+ }
911
+
912
+ if ((entry.relationChanges?.length || 0) > 0) {
913
+ const relationSummary = entry.relationChanges
914
+ .map((relation) => `${relation.type} ${relation.identifier}`)
915
+ .join(', ');
916
+ return `updated relations: ${relationSummary}`;
917
+ }
918
+
919
+ if (entry.updatedDescription) {
920
+ return 'updated description';
921
+ }
922
+
923
+ if (entry.attachment?.title || entry.attachment?.url) {
924
+ return `linked attachment ${entry.attachment.title ? `"${entry.attachment.title}"` : entry.attachment.url}`;
925
+ }
926
+
927
+ if (entry.archivedAt || entry.archived === true) {
928
+ return entry.autoArchived ? 'auto-archived issue' : 'archived issue';
929
+ }
930
+
931
+ if (entry.autoClosed) {
932
+ return 'auto-closed issue';
933
+ }
934
+
935
+ if (entry.trashed === true) {
936
+ return 'trashed issue';
937
+ }
938
+
939
+ if (entry.trashed === false) {
940
+ return 'restored issue from trash';
941
+ }
942
+
943
+ return 'updated issue';
944
+ }
945
+
352
946
  /**
353
947
  * Safely resolve a lazy-loaded relation without triggering unnecessary API calls
354
948
  * Only fetches if the relation is already cached or if we have minimal data
@@ -510,15 +1104,24 @@ function normalizeIssueRefList(value) {
510
1104
  */
511
1105
  export async function fetchViewer(client) {
512
1106
  return withLinearErrorHandling(async () => {
1107
+ const cacheKey = getClientCacheKey(client);
1108
+ const cached = getCache(viewerCache, cacheKey);
1109
+ if (cached) return cached;
1110
+
513
1111
  const viewer = await client.viewer;
514
- return {
1112
+ const result = {
515
1113
  id: viewer.id,
516
1114
  name: viewer.name,
517
1115
  displayName: viewer.displayName,
518
1116
  };
1117
+
1118
+ setCache(viewerCache, cacheKey, result, CACHE_TTL_MS.viewer);
1119
+ return result;
519
1120
  }, 'fetchViewer');
520
1121
  }
521
1122
 
1123
+ export const getViewer = fetchViewer;
1124
+
522
1125
  /**
523
1126
  * Fetch issues in specific states, optionally filtered by assignee
524
1127
  * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
@@ -580,12 +1183,13 @@ export async function fetchIssues(client, assigneeId, openStates, limit) {
580
1183
  * @param {Array<string>|null} states - List of state names to include (null = all states)
581
1184
  * @param {Object} options
582
1185
  * @param {string|null} options.assigneeId - Assignee ID to filter by (null = all assignees)
1186
+ * @param {string|null} options.teamId - Team ID to filter by (null = all teams)
583
1187
  * @param {number} options.limit - Maximum number of issues to fetch
584
1188
  * @returns {Promise<{issues: Array, truncated: boolean}>}
585
1189
  */
586
1190
  export async function fetchIssuesByProject(client, projectId, states, options = {}) {
587
1191
  return withLinearErrorHandling(async () => {
588
- const { assigneeId = null, limit = 20 } = options;
1192
+ const { assigneeId = null, teamId = null, limit = 20 } = options;
589
1193
 
590
1194
  const filter = {
591
1195
  project: { id: { eq: projectId } },
@@ -599,6 +1203,10 @@ export async function fetchIssuesByProject(client, projectId, states, options =
599
1203
  filter.assignee = { id: { eq: assigneeId } };
600
1204
  }
601
1205
 
1206
+ if (teamId) {
1207
+ filter.team = { id: { eq: teamId } };
1208
+ }
1209
+
602
1210
  // Use optimized rawRequest to fetch issues with ALL relations in ONE request
603
1211
  // This eliminates the N+1 problem where each issue triggered 5 additional API calls
604
1212
  const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
@@ -639,196 +1247,674 @@ export async function fetchIssuesByProject(client, projectId, states, options =
639
1247
  /**
640
1248
  * Fetch all accessible projects from Linear API
641
1249
  * @param {LinearClient} client - Linear SDK client
1250
+ * @param {{ includeArchived?: boolean }} options - Fetch options
642
1251
  * @returns {Promise<Array<{id: string, name: string}>>}
643
1252
  */
644
- export async function fetchProjects(client) {
1253
+ export async function fetchProjects(client, options = {}) {
645
1254
  return withLinearErrorHandling(async () => {
646
- const result = await client.projects();
647
- const nodes = result.nodes ?? [];
1255
+ const { includeArchived = false, forceGraphql = false } = options;
1256
+ const cacheKey = getClientCacheKey(client);
1257
+ const scopedCacheKey = `${cacheKey}::projects::${includeArchived ? 'all' : 'active'}::${forceGraphql ? 'graphql' : 'sdk'}`;
1258
+ const cached = getCache(projectsCache, scopedCacheKey);
1259
+ if (cached) return cached;
1260
+
1261
+ let nodes = [];
1262
+ if (includeArchived || forceGraphql) {
1263
+ const data = await executeGraphQL(client, PROJECTS_LOOKUP_QUERY, {
1264
+ includeArchived,
1265
+ });
1266
+ nodes = data?.projects?.nodes ?? [];
1267
+ } else {
1268
+ const result = await client.projects();
1269
+ nodes = result.nodes ?? [];
1270
+ }
1271
+
1272
+ debug('Fetched Linear projects', {
1273
+ projectCount: nodes.length,
1274
+ includeArchived,
1275
+ projects: nodes.map((p) => ({ id: p.id, name: p.name })),
1276
+ });
1277
+
1278
+ const projects = nodes.map((p) => ({
1279
+ id: p.id,
1280
+ name: p.name,
1281
+ slugId: p.slugId ?? null,
1282
+ archivedAt: p.archivedAt ?? null,
1283
+ }));
1284
+ setCache(projectsCache, scopedCacheKey, projects, CACHE_TTL_MS.projects);
1285
+ return projects;
1286
+ }, 'fetchProjects');
1287
+ }
1288
+
1289
+ export async function fetchProjectDetails(client, projectRef, options = {}) {
1290
+ return withLinearErrorHandling(async () => {
1291
+ const { milestoneLimit = 10 } = options;
1292
+ const ref = String(projectRef || '').trim();
1293
+ const projectId = isLinearId(ref)
1294
+ ? ref
1295
+ : (await resolveProjectRef(client, ref)).id;
1296
+ const data = await executeGraphQL(client, PROJECT_DETAILS_QUERY, {
1297
+ id: projectId,
1298
+ milestoneLimit,
1299
+ });
1300
+
1301
+ if (!data?.project) {
1302
+ throw new Error(`Project not found: ${projectRef}`);
1303
+ }
1304
+
1305
+ return transformProject(data.project);
1306
+ }, 'fetchProjectDetails');
1307
+ }
1308
+
1309
+ /**
1310
+ * Fetch available workspaces (organization context) from Linear API
1311
+ * @param {LinearClient} client - Linear SDK client
1312
+ * @returns {Promise<Array<{id: string, name: string}>>}
1313
+ */
1314
+ export async function fetchWorkspaces(client) {
1315
+ return withLinearErrorHandling(async () => {
1316
+ const viewer = await client.viewer;
1317
+ const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
1318
+
1319
+ if (!organization) {
1320
+ debug('No organization available from viewer context');
1321
+ return [];
1322
+ }
1323
+
1324
+ const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
1325
+
1326
+ debug('Fetched Linear workspace from viewer organization', {
1327
+ workspace,
1328
+ });
1329
+
1330
+ return [workspace];
1331
+ }, 'fetchWorkspaces');
1332
+ }
1333
+
1334
+ /**
1335
+ * Fetch all accessible teams from Linear API
1336
+ * @param {LinearClient} client - Linear SDK client
1337
+ * @returns {Promise<Array<{id: string, key: string, name: string}>>}
1338
+ */
1339
+ export async function fetchTeams(client) {
1340
+ return withLinearErrorHandling(async () => {
1341
+ const cacheKey = getClientCacheKey(client);
1342
+ const cached = getCache(teamsCache, cacheKey);
1343
+ if (cached) return cached;
1344
+
1345
+ const result = await client.teams();
1346
+ const nodes = result.nodes ?? [];
1347
+
1348
+ debug('Fetched Linear teams', {
1349
+ teamCount: nodes.length,
1350
+ teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
1351
+ });
1352
+
1353
+ const teams = nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
1354
+ setCache(teamsCache, cacheKey, teams, CACHE_TTL_MS.teams);
1355
+ return teams;
1356
+ }, 'fetchTeams');
1357
+ }
1358
+
1359
+ /**
1360
+ * Resolve a team reference (key, name, or ID) to a team object
1361
+ * @param {LinearClient} client - Linear SDK client
1362
+ * @param {string} teamRef - Team key, name, or ID
1363
+ * @returns {Promise<{id: string, key: string, name: string}>}
1364
+ */
1365
+ export async function resolveTeamRef(client, teamRef) {
1366
+ const ref = String(teamRef || '').trim();
1367
+ if (!ref) {
1368
+ throw new Error('Missing team reference');
1369
+ }
1370
+
1371
+ // If it looks like a Linear ID (UUID), try direct lookup first (cheap)
1372
+ if (isLinearId(ref)) {
1373
+ try {
1374
+ const direct = await client.team(ref);
1375
+ if (direct) {
1376
+ return { id: direct.id, key: direct.key, name: direct.name };
1377
+ }
1378
+ } catch {
1379
+ // fall back to cached/full-team lookup below
1380
+ }
1381
+
1382
+ const byId = teams.find((t) => t.id === ref);
1383
+ if (byId) {
1384
+ return byId;
1385
+ }
1386
+ throw new Error(`Team not found with ID: ${ref}`);
1387
+ }
1388
+
1389
+ const teams = await fetchTeams(client);
1390
+
1391
+ // Try exact key match (e.g., "ENG")
1392
+ const byKey = teams.find((t) => t.key === ref);
1393
+ if (byKey) {
1394
+ return byKey;
1395
+ }
1396
+
1397
+ // Try exact name match
1398
+ const exactName = teams.find((t) => t.name === ref);
1399
+ if (exactName) {
1400
+ return exactName;
1401
+ }
1402
+
1403
+ // Try case-insensitive key or name match
1404
+ const lowerRef = ref.toLowerCase();
1405
+ const insensitiveMatch = teams.find(
1406
+ (t) => t.key?.toLowerCase() === lowerRef || t.name?.toLowerCase() === lowerRef
1407
+ );
1408
+ if (insensitiveMatch) {
1409
+ return insensitiveMatch;
1410
+ }
1411
+
1412
+ throw new Error(`Team not found: ${ref}. Available teams: ${teams.map((t) => `${t.key} (${t.name})`).join(', ')}`);
1413
+ }
1414
+
1415
+ /**
1416
+ * Resolve an issue by ID or identifier
1417
+ * @param {LinearClient} client - Linear SDK client
1418
+ * @param {string} issueRef - Issue identifier (ABC-123) or Linear issue ID
1419
+ * @returns {Promise<Object>} Resolved issue object
1420
+ */
1421
+ export async function resolveIssue(client, issueRef) {
1422
+ return withLinearErrorHandling(async () => {
1423
+ const lookup = normalizeIssueLookupInput(issueRef);
1424
+
1425
+ // The SDK's client.issue() method accepts both UUIDs and identifiers (ABC-123)
1426
+ try {
1427
+ const issue = await client.issue(lookup);
1428
+ if (issue) {
1429
+ return transformIssue(issue);
1430
+ }
1431
+ } catch (err) {
1432
+ // Fall through to error
1433
+ }
1434
+
1435
+ throw new Error(`Issue not found: ${lookup}`);
1436
+ }, 'resolveIssue');
1437
+ }
1438
+
1439
+ /**
1440
+ * Get workflow states for a team
1441
+ * @param {LinearClient} client - Linear SDK client
1442
+ * @param {string} teamRef - Team ID or key
1443
+ * @returns {Promise<Array<{id: string, name: string, type: string}>>}
1444
+ */
1445
+ export async function getTeamWorkflowStates(client, teamRef) {
1446
+ return withLinearErrorHandling(async () => {
1447
+ const cacheKey = `${getClientCacheKey(client)}::${teamRef}`;
1448
+ const cached = getCache(teamStatesCache, cacheKey);
1449
+ if (cached) return cached;
1450
+
1451
+ const team = await client.team(teamRef);
1452
+ if (!team) {
1453
+ throw new Error(`Team not found: ${teamRef}`);
1454
+ }
1455
+
1456
+ const states = await team.states();
1457
+ const mapped = (states.nodes || []).map(s => ({
1458
+ id: s.id,
1459
+ name: s.name,
1460
+ type: s.type,
1461
+ }));
1462
+
1463
+ setCache(teamStatesCache, cacheKey, mapped, CACHE_TTL_MS.teamStates);
1464
+ return mapped;
1465
+ }, 'getTeamWorkflowStates');
1466
+ }
1467
+
1468
+ /**
1469
+ * Resolve a project reference (name or ID) to a project object
1470
+ * @param {LinearClient} client - Linear SDK client
1471
+ * @param {string} projectRef - Project name or ID
1472
+ * @param {{ includeArchived?: boolean }} options - Lookup options
1473
+ * @returns {Promise<{id: string, name: string}>}
1474
+ */
1475
+ export async function resolveProjectRef(client, projectRef, options = {}) {
1476
+ const ref = String(projectRef || '').trim();
1477
+ const { includeArchived = false } = options;
1478
+ if (!ref) {
1479
+ throw new Error('Missing project reference');
1480
+ }
1481
+
1482
+ // If it looks like a Linear ID (UUID), try direct lookup first (cheap)
1483
+ if (isLinearId(ref)) {
1484
+ try {
1485
+ const direct = await client.project(ref);
1486
+ if (direct) {
1487
+ return { id: direct.id, name: direct.name };
1488
+ }
1489
+ } catch {
1490
+ // fall back to cached/full-project lookup below
1491
+ }
1492
+
1493
+ const projectsById = await fetchProjects(client, { includeArchived });
1494
+ const byId = projectsById.find((p) => p.id === ref);
1495
+ if (byId) {
1496
+ return byId;
1497
+ }
1498
+ throw new Error(`Project not found with ID: ${ref}`);
1499
+ }
1500
+
1501
+ const lookupCandidates = getProjectLookupCandidates(ref);
1502
+ const lookupValue = lookupCandidates[0] || ref;
1503
+ const shouldUseGraphqlLookup = lookupValue !== ref;
1504
+ const projects = await fetchProjects(client, {
1505
+ includeArchived,
1506
+ forceGraphql: shouldUseGraphqlLookup,
1507
+ });
1508
+
1509
+ // Try exact name match
1510
+ const exactName = projects.find((p) => p.name === ref);
1511
+ if (exactName) {
1512
+ return exactName;
1513
+ }
1514
+
1515
+ // Try exact slug match
1516
+ const exactSlug = projects.find((p) => lookupCandidates.includes(p.slugId));
1517
+ if (exactSlug) {
1518
+ return exactSlug;
1519
+ }
1520
+
1521
+ // Try case-insensitive name match
1522
+ const lowerRef = ref.toLowerCase();
1523
+ const insensitiveName = projects.find((p) => p.name?.toLowerCase() === lowerRef);
1524
+ if (insensitiveName) {
1525
+ return insensitiveName;
1526
+ }
1527
+
1528
+ // Try case-insensitive slug match
1529
+ const lowerLookupValues = lookupCandidates.map((candidate) => candidate.toLowerCase());
1530
+ const insensitiveSlug = projects.find((p) => p.slugId && lowerLookupValues.includes(p.slugId.toLowerCase()));
1531
+ if (insensitiveSlug) {
1532
+ return insensitiveSlug;
1533
+ }
1534
+
1535
+ throw new Error(`Project not found: ${ref}. Available projects: ${projects.map((p) => p.name).join(', ')}`);
1536
+ }
1537
+
1538
+ export async function createProject(client, input) {
1539
+ return withLinearErrorHandling(async () => {
1540
+ const name = String(input.name || '').trim();
1541
+ if (!name) {
1542
+ throw new Error('Missing required field: name');
1543
+ }
1544
+
1545
+ const teamIds = Array.isArray(input.teamIds)
1546
+ ? input.teamIds.map((value) => String(value || '').trim()).filter(Boolean)
1547
+ : [];
1548
+
1549
+ if (teamIds.length === 0) {
1550
+ throw new Error('Missing required field: teamIds');
1551
+ }
1552
+
1553
+ const createInput = {
1554
+ name,
1555
+ teamIds,
1556
+ };
1557
+
1558
+ for (const field of ['description', 'color', 'icon', 'leadId', 'startDate', 'targetDate']) {
1559
+ if (input[field] !== undefined) {
1560
+ createInput[field] = input[field];
1561
+ }
1562
+ }
1563
+
1564
+ if (input.priority !== undefined) {
1565
+ createInput.priority = input.priority;
1566
+ }
1567
+
1568
+ const payload = await executeGraphQL(client, PROJECT_CREATE_MUTATION, {
1569
+ input: createInput,
1570
+ });
1571
+
1572
+ if (!payload?.projectCreate?.success || !payload?.projectCreate?.project?.id) {
1573
+ throw new Error('Failed to create project');
1574
+ }
1575
+
1576
+ invalidateProjectsCache(client);
1577
+ return fetchProjectDetails(client, payload.projectCreate.project.id);
1578
+ }, 'createProject');
1579
+ }
1580
+
1581
+ export async function updateProject(client, projectRef, patch = {}) {
1582
+ return withLinearErrorHandling(async () => {
1583
+ const resolved = await resolveProjectRef(client, projectRef);
1584
+ const updateInput = {};
1585
+
1586
+ for (const field of ['name', 'description', 'content', 'color', 'icon', 'startDate', 'targetDate']) {
1587
+ if (patch[field] !== undefined) {
1588
+ updateInput[field] = patch[field];
1589
+ }
1590
+ }
1591
+
1592
+ if (patch.priority !== undefined) {
1593
+ updateInput.priority = patch.priority;
1594
+ }
1595
+
1596
+ if (patch.leadId !== undefined) {
1597
+ updateInput.leadId = patch.leadId;
1598
+ }
1599
+
1600
+ if (patch.teamIds !== undefined) {
1601
+ updateInput.teamIds = patch.teamIds;
1602
+ }
1603
+
1604
+ if (Object.keys(updateInput).length === 0) {
1605
+ throw new Error('No update fields provided');
1606
+ }
1607
+
1608
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_MUTATION, {
1609
+ id: resolved.id,
1610
+ input: updateInput,
1611
+ });
1612
+
1613
+ if (!payload?.projectUpdate?.success || !payload?.projectUpdate?.project?.id) {
1614
+ throw new Error('Failed to update project');
1615
+ }
1616
+
1617
+ invalidateProjectsCache(client);
1618
+ const project = await fetchProjectDetails(client, payload.projectUpdate.project.id);
1619
+
1620
+ return {
1621
+ project,
1622
+ changed: Object.keys(updateInput),
1623
+ };
1624
+ }, 'updateProject');
1625
+ }
1626
+
1627
+ export async function deleteProject(client, projectRef) {
1628
+ return withLinearErrorHandling(async () => {
1629
+ const resolved = await resolveProjectRef(client, projectRef);
1630
+ const payload = await executeGraphQL(client, PROJECT_DELETE_MUTATION, {
1631
+ id: resolved.id,
1632
+ });
1633
+
1634
+ if (!payload?.projectDelete?.success) {
1635
+ throw new Error('Failed to delete project');
1636
+ }
1637
+
1638
+ invalidateProjectsCache(client);
1639
+
1640
+ return {
1641
+ success: true,
1642
+ projectId: resolved.id,
1643
+ name: resolved.name,
1644
+ entity: transformProject(payload.projectDelete.entity),
1645
+ };
1646
+ }, 'deleteProject');
1647
+ }
1648
+
1649
+ export async function archiveProject(client, projectRef) {
1650
+ return withLinearErrorHandling(async () => {
1651
+ const resolved = await resolveProjectRef(client, projectRef);
1652
+ const payload = await executeGraphQL(client, PROJECT_ARCHIVE_MUTATION, {
1653
+ id: resolved.id,
1654
+ });
1655
+
1656
+ if (!payload?.projectArchiveResult?.success) {
1657
+ throw new Error('Failed to archive project');
1658
+ }
1659
+
1660
+ invalidateProjectsCache(client);
1661
+
1662
+ return {
1663
+ success: true,
1664
+ projectId: resolved.id,
1665
+ name: resolved.name,
1666
+ entity: transformProject(payload.projectArchiveResult.entity),
1667
+ };
1668
+ }, 'archiveProject');
1669
+ }
1670
+
1671
+ export async function unarchiveProject(client, projectRef) {
1672
+ return withLinearErrorHandling(async () => {
1673
+ const ref = String(projectRef || '').trim();
1674
+ const resolved = isLinearId(ref)
1675
+ ? { id: ref, name: null }
1676
+ : await resolveProjectRef(client, ref, { includeArchived: true });
1677
+
1678
+ const payload = await executeGraphQL(client, PROJECT_UNARCHIVE_MUTATION, {
1679
+ id: resolved.id,
1680
+ });
1681
+
1682
+ if (!payload?.projectUnarchive?.success) {
1683
+ throw new Error('Failed to unarchive project');
1684
+ }
1685
+
1686
+ invalidateProjectsCache(client);
1687
+ const project = await fetchProjectDetails(client, resolved.id);
1688
+
1689
+ return {
1690
+ success: true,
1691
+ project,
1692
+ };
1693
+ }, 'unarchiveProject');
1694
+ }
1695
+
1696
+ export async function fetchProjectUpdates(client, projectRef, options = {}) {
1697
+ return withLinearErrorHandling(async () => {
1698
+ const includeArchived = options.includeArchived === true;
1699
+ const limit = normalizePositiveInteger(options.limit, 'limit', 10);
1700
+ const resolved = await resolveProjectRef(client, projectRef, { includeArchived });
1701
+ const data = await executeGraphQL(client, PROJECT_UPDATES_BY_PROJECT_QUERY, {
1702
+ id: resolved.id,
1703
+ first: limit,
1704
+ includeArchived,
1705
+ });
1706
+
1707
+ const nodes = data?.project?.projectUpdates?.nodes || [];
1708
+ return {
1709
+ project: {
1710
+ id: data?.project?.id || resolved.id,
1711
+ name: data?.project?.name || resolved.name,
1712
+ },
1713
+ updates: nodes.map(transformProjectUpdate),
1714
+ };
1715
+ }, 'fetchProjectUpdates');
1716
+ }
1717
+
1718
+ export async function fetchProjectUpdateDetails(client, projectUpdateId) {
1719
+ return withLinearErrorHandling(async () => {
1720
+ const id = String(projectUpdateId || '').trim();
1721
+ if (!id) {
1722
+ throw new Error('Missing required field: projectUpdate');
1723
+ }
1724
+
1725
+ const data = await executeGraphQL(client, PROJECT_UPDATE_DETAILS_QUERY, { id });
648
1726
 
649
- debug('Fetched Linear projects', {
650
- projectCount: nodes.length,
651
- projects: nodes.map((p) => ({ id: p.id, name: p.name })),
652
- });
1727
+ if (!data?.projectUpdate) {
1728
+ throw new Error(`Project update not found: ${id}`);
1729
+ }
653
1730
 
654
- return nodes.map(p => ({ id: p.id, name: p.name }));
655
- }, 'fetchProjects');
1731
+ return transformProjectUpdate(data.projectUpdate);
1732
+ }, 'fetchProjectUpdateDetails');
656
1733
  }
657
1734
 
658
- /**
659
- * Fetch available workspaces (organization context) from Linear API
660
- * @param {LinearClient} client - Linear SDK client
661
- * @returns {Promise<Array<{id: string, name: string}>>}
662
- */
663
- export async function fetchWorkspaces(client) {
1735
+ export async function createProjectUpdate(client, input) {
664
1736
  return withLinearErrorHandling(async () => {
665
- const viewer = await client.viewer;
666
- const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
667
-
668
- if (!organization) {
669
- debug('No organization available from viewer context');
670
- return [];
1737
+ const projectId = String(input.projectId || '').trim();
1738
+ if (!projectId) {
1739
+ throw new Error('Missing required field: projectId');
671
1740
  }
672
1741
 
673
- const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
1742
+ const createInput = { projectId };
1743
+ if (input.body !== undefined) createInput.body = String(input.body);
1744
+ if (input.health !== undefined) createInput.health = normalizeProjectUpdateHealth(input.health);
1745
+ if (input.isDiffHidden !== undefined) createInput.isDiffHidden = input.isDiffHidden;
674
1746
 
675
- debug('Fetched Linear workspace from viewer organization', {
676
- workspace,
1747
+ if (createInput.body === undefined && createInput.health === undefined) {
1748
+ throw new Error('At least one of body or health is required');
1749
+ }
1750
+
1751
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_CREATE_MUTATION, {
1752
+ input: createInput,
677
1753
  });
678
1754
 
679
- return [workspace];
680
- }, 'fetchWorkspaces');
1755
+ if (!payload?.projectUpdateCreate?.success || !payload?.projectUpdateCreate?.projectUpdate?.id) {
1756
+ throw new Error('Failed to create project update');
1757
+ }
1758
+
1759
+ return fetchProjectUpdateDetails(client, payload.projectUpdateCreate.projectUpdate.id);
1760
+ }, 'createProjectUpdate');
681
1761
  }
682
1762
 
683
- /**
684
- * Fetch all accessible teams from Linear API
685
- * @param {LinearClient} client - Linear SDK client
686
- * @returns {Promise<Array<{id: string, key: string, name: string}>>}
687
- */
688
- export async function fetchTeams(client) {
1763
+ export async function updateProjectUpdate(client, projectUpdateId, patch = {}) {
689
1764
  return withLinearErrorHandling(async () => {
690
- const result = await client.teams();
691
- const nodes = result.nodes ?? [];
1765
+ const id = String(projectUpdateId || '').trim();
1766
+ if (!id) {
1767
+ throw new Error('Missing required field: projectUpdate');
1768
+ }
692
1769
 
693
- debug('Fetched Linear teams', {
694
- teamCount: nodes.length,
695
- teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
1770
+ const updateInput = {};
1771
+ if (patch.body !== undefined) updateInput.body = String(patch.body);
1772
+ if (patch.health !== undefined) updateInput.health = normalizeProjectUpdateHealth(patch.health);
1773
+ if (patch.isDiffHidden !== undefined) updateInput.isDiffHidden = patch.isDiffHidden;
1774
+
1775
+ if (Object.keys(updateInput).length === 0) {
1776
+ throw new Error('No update fields provided');
1777
+ }
1778
+
1779
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_UPDATE_MUTATION, {
1780
+ id,
1781
+ input: updateInput,
696
1782
  });
697
1783
 
698
- return nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
699
- }, 'fetchTeams');
700
- }
1784
+ if (!payload?.projectUpdateUpdate?.success || !payload?.projectUpdateUpdate?.projectUpdate?.id) {
1785
+ throw new Error('Failed to update project update');
1786
+ }
701
1787
 
702
- /**
703
- * Resolve a team reference (key, name, or ID) to a team object
704
- * @param {LinearClient} client - Linear SDK client
705
- * @param {string} teamRef - Team key, name, or ID
706
- * @returns {Promise<{id: string, key: string, name: string}>}
707
- */
708
- export async function resolveTeamRef(client, teamRef) {
709
- const ref = String(teamRef || '').trim();
710
- if (!ref) {
711
- throw new Error('Missing team reference');
712
- }
1788
+ const projectUpdate = await fetchProjectUpdateDetails(client, payload.projectUpdateUpdate.projectUpdate.id);
1789
+ return {
1790
+ projectUpdate,
1791
+ changed: Object.keys(updateInput),
1792
+ };
1793
+ }, 'updateProjectUpdate');
1794
+ }
713
1795
 
714
- const teams = await fetchTeams(client);
1796
+ export async function archiveProjectUpdate(client, projectUpdateId) {
1797
+ return withLinearErrorHandling(async () => {
1798
+ const id = String(projectUpdateId || '').trim();
1799
+ if (!id) {
1800
+ throw new Error('Missing required field: projectUpdate');
1801
+ }
715
1802
 
716
- // If it looks like a Linear ID (UUID), try direct lookup first
717
- if (isLinearId(ref)) {
718
- const byId = teams.find((t) => t.id === ref);
719
- if (byId) {
720
- return byId;
1803
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_ARCHIVE_MUTATION, { id });
1804
+ if (!payload?.projectUpdateArchive?.success) {
1805
+ throw new Error('Failed to archive project update');
721
1806
  }
722
- throw new Error(`Team not found with ID: ${ref}`);
723
- }
724
1807
 
725
- // Try exact key match (e.g., "ENG")
726
- const byKey = teams.find((t) => t.key === ref);
727
- if (byKey) {
728
- return byKey;
729
- }
1808
+ return {
1809
+ success: true,
1810
+ projectUpdateId: id,
1811
+ };
1812
+ }, 'archiveProjectUpdate');
1813
+ }
730
1814
 
731
- // Try exact name match
732
- const exactName = teams.find((t) => t.name === ref);
733
- if (exactName) {
734
- return exactName;
735
- }
1815
+ export async function unarchiveProjectUpdate(client, projectUpdateId) {
1816
+ return withLinearErrorHandling(async () => {
1817
+ const id = String(projectUpdateId || '').trim();
1818
+ if (!id) {
1819
+ throw new Error('Missing required field: projectUpdate');
1820
+ }
736
1821
 
737
- // Try case-insensitive key or name match
738
- const lowerRef = ref.toLowerCase();
739
- const insensitiveMatch = teams.find(
740
- (t) => t.key?.toLowerCase() === lowerRef || t.name?.toLowerCase() === lowerRef
741
- );
742
- if (insensitiveMatch) {
743
- return insensitiveMatch;
744
- }
1822
+ const payload = await executeGraphQL(client, PROJECT_UPDATE_UNARCHIVE_MUTATION, { id });
1823
+ if (!payload?.projectUpdateUnarchive?.success) {
1824
+ throw new Error('Failed to unarchive project update');
1825
+ }
745
1826
 
746
- throw new Error(`Team not found: ${ref}. Available teams: ${teams.map((t) => `${t.key} (${t.name})`).join(', ')}`);
1827
+ const projectUpdate = await fetchProjectUpdateDetails(client, id);
1828
+ return {
1829
+ success: true,
1830
+ projectUpdate,
1831
+ };
1832
+ }, 'unarchiveProjectUpdate');
747
1833
  }
748
1834
 
749
- /**
750
- * Resolve an issue by ID or identifier
751
- * @param {LinearClient} client - Linear SDK client
752
- * @param {string} issueRef - Issue identifier (ABC-123) or Linear issue ID
753
- * @returns {Promise<Object>} Resolved issue object
754
- */
755
- export async function resolveIssue(client, issueRef) {
1835
+ export async function fetchDocumentDetails(client, documentRef) {
756
1836
  return withLinearErrorHandling(async () => {
757
- const lookup = normalizeIssueLookupInput(issueRef);
1837
+ const id = String(documentRef || '').trim();
1838
+ if (!id) {
1839
+ throw new Error('Missing required field: document');
1840
+ }
758
1841
 
759
- // The SDK's client.issue() method accepts both UUIDs and identifiers (ABC-123)
760
- try {
761
- const issue = await client.issue(lookup);
762
- if (issue) {
763
- return transformIssue(issue);
764
- }
765
- } catch (err) {
766
- // Fall through to error
1842
+ const data = await executeGraphQL(client, DOCUMENT_DETAILS_QUERY, { id });
1843
+
1844
+ if (!data?.document) {
1845
+ throw new Error(`Document not found: ${id}`);
767
1846
  }
768
1847
 
769
- throw new Error(`Issue not found: ${lookup}`);
770
- }, 'resolveIssue');
1848
+ return transformDocument(data.document);
1849
+ }, 'fetchDocumentDetails');
771
1850
  }
772
1851
 
773
- /**
774
- * Get workflow states for a team
775
- * @param {LinearClient} client - Linear SDK client
776
- * @param {string} teamRef - Team ID or key
777
- * @returns {Promise<Array<{id: string, name: string, type: string}>>}
778
- */
779
- export async function getTeamWorkflowStates(client, teamRef) {
1852
+ export async function createDocument(client, input = {}) {
780
1853
  return withLinearErrorHandling(async () => {
781
- const team = await client.team(teamRef);
782
- if (!team) {
783
- throw new Error(`Team not found: ${teamRef}`);
1854
+ const title = String(input.title || '').trim();
1855
+ if (!title) {
1856
+ throw new Error('Missing required field: title');
784
1857
  }
785
1858
 
786
- const states = await team.states();
787
- return (states.nodes || []).map(s => ({
788
- id: s.id,
789
- name: s.name,
790
- type: s.type,
791
- }));
792
- }, 'getTeamWorkflowStates');
1859
+ const createInput = { title };
1860
+ if (input.projectId !== undefined) createInput.projectId = input.projectId;
1861
+ if (input.issueId !== undefined) createInput.issueId = input.issueId;
1862
+
1863
+ if (!createInput.projectId && !createInput.issueId) {
1864
+ throw new Error('Document create requires either projectId or issueId');
1865
+ }
1866
+
1867
+ for (const field of ['content', 'icon', 'color']) {
1868
+ if (input[field] !== undefined) {
1869
+ createInput[field] = input[field];
1870
+ }
1871
+ }
1872
+
1873
+ const payload = await executeGraphQL(client, DOCUMENT_CREATE_MUTATION, {
1874
+ input: createInput,
1875
+ });
1876
+
1877
+ if (!payload?.documentCreate?.success || !payload?.documentCreate?.document?.id) {
1878
+ throw new Error('Failed to create document');
1879
+ }
1880
+
1881
+ return fetchDocumentDetails(client, payload.documentCreate.document.id);
1882
+ }, 'createDocument');
793
1883
  }
794
1884
 
795
- /**
796
- * Resolve a project reference (name or ID) to a project object
797
- * @param {LinearClient} client - Linear SDK client
798
- * @param {string} projectRef - Project name or ID
799
- * @returns {Promise<{id: string, name: string}>}
800
- */
801
- export async function resolveProjectRef(client, projectRef) {
802
- const ref = String(projectRef || '').trim();
803
- if (!ref) {
804
- throw new Error('Missing project reference');
805
- }
1885
+ export async function updateDocument(client, documentRef, patch = {}) {
1886
+ return withLinearErrorHandling(async () => {
1887
+ const id = String(documentRef || '').trim();
1888
+ if (!id) {
1889
+ throw new Error('Missing required field: document');
1890
+ }
806
1891
 
807
- const projects = await fetchProjects(client);
1892
+ const updateInput = {};
1893
+ for (const field of ['title', 'content', 'icon', 'color', 'projectId', 'issueId']) {
1894
+ if (patch[field] !== undefined) {
1895
+ updateInput[field] = patch[field];
1896
+ }
1897
+ }
808
1898
 
809
- // If it looks like a Linear ID (UUID), try direct lookup first
810
- if (isLinearId(ref)) {
811
- const byId = projects.find((p) => p.id === ref);
812
- if (byId) {
813
- return byId;
1899
+ if (Object.keys(updateInput).length === 0) {
1900
+ throw new Error('No update fields provided');
814
1901
  }
815
- throw new Error(`Project not found with ID: ${ref}`);
816
- }
817
1902
 
818
- // Try exact name match
819
- const exactName = projects.find((p) => p.name === ref);
820
- if (exactName) {
821
- return exactName;
822
- }
1903
+ const payload = await executeGraphQL(client, DOCUMENT_UPDATE_MUTATION, {
1904
+ id,
1905
+ input: updateInput,
1906
+ });
823
1907
 
824
- // Try case-insensitive name match
825
- const lowerRef = ref.toLowerCase();
826
- const insensitiveName = projects.find((p) => p.name?.toLowerCase() === lowerRef);
827
- if (insensitiveName) {
828
- return insensitiveName;
829
- }
1908
+ if (!payload?.documentUpdate?.success || !payload?.documentUpdate?.document?.id) {
1909
+ throw new Error('Failed to update document');
1910
+ }
830
1911
 
831
- throw new Error(`Project not found: ${ref}. Available projects: ${projects.map((p) => p.name).join(', ')}`);
1912
+ const document = await fetchDocumentDetails(client, payload.documentUpdate.document.id);
1913
+ return {
1914
+ document,
1915
+ changed: Object.keys(updateInput),
1916
+ };
1917
+ }, 'updateDocument');
832
1918
  }
833
1919
 
834
1920
  /**
@@ -945,6 +2031,79 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
945
2031
  }, 'fetchIssueDetails');
946
2032
  }
947
2033
 
2034
+ export async function fetchIssueActivity(client, issueRef, options = {}) {
2035
+ return withLinearErrorHandling(async () => {
2036
+ const limit = normalizePositiveInteger(options.limit, 'limit', 20);
2037
+ const includeArchived = options.includeArchived === true;
2038
+ const resolved = await resolveIssue(client, issueRef);
2039
+ const data = await executeGraphQL(client, ISSUE_ACTIVITY_QUERY, {
2040
+ id: resolved.id,
2041
+ first: limit,
2042
+ includeArchived,
2043
+ });
2044
+
2045
+ if (!data?.issue) {
2046
+ throw new Error(`Issue not found: ${issueRef}`);
2047
+ }
2048
+
2049
+ return {
2050
+ issue: {
2051
+ id: data.issue.id,
2052
+ identifier: data.issue.identifier,
2053
+ title: data.issue.title,
2054
+ url: data.issue.url ?? null,
2055
+ },
2056
+ activity: (data.issue.history?.nodes || []).map((entry) => ({
2057
+ id: entry.id,
2058
+ createdAt: entry.createdAt ?? null,
2059
+ updatedAt: entry.updatedAt ?? null,
2060
+ actor: entry.actor ? {
2061
+ id: entry.actor.id,
2062
+ name: entry.actor.name,
2063
+ displayName: entry.actor.displayName,
2064
+ } : null,
2065
+ fromState: entry.fromState ? { id: entry.fromState.id, name: entry.fromState.name } : null,
2066
+ toState: entry.toState ? { id: entry.toState.id, name: entry.toState.name } : null,
2067
+ fromAssignee: entry.fromAssignee ? {
2068
+ id: entry.fromAssignee.id,
2069
+ name: entry.fromAssignee.name,
2070
+ displayName: entry.fromAssignee.displayName,
2071
+ } : null,
2072
+ toAssignee: entry.toAssignee ? {
2073
+ id: entry.toAssignee.id,
2074
+ name: entry.toAssignee.name,
2075
+ displayName: entry.toAssignee.displayName,
2076
+ } : null,
2077
+ fromTitle: entry.fromTitle ?? null,
2078
+ toTitle: entry.toTitle ?? null,
2079
+ fromPriority: entry.fromPriority ?? null,
2080
+ toPriority: entry.toPriority ?? null,
2081
+ fromProject: entry.fromProject ? { id: entry.fromProject.id, name: entry.fromProject.name } : null,
2082
+ toProject: entry.toProject ? { id: entry.toProject.id, name: entry.toProject.name } : null,
2083
+ fromProjectMilestone: entry.fromProjectMilestone ? { id: entry.fromProjectMilestone.id, name: entry.fromProjectMilestone.name } : null,
2084
+ toProjectMilestone: entry.toProjectMilestone ? { id: entry.toProjectMilestone.id, name: entry.toProjectMilestone.name } : null,
2085
+ addedLabels: (entry.addedLabels || []).map((label) => ({ id: label.id, name: label.name })),
2086
+ removedLabels: (entry.removedLabels || []).map((label) => ({ id: label.id, name: label.name })),
2087
+ relationChanges: (entry.relationChanges || []).map((relation) => ({
2088
+ identifier: relation.identifier,
2089
+ type: relation.type,
2090
+ })),
2091
+ attachment: entry.attachment ? {
2092
+ id: entry.attachment.id,
2093
+ title: entry.attachment.title,
2094
+ url: entry.attachment.url,
2095
+ } : null,
2096
+ archived: entry.archived ?? null,
2097
+ archivedAt: entry.archivedAt ?? null,
2098
+ autoArchived: entry.autoArchived ?? false,
2099
+ autoClosed: entry.autoClosed ?? false,
2100
+ trashed: entry.trashed ?? null,
2101
+ updatedDescription: entry.updatedDescription ?? false,
2102
+ })),
2103
+ };
2104
+ }, 'fetchIssueActivity');
2105
+ }
2106
+
948
2107
  // ===== MUTATION FUNCTIONS =====
949
2108
 
950
2109
  /**
@@ -1114,6 +2273,47 @@ export async function addIssueComment(client, issueRef, body, parentCommentId) {
1114
2273
  * @param {Object} patch - Fields to update
1115
2274
  * @returns {Promise<{issue: Object, changed: Array<string>}>}
1116
2275
  */
2276
+ function buildFallbackUpdatedIssue(targetIssue, updateInput) {
2277
+ const fallback = { ...(targetIssue || {}) };
2278
+
2279
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'title')) {
2280
+ fallback.title = updateInput.title;
2281
+ }
2282
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'description')) {
2283
+ fallback.description = updateInput.description;
2284
+ }
2285
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'priority')) {
2286
+ fallback.priority = updateInput.priority;
2287
+ }
2288
+
2289
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'assigneeId')) {
2290
+ const assigneeId = updateInput.assigneeId;
2291
+ if (assigneeId === null) {
2292
+ fallback.assignee = null;
2293
+ } else if (typeof assigneeId === 'string' && assigneeId.trim()) {
2294
+ fallback.assignee = {
2295
+ id: assigneeId,
2296
+ name: fallback.assignee?.name || 'Unknown',
2297
+ displayName: fallback.assignee?.displayName || 'Unknown',
2298
+ };
2299
+ }
2300
+ }
2301
+
2302
+ if (Object.prototype.hasOwnProperty.call(updateInput, 'projectMilestoneId')) {
2303
+ const milestoneId = updateInput.projectMilestoneId;
2304
+ if (milestoneId === null) {
2305
+ fallback.projectMilestone = null;
2306
+ } else if (typeof milestoneId === 'string' && milestoneId.trim()) {
2307
+ fallback.projectMilestone = {
2308
+ id: milestoneId,
2309
+ name: fallback.projectMilestone?.name || 'Unknown',
2310
+ };
2311
+ }
2312
+ }
2313
+
2314
+ return fallback;
2315
+ }
2316
+
1117
2317
  export async function updateIssue(client, issueRef, patch = {}) {
1118
2318
  return withLinearErrorHandling(async () => {
1119
2319
  const targetIssue = await resolveIssue(client, issueRef);
@@ -1275,14 +2475,28 @@ export async function updateIssue(client, issueRef, patch = {}) {
1275
2475
  }
1276
2476
 
1277
2477
  // Prefer official data path: refetch the issue after successful mutation.
1278
- let updatedSdkIssue = null;
2478
+ // If this extra fetch is rate-limited, keep operation successful and return fallback data.
2479
+ let updatedIssue = null;
2480
+ let usedRateLimitFallback = false;
1279
2481
  try {
1280
- updatedSdkIssue = await client.issue(targetIssue.id);
1281
- } catch {
1282
- updatedSdkIssue = null;
1283
- }
2482
+ const updatedSdkIssue = await client.issue(targetIssue.id);
2483
+ updatedIssue = await transformIssue(updatedSdkIssue);
2484
+ } catch (refreshError) {
2485
+ const refreshMessage = String(refreshError?.message || refreshError || 'unknown');
2486
+ const isRefreshRateLimited = refreshError?.type === 'Ratelimited' || refreshMessage.toLowerCase().includes('rate limit');
2487
+
2488
+ if (!isRefreshRateLimited) {
2489
+ throw refreshError;
2490
+ }
2491
+
2492
+ debug('updateIssue: post-update refresh was rate-limited; returning fallback issue payload', {
2493
+ issueRef,
2494
+ issueId: targetIssue?.id,
2495
+ });
1284
2496
 
1285
- const updatedIssue = await transformIssue(updatedSdkIssue);
2497
+ usedRateLimitFallback = true;
2498
+ updatedIssue = buildFallbackUpdatedIssue(targetIssue, updateInput);
2499
+ }
1286
2500
 
1287
2501
  const changed = [...Object.keys(updateInput)];
1288
2502
  if (parentOfRefs.length > 0) changed.push('parentOf');
@@ -1294,6 +2508,7 @@ export async function updateIssue(client, issueRef, patch = {}) {
1294
2508
  return {
1295
2509
  issue: updatedIssue || targetIssue,
1296
2510
  changed,
2511
+ usedRateLimitFallback,
1297
2512
  };
1298
2513
  }, 'updateIssue');
1299
2514
  }
@@ -1373,6 +2588,113 @@ async function transformMilestone(sdkMilestone) {
1373
2588
  };
1374
2589
  }
1375
2590
 
2591
+ function transformProject(rawProject) {
2592
+ if (!rawProject) return null;
2593
+
2594
+ const milestoneNodes = rawProject.projectMilestones?.nodes || [];
2595
+ const teamNodes = rawProject.teams?.nodes || [];
2596
+
2597
+ return {
2598
+ id: rawProject.id,
2599
+ name: rawProject.name,
2600
+ description: rawProject.description ?? '',
2601
+ content: rawProject.content ?? null,
2602
+ color: rawProject.color ?? null,
2603
+ icon: rawProject.icon ?? null,
2604
+ priority: rawProject.priority ?? null,
2605
+ progress: rawProject.progress ?? null,
2606
+ health: rawProject.health ?? null,
2607
+ startDate: rawProject.startDate ?? null,
2608
+ targetDate: rawProject.targetDate ?? null,
2609
+ slugId: rawProject.slugId ?? null,
2610
+ url: rawProject.url ?? null,
2611
+ archivedAt: rawProject.archivedAt ?? null,
2612
+ completedAt: rawProject.completedAt ?? null,
2613
+ canceledAt: rawProject.canceledAt ?? null,
2614
+ status: rawProject.status ? {
2615
+ id: rawProject.status.id,
2616
+ name: rawProject.status.name,
2617
+ type: rawProject.status.type,
2618
+ color: rawProject.status.color,
2619
+ } : null,
2620
+ lead: rawProject.lead ? {
2621
+ id: rawProject.lead.id,
2622
+ name: rawProject.lead.name,
2623
+ displayName: rawProject.lead.displayName,
2624
+ } : null,
2625
+ teams: teamNodes.map((team) => ({
2626
+ id: team.id,
2627
+ key: team.key,
2628
+ name: team.name,
2629
+ })),
2630
+ projectMilestones: milestoneNodes.map((milestone) => ({
2631
+ id: milestone.id,
2632
+ name: milestone.name,
2633
+ status: milestone.status,
2634
+ progress: milestone.progress ?? null,
2635
+ targetDate: milestone.targetDate ?? null,
2636
+ })),
2637
+ };
2638
+ }
2639
+
2640
+ function transformProjectUpdate(rawUpdate) {
2641
+ if (!rawUpdate) return null;
2642
+
2643
+ return {
2644
+ id: rawUpdate.id,
2645
+ body: rawUpdate.body ?? '',
2646
+ health: rawUpdate.health ?? null,
2647
+ createdAt: rawUpdate.createdAt ?? null,
2648
+ updatedAt: rawUpdate.updatedAt ?? null,
2649
+ archivedAt: rawUpdate.archivedAt ?? null,
2650
+ editedAt: rawUpdate.editedAt ?? null,
2651
+ url: rawUpdate.url ?? null,
2652
+ slugId: rawUpdate.slugId ?? null,
2653
+ isDiffHidden: rawUpdate.isDiffHidden ?? false,
2654
+ isStale: rawUpdate.isStale ?? false,
2655
+ project: rawUpdate.project ? {
2656
+ id: rawUpdate.project.id,
2657
+ name: rawUpdate.project.name,
2658
+ } : null,
2659
+ user: rawUpdate.user ? {
2660
+ id: rawUpdate.user.id,
2661
+ name: rawUpdate.user.name,
2662
+ displayName: rawUpdate.user.displayName,
2663
+ } : null,
2664
+ };
2665
+ }
2666
+
2667
+ function transformDocument(rawDocument) {
2668
+ if (!rawDocument) return null;
2669
+
2670
+ return {
2671
+ id: rawDocument.id,
2672
+ title: rawDocument.title ?? '',
2673
+ content: rawDocument.content ?? '',
2674
+ icon: rawDocument.icon ?? null,
2675
+ color: rawDocument.color ?? null,
2676
+ slugId: rawDocument.slugId ?? null,
2677
+ url: rawDocument.url ?? null,
2678
+ archivedAt: rawDocument.archivedAt ?? null,
2679
+ createdAt: rawDocument.createdAt ?? null,
2680
+ updatedAt: rawDocument.updatedAt ?? null,
2681
+ project: rawDocument.project ? {
2682
+ id: rawDocument.project.id,
2683
+ name: rawDocument.project.name,
2684
+ } : null,
2685
+ issue: rawDocument.issue ? {
2686
+ id: rawDocument.issue.id,
2687
+ identifier: rawDocument.issue.identifier,
2688
+ title: rawDocument.issue.title,
2689
+ } : null,
2690
+ };
2691
+ }
2692
+
2693
+ function invalidateProjectsCache(client) {
2694
+ const cacheKey = getClientCacheKey(client);
2695
+ projectsCache.delete(cacheKey);
2696
+ }
2697
+
1376
2698
  /**
1377
2699
  * Fetch milestones for a project
1378
2700
  * @param {LinearClient} client - Linear SDK client
@@ -1868,3 +3190,31 @@ export function formatIssueAsMarkdown(issueData, options = {}) {
1868
3190
 
1869
3191
  return lines.join('\n');
1870
3192
  }
3193
+
3194
+ export function formatIssueActivityAsMarkdown(issueData, options = {}) {
3195
+ const { limit } = options;
3196
+ const lines = [`# Activity for ${issueData.issue.identifier}: ${issueData.issue.title}`];
3197
+
3198
+ if (issueData.issue.url) {
3199
+ lines.push('');
3200
+ lines.push(`**URL:** ${issueData.issue.url}`);
3201
+ }
3202
+
3203
+ if (!issueData.activity || issueData.activity.length === 0) {
3204
+ lines.push('');
3205
+ lines.push('No activity entries found.');
3206
+ return lines.join('\n');
3207
+ }
3208
+
3209
+ lines.push('');
3210
+ lines.push(`Showing ${issueData.activity.length}${limit ? ` of up to ${limit}` : ''} activity entries.`);
3211
+ lines.push('');
3212
+
3213
+ for (const entry of issueData.activity) {
3214
+ const actor = entry.actor ? getUserDisplayName(entry.actor) : 'System';
3215
+ const timestamp = entry.createdAt ? formatRelativeTime(entry.createdAt) : 'unknown time';
3216
+ lines.push(`- **${actor}** ${summarizeIssueHistoryEntry(entry)} _(${timestamp})_`);
3217
+ }
3218
+
3219
+ return lines.join('\n');
3220
+ }