@fink-andreas/pi-linear-tools 0.2.0 → 0.3.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
@@ -7,6 +7,330 @@
7
7
 
8
8
  import { warn, info, debug } from './logger.js';
9
9
 
10
+ // ===== OPTIMIZED GRAPHQL QUERIES =====
11
+ // These queries fetch relations upfront to avoid N+1 API calls
12
+
13
+ /**
14
+ * Optimized GraphQL query to fetch issues with all relations in a single request
15
+ * This reduces API calls from ~251 (N+1) to 1 per query
16
+ */
17
+ const ISSUES_WITH_RELATIONS_QUERY = `
18
+ query IssuesWithRelations($first: Int, $filter: IssueFilter) {
19
+ issues(first: $first, filter: $filter) {
20
+ nodes {
21
+ id
22
+ identifier
23
+ title
24
+ description
25
+ url
26
+ branchName
27
+ priority
28
+ state {
29
+ id
30
+ name
31
+ type
32
+ }
33
+ team {
34
+ id
35
+ key
36
+ name
37
+ }
38
+ project {
39
+ id
40
+ name
41
+ }
42
+ projectMilestone {
43
+ id
44
+ name
45
+ }
46
+ assignee {
47
+ id
48
+ name
49
+ displayName
50
+ }
51
+ }
52
+ pageInfo {
53
+ hasNextPage
54
+ endCursor
55
+ }
56
+ }
57
+ }
58
+ `;
59
+
60
+ /**
61
+ * Execute an optimized GraphQL query using rawRequest
62
+ * Falls back to SDK method if rawRequest is not available (e.g., in tests)
63
+ * @param {LinearClient} client - Linear SDK client
64
+ * @param {string} query - GraphQL query string
65
+ * @param {Object} variables - Query variables
66
+ * @returns {Promise<{data: Object, headers: Headers}>}
67
+ */
68
+ async function executeOptimizedQuery(client, query, variables) {
69
+ // Try rawRequest first (preferred - more efficient)
70
+ if (typeof client.rawRequest === 'function') {
71
+ const response = await client.rawRequest(query, variables);
72
+ return {
73
+ data: response.data,
74
+ headers: response.headers,
75
+ };
76
+ }
77
+
78
+ // Fallback to SDK method for testing/compatibility
79
+ if (typeof client.client?.rawRequest === 'function') {
80
+ const response = await client.client.rawRequest(query, variables);
81
+ return {
82
+ data: response.data,
83
+ headers: response.headers,
84
+ };
85
+ }
86
+
87
+ // Fallback: use SDK's issues() method (less efficient but always available)
88
+ warn('executeOptimizedQuery: rawRequest not available, falling back to SDK method');
89
+ const filter = variables.filter || {};
90
+ const result = await client.issues({
91
+ first: variables.first,
92
+ filter,
93
+ });
94
+
95
+ return {
96
+ data: {
97
+ issues: {
98
+ nodes: result.nodes || [],
99
+ pageInfo: result.pageInfo || { hasNextPage: false },
100
+ },
101
+ },
102
+ headers: new Headers(),
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Transform raw GraphQL issue data to plain object format
108
+ * Used by optimized queries to avoid SDK lazy loading
109
+ */
110
+ function transformRawIssue(rawIssue) {
111
+ if (!rawIssue) return null;
112
+
113
+ return {
114
+ id: rawIssue.id,
115
+ identifier: rawIssue.identifier,
116
+ title: rawIssue.title,
117
+ description: rawIssue.description,
118
+ url: rawIssue.url,
119
+ branchName: rawIssue.branchName,
120
+ priority: rawIssue.priority,
121
+ state: rawIssue.state ? { id: rawIssue.state.id, name: rawIssue.state.name, type: rawIssue.state.type } : null,
122
+ team: rawIssue.team ? { id: rawIssue.team.id, key: rawIssue.team.key, name: rawIssue.team.name } : null,
123
+ project: rawIssue.project ? { id: rawIssue.project.id, name: rawIssue.project.name } : null,
124
+ projectMilestone: rawIssue.projectMilestone ? { id: rawIssue.projectMilestone.id, name: rawIssue.projectMilestone.name } : null,
125
+ assignee: rawIssue.assignee ? { id: rawIssue.assignee.id, name: rawIssue.assignee.name, displayName: rawIssue.assignee.displayName } : null,
126
+ };
127
+ }
128
+
129
+ // ===== RATE LIMIT TRACKING =====
130
+
131
+ /**
132
+ * Track rate limit status from API responses
133
+ * Linear API returns headers: X-RateLimit-Requests-Remaining, X-RateLimit-Requests-Reset
134
+ */
135
+ const rateLimitState = {
136
+ remaining: null,
137
+ resetAt: null,
138
+ lastWarnAt: 0,
139
+ };
140
+
141
+ /**
142
+ * Update rate limit state from response headers
143
+ * @param {Response} response - Fetch response object
144
+ */
145
+ export function updateRateLimitState(response) {
146
+ if (!response) return;
147
+
148
+ const headers = response.headers;
149
+ if (headers) {
150
+ const remaining = headers.get('X-RateLimit-Requests-Remaining');
151
+ const resetAt = headers.get('X-RateLimit-Requests-Reset');
152
+
153
+ if (remaining !== null) {
154
+ rateLimitState.remaining = parseInt(remaining, 10);
155
+ }
156
+ if (resetAt !== null) {
157
+ rateLimitState.resetAt = parseInt(resetAt, 10);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Get current rate limit status
164
+ * @returns {{remaining: number|null, resetAt: number|null, resetTime: string|null, usagePercent: number|null, shouldWarn: boolean}}
165
+ */
166
+ export function getRateLimitStatus() {
167
+ const result = {
168
+ remaining: rateLimitState.remaining,
169
+ resetAt: rateLimitState.resetAt,
170
+ resetTime: null,
171
+ usagePercent: null,
172
+ shouldWarn: false,
173
+ };
174
+
175
+ if (rateLimitState.resetAt) {
176
+ result.resetTime = new Date(rateLimitState.resetAt).toLocaleTimeString();
177
+ const remaining = rateLimitState.remaining;
178
+ if (remaining !== null && remaining <= 1000) {
179
+ result.usagePercent = Math.round(((5000 - remaining) / 5000) * 100);
180
+ result.shouldWarn = remaining <= 500;
181
+ }
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Check and warn about low rate limit
189
+ */
190
+ function checkRateLimitWarning() {
191
+ const now = Date.now();
192
+ // Only warn once per 30 seconds to avoid spam
193
+ if (now - rateLimitState.lastWarnAt < 30000) return;
194
+
195
+ const status = getRateLimitStatus();
196
+ if (status.shouldWarn && status.remaining !== null) {
197
+ rateLimitState.lastWarnAt = now;
198
+ warn(`Linear API rate limit running low: ${status.remaining} requests remaining (~${status.usagePercent}% used). Resets at ${status.resetTime}`, {
199
+ remaining: status.remaining,
200
+ resetAt: status.resetTime,
201
+ usagePercent: status.usagePercent,
202
+ });
203
+ }
204
+ }
205
+
206
+ // ===== ERROR HANDLING =====
207
+
208
+ /**
209
+ * Check if an error is a Linear SDK error type
210
+ * @param {Error} error - The error to check
211
+ * @returns {boolean}
212
+ */
213
+ function isLinearError(error) {
214
+ return error?.constructor?.name?.includes('LinearError') ||
215
+ error?.name?.includes('LinearError') ||
216
+ error?.type?.startsWith?.('Ratelimited') ||
217
+ error?.type?.startsWith?.('Forbidden') ||
218
+ error?.type?.startsWith?.('Authentication') ||
219
+ error?.type === 'invalid_request' ||
220
+ error?.type === 'NetworkError' ||
221
+ error?.type === 'InternalError';
222
+ }
223
+
224
+ /**
225
+ * Format a Linear API error into a user-friendly message
226
+ * @param {Error} error - The original error
227
+ * @returns {Error} Formatted error with user-friendly message
228
+ */
229
+ function formatLinearError(error) {
230
+ const message = String(error?.message || error || 'Unknown error');
231
+ const errorType = error?.type || 'Unknown';
232
+
233
+ // Rate limit error
234
+ if (errorType === 'Ratelimited' || message.toLowerCase().includes('rate limit')) {
235
+ const retryAfter = error?.retryAfter;
236
+ const resetAt = error?.requestsResetAt
237
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
238
+ : '1 hour';
239
+
240
+ return new Error(
241
+ `Linear API rate limit exceeded. Please wait before making more requests.\n` +
242
+ `Rate limit resets at: ${resetAt}\n` +
243
+ `Hint: Reduce request frequency or wait before retrying.`
244
+ );
245
+ }
246
+
247
+ // Authentication/Forbidden errors
248
+ if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
249
+ message.toLowerCase().includes('forbidden') || message.toLowerCase().includes('unauthorized')) {
250
+ return new Error(
251
+ `${message}\nHint: Check your Linear API key or OAuth token permissions.`
252
+ );
253
+ }
254
+
255
+ // Network errors
256
+ if (errorType === 'NetworkError' || message.toLowerCase().includes('network')) {
257
+ return new Error(
258
+ `Network error while communicating with Linear API.\nHint: Check your internet connection and try again.`
259
+ );
260
+ }
261
+
262
+ // Internal server errors
263
+ if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
264
+ return new Error(
265
+ `Linear API server error (${error?.status || 'unknown'}).\nHint: Linear may be experiencing issues. Try again later.`
266
+ );
267
+ }
268
+
269
+ // Generic Linear API error
270
+ if (isLinearError(error)) {
271
+ return new Error(
272
+ `Linear API error: ${message}`
273
+ );
274
+ }
275
+
276
+ return error;
277
+ }
278
+
279
+ /**
280
+ * Wrap an async function with Linear-specific error handling
281
+ * @param {Function} fn - Async function to wrap
282
+ * @param {string} operation - Description of the operation for error messages
283
+ * @returns {Promise<*>} Result of the wrapped function
284
+ */
285
+ async function withLinearErrorHandling(fn, operation = 'Linear API operation') {
286
+ try {
287
+ return await fn();
288
+ } catch (error) {
289
+ if (isLinearError(error)) {
290
+ const formatted = formatLinearError(error);
291
+ debug(`${operation} failed with Linear error`, {
292
+ originalError: error?.message,
293
+ errorType: error?.type,
294
+ formattedMessage: formatted.message,
295
+ });
296
+ throw formatted;
297
+ }
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Wrap a handler function with comprehensive error handling for Linear API errors.
304
+ * This provides user-friendly error messages for rate limits, auth issues, etc.
305
+ * Use this in the execute() functions of tool handlers.
306
+ *
307
+ * @param {Function} fn - Async handler function to wrap
308
+ * @param {string} operation - Description of the operation for error messages
309
+ * @returns {Promise<*>} Result of the wrapped function
310
+ * @example
311
+ * ```js
312
+ * export async function executeIssueList(client, params) {
313
+ * return withHandlerErrorHandling(async () => {
314
+ * // ... implementation
315
+ * }, 'executeIssueList');
316
+ * }
317
+ * ```
318
+ */
319
+ export async function withHandlerErrorHandling(fn, operation = 'Handler') {
320
+ return withLinearErrorHandling(async () => {
321
+ try {
322
+ return await fn();
323
+ } catch (error) {
324
+ // Log additional context for unexpected errors
325
+ debug(`${operation} failed unexpectedly`, {
326
+ error: error?.message,
327
+ stack: error?.stack,
328
+ });
329
+ throw error;
330
+ }
331
+ }, operation);
332
+ }
333
+
10
334
  // ===== HELPERS =====
11
335
 
12
336
  /**
@@ -25,21 +349,95 @@ function normalizeIssueLookupInput(issue) {
25
349
  return value;
26
350
  }
27
351
 
352
+ /**
353
+ * Safely resolve a lazy-loaded relation without triggering unnecessary API calls
354
+ * Only fetches if the relation is already cached or if we have minimal data
355
+ */
356
+ async function safeResolveRelation(sdkIssue, relationKey) {
357
+ try {
358
+ const relation = sdkIssue[relationKey];
359
+ if (!relation) return null;
360
+
361
+ // If it's a function (lazy loader), check if we can call it safely
362
+ if (typeof relation === 'function') {
363
+ const result = await relation().catch(() => null);
364
+ return result || null;
365
+ }
366
+
367
+ // If it's already resolved, return it
368
+ return relation;
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
28
374
  /**
29
375
  * Transform SDK issue object to plain object for consumers
30
- * Handles both SDK Issue objects and already-resolved plain objects
376
+ * Optimized to minimize API calls by avoiding unnecessary lazy loads
31
377
  */
32
378
  async function transformIssue(sdkIssue) {
33
379
  if (!sdkIssue) return null;
34
380
 
35
- // Handle SDK issue with lazy-loaded relations
36
- const [state, team, project, assignee, projectMilestone] = await Promise.all([
37
- sdkIssue.state?.catch?.(() => null) ?? sdkIssue.state,
38
- sdkIssue.team?.catch?.(() => null) ?? sdkIssue.team,
39
- sdkIssue.project?.catch?.(() => null) ?? sdkIssue.project,
40
- sdkIssue.assignee?.catch?.(() => null) ?? sdkIssue.assignee,
41
- sdkIssue.projectMilestone?.catch?.(() => null) ?? sdkIssue.projectMilestone,
42
- ]);
381
+ // If the SDK issue has _data, relations might already be available
382
+ // Check if we can extract data without making extra calls
383
+ const hasRelations = sdkIssue.state?.id || sdkIssue.assignee?.id || sdkIssue.project?.id;
384
+
385
+ // Only resolve relations if not already available
386
+ let state = null;
387
+ let team = null;
388
+ let project = null;
389
+ let assignee = null;
390
+ let projectMilestone = null;
391
+
392
+ if (sdkIssue.state?.id) {
393
+ state = sdkIssue.state;
394
+ } else if (sdkIssue._data?.state) {
395
+ state = { id: sdkIssue._data.state?.id, name: sdkIssue._data.state?.name, type: sdkIssue._data.state?.type };
396
+ }
397
+
398
+ if (sdkIssue.team?.id) {
399
+ team = sdkIssue.team;
400
+ } else if (sdkIssue._data?.team) {
401
+ team = { id: sdkIssue._data.team?.id, key: sdkIssue._data.team?.key, name: sdkIssue._data.team?.name };
402
+ }
403
+
404
+ if (sdkIssue.project?.id) {
405
+ project = sdkIssue.project;
406
+ } else if (sdkIssue._data?.project) {
407
+ project = { id: sdkIssue._data.project?.id, name: sdkIssue._data.project?.name };
408
+ }
409
+
410
+ if (sdkIssue.assignee?.id) {
411
+ assignee = sdkIssue.assignee;
412
+ } else if (sdkIssue._data?.assignee) {
413
+ assignee = { id: sdkIssue._data.assignee?.id, name: sdkIssue._data.assignee?.name, displayName: sdkIssue._data.assignee?.displayName };
414
+ }
415
+
416
+ if (sdkIssue.projectMilestone?.id) {
417
+ projectMilestone = sdkIssue.projectMilestone;
418
+ } else if (sdkIssue._data?.projectMilestone) {
419
+ projectMilestone = { id: sdkIssue._data.projectMilestone?.id, name: sdkIssue._data.projectMilestone?.name };
420
+ }
421
+
422
+ // Only trigger lazy loads if we don't have data from cache
423
+ const needsLazyLoad = !state && !team && !project && !assignee && !projectMilestone;
424
+
425
+ if (needsLazyLoad) {
426
+ // Use Promise.all with small timeout to avoid blocking
427
+ const [resolvedState, resolvedTeam, resolvedProject, resolvedAssignee, resolvedMilestone] = await Promise.all([
428
+ safeResolveRelation(sdkIssue, 'state'),
429
+ safeResolveRelation(sdkIssue, 'team'),
430
+ safeResolveRelation(sdkIssue, 'project'),
431
+ safeResolveRelation(sdkIssue, 'assignee'),
432
+ safeResolveRelation(sdkIssue, 'projectMilestone'),
433
+ ]);
434
+
435
+ state = state || resolvedState;
436
+ team = team || resolvedTeam;
437
+ project = project || resolvedProject;
438
+ assignee = assignee || resolvedAssignee;
439
+ projectMilestone = projectMilestone || resolvedMilestone;
440
+ }
43
441
 
44
442
  return {
45
443
  id: sdkIssue.id,
@@ -111,16 +509,19 @@ function normalizeIssueRefList(value) {
111
509
  * @returns {Promise<{id: string, name: string}>}
112
510
  */
113
511
  export async function fetchViewer(client) {
114
- const viewer = await client.viewer;
115
- return {
116
- id: viewer.id,
117
- name: viewer.name,
118
- displayName: viewer.displayName,
119
- };
512
+ return withLinearErrorHandling(async () => {
513
+ const viewer = await client.viewer;
514
+ return {
515
+ id: viewer.id,
516
+ name: viewer.name,
517
+ displayName: viewer.displayName,
518
+ };
519
+ }, 'fetchViewer');
120
520
  }
121
521
 
122
522
  /**
123
523
  * Fetch issues in specific states, optionally filtered by assignee
524
+ * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
124
525
  * @param {LinearClient} client - Linear SDK client
125
526
  * @param {string|null} assigneeId - Assignee ID to filter by (null = all assignees)
126
527
  * @param {Array<string>} openStates - List of state names to include
@@ -128,55 +529,52 @@ export async function fetchViewer(client) {
128
529
  * @returns {Promise<{issues: Array, truncated: boolean}>}
129
530
  */
130
531
  export async function fetchIssues(client, assigneeId, openStates, limit) {
131
- const filter = {
132
- state: { name: { in: openStates } },
133
- };
134
-
135
- if (assigneeId) {
136
- filter.assignee = { id: { eq: assigneeId } };
137
- }
532
+ return withLinearErrorHandling(async () => {
533
+ const filter = {
534
+ state: { name: { in: openStates } },
535
+ };
138
536
 
139
- const result = await client.issues({
140
- first: limit,
141
- filter,
142
- });
537
+ if (assigneeId) {
538
+ filter.assignee = { id: { eq: assigneeId } };
539
+ }
143
540
 
144
- const nodes = result.nodes || [];
145
- const hasNextPage = result.pageInfo?.hasNextPage ?? false;
541
+ // Use optimized rawRequest to fetch issues with ALL relations in ONE request
542
+ const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
543
+ first: limit,
544
+ filter,
545
+ });
146
546
 
147
- // Transform SDK issues to plain objects
148
- const issues = await Promise.all(nodes.map(transformIssue));
547
+ const nodes = data?.issues?.nodes || [];
548
+ const pageInfo = data?.issues?.pageInfo;
549
+ const hasNextPage = pageInfo?.hasNextPage ?? false;
149
550
 
150
- // DEBUG: Log issues delivered by Linear API
151
- debug('Issues delivered by Linear API', {
152
- issueCount: issues.length,
153
- issues: issues.map(issue => ({
154
- id: issue.id,
155
- title: issue.title,
156
- state: issue.state?.name,
157
- assigneeId: issue.assignee?.id,
158
- project: issue.project?.name,
159
- projectId: issue.project?.id,
160
- })),
161
- });
551
+ // Transform raw GraphQL response directly - no lazy loading needed
552
+ const issues = nodes.map(transformRawIssue);
162
553
 
163
- const truncated = hasNextPage || nodes.length >= limit;
164
- if (truncated) {
165
- warn('Linear issues query may be truncated by LINEAR_PAGE_LIMIT', {
166
- limit,
167
- returned: nodes.length,
168
- hasNextPage,
554
+ debug('Fetched issues (optimized)', {
555
+ issueCount: issues.length,
169
556
  });
170
- }
171
557
 
172
- return {
173
- issues,
174
- truncated,
175
- };
558
+ const truncated = hasNextPage || nodes.length >= limit;
559
+ if (truncated) {
560
+ warn('Issues query may be truncated', {
561
+ limit,
562
+ returned: nodes.length,
563
+ hasNextPage,
564
+ });
565
+ }
566
+
567
+ return {
568
+ issues,
569
+ truncated,
570
+ };
571
+ }, 'fetchIssues');
176
572
  }
177
573
 
178
574
  /**
179
575
  * Fetch issues by project and optional state filter
576
+ * OPTIMIZED: Uses rawRequest with custom GraphQL to fetch all relations in ONE request
577
+ * instead of N+1 requests (1 for list + 5 per issue for lazy-loaded relations)
180
578
  * @param {LinearClient} client - Linear SDK client
181
579
  * @param {string} projectId - Project ID to filter by
182
580
  * @param {Array<string>|null} states - List of state names to include (null = all states)
@@ -186,51 +584,56 @@ export async function fetchIssues(client, assigneeId, openStates, limit) {
186
584
  * @returns {Promise<{issues: Array, truncated: boolean}>}
187
585
  */
188
586
  export async function fetchIssuesByProject(client, projectId, states, options = {}) {
189
- const { assigneeId = null, limit = 50 } = options;
190
-
191
- const filter = {
192
- project: { id: { eq: projectId } },
193
- };
587
+ return withLinearErrorHandling(async () => {
588
+ const { assigneeId = null, limit = 20 } = options;
194
589
 
195
- if (states && states.length > 0) {
196
- filter.state = { name: { in: states } };
197
- }
590
+ const filter = {
591
+ project: { id: { eq: projectId } },
592
+ };
198
593
 
199
- if (assigneeId) {
200
- filter.assignee = { id: { eq: assigneeId } };
201
- }
594
+ if (states && states.length > 0) {
595
+ filter.state = { name: { in: states } };
596
+ }
202
597
 
203
- const result = await client.issues({
204
- first: limit,
205
- filter,
206
- });
598
+ if (assigneeId) {
599
+ filter.assignee = { id: { eq: assigneeId } };
600
+ }
207
601
 
208
- const nodes = result.nodes || [];
209
- const hasNextPage = result.pageInfo?.hasNextPage ?? false;
602
+ // Use optimized rawRequest to fetch issues with ALL relations in ONE request
603
+ // This eliminates the N+1 problem where each issue triggered 5 additional API calls
604
+ const { data } = await executeOptimizedQuery(client, ISSUES_WITH_RELATIONS_QUERY, {
605
+ first: limit,
606
+ filter,
607
+ });
210
608
 
211
- // Transform SDK issues to plain objects
212
- const issues = await Promise.all(nodes.map(transformIssue));
609
+ const nodes = data?.issues?.nodes || [];
610
+ const pageInfo = data?.issues?.pageInfo;
611
+ const hasNextPage = pageInfo?.hasNextPage ?? false;
213
612
 
214
- debug('Fetched issues by project', {
215
- projectId,
216
- stateCount: states?.length ?? 0,
217
- issueCount: issues.length,
218
- truncated: hasNextPage,
219
- });
613
+ // Transform raw GraphQL response directly - no lazy loading needed
614
+ const issues = nodes.map(transformRawIssue);
220
615
 
221
- const truncated = hasNextPage || nodes.length >= limit;
222
- if (truncated) {
223
- warn('Issues query may be truncated', {
224
- limit,
225
- returned: nodes.length,
226
- hasNextPage,
616
+ debug('Fetched issues by project (optimized)', {
617
+ projectId,
618
+ stateCount: states?.length ?? 0,
619
+ issueCount: issues.length,
620
+ truncated: hasNextPage,
227
621
  });
228
- }
229
622
 
230
- return {
231
- issues,
232
- truncated,
233
- };
623
+ const truncated = hasNextPage || nodes.length >= limit;
624
+ if (truncated) {
625
+ warn('Issues query may be truncated', {
626
+ limit,
627
+ returned: nodes.length,
628
+ hasNextPage,
629
+ });
630
+ }
631
+
632
+ return {
633
+ issues,
634
+ truncated,
635
+ };
636
+ }, 'fetchIssuesByProject');
234
637
  }
235
638
 
236
639
  /**
@@ -239,15 +642,17 @@ export async function fetchIssuesByProject(client, projectId, states, options =
239
642
  * @returns {Promise<Array<{id: string, name: string}>>}
240
643
  */
241
644
  export async function fetchProjects(client) {
242
- const result = await client.projects();
243
- const nodes = result.nodes ?? [];
645
+ return withLinearErrorHandling(async () => {
646
+ const result = await client.projects();
647
+ const nodes = result.nodes ?? [];
244
648
 
245
- debug('Fetched Linear projects', {
246
- projectCount: nodes.length,
247
- projects: nodes.map((p) => ({ id: p.id, name: p.name })),
248
- });
649
+ debug('Fetched Linear projects', {
650
+ projectCount: nodes.length,
651
+ projects: nodes.map((p) => ({ id: p.id, name: p.name })),
652
+ });
249
653
 
250
- return nodes.map(p => ({ id: p.id, name: p.name }));
654
+ return nodes.map(p => ({ id: p.id, name: p.name }));
655
+ }, 'fetchProjects');
251
656
  }
252
657
 
253
658
  /**
@@ -256,21 +661,23 @@ export async function fetchProjects(client) {
256
661
  * @returns {Promise<Array<{id: string, name: string}>>}
257
662
  */
258
663
  export async function fetchWorkspaces(client) {
259
- const viewer = await client.viewer;
260
- const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
664
+ return withLinearErrorHandling(async () => {
665
+ const viewer = await client.viewer;
666
+ const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
261
667
 
262
- if (!organization) {
263
- debug('No organization available from viewer context');
264
- return [];
265
- }
668
+ if (!organization) {
669
+ debug('No organization available from viewer context');
670
+ return [];
671
+ }
266
672
 
267
- const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
673
+ const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
268
674
 
269
- debug('Fetched Linear workspace from viewer organization', {
270
- workspace,
271
- });
675
+ debug('Fetched Linear workspace from viewer organization', {
676
+ workspace,
677
+ });
272
678
 
273
- return [workspace];
679
+ return [workspace];
680
+ }, 'fetchWorkspaces');
274
681
  }
275
682
 
276
683
  /**
@@ -279,15 +686,17 @@ export async function fetchWorkspaces(client) {
279
686
  * @returns {Promise<Array<{id: string, key: string, name: string}>>}
280
687
  */
281
688
  export async function fetchTeams(client) {
282
- const result = await client.teams();
283
- const nodes = result.nodes ?? [];
689
+ return withLinearErrorHandling(async () => {
690
+ const result = await client.teams();
691
+ const nodes = result.nodes ?? [];
284
692
 
285
- debug('Fetched Linear teams', {
286
- teamCount: nodes.length,
287
- teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
288
- });
693
+ debug('Fetched Linear teams', {
694
+ teamCount: nodes.length,
695
+ teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
696
+ });
289
697
 
290
- return nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
698
+ return nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
699
+ }, 'fetchTeams');
291
700
  }
292
701
 
293
702
  /**
@@ -344,19 +753,21 @@ export async function resolveTeamRef(client, teamRef) {
344
753
  * @returns {Promise<Object>} Resolved issue object
345
754
  */
346
755
  export async function resolveIssue(client, issueRef) {
347
- const lookup = normalizeIssueLookupInput(issueRef);
756
+ return withLinearErrorHandling(async () => {
757
+ const lookup = normalizeIssueLookupInput(issueRef);
348
758
 
349
- // The SDK's client.issue() method accepts both UUIDs and identifiers (ABC-123)
350
- try {
351
- const issue = await client.issue(lookup);
352
- if (issue) {
353
- return transformIssue(issue);
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
354
767
  }
355
- } catch (err) {
356
- // Fall through to error
357
- }
358
768
 
359
- throw new Error(`Issue not found: ${lookup}`);
769
+ throw new Error(`Issue not found: ${lookup}`);
770
+ }, 'resolveIssue');
360
771
  }
361
772
 
362
773
  /**
@@ -366,17 +777,19 @@ export async function resolveIssue(client, issueRef) {
366
777
  * @returns {Promise<Array<{id: string, name: string, type: string}>>}
367
778
  */
368
779
  export async function getTeamWorkflowStates(client, teamRef) {
369
- const team = await client.team(teamRef);
370
- if (!team) {
371
- throw new Error(`Team not found: ${teamRef}`);
372
- }
780
+ return withLinearErrorHandling(async () => {
781
+ const team = await client.team(teamRef);
782
+ if (!team) {
783
+ throw new Error(`Team not found: ${teamRef}`);
784
+ }
373
785
 
374
- const states = await team.states();
375
- return (states.nodes || []).map(s => ({
376
- id: s.id,
377
- name: s.name,
378
- type: s.type,
379
- }));
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');
380
793
  }
381
794
 
382
795
  /**
@@ -427,111 +840,113 @@ export async function resolveProjectRef(client, projectRef) {
427
840
  * @returns {Promise<Object>} Issue details
428
841
  */
429
842
  export async function fetchIssueDetails(client, issueRef, options = {}) {
430
- const { includeComments = true } = options;
843
+ return withLinearErrorHandling(async () => {
844
+ const { includeComments = true } = options;
431
845
 
432
- // Resolve issue - client.issue() accepts both UUIDs and identifiers
433
- const lookup = normalizeIssueLookupInput(issueRef);
434
- const sdkIssue = await client.issue(lookup);
846
+ // Resolve issue - client.issue() accepts both UUIDs and identifiers
847
+ const lookup = normalizeIssueLookupInput(issueRef);
848
+ const sdkIssue = await client.issue(lookup);
435
849
 
436
- if (!sdkIssue) {
437
- throw new Error(`Issue not found: ${lookup}`);
438
- }
850
+ if (!sdkIssue) {
851
+ throw new Error(`Issue not found: ${lookup}`);
852
+ }
439
853
 
440
- // Fetch all nested relations in parallel
441
- const [
442
- state,
443
- team,
444
- project,
445
- projectMilestone,
446
- assignee,
447
- creator,
448
- labelsResult,
449
- parent,
450
- childrenResult,
451
- commentsResult,
452
- attachmentsResult,
453
- ] = await Promise.all([
454
- sdkIssue.state?.catch?.(() => null) ?? sdkIssue.state,
455
- sdkIssue.team?.catch?.(() => null) ?? sdkIssue.team,
456
- sdkIssue.project?.catch?.(() => null) ?? sdkIssue.project,
457
- sdkIssue.projectMilestone?.catch?.(() => null) ?? sdkIssue.projectMilestone,
458
- sdkIssue.assignee?.catch?.(() => null) ?? sdkIssue.assignee,
459
- sdkIssue.creator?.catch?.(() => null) ?? sdkIssue.creator,
460
- sdkIssue.labels?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.labels?.() ?? { nodes: [] },
461
- sdkIssue.parent?.catch?.(() => null) ?? sdkIssue.parent,
462
- sdkIssue.children?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.children?.() ?? { nodes: [] },
463
- includeComments ? (sdkIssue.comments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.comments?.() ?? { nodes: [] }) : Promise.resolve({ nodes: [] }),
464
- sdkIssue.attachments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.attachments?.() ?? { nodes: [] },
465
- ]);
854
+ // Fetch all nested relations in parallel
855
+ const [
856
+ state,
857
+ team,
858
+ project,
859
+ projectMilestone,
860
+ assignee,
861
+ creator,
862
+ labelsResult,
863
+ parent,
864
+ childrenResult,
865
+ commentsResult,
866
+ attachmentsResult,
867
+ ] = await Promise.all([
868
+ sdkIssue.state?.catch?.(() => null) ?? sdkIssue.state,
869
+ sdkIssue.team?.catch?.(() => null) ?? sdkIssue.team,
870
+ sdkIssue.project?.catch?.(() => null) ?? sdkIssue.project,
871
+ sdkIssue.projectMilestone?.catch?.(() => null) ?? sdkIssue.projectMilestone,
872
+ sdkIssue.assignee?.catch?.(() => null) ?? sdkIssue.assignee,
873
+ sdkIssue.creator?.catch?.(() => null) ?? sdkIssue.creator,
874
+ sdkIssue.labels?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.labels?.() ?? { nodes: [] },
875
+ sdkIssue.parent?.catch?.(() => null) ?? sdkIssue.parent,
876
+ sdkIssue.children?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.children?.() ?? { nodes: [] },
877
+ includeComments ? (sdkIssue.comments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.comments?.() ?? { nodes: [] }) : Promise.resolve({ nodes: [] }),
878
+ sdkIssue.attachments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.attachments?.() ?? { nodes: [] },
879
+ ]);
880
+
881
+ // Transform parent if exists
882
+ let transformedParent = null;
883
+ if (parent) {
884
+ const parentState = await parent.state?.catch?.(() => null) ?? parent.state;
885
+ transformedParent = {
886
+ identifier: parent.identifier,
887
+ title: parent.title,
888
+ state: parentState ? { name: parentState.name, color: parentState.color } : null,
889
+ };
890
+ }
466
891
 
467
- // Transform parent if exists
468
- let transformedParent = null;
469
- if (parent) {
470
- const parentState = await parent.state?.catch?.(() => null) ?? parent.state;
471
- transformedParent = {
472
- identifier: parent.identifier,
473
- title: parent.title,
474
- state: parentState ? { name: parentState.name, color: parentState.color } : null,
892
+ // Transform children
893
+ const children = (childrenResult.nodes || []).map(c => ({
894
+ identifier: c.identifier,
895
+ title: c.title,
896
+ state: c.state ? { name: c.state.name, color: c.state.color } : null,
897
+ }));
898
+
899
+ // Transform comments
900
+ const comments = (commentsResult.nodes || []).map(c => ({
901
+ id: c.id,
902
+ body: c.body,
903
+ createdAt: c.createdAt,
904
+ updatedAt: c.updatedAt,
905
+ user: c.user ? { name: c.user.name, displayName: c.user.displayName } : null,
906
+ externalUser: c.externalUser ? { name: c.externalUser.name, displayName: c.externalUser.displayName } : null,
907
+ parent: c.parent ? { id: c.parent.id } : null,
908
+ }));
909
+
910
+ // Transform attachments
911
+ const attachments = (attachmentsResult.nodes || []).map(a => ({
912
+ id: a.id,
913
+ title: a.title,
914
+ url: a.url,
915
+ subtitle: a.subtitle,
916
+ sourceType: a.sourceType,
917
+ createdAt: a.createdAt,
918
+ }));
919
+
920
+ // Transform labels
921
+ const labels = (labelsResult.nodes || []).map(l => ({
922
+ id: l.id,
923
+ name: l.name,
924
+ color: l.color,
925
+ }));
926
+
927
+ return {
928
+ identifier: sdkIssue.identifier,
929
+ title: sdkIssue.title,
930
+ description: sdkIssue.description,
931
+ url: sdkIssue.url,
932
+ branchName: sdkIssue.branchName,
933
+ priority: sdkIssue.priority,
934
+ estimate: sdkIssue.estimate,
935
+ createdAt: sdkIssue.createdAt,
936
+ updatedAt: sdkIssue.updatedAt,
937
+ state: state ? { name: state.name, color: state.color, type: state.type } : null,
938
+ team: team ? { id: team.id, key: team.key, name: team.name } : null,
939
+ project: project ? { id: project.id, name: project.name } : null,
940
+ projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
941
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
942
+ creator: creator ? { id: creator.id, name: creator.name, displayName: creator.displayName } : null,
943
+ labels,
944
+ parent: transformedParent,
945
+ children,
946
+ comments,
947
+ attachments,
475
948
  };
476
- }
477
-
478
- // Transform children
479
- const children = (childrenResult.nodes || []).map(c => ({
480
- identifier: c.identifier,
481
- title: c.title,
482
- state: c.state ? { name: c.state.name, color: c.state.color } : null,
483
- }));
484
-
485
- // Transform comments
486
- const comments = (commentsResult.nodes || []).map(c => ({
487
- id: c.id,
488
- body: c.body,
489
- createdAt: c.createdAt,
490
- updatedAt: c.updatedAt,
491
- user: c.user ? { name: c.user.name, displayName: c.user.displayName } : null,
492
- externalUser: c.externalUser ? { name: c.externalUser.name, displayName: c.externalUser.displayName } : null,
493
- parent: c.parent ? { id: c.parent.id } : null,
494
- }));
495
-
496
- // Transform attachments
497
- const attachments = (attachmentsResult.nodes || []).map(a => ({
498
- id: a.id,
499
- title: a.title,
500
- url: a.url,
501
- subtitle: a.subtitle,
502
- sourceType: a.sourceType,
503
- createdAt: a.createdAt,
504
- }));
505
-
506
- // Transform labels
507
- const labels = (labelsResult.nodes || []).map(l => ({
508
- id: l.id,
509
- name: l.name,
510
- color: l.color,
511
- }));
512
-
513
- return {
514
- identifier: sdkIssue.identifier,
515
- title: sdkIssue.title,
516
- description: sdkIssue.description,
517
- url: sdkIssue.url,
518
- branchName: sdkIssue.branchName,
519
- priority: sdkIssue.priority,
520
- estimate: sdkIssue.estimate,
521
- createdAt: sdkIssue.createdAt,
522
- updatedAt: sdkIssue.updatedAt,
523
- state: state ? { name: state.name, color: state.color, type: state.type } : null,
524
- team: team ? { id: team.id, key: team.key, name: team.name } : null,
525
- project: project ? { id: project.id, name: project.name } : null,
526
- projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
527
- assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
528
- creator: creator ? { id: creator.id, name: creator.name, displayName: creator.displayName } : null,
529
- labels,
530
- parent: transformedParent,
531
- children,
532
- comments,
533
- attachments,
534
- };
949
+ }, 'fetchIssueDetails');
535
950
  }
536
951
 
537
952
  // ===== MUTATION FUNCTIONS =====
@@ -544,17 +959,19 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
544
959
  * @returns {Promise<Object>} Updated issue
545
960
  */
546
961
  export async function setIssueState(client, issueId, stateId) {
547
- const issue = await client.issue(issueId);
548
- if (!issue) {
549
- throw new Error(`Issue not found: ${issueId}`);
550
- }
962
+ return withLinearErrorHandling(async () => {
963
+ const issue = await client.issue(issueId);
964
+ if (!issue) {
965
+ throw new Error(`Issue not found: ${issueId}`);
966
+ }
551
967
 
552
- const result = await issue.update({ stateId });
553
- if (!result.success) {
554
- throw new Error('Failed to update issue state');
555
- }
968
+ const result = await issue.update({ stateId });
969
+ if (!result.success) {
970
+ throw new Error('Failed to update issue state');
971
+ }
556
972
 
557
- return transformIssue(result.issue);
973
+ return transformIssue(result.issue);
974
+ }, 'setIssueState');
558
975
  }
559
976
 
560
977
  /**
@@ -571,86 +988,88 @@ export async function setIssueState(client, issueId, stateId) {
571
988
  * @returns {Promise<Object>} Created issue
572
989
  */
573
990
  export async function createIssue(client, input) {
574
- const title = String(input.title || '').trim();
575
- if (!title) {
576
- throw new Error('Missing required field: title');
577
- }
578
-
579
- const teamId = String(input.teamId || '').trim();
580
- if (!teamId) {
581
- throw new Error('Missing required field: teamId');
582
- }
991
+ return withLinearErrorHandling(async () => {
992
+ const title = String(input.title || '').trim();
993
+ if (!title) {
994
+ throw new Error('Missing required field: title');
995
+ }
583
996
 
584
- const createInput = {
585
- teamId,
586
- title,
587
- };
997
+ const teamId = String(input.teamId || '').trim();
998
+ if (!teamId) {
999
+ throw new Error('Missing required field: teamId');
1000
+ }
588
1001
 
589
- if (input.description !== undefined) {
590
- createInput.description = String(input.description);
591
- }
1002
+ const createInput = {
1003
+ teamId,
1004
+ title,
1005
+ };
592
1006
 
593
- if (input.projectId !== undefined) {
594
- createInput.projectId = input.projectId;
595
- }
1007
+ if (input.description !== undefined) {
1008
+ createInput.description = String(input.description);
1009
+ }
596
1010
 
597
- if (input.priority !== undefined) {
598
- const parsed = Number.parseInt(String(input.priority), 10);
599
- if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
600
- throw new Error(`Invalid priority: ${input.priority}. Valid range: 0..4`);
1011
+ if (input.projectId !== undefined) {
1012
+ createInput.projectId = input.projectId;
601
1013
  }
602
- createInput.priority = parsed;
603
- }
604
1014
 
605
- if (input.assigneeId !== undefined) {
606
- createInput.assigneeId = input.assigneeId;
607
- }
1015
+ if (input.priority !== undefined) {
1016
+ const parsed = Number.parseInt(String(input.priority), 10);
1017
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
1018
+ throw new Error(`Invalid priority: ${input.priority}. Valid range: 0..4`);
1019
+ }
1020
+ createInput.priority = parsed;
1021
+ }
608
1022
 
609
- if (input.parentId !== undefined) {
610
- createInput.parentId = input.parentId;
611
- }
1023
+ if (input.assigneeId !== undefined) {
1024
+ createInput.assigneeId = input.assigneeId;
1025
+ }
612
1026
 
613
- if (input.stateId !== undefined) {
614
- createInput.stateId = input.stateId;
615
- }
1027
+ if (input.parentId !== undefined) {
1028
+ createInput.parentId = input.parentId;
1029
+ }
616
1030
 
617
- const result = await client.createIssue(createInput);
1031
+ if (input.stateId !== undefined) {
1032
+ createInput.stateId = input.stateId;
1033
+ }
618
1034
 
619
- if (!result.success) {
620
- throw new Error('Failed to create issue');
621
- }
1035
+ const result = await client.createIssue(createInput);
622
1036
 
623
- // Prefer official data path: resolve created issue ID then refetch full issue.
624
- const createdIssueId =
625
- result.issue?.id
626
- || result._issue?.id
627
- || null;
1037
+ if (!result.success) {
1038
+ throw new Error('Failed to create issue');
1039
+ }
628
1040
 
629
- if (createdIssueId) {
630
- try {
631
- const fullIssue = await client.issue(createdIssueId);
632
- if (fullIssue) {
633
- const transformed = await transformIssue(fullIssue);
634
- return transformed;
1041
+ // Prefer official data path: resolve created issue ID then refetch full issue.
1042
+ const createdIssueId =
1043
+ result.issue?.id
1044
+ || result._issue?.id
1045
+ || null;
1046
+
1047
+ if (createdIssueId) {
1048
+ try {
1049
+ const fullIssue = await client.issue(createdIssueId);
1050
+ if (fullIssue) {
1051
+ const transformed = await transformIssue(fullIssue);
1052
+ return transformed;
1053
+ }
1054
+ } catch {
1055
+ // continue to fallback
635
1056
  }
636
- } catch {
637
- // continue to fallback
638
1057
  }
639
- }
640
1058
 
641
- // Minimal fallback when SDK payload does not expose a resolvable issue ID.
642
- return {
643
- id: createdIssueId,
644
- identifier: null,
645
- title,
646
- description: input.description ?? null,
647
- url: null,
648
- priority: input.priority ?? null,
649
- state: null,
650
- team: null,
651
- project: null,
652
- assignee: null,
653
- };
1059
+ // Minimal fallback when SDK payload does not expose a resolvable issue ID.
1060
+ return {
1061
+ id: createdIssueId,
1062
+ identifier: null,
1063
+ title,
1064
+ description: input.description ?? null,
1065
+ url: null,
1066
+ priority: input.priority ?? null,
1067
+ state: null,
1068
+ team: null,
1069
+ project: null,
1070
+ assignee: null,
1071
+ };
1072
+ }, 'createIssue');
654
1073
  }
655
1074
 
656
1075
  /**
@@ -662,32 +1081,34 @@ export async function createIssue(client, input) {
662
1081
  * @returns {Promise<{issue: Object, comment: Object}>}
663
1082
  */
664
1083
  export async function addIssueComment(client, issueRef, body, parentCommentId) {
665
- const commentBody = String(body || '').trim();
666
- if (!commentBody) {
667
- throw new Error('Missing required comment body');
668
- }
1084
+ return withLinearErrorHandling(async () => {
1085
+ const commentBody = String(body || '').trim();
1086
+ if (!commentBody) {
1087
+ throw new Error('Missing required comment body');
1088
+ }
669
1089
 
670
- const targetIssue = await resolveIssue(client, issueRef);
1090
+ const targetIssue = await resolveIssue(client, issueRef);
671
1091
 
672
- const input = {
673
- issueId: targetIssue.id,
674
- body: commentBody,
675
- };
1092
+ const input = {
1093
+ issueId: targetIssue.id,
1094
+ body: commentBody,
1095
+ };
676
1096
 
677
- if (parentCommentId) {
678
- input.parentId = parentCommentId;
679
- }
1097
+ if (parentCommentId) {
1098
+ input.parentId = parentCommentId;
1099
+ }
680
1100
 
681
- const result = await client.createComment(input);
1101
+ const result = await client.createComment(input);
682
1102
 
683
- if (!result.success) {
684
- throw new Error('Failed to create comment');
685
- }
1103
+ if (!result.success) {
1104
+ throw new Error('Failed to create comment');
1105
+ }
686
1106
 
687
- return {
688
- issue: targetIssue,
689
- comment: result.comment,
690
- };
1107
+ return {
1108
+ issue: targetIssue,
1109
+ comment: result.comment,
1110
+ };
1111
+ }, 'addIssueComment');
691
1112
  }
692
1113
 
693
1114
  /**
@@ -698,185 +1119,187 @@ export async function addIssueComment(client, issueRef, body, parentCommentId) {
698
1119
  * @returns {Promise<{issue: Object, changed: Array<string>}>}
699
1120
  */
700
1121
  export async function updateIssue(client, issueRef, patch = {}) {
701
- const targetIssue = await resolveIssue(client, issueRef);
702
- const updateInput = {};
703
-
704
- debug('updateIssue: received patch', {
705
- issueRef,
706
- resolvedIssueId: targetIssue?.id,
707
- resolvedIdentifier: targetIssue?.identifier,
708
- patchKeys: Object.keys(patch || {}),
709
- hasTitle: patch.title !== undefined,
710
- hasDescription: patch.description !== undefined,
711
- priority: patch.priority,
712
- state: patch.state,
713
- assigneeId: patch.assigneeId,
714
- milestone: patch.milestone,
715
- projectMilestoneId: patch.projectMilestoneId,
716
- subIssueOf: patch.subIssueOf,
717
- parentOf: patch.parentOf,
718
- blockedBy: patch.blockedBy,
719
- blocking: patch.blocking,
720
- relatedTo: patch.relatedTo,
721
- duplicateOf: patch.duplicateOf,
722
- });
723
-
724
- if (patch.title !== undefined) {
725
- updateInput.title = String(patch.title);
726
- }
1122
+ return withLinearErrorHandling(async () => {
1123
+ const targetIssue = await resolveIssue(client, issueRef);
1124
+ const updateInput = {};
1125
+
1126
+ debug('updateIssue: received patch', {
1127
+ issueRef,
1128
+ resolvedIssueId: targetIssue?.id,
1129
+ resolvedIdentifier: targetIssue?.identifier,
1130
+ patchKeys: Object.keys(patch || {}),
1131
+ hasTitle: patch.title !== undefined,
1132
+ hasDescription: patch.description !== undefined,
1133
+ priority: patch.priority,
1134
+ state: patch.state,
1135
+ assigneeId: patch.assigneeId,
1136
+ milestone: patch.milestone,
1137
+ projectMilestoneId: patch.projectMilestoneId,
1138
+ subIssueOf: patch.subIssueOf,
1139
+ parentOf: patch.parentOf,
1140
+ blockedBy: patch.blockedBy,
1141
+ blocking: patch.blocking,
1142
+ relatedTo: patch.relatedTo,
1143
+ duplicateOf: patch.duplicateOf,
1144
+ });
727
1145
 
728
- if (patch.description !== undefined) {
729
- updateInput.description = String(patch.description);
730
- }
1146
+ if (patch.title !== undefined) {
1147
+ updateInput.title = String(patch.title);
1148
+ }
731
1149
 
732
- if (patch.priority !== undefined) {
733
- const parsed = Number.parseInt(String(patch.priority), 10);
734
- if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
735
- throw new Error(`Invalid priority: ${patch.priority}. Valid range: 0..4`);
1150
+ if (patch.description !== undefined) {
1151
+ updateInput.description = String(patch.description);
736
1152
  }
737
- updateInput.priority = parsed;
738
- }
739
1153
 
740
- if (patch.state !== undefined) {
741
- // Need to resolve state ID from team's workflow states
742
- const team = targetIssue.team;
743
- if (!team?.id) {
744
- throw new Error(`Issue ${targetIssue.identifier} has no team assigned`);
1154
+ if (patch.priority !== undefined) {
1155
+ const parsed = Number.parseInt(String(patch.priority), 10);
1156
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
1157
+ throw new Error(`Invalid priority: ${patch.priority}. Valid range: 0..4`);
1158
+ }
1159
+ updateInput.priority = parsed;
745
1160
  }
746
1161
 
747
- const states = await getTeamWorkflowStates(client, team.id);
748
- updateInput.stateId = resolveStateIdFromInput(states, patch.state);
749
- }
1162
+ if (patch.state !== undefined) {
1163
+ // Need to resolve state ID from team's workflow states
1164
+ const team = targetIssue.team;
1165
+ if (!team?.id) {
1166
+ throw new Error(`Issue ${targetIssue.identifier} has no team assigned`);
1167
+ }
750
1168
 
751
- if (patch.assigneeId !== undefined) {
752
- updateInput.assigneeId = patch.assigneeId;
753
- }
1169
+ const states = await getTeamWorkflowStates(client, team.id);
1170
+ updateInput.stateId = resolveStateIdFromInput(states, patch.state);
1171
+ }
754
1172
 
755
- if (patch.projectMilestoneId !== undefined) {
756
- updateInput.projectMilestoneId = patch.projectMilestoneId;
757
- } else if (patch.milestone !== undefined) {
758
- const milestoneRef = String(patch.milestone || '').trim();
759
- const clearMilestoneValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
1173
+ if (patch.assigneeId !== undefined) {
1174
+ updateInput.assigneeId = patch.assigneeId;
1175
+ }
760
1176
 
761
- if (clearMilestoneValues.has(milestoneRef.toLowerCase())) {
762
- updateInput.projectMilestoneId = null;
763
- } else {
764
- const projectId = targetIssue.project?.id;
765
- if (!projectId) {
766
- throw new Error(`Issue ${targetIssue.identifier} has no project; cannot resolve milestone by name`);
1177
+ if (patch.projectMilestoneId !== undefined) {
1178
+ updateInput.projectMilestoneId = patch.projectMilestoneId;
1179
+ } else if (patch.milestone !== undefined) {
1180
+ const milestoneRef = String(patch.milestone || '').trim();
1181
+ const clearMilestoneValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
1182
+
1183
+ if (clearMilestoneValues.has(milestoneRef.toLowerCase())) {
1184
+ updateInput.projectMilestoneId = null;
1185
+ } else {
1186
+ const projectId = targetIssue.project?.id;
1187
+ if (!projectId) {
1188
+ throw new Error(`Issue ${targetIssue.identifier} has no project; cannot resolve milestone by name`);
1189
+ }
1190
+
1191
+ const milestones = await fetchProjectMilestones(client, projectId);
1192
+ updateInput.projectMilestoneId = resolveProjectMilestoneIdFromInput(milestones, milestoneRef);
767
1193
  }
768
-
769
- const milestones = await fetchProjectMilestones(client, projectId);
770
- updateInput.projectMilestoneId = resolveProjectMilestoneIdFromInput(milestones, milestoneRef);
771
1194
  }
772
- }
773
1195
 
774
- if (patch.subIssueOf !== undefined) {
775
- const parentRef = String(patch.subIssueOf || '').trim();
776
- const clearParentValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
777
- if (clearParentValues.has(parentRef.toLowerCase())) {
778
- updateInput.parentId = null;
779
- } else {
780
- const parentIssue = await resolveIssue(client, parentRef);
781
- updateInput.parentId = parentIssue.id;
1196
+ if (patch.subIssueOf !== undefined) {
1197
+ const parentRef = String(patch.subIssueOf || '').trim();
1198
+ const clearParentValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
1199
+ if (clearParentValues.has(parentRef.toLowerCase())) {
1200
+ updateInput.parentId = null;
1201
+ } else {
1202
+ const parentIssue = await resolveIssue(client, parentRef);
1203
+ updateInput.parentId = parentIssue.id;
1204
+ }
782
1205
  }
783
- }
784
-
785
- const relationCreates = [];
786
- const parentOfRefs = normalizeIssueRefList(patch.parentOf);
787
- const blockedByRefs = normalizeIssueRefList(patch.blockedBy);
788
- const blockingRefs = normalizeIssueRefList(patch.blocking);
789
- const relatedToRefs = normalizeIssueRefList(patch.relatedTo);
790
- const duplicateOfRef = patch.duplicateOf !== undefined ? String(patch.duplicateOf || '').trim() : null;
791
1206
 
792
- for (const childRef of parentOfRefs) {
793
- const childIssue = await resolveIssue(client, childRef);
794
- const childSdkIssue = await client.issue(childIssue.id);
795
- if (!childSdkIssue) {
796
- throw new Error(`Issue not found: ${childRef}`);
1207
+ const relationCreates = [];
1208
+ const parentOfRefs = normalizeIssueRefList(patch.parentOf);
1209
+ const blockedByRefs = normalizeIssueRefList(patch.blockedBy);
1210
+ const blockingRefs = normalizeIssueRefList(patch.blocking);
1211
+ const relatedToRefs = normalizeIssueRefList(patch.relatedTo);
1212
+ const duplicateOfRef = patch.duplicateOf !== undefined ? String(patch.duplicateOf || '').trim() : null;
1213
+
1214
+ for (const childRef of parentOfRefs) {
1215
+ const childIssue = await resolveIssue(client, childRef);
1216
+ const childSdkIssue = await client.issue(childIssue.id);
1217
+ if (!childSdkIssue) {
1218
+ throw new Error(`Issue not found: ${childRef}`);
1219
+ }
1220
+ const rel = await childSdkIssue.update({ parentId: targetIssue.id });
1221
+ if (!rel.success) {
1222
+ throw new Error(`Failed to set parent for issue: ${childRef}`);
1223
+ }
797
1224
  }
798
- const rel = await childSdkIssue.update({ parentId: targetIssue.id });
799
- if (!rel.success) {
800
- throw new Error(`Failed to set parent for issue: ${childRef}`);
1225
+
1226
+ for (const blockerRef of blockedByRefs) {
1227
+ const blocker = await resolveIssue(client, blockerRef);
1228
+ relationCreates.push({ issueId: blocker.id, relatedIssueId: targetIssue.id, type: 'blocks' });
801
1229
  }
802
- }
803
1230
 
804
- for (const blockerRef of blockedByRefs) {
805
- const blocker = await resolveIssue(client, blockerRef);
806
- relationCreates.push({ issueId: blocker.id, relatedIssueId: targetIssue.id, type: 'blocks' });
807
- }
1231
+ for (const blockedRef of blockingRefs) {
1232
+ const blocked = await resolveIssue(client, blockedRef);
1233
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: blocked.id, type: 'blocks' });
1234
+ }
808
1235
 
809
- for (const blockedRef of blockingRefs) {
810
- const blocked = await resolveIssue(client, blockedRef);
811
- relationCreates.push({ issueId: targetIssue.id, relatedIssueId: blocked.id, type: 'blocks' });
812
- }
1236
+ for (const relatedRef of relatedToRefs) {
1237
+ const related = await resolveIssue(client, relatedRef);
1238
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: related.id, type: 'related' });
1239
+ }
813
1240
 
814
- for (const relatedRef of relatedToRefs) {
815
- const related = await resolveIssue(client, relatedRef);
816
- relationCreates.push({ issueId: targetIssue.id, relatedIssueId: related.id, type: 'related' });
817
- }
1241
+ if (duplicateOfRef) {
1242
+ const duplicateTarget = await resolveIssue(client, duplicateOfRef);
1243
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: duplicateTarget.id, type: 'duplicate' });
1244
+ }
818
1245
 
819
- if (duplicateOfRef) {
820
- const duplicateTarget = await resolveIssue(client, duplicateOfRef);
821
- relationCreates.push({ issueId: targetIssue.id, relatedIssueId: duplicateTarget.id, type: 'duplicate' });
822
- }
1246
+ debug('updateIssue: computed update input', {
1247
+ issueRef,
1248
+ resolvedIdentifier: targetIssue?.identifier,
1249
+ updateKeys: Object.keys(updateInput),
1250
+ updateInput,
1251
+ relationCreateCount: relationCreates.length,
1252
+ parentOfCount: parentOfRefs.length,
1253
+ });
823
1254
 
824
- debug('updateIssue: computed update input', {
825
- issueRef,
826
- resolvedIdentifier: targetIssue?.identifier,
827
- updateKeys: Object.keys(updateInput),
828
- updateInput,
829
- relationCreateCount: relationCreates.length,
830
- parentOfCount: parentOfRefs.length,
831
- });
1255
+ if (Object.keys(updateInput).length === 0
1256
+ && relationCreates.length === 0
1257
+ && parentOfRefs.length === 0) {
1258
+ throw new Error('No update fields provided');
1259
+ }
832
1260
 
833
- if (Object.keys(updateInput).length === 0
834
- && relationCreates.length === 0
835
- && parentOfRefs.length === 0) {
836
- throw new Error('No update fields provided');
837
- }
1261
+ if (Object.keys(updateInput).length > 0) {
1262
+ // Get fresh issue instance for update
1263
+ const sdkIssue = await client.issue(targetIssue.id);
1264
+ if (!sdkIssue) {
1265
+ throw new Error(`Issue not found: ${targetIssue.id}`);
1266
+ }
838
1267
 
839
- if (Object.keys(updateInput).length > 0) {
840
- // Get fresh issue instance for update
841
- const sdkIssue = await client.issue(targetIssue.id);
842
- if (!sdkIssue) {
843
- throw new Error(`Issue not found: ${targetIssue.id}`);
1268
+ const result = await sdkIssue.update(updateInput);
1269
+ if (!result.success) {
1270
+ throw new Error('Failed to update issue');
1271
+ }
844
1272
  }
845
1273
 
846
- const result = await sdkIssue.update(updateInput);
847
- if (!result.success) {
848
- throw new Error('Failed to update issue');
1274
+ for (const relationInput of relationCreates) {
1275
+ const relationResult = await client.createIssueRelation(relationInput);
1276
+ if (!relationResult.success) {
1277
+ throw new Error(`Failed to create issue relation (${relationInput.type})`);
1278
+ }
849
1279
  }
850
- }
851
1280
 
852
- for (const relationInput of relationCreates) {
853
- const relationResult = await client.createIssueRelation(relationInput);
854
- if (!relationResult.success) {
855
- throw new Error(`Failed to create issue relation (${relationInput.type})`);
1281
+ // Prefer official data path: refetch the issue after successful mutation.
1282
+ let updatedSdkIssue = null;
1283
+ try {
1284
+ updatedSdkIssue = await client.issue(targetIssue.id);
1285
+ } catch {
1286
+ updatedSdkIssue = null;
856
1287
  }
857
- }
858
1288
 
859
- // Prefer official data path: refetch the issue after successful mutation.
860
- let updatedSdkIssue = null;
861
- try {
862
- updatedSdkIssue = await client.issue(targetIssue.id);
863
- } catch {
864
- updatedSdkIssue = null;
865
- }
1289
+ const updatedIssue = await transformIssue(updatedSdkIssue);
866
1290
 
867
- const updatedIssue = await transformIssue(updatedSdkIssue);
1291
+ const changed = [...Object.keys(updateInput)];
1292
+ if (parentOfRefs.length > 0) changed.push('parentOf');
1293
+ if (blockedByRefs.length > 0) changed.push('blockedBy');
1294
+ if (blockingRefs.length > 0) changed.push('blocking');
1295
+ if (relatedToRefs.length > 0) changed.push('relatedTo');
1296
+ if (duplicateOfRef) changed.push('duplicateOf');
868
1297
 
869
- const changed = [...Object.keys(updateInput)];
870
- if (parentOfRefs.length > 0) changed.push('parentOf');
871
- if (blockedByRefs.length > 0) changed.push('blockedBy');
872
- if (blockingRefs.length > 0) changed.push('blocking');
873
- if (relatedToRefs.length > 0) changed.push('relatedTo');
874
- if (duplicateOfRef) changed.push('duplicateOf');
875
-
876
- return {
877
- issue: updatedIssue || targetIssue,
878
- changed,
879
- };
1298
+ return {
1299
+ issue: updatedIssue || targetIssue,
1300
+ changed,
1301
+ };
1302
+ }, 'updateIssue');
880
1303
  }
881
1304
 
882
1305
  /**
@@ -961,23 +1384,25 @@ async function transformMilestone(sdkMilestone) {
961
1384
  * @returns {Promise<Array<Object>>} Array of milestones
962
1385
  */
963
1386
  export async function fetchProjectMilestones(client, projectId) {
964
- const project = await client.project(projectId);
965
- if (!project) {
966
- throw new Error(`Project not found: ${projectId}`);
967
- }
1387
+ return withLinearErrorHandling(async () => {
1388
+ const project = await client.project(projectId);
1389
+ if (!project) {
1390
+ throw new Error(`Project not found: ${projectId}`);
1391
+ }
968
1392
 
969
- const result = await project.projectMilestones();
970
- const nodes = result.nodes || [];
1393
+ const result = await project.projectMilestones();
1394
+ const nodes = result.nodes || [];
971
1395
 
972
- const milestones = await Promise.all(nodes.map(transformMilestone));
1396
+ const milestones = await Promise.all(nodes.map(transformMilestone));
973
1397
 
974
- debug('Fetched project milestones', {
975
- projectId,
976
- milestoneCount: milestones.length,
977
- milestones: milestones.map((m) => ({ id: m.id, name: m.name, status: m.status })),
978
- });
1398
+ debug('Fetched project milestones', {
1399
+ projectId,
1400
+ milestoneCount: milestones.length,
1401
+ milestones: milestones.map((m) => ({ id: m.id, name: m.name, status: m.status })),
1402
+ });
979
1403
 
980
- return milestones;
1404
+ return milestones;
1405
+ }, 'fetchProjectMilestones');
981
1406
  }
982
1407
 
983
1408
  /**
@@ -987,48 +1412,50 @@ export async function fetchProjectMilestones(client, projectId) {
987
1412
  * @returns {Promise<Object>} Milestone details with issues
988
1413
  */
989
1414
  export async function fetchMilestoneDetails(client, milestoneId) {
990
- const milestone = await client.projectMilestone(milestoneId);
991
- if (!milestone) {
992
- throw new Error(`Milestone not found: ${milestoneId}`);
993
- }
994
-
995
- // Fetch project and issues in parallel
996
- const [project, issuesResult] = await Promise.all([
997
- milestone.project?.catch?.(() => null) ?? milestone.project,
998
- milestone.issues?.()?.catch?.(() => ({ nodes: [] })) ?? milestone.issues?.() ?? { nodes: [] },
999
- ]);
1000
-
1001
- // Transform issues
1002
- const issues = await Promise.all(
1003
- (issuesResult.nodes || []).map(async (issue) => {
1004
- const [state, assignee] = await Promise.all([
1005
- issue.state?.catch?.(() => null) ?? issue.state,
1006
- issue.assignee?.catch?.(() => null) ?? issue.assignee,
1007
- ]);
1008
-
1009
- return {
1010
- id: issue.id,
1011
- identifier: issue.identifier,
1012
- title: issue.title,
1013
- state: state ? { name: state.name, color: state.color, type: state.type } : null,
1014
- assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
1015
- priority: issue.priority,
1016
- estimate: issue.estimate,
1017
- };
1018
- })
1019
- );
1415
+ return withLinearErrorHandling(async () => {
1416
+ const milestone = await client.projectMilestone(milestoneId);
1417
+ if (!milestone) {
1418
+ throw new Error(`Milestone not found: ${milestoneId}`);
1419
+ }
1020
1420
 
1021
- return {
1022
- id: milestone.id,
1023
- name: milestone.name,
1024
- description: milestone.description,
1025
- progress: milestone.progress,
1026
- order: milestone.order,
1027
- targetDate: milestone.targetDate,
1028
- status: milestone.status,
1029
- project: project ? { id: project.id, name: project.name } : null,
1030
- issues,
1031
- };
1421
+ // Fetch project and issues in parallel
1422
+ const [project, issuesResult] = await Promise.all([
1423
+ milestone.project?.catch?.(() => null) ?? milestone.project,
1424
+ milestone.issues?.()?.catch?.(() => ({ nodes: [] })) ?? milestone.issues?.() ?? { nodes: [] },
1425
+ ]);
1426
+
1427
+ // Transform issues
1428
+ const issues = await Promise.all(
1429
+ (issuesResult.nodes || []).map(async (issue) => {
1430
+ const [state, assignee] = await Promise.all([
1431
+ issue.state?.catch?.(() => null) ?? issue.state,
1432
+ issue.assignee?.catch?.(() => null) ?? issue.assignee,
1433
+ ]);
1434
+
1435
+ return {
1436
+ id: issue.id,
1437
+ identifier: issue.identifier,
1438
+ title: issue.title,
1439
+ state: state ? { name: state.name, color: state.color, type: state.type } : null,
1440
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
1441
+ priority: issue.priority,
1442
+ estimate: issue.estimate,
1443
+ };
1444
+ })
1445
+ );
1446
+
1447
+ return {
1448
+ id: milestone.id,
1449
+ name: milestone.name,
1450
+ description: milestone.description,
1451
+ progress: milestone.progress,
1452
+ order: milestone.order,
1453
+ targetDate: milestone.targetDate,
1454
+ status: milestone.status,
1455
+ project: project ? { id: project.id, name: project.name } : null,
1456
+ issues,
1457
+ };
1458
+ }, 'fetchMilestoneDetails');
1032
1459
  }
1033
1460
 
1034
1461
  /**
@@ -1043,70 +1470,72 @@ export async function fetchMilestoneDetails(client, milestoneId) {
1043
1470
  * @returns {Promise<Object>} Created milestone
1044
1471
  */
1045
1472
  export async function createProjectMilestone(client, input) {
1046
- const name = String(input.name || '').trim();
1047
- if (!name) {
1048
- throw new Error('Missing required field: name');
1049
- }
1473
+ return withLinearErrorHandling(async () => {
1474
+ const name = String(input.name || '').trim();
1475
+ if (!name) {
1476
+ throw new Error('Missing required field: name');
1477
+ }
1050
1478
 
1051
- const projectId = String(input.projectId || '').trim();
1052
- if (!projectId) {
1053
- throw new Error('Missing required field: projectId');
1054
- }
1479
+ const projectId = String(input.projectId || '').trim();
1480
+ if (!projectId) {
1481
+ throw new Error('Missing required field: projectId');
1482
+ }
1055
1483
 
1056
- const createInput = {
1057
- projectId,
1058
- name,
1059
- };
1484
+ const createInput = {
1485
+ projectId,
1486
+ name,
1487
+ };
1060
1488
 
1061
- if (input.description !== undefined) {
1062
- createInput.description = String(input.description);
1063
- }
1489
+ if (input.description !== undefined) {
1490
+ createInput.description = String(input.description);
1491
+ }
1064
1492
 
1065
- if (input.targetDate !== undefined) {
1066
- createInput.targetDate = input.targetDate;
1067
- }
1493
+ if (input.targetDate !== undefined) {
1494
+ createInput.targetDate = input.targetDate;
1495
+ }
1068
1496
 
1069
- if (input.status !== undefined) {
1070
- const validStatuses = ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'];
1071
- const status = String(input.status);
1072
- if (!validStatuses.includes(status)) {
1073
- throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
1497
+ if (input.status !== undefined) {
1498
+ const validStatuses = ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'];
1499
+ const status = String(input.status);
1500
+ if (!validStatuses.includes(status)) {
1501
+ throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
1502
+ }
1503
+ createInput.status = status;
1074
1504
  }
1075
- createInput.status = status;
1076
- }
1077
1505
 
1078
- const result = await client.createProjectMilestone(createInput);
1506
+ const result = await client.createProjectMilestone(createInput);
1079
1507
 
1080
- if (!result.success) {
1081
- throw new Error('Failed to create milestone');
1082
- }
1508
+ if (!result.success) {
1509
+ throw new Error('Failed to create milestone');
1510
+ }
1083
1511
 
1084
- // The payload has projectMilestone
1085
- const created = result.projectMilestone || result._projectMilestone;
1512
+ // The payload has projectMilestone
1513
+ const created = result.projectMilestone || result._projectMilestone;
1086
1514
 
1087
- // Try to fetch the full milestone
1088
- try {
1089
- if (created?.id) {
1090
- const fullMilestone = await client.projectMilestone(created.id);
1091
- if (fullMilestone) {
1092
- return transformMilestone(fullMilestone);
1515
+ // Try to fetch the full milestone
1516
+ try {
1517
+ if (created?.id) {
1518
+ const fullMilestone = await client.projectMilestone(created.id);
1519
+ if (fullMilestone) {
1520
+ return transformMilestone(fullMilestone);
1521
+ }
1093
1522
  }
1523
+ } catch {
1524
+ // Continue with fallback
1094
1525
  }
1095
- } catch {
1096
- // Continue with fallback
1097
- }
1098
1526
 
1099
- // Fallback: Build response from create result
1100
- return {
1101
- id: created?.id || null,
1102
- name: created?.name || name,
1103
- description: created?.description ?? input.description ?? null,
1104
- progress: created?.progress ?? 0,
1105
- order: created?.order ?? null,
1106
- targetDate: created?.targetDate ?? input.targetDate ?? null,
1107
- status: created?.status ?? input.status ?? 'backlogged',
1108
- project: null,
1109
- };
1527
+ // Fallback: Build response from create result
1528
+ return {
1529
+ id: created?.id || null,
1530
+ name: created?.name || name,
1531
+ description: created?.description ?? input.description ?? null,
1532
+ progress: created?.progress ?? 0,
1533
+ order: created?.order ?? null,
1534
+ targetDate: created?.targetDate ?? input.targetDate ?? null,
1535
+ status: created?.status ?? input.status ?? 'backlogged',
1536
+ project: null,
1537
+ };
1538
+ }, 'createProjectMilestone');
1110
1539
  }
1111
1540
 
1112
1541
  /**
@@ -1117,49 +1546,46 @@ export async function createProjectMilestone(client, input) {
1117
1546
  * @returns {Promise<{milestone: Object, changed: Array<string>}>}
1118
1547
  */
1119
1548
  export async function updateProjectMilestone(client, milestoneId, patch = {}) {
1120
- const milestone = await client.projectMilestone(milestoneId);
1121
- if (!milestone) {
1122
- throw new Error(`Milestone not found: ${milestoneId}`);
1123
- }
1124
-
1125
- const updateInput = {};
1549
+ return withLinearErrorHandling(async () => {
1550
+ const milestone = await client.projectMilestone(milestoneId);
1551
+ if (!milestone) {
1552
+ throw new Error(`Milestone not found: ${milestoneId}`);
1553
+ }
1126
1554
 
1127
- if (patch.name !== undefined) {
1128
- updateInput.name = String(patch.name);
1129
- }
1555
+ const updateInput = {};
1130
1556
 
1131
- if (patch.description !== undefined) {
1132
- updateInput.description = String(patch.description);
1133
- }
1557
+ if (patch.name !== undefined) {
1558
+ updateInput.name = String(patch.name);
1559
+ }
1134
1560
 
1135
- if (patch.targetDate !== undefined) {
1136
- updateInput.targetDate = patch.targetDate;
1137
- }
1561
+ if (patch.description !== undefined) {
1562
+ updateInput.description = String(patch.description);
1563
+ }
1138
1564
 
1139
- if (patch.status !== undefined) {
1140
- const validStatuses = ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'];
1141
- const status = String(patch.status);
1142
- if (!validStatuses.includes(status)) {
1143
- throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
1565
+ if (patch.targetDate !== undefined) {
1566
+ updateInput.targetDate = patch.targetDate;
1144
1567
  }
1145
- updateInput.status = status;
1146
- }
1147
1568
 
1148
- if (Object.keys(updateInput).length === 0) {
1149
- throw new Error('No update fields provided');
1150
- }
1569
+ // Note: status is a computed/read-only field in Linear's API (ProjectMilestoneStatus enum)
1570
+ // It cannot be set via ProjectMilestoneUpdateInput. The status values (done, next, overdue, unstarted)
1571
+ // are automatically determined by Linear based on milestone progress and dates.
1151
1572
 
1152
- const result = await milestone.update(updateInput);
1153
- if (!result.success) {
1154
- throw new Error('Failed to update milestone');
1155
- }
1573
+ if (Object.keys(updateInput).length === 0) {
1574
+ throw new Error('No update fields provided');
1575
+ }
1156
1576
 
1157
- const updatedMilestone = await transformMilestone(result.projectMilestone || result._projectMilestone || milestone);
1577
+ const result = await milestone.update(updateInput);
1578
+ if (!result.success) {
1579
+ throw new Error('Failed to update milestone');
1580
+ }
1158
1581
 
1159
- return {
1160
- milestone: updatedMilestone,
1161
- changed: Object.keys(updateInput),
1162
- };
1582
+ const updatedMilestone = await transformMilestone(result.projectMilestone || result._projectMilestone || milestone);
1583
+
1584
+ return {
1585
+ milestone: updatedMilestone,
1586
+ changed: Object.keys(updateInput),
1587
+ };
1588
+ }, 'updateProjectMilestone');
1163
1589
  }
1164
1590
 
1165
1591
  /**
@@ -1169,24 +1595,26 @@ export async function updateProjectMilestone(client, milestoneId, patch = {}) {
1169
1595
  * @returns {Promise<{success: boolean, milestoneId: string, name: string|null}>}
1170
1596
  */
1171
1597
  export async function deleteProjectMilestone(client, milestoneId) {
1172
- let milestoneName = null;
1598
+ return withLinearErrorHandling(async () => {
1599
+ let milestoneName = null;
1173
1600
 
1174
- try {
1175
- const existing = await client.projectMilestone(milestoneId);
1176
- if (existing?.name) {
1177
- milestoneName = existing.name;
1601
+ try {
1602
+ const existing = await client.projectMilestone(milestoneId);
1603
+ if (existing?.name) {
1604
+ milestoneName = existing.name;
1605
+ }
1606
+ } catch {
1607
+ milestoneName = null;
1178
1608
  }
1179
- } catch {
1180
- milestoneName = null;
1181
- }
1182
1609
 
1183
- const result = await client.deleteProjectMilestone(milestoneId);
1610
+ const result = await client.deleteProjectMilestone(milestoneId);
1184
1611
 
1185
- return {
1186
- success: result.success,
1187
- milestoneId,
1188
- name: milestoneName,
1189
- };
1612
+ return {
1613
+ success: result.success,
1614
+ milestoneId,
1615
+ name: milestoneName,
1616
+ };
1617
+ }, 'deleteProjectMilestone');
1190
1618
  }
1191
1619
 
1192
1620
  /**
@@ -1196,21 +1624,23 @@ export async function deleteProjectMilestone(client, milestoneId) {
1196
1624
  * @returns {Promise<{success: boolean, issueId: string, identifier: string}>}
1197
1625
  */
1198
1626
  export async function deleteIssue(client, issueRef) {
1199
- const targetIssue = await resolveIssue(client, issueRef);
1627
+ return withLinearErrorHandling(async () => {
1628
+ const targetIssue = await resolveIssue(client, issueRef);
1200
1629
 
1201
- // Get SDK issue instance for delete
1202
- const sdkIssue = await client.issue(targetIssue.id);
1203
- if (!sdkIssue) {
1204
- throw new Error(`Issue not found: ${targetIssue.id}`);
1205
- }
1630
+ // Get SDK issue instance for delete
1631
+ const sdkIssue = await client.issue(targetIssue.id);
1632
+ if (!sdkIssue) {
1633
+ throw new Error(`Issue not found: ${targetIssue.id}`);
1634
+ }
1206
1635
 
1207
- const result = await sdkIssue.delete();
1636
+ const result = await sdkIssue.delete();
1208
1637
 
1209
- return {
1210
- success: result.success,
1211
- issueId: targetIssue.id,
1212
- identifier: targetIssue.identifier,
1213
- };
1638
+ return {
1639
+ success: result.success,
1640
+ issueId: targetIssue.id,
1641
+ identifier: targetIssue.identifier,
1642
+ };
1643
+ }, 'deleteIssue');
1214
1644
  }
1215
1645
 
1216
1646
  // ===== PURE HELPER FUNCTIONS (unchanged) =====