@fink-andreas/pi-linear-tools 0.2.1 → 0.4.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 (internal)
143
+ * @param {Response} response - Fetch response object
144
+ */
145
+ 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|unknown} Formatted error with user-friendly message, or original if unhandled
228
+ */
229
+ function formatLinearError(error) {
230
+ const message = String(error?.message || error || 'Unknown error');
231
+ const errorType = error?.type || 'Unknown';
232
+
233
+ // Rate limit: provide reset time and reduce-frequency hint
234
+ if (errorType === 'Ratelimited' || message.toLowerCase().includes('rate limit')) {
235
+ const resetAt = error?.requestsResetAt
236
+ ? new Date(error.requestsResetAt).toLocaleTimeString()
237
+ : '1 hour';
238
+
239
+ return new Error(
240
+ `Linear API rate limit exceeded. Please wait before making more requests.\n` +
241
+ `Rate limit resets at: ${resetAt}\n` +
242
+ `Hint: Reduce request frequency or wait before retrying.`
243
+ );
244
+ }
245
+
246
+ // Auth/permission failures: prompt to check credentials
247
+ if (errorType === 'Forbidden' || errorType === 'AuthenticationError' ||
248
+ message.toLowerCase().includes('forbidden') || message.toLowerCase().includes('unauthorized')) {
249
+ return new Error(
250
+ `${message}\nHint: Check your Linear API key or OAuth token permissions.`
251
+ );
252
+ }
253
+
254
+ // Network errors
255
+ if (errorType === 'NetworkError' || message.toLowerCase().includes('network')) {
256
+ return new Error(
257
+ `Network error while communicating with Linear API.\nHint: Check your internet connection and try again.`
258
+ );
259
+ }
260
+
261
+ // Internal server errors
262
+ if (errorType === 'InternalError' || (error?.status >= 500 && error?.status < 600)) {
263
+ return new Error(
264
+ `Linear API server error (${error?.status || 'unknown'}).\nHint: Linear may be experiencing issues. Try again later.`
265
+ );
266
+ }
267
+
268
+ // Generic Linear API error
269
+ if (isLinearError(error)) {
270
+ return new Error(
271
+ `Linear API error: ${message}`
272
+ );
273
+ }
274
+
275
+ // Unknown error - wrap if not already an Error
276
+ return error instanceof Error ? error : new Error(String(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;
587
+ return withLinearErrorHandling(async () => {
588
+ const { assigneeId = null, limit = 20 } = options;
190
589
 
191
- const filter = {
192
- project: { id: { eq: projectId } },
193
- };
194
-
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,109 @@ 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
+ const children = (childrenResult.nodes || []).map(c => ({
893
+ identifier: c.identifier,
894
+ title: c.title,
895
+ state: c.state ? { name: c.state.name, color: c.state.color } : null,
896
+ }));
897
+
898
+ const comments = (commentsResult.nodes || []).map(c => ({
899
+ id: c.id,
900
+ body: c.body,
901
+ createdAt: c.createdAt,
902
+ updatedAt: c.updatedAt,
903
+ user: c.user ? { name: c.user.name, displayName: c.user.displayName } : null,
904
+ externalUser: c.externalUser ? { name: c.externalUser.name, displayName: c.externalUser.displayName } : null,
905
+ parent: c.parent ? { id: c.parent.id } : null,
906
+ }));
907
+
908
+ const attachments = (attachmentsResult.nodes || []).map(a => ({
909
+ id: a.id,
910
+ title: a.title,
911
+ url: a.url,
912
+ subtitle: a.subtitle,
913
+ sourceType: a.sourceType,
914
+ createdAt: a.createdAt,
915
+ }));
916
+
917
+ const labels = (labelsResult.nodes || []).map(l => ({
918
+ id: l.id,
919
+ name: l.name,
920
+ color: l.color,
921
+ }));
922
+
923
+ return {
924
+ identifier: sdkIssue.identifier,
925
+ title: sdkIssue.title,
926
+ description: sdkIssue.description,
927
+ url: sdkIssue.url,
928
+ branchName: sdkIssue.branchName,
929
+ priority: sdkIssue.priority,
930
+ estimate: sdkIssue.estimate,
931
+ createdAt: sdkIssue.createdAt,
932
+ updatedAt: sdkIssue.updatedAt,
933
+ state: state ? { name: state.name, color: state.color, type: state.type } : null,
934
+ team: team ? { id: team.id, key: team.key, name: team.name } : null,
935
+ project: project ? { id: project.id, name: project.name } : null,
936
+ projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
937
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
938
+ creator: creator ? { id: creator.id, name: creator.name, displayName: creator.displayName } : null,
939
+ labels,
940
+ parent: transformedParent,
941
+ children,
942
+ comments,
943
+ attachments,
475
944
  };
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
- };
945
+ }, 'fetchIssueDetails');
535
946
  }
536
947
 
537
948
  // ===== MUTATION FUNCTIONS =====
@@ -544,17 +955,19 @@ export async function fetchIssueDetails(client, issueRef, options = {}) {
544
955
  * @returns {Promise<Object>} Updated issue
545
956
  */
546
957
  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
- }
958
+ return withLinearErrorHandling(async () => {
959
+ const issue = await client.issue(issueId);
960
+ if (!issue) {
961
+ throw new Error(`Issue not found: ${issueId}`);
962
+ }
551
963
 
552
- const result = await issue.update({ stateId });
553
- if (!result.success) {
554
- throw new Error('Failed to update issue state');
555
- }
964
+ const result = await issue.update({ stateId });
965
+ if (!result.success) {
966
+ throw new Error('Failed to update issue state');
967
+ }
556
968
 
557
- return transformIssue(result.issue);
969
+ return transformIssue(result.issue);
970
+ }, 'setIssueState');
558
971
  }
559
972
 
560
973
  /**
@@ -571,86 +984,88 @@ export async function setIssueState(client, issueId, stateId) {
571
984
  * @returns {Promise<Object>} Created issue
572
985
  */
573
986
  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
- }
987
+ return withLinearErrorHandling(async () => {
988
+ const title = String(input.title || '').trim();
989
+ if (!title) {
990
+ throw new Error('Missing required field: title');
991
+ }
583
992
 
584
- const createInput = {
585
- teamId,
586
- title,
587
- };
993
+ const teamId = String(input.teamId || '').trim();
994
+ if (!teamId) {
995
+ throw new Error('Missing required field: teamId');
996
+ }
588
997
 
589
- if (input.description !== undefined) {
590
- createInput.description = String(input.description);
591
- }
998
+ const createInput = {
999
+ teamId,
1000
+ title,
1001
+ };
592
1002
 
593
- if (input.projectId !== undefined) {
594
- createInput.projectId = input.projectId;
595
- }
1003
+ if (input.description !== undefined) {
1004
+ createInput.description = String(input.description);
1005
+ }
596
1006
 
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`);
1007
+ if (input.projectId !== undefined) {
1008
+ createInput.projectId = input.projectId;
601
1009
  }
602
- createInput.priority = parsed;
603
- }
604
1010
 
605
- if (input.assigneeId !== undefined) {
606
- createInput.assigneeId = input.assigneeId;
607
- }
1011
+ if (input.priority !== undefined) {
1012
+ const parsed = Number.parseInt(String(input.priority), 10);
1013
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
1014
+ throw new Error(`Invalid priority: ${input.priority}. Valid range: 0..4`);
1015
+ }
1016
+ createInput.priority = parsed;
1017
+ }
608
1018
 
609
- if (input.parentId !== undefined) {
610
- createInput.parentId = input.parentId;
611
- }
1019
+ if (input.assigneeId !== undefined) {
1020
+ createInput.assigneeId = input.assigneeId;
1021
+ }
612
1022
 
613
- if (input.stateId !== undefined) {
614
- createInput.stateId = input.stateId;
615
- }
1023
+ if (input.parentId !== undefined) {
1024
+ createInput.parentId = input.parentId;
1025
+ }
616
1026
 
617
- const result = await client.createIssue(createInput);
1027
+ if (input.stateId !== undefined) {
1028
+ createInput.stateId = input.stateId;
1029
+ }
618
1030
 
619
- if (!result.success) {
620
- throw new Error('Failed to create issue');
621
- }
1031
+ const result = await client.createIssue(createInput);
622
1032
 
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;
1033
+ if (!result.success) {
1034
+ throw new Error('Failed to create issue');
1035
+ }
628
1036
 
629
- if (createdIssueId) {
630
- try {
631
- const fullIssue = await client.issue(createdIssueId);
632
- if (fullIssue) {
633
- const transformed = await transformIssue(fullIssue);
634
- return transformed;
1037
+ // Prefer official data path: resolve created issue ID then refetch full issue.
1038
+ const createdIssueId =
1039
+ result.issue?.id
1040
+ || result._issue?.id
1041
+ || null;
1042
+
1043
+ if (createdIssueId) {
1044
+ try {
1045
+ const fullIssue = await client.issue(createdIssueId);
1046
+ if (fullIssue) {
1047
+ const transformed = await transformIssue(fullIssue);
1048
+ return transformed;
1049
+ }
1050
+ } catch {
1051
+ // continue to fallback
635
1052
  }
636
- } catch {
637
- // continue to fallback
638
1053
  }
639
- }
640
1054
 
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
- };
1055
+ // Minimal fallback when SDK payload does not expose a resolvable issue ID.
1056
+ return {
1057
+ id: createdIssueId,
1058
+ identifier: null,
1059
+ title,
1060
+ description: input.description ?? null,
1061
+ url: null,
1062
+ priority: input.priority ?? null,
1063
+ state: null,
1064
+ team: null,
1065
+ project: null,
1066
+ assignee: null,
1067
+ };
1068
+ }, 'createIssue');
654
1069
  }
655
1070
 
656
1071
  /**
@@ -662,32 +1077,34 @@ export async function createIssue(client, input) {
662
1077
  * @returns {Promise<{issue: Object, comment: Object}>}
663
1078
  */
664
1079
  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
- }
1080
+ return withLinearErrorHandling(async () => {
1081
+ const commentBody = String(body || '').trim();
1082
+ if (!commentBody) {
1083
+ throw new Error('Missing required comment body');
1084
+ }
669
1085
 
670
- const targetIssue = await resolveIssue(client, issueRef);
1086
+ const targetIssue = await resolveIssue(client, issueRef);
671
1087
 
672
- const input = {
673
- issueId: targetIssue.id,
674
- body: commentBody,
675
- };
1088
+ const input = {
1089
+ issueId: targetIssue.id,
1090
+ body: commentBody,
1091
+ };
676
1092
 
677
- if (parentCommentId) {
678
- input.parentId = parentCommentId;
679
- }
1093
+ if (parentCommentId) {
1094
+ input.parentId = parentCommentId;
1095
+ }
680
1096
 
681
- const result = await client.createComment(input);
1097
+ const result = await client.createComment(input);
682
1098
 
683
- if (!result.success) {
684
- throw new Error('Failed to create comment');
685
- }
1099
+ if (!result.success) {
1100
+ throw new Error('Failed to create comment');
1101
+ }
686
1102
 
687
- return {
688
- issue: targetIssue,
689
- comment: result.comment,
690
- };
1103
+ return {
1104
+ issue: targetIssue,
1105
+ comment: result.comment,
1106
+ };
1107
+ }, 'addIssueComment');
691
1108
  }
692
1109
 
693
1110
  /**
@@ -698,185 +1115,187 @@ export async function addIssueComment(client, issueRef, body, parentCommentId) {
698
1115
  * @returns {Promise<{issue: Object, changed: Array<string>}>}
699
1116
  */
700
1117
  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
- }
1118
+ return withLinearErrorHandling(async () => {
1119
+ const targetIssue = await resolveIssue(client, issueRef);
1120
+ const updateInput = {};
1121
+
1122
+ debug('updateIssue: received patch', {
1123
+ issueRef,
1124
+ resolvedIssueId: targetIssue?.id,
1125
+ resolvedIdentifier: targetIssue?.identifier,
1126
+ patchKeys: Object.keys(patch || {}),
1127
+ hasTitle: patch.title !== undefined,
1128
+ hasDescription: patch.description !== undefined,
1129
+ priority: patch.priority,
1130
+ state: patch.state,
1131
+ assigneeId: patch.assigneeId,
1132
+ milestone: patch.milestone,
1133
+ projectMilestoneId: patch.projectMilestoneId,
1134
+ subIssueOf: patch.subIssueOf,
1135
+ parentOf: patch.parentOf,
1136
+ blockedBy: patch.blockedBy,
1137
+ blocking: patch.blocking,
1138
+ relatedTo: patch.relatedTo,
1139
+ duplicateOf: patch.duplicateOf,
1140
+ });
727
1141
 
728
- if (patch.description !== undefined) {
729
- updateInput.description = String(patch.description);
730
- }
1142
+ if (patch.title !== undefined) {
1143
+ updateInput.title = String(patch.title);
1144
+ }
731
1145
 
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`);
1146
+ if (patch.description !== undefined) {
1147
+ updateInput.description = String(patch.description);
736
1148
  }
737
- updateInput.priority = parsed;
738
- }
739
1149
 
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`);
1150
+ if (patch.priority !== undefined) {
1151
+ const parsed = Number.parseInt(String(patch.priority), 10);
1152
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
1153
+ throw new Error(`Invalid priority: ${patch.priority}. Valid range: 0..4`);
1154
+ }
1155
+ updateInput.priority = parsed;
745
1156
  }
746
1157
 
747
- const states = await getTeamWorkflowStates(client, team.id);
748
- updateInput.stateId = resolveStateIdFromInput(states, patch.state);
749
- }
1158
+ if (patch.state !== undefined) {
1159
+ // Need to resolve state ID from team's workflow states
1160
+ const team = targetIssue.team;
1161
+ if (!team?.id) {
1162
+ throw new Error(`Issue ${targetIssue.identifier} has no team assigned`);
1163
+ }
750
1164
 
751
- if (patch.assigneeId !== undefined) {
752
- updateInput.assigneeId = patch.assigneeId;
753
- }
1165
+ const states = await getTeamWorkflowStates(client, team.id);
1166
+ updateInput.stateId = resolveStateIdFromInput(states, patch.state);
1167
+ }
754
1168
 
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']);
1169
+ if (patch.assigneeId !== undefined) {
1170
+ updateInput.assigneeId = patch.assigneeId;
1171
+ }
760
1172
 
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`);
1173
+ if (patch.projectMilestoneId !== undefined) {
1174
+ updateInput.projectMilestoneId = patch.projectMilestoneId;
1175
+ } else if (patch.milestone !== undefined) {
1176
+ const milestoneRef = String(patch.milestone || '').trim();
1177
+ const clearMilestoneValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
1178
+
1179
+ if (clearMilestoneValues.has(milestoneRef.toLowerCase())) {
1180
+ updateInput.projectMilestoneId = null;
1181
+ } else {
1182
+ const projectId = targetIssue.project?.id;
1183
+ if (!projectId) {
1184
+ throw new Error(`Issue ${targetIssue.identifier} has no project; cannot resolve milestone by name`);
1185
+ }
1186
+
1187
+ const milestones = await fetchProjectMilestones(client, projectId);
1188
+ updateInput.projectMilestoneId = resolveProjectMilestoneIdFromInput(milestones, milestoneRef);
767
1189
  }
768
-
769
- const milestones = await fetchProjectMilestones(client, projectId);
770
- updateInput.projectMilestoneId = resolveProjectMilestoneIdFromInput(milestones, milestoneRef);
771
1190
  }
772
- }
773
1191
 
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;
1192
+ if (patch.subIssueOf !== undefined) {
1193
+ const parentRef = String(patch.subIssueOf || '').trim();
1194
+ const clearParentValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
1195
+ if (clearParentValues.has(parentRef.toLowerCase())) {
1196
+ updateInput.parentId = null;
1197
+ } else {
1198
+ const parentIssue = await resolveIssue(client, parentRef);
1199
+ updateInput.parentId = parentIssue.id;
1200
+ }
782
1201
  }
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
1202
 
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}`);
1203
+ const relationCreates = [];
1204
+ const parentOfRefs = normalizeIssueRefList(patch.parentOf);
1205
+ const blockedByRefs = normalizeIssueRefList(patch.blockedBy);
1206
+ const blockingRefs = normalizeIssueRefList(patch.blocking);
1207
+ const relatedToRefs = normalizeIssueRefList(patch.relatedTo);
1208
+ const duplicateOfRef = patch.duplicateOf !== undefined ? String(patch.duplicateOf || '').trim() : null;
1209
+
1210
+ for (const childRef of parentOfRefs) {
1211
+ const childIssue = await resolveIssue(client, childRef);
1212
+ const childSdkIssue = await client.issue(childIssue.id);
1213
+ if (!childSdkIssue) {
1214
+ throw new Error(`Issue not found: ${childRef}`);
1215
+ }
1216
+ const rel = await childSdkIssue.update({ parentId: targetIssue.id });
1217
+ if (!rel.success) {
1218
+ throw new Error(`Failed to set parent for issue: ${childRef}`);
1219
+ }
797
1220
  }
798
- const rel = await childSdkIssue.update({ parentId: targetIssue.id });
799
- if (!rel.success) {
800
- throw new Error(`Failed to set parent for issue: ${childRef}`);
1221
+
1222
+ for (const blockerRef of blockedByRefs) {
1223
+ const blocker = await resolveIssue(client, blockerRef);
1224
+ relationCreates.push({ issueId: blocker.id, relatedIssueId: targetIssue.id, type: 'blocks' });
801
1225
  }
802
- }
803
1226
 
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
- }
1227
+ for (const blockedRef of blockingRefs) {
1228
+ const blocked = await resolveIssue(client, blockedRef);
1229
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: blocked.id, type: 'blocks' });
1230
+ }
808
1231
 
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
- }
1232
+ for (const relatedRef of relatedToRefs) {
1233
+ const related = await resolveIssue(client, relatedRef);
1234
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: related.id, type: 'related' });
1235
+ }
813
1236
 
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
- }
1237
+ if (duplicateOfRef) {
1238
+ const duplicateTarget = await resolveIssue(client, duplicateOfRef);
1239
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: duplicateTarget.id, type: 'duplicate' });
1240
+ }
818
1241
 
819
- if (duplicateOfRef) {
820
- const duplicateTarget = await resolveIssue(client, duplicateOfRef);
821
- relationCreates.push({ issueId: targetIssue.id, relatedIssueId: duplicateTarget.id, type: 'duplicate' });
822
- }
1242
+ debug('updateIssue: computed update input', {
1243
+ issueRef,
1244
+ resolvedIdentifier: targetIssue?.identifier,
1245
+ updateKeys: Object.keys(updateInput),
1246
+ updateInput,
1247
+ relationCreateCount: relationCreates.length,
1248
+ parentOfCount: parentOfRefs.length,
1249
+ });
823
1250
 
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
- });
1251
+ if (Object.keys(updateInput).length === 0
1252
+ && relationCreates.length === 0
1253
+ && parentOfRefs.length === 0) {
1254
+ throw new Error('No update fields provided');
1255
+ }
832
1256
 
833
- if (Object.keys(updateInput).length === 0
834
- && relationCreates.length === 0
835
- && parentOfRefs.length === 0) {
836
- throw new Error('No update fields provided');
837
- }
1257
+ if (Object.keys(updateInput).length > 0) {
1258
+ // Get fresh issue instance for update
1259
+ const sdkIssue = await client.issue(targetIssue.id);
1260
+ if (!sdkIssue) {
1261
+ throw new Error(`Issue not found: ${targetIssue.id}`);
1262
+ }
838
1263
 
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}`);
1264
+ const result = await sdkIssue.update(updateInput);
1265
+ if (!result.success) {
1266
+ throw new Error('Failed to update issue');
1267
+ }
844
1268
  }
845
1269
 
846
- const result = await sdkIssue.update(updateInput);
847
- if (!result.success) {
848
- throw new Error('Failed to update issue');
1270
+ for (const relationInput of relationCreates) {
1271
+ const relationResult = await client.createIssueRelation(relationInput);
1272
+ if (!relationResult.success) {
1273
+ throw new Error(`Failed to create issue relation (${relationInput.type})`);
1274
+ }
849
1275
  }
850
- }
851
1276
 
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})`);
1277
+ // Prefer official data path: refetch the issue after successful mutation.
1278
+ let updatedSdkIssue = null;
1279
+ try {
1280
+ updatedSdkIssue = await client.issue(targetIssue.id);
1281
+ } catch {
1282
+ updatedSdkIssue = null;
856
1283
  }
857
- }
858
-
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
- }
866
1284
 
867
- const updatedIssue = await transformIssue(updatedSdkIssue);
1285
+ const updatedIssue = await transformIssue(updatedSdkIssue);
868
1286
 
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');
1287
+ const changed = [...Object.keys(updateInput)];
1288
+ if (parentOfRefs.length > 0) changed.push('parentOf');
1289
+ if (blockedByRefs.length > 0) changed.push('blockedBy');
1290
+ if (blockingRefs.length > 0) changed.push('blocking');
1291
+ if (relatedToRefs.length > 0) changed.push('relatedTo');
1292
+ if (duplicateOfRef) changed.push('duplicateOf');
875
1293
 
876
- return {
877
- issue: updatedIssue || targetIssue,
878
- changed,
879
- };
1294
+ return {
1295
+ issue: updatedIssue || targetIssue,
1296
+ changed,
1297
+ };
1298
+ }, 'updateIssue');
880
1299
  }
881
1300
 
882
1301
  /**
@@ -961,23 +1380,25 @@ async function transformMilestone(sdkMilestone) {
961
1380
  * @returns {Promise<Array<Object>>} Array of milestones
962
1381
  */
963
1382
  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
- }
1383
+ return withLinearErrorHandling(async () => {
1384
+ const project = await client.project(projectId);
1385
+ if (!project) {
1386
+ throw new Error(`Project not found: ${projectId}`);
1387
+ }
968
1388
 
969
- const result = await project.projectMilestones();
970
- const nodes = result.nodes || [];
1389
+ const result = await project.projectMilestones();
1390
+ const nodes = result.nodes || [];
971
1391
 
972
- const milestones = await Promise.all(nodes.map(transformMilestone));
1392
+ const milestones = await Promise.all(nodes.map(transformMilestone));
973
1393
 
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
- });
1394
+ debug('Fetched project milestones', {
1395
+ projectId,
1396
+ milestoneCount: milestones.length,
1397
+ milestones: milestones.map((m) => ({ id: m.id, name: m.name, status: m.status })),
1398
+ });
979
1399
 
980
- return milestones;
1400
+ return milestones;
1401
+ }, 'fetchProjectMilestones');
981
1402
  }
982
1403
 
983
1404
  /**
@@ -987,48 +1408,49 @@ export async function fetchProjectMilestones(client, projectId) {
987
1408
  * @returns {Promise<Object>} Milestone details with issues
988
1409
  */
989
1410
  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
- );
1411
+ return withLinearErrorHandling(async () => {
1412
+ const milestone = await client.projectMilestone(milestoneId);
1413
+ if (!milestone) {
1414
+ throw new Error(`Milestone not found: ${milestoneId}`);
1415
+ }
1020
1416
 
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
- };
1417
+ // Fetch project and issues in parallel
1418
+ const [project, issuesResult] = await Promise.all([
1419
+ milestone.project?.catch?.(() => null) ?? milestone.project,
1420
+ milestone.issues?.()?.catch?.(() => ({ nodes: [] })) ?? milestone.issues?.() ?? { nodes: [] },
1421
+ ]);
1422
+
1423
+ const issues = await Promise.all(
1424
+ (issuesResult.nodes || []).map(async (issue) => {
1425
+ const [state, assignee] = await Promise.all([
1426
+ issue.state?.catch?.(() => null) ?? issue.state,
1427
+ issue.assignee?.catch?.(() => null) ?? issue.assignee,
1428
+ ]);
1429
+
1430
+ return {
1431
+ id: issue.id,
1432
+ identifier: issue.identifier,
1433
+ title: issue.title,
1434
+ state: state ? { name: state.name, color: state.color, type: state.type } : null,
1435
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
1436
+ priority: issue.priority,
1437
+ estimate: issue.estimate,
1438
+ };
1439
+ })
1440
+ );
1441
+
1442
+ return {
1443
+ id: milestone.id,
1444
+ name: milestone.name,
1445
+ description: milestone.description,
1446
+ progress: milestone.progress,
1447
+ order: milestone.order,
1448
+ targetDate: milestone.targetDate,
1449
+ status: milestone.status,
1450
+ project: project ? { id: project.id, name: project.name } : null,
1451
+ issues,
1452
+ };
1453
+ }, 'fetchMilestoneDetails');
1032
1454
  }
1033
1455
 
1034
1456
  /**
@@ -1043,70 +1465,72 @@ export async function fetchMilestoneDetails(client, milestoneId) {
1043
1465
  * @returns {Promise<Object>} Created milestone
1044
1466
  */
1045
1467
  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
- }
1468
+ return withLinearErrorHandling(async () => {
1469
+ const name = String(input.name || '').trim();
1470
+ if (!name) {
1471
+ throw new Error('Missing required field: name');
1472
+ }
1050
1473
 
1051
- const projectId = String(input.projectId || '').trim();
1052
- if (!projectId) {
1053
- throw new Error('Missing required field: projectId');
1054
- }
1474
+ const projectId = String(input.projectId || '').trim();
1475
+ if (!projectId) {
1476
+ throw new Error('Missing required field: projectId');
1477
+ }
1055
1478
 
1056
- const createInput = {
1057
- projectId,
1058
- name,
1059
- };
1479
+ const createInput = {
1480
+ projectId,
1481
+ name,
1482
+ };
1060
1483
 
1061
- if (input.description !== undefined) {
1062
- createInput.description = String(input.description);
1063
- }
1484
+ if (input.description !== undefined) {
1485
+ createInput.description = String(input.description);
1486
+ }
1064
1487
 
1065
- if (input.targetDate !== undefined) {
1066
- createInput.targetDate = input.targetDate;
1067
- }
1488
+ if (input.targetDate !== undefined) {
1489
+ createInput.targetDate = input.targetDate;
1490
+ }
1068
1491
 
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(', ')}`);
1492
+ if (input.status !== undefined) {
1493
+ const validStatuses = ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'];
1494
+ const status = String(input.status);
1495
+ if (!validStatuses.includes(status)) {
1496
+ throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
1497
+ }
1498
+ createInput.status = status;
1074
1499
  }
1075
- createInput.status = status;
1076
- }
1077
1500
 
1078
- const result = await client.createProjectMilestone(createInput);
1501
+ const result = await client.createProjectMilestone(createInput);
1079
1502
 
1080
- if (!result.success) {
1081
- throw new Error('Failed to create milestone');
1082
- }
1503
+ if (!result.success) {
1504
+ throw new Error('Failed to create milestone');
1505
+ }
1083
1506
 
1084
- // The payload has projectMilestone
1085
- const created = result.projectMilestone || result._projectMilestone;
1507
+ // The payload has projectMilestone
1508
+ const created = result.projectMilestone || result._projectMilestone;
1086
1509
 
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);
1510
+ // Try to fetch the full milestone
1511
+ try {
1512
+ if (created?.id) {
1513
+ const fullMilestone = await client.projectMilestone(created.id);
1514
+ if (fullMilestone) {
1515
+ return transformMilestone(fullMilestone);
1516
+ }
1093
1517
  }
1518
+ } catch {
1519
+ // Continue with fallback
1094
1520
  }
1095
- } catch {
1096
- // Continue with fallback
1097
- }
1098
1521
 
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
- };
1522
+ // Fallback: Build response from create result
1523
+ return {
1524
+ id: created?.id || null,
1525
+ name: created?.name || name,
1526
+ description: created?.description ?? input.description ?? null,
1527
+ progress: created?.progress ?? 0,
1528
+ order: created?.order ?? null,
1529
+ targetDate: created?.targetDate ?? input.targetDate ?? null,
1530
+ status: created?.status ?? input.status ?? 'backlogged',
1531
+ project: null,
1532
+ };
1533
+ }, 'createProjectMilestone');
1110
1534
  }
1111
1535
 
1112
1536
  /**
@@ -1117,44 +1541,46 @@ export async function createProjectMilestone(client, input) {
1117
1541
  * @returns {Promise<{milestone: Object, changed: Array<string>}>}
1118
1542
  */
1119
1543
  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
- }
1544
+ return withLinearErrorHandling(async () => {
1545
+ const milestone = await client.projectMilestone(milestoneId);
1546
+ if (!milestone) {
1547
+ throw new Error(`Milestone not found: ${milestoneId}`);
1548
+ }
1124
1549
 
1125
- const updateInput = {};
1550
+ const updateInput = {};
1126
1551
 
1127
- if (patch.name !== undefined) {
1128
- updateInput.name = String(patch.name);
1129
- }
1552
+ if (patch.name !== undefined) {
1553
+ updateInput.name = String(patch.name);
1554
+ }
1130
1555
 
1131
- if (patch.description !== undefined) {
1132
- updateInput.description = String(patch.description);
1133
- }
1556
+ if (patch.description !== undefined) {
1557
+ updateInput.description = String(patch.description);
1558
+ }
1134
1559
 
1135
- if (patch.targetDate !== undefined) {
1136
- updateInput.targetDate = patch.targetDate;
1137
- }
1560
+ if (patch.targetDate !== undefined) {
1561
+ updateInput.targetDate = patch.targetDate;
1562
+ }
1138
1563
 
1139
- // Note: status is a computed/read-only field in Linear's API (ProjectMilestoneStatus enum)
1140
- // It cannot be set via ProjectMilestoneUpdateInput. The status values (done, next, overdue, unstarted)
1141
- // are automatically determined by Linear based on milestone progress and dates.
1564
+ // Note: status is a computed/read-only field in Linear's API (ProjectMilestoneStatus enum)
1565
+ // It cannot be set via ProjectMilestoneUpdateInput. The status values (done, next, overdue, unstarted)
1566
+ // are automatically determined by Linear based on milestone progress and dates.
1142
1567
 
1143
- if (Object.keys(updateInput).length === 0) {
1144
- throw new Error('No update fields provided');
1145
- }
1568
+ if (Object.keys(updateInput).length === 0) {
1569
+ throw new Error('No update fields provided');
1570
+ }
1146
1571
 
1147
- const result = await milestone.update(updateInput);
1148
- if (!result.success) {
1149
- throw new Error('Failed to update milestone');
1150
- }
1572
+ const result = await milestone.update(updateInput);
1573
+ if (!result.success) {
1574
+ throw new Error('Failed to update milestone');
1575
+ }
1151
1576
 
1152
- const updatedMilestone = await transformMilestone(result.projectMilestone || result._projectMilestone || milestone);
1577
+ const updatedMilestone = await transformMilestone(result.projectMilestone || result._projectMilestone || milestone);
1153
1578
 
1154
- return {
1155
- milestone: updatedMilestone,
1156
- changed: Object.keys(updateInput),
1157
- };
1579
+ return {
1580
+ milestone: updatedMilestone,
1581
+ changed: Object.keys(updateInput),
1582
+ };
1583
+ }, 'updateProjectMilestone');
1158
1584
  }
1159
1585
 
1160
1586
  /**
@@ -1164,24 +1590,26 @@ export async function updateProjectMilestone(client, milestoneId, patch = {}) {
1164
1590
  * @returns {Promise<{success: boolean, milestoneId: string, name: string|null}>}
1165
1591
  */
1166
1592
  export async function deleteProjectMilestone(client, milestoneId) {
1167
- let milestoneName = null;
1593
+ return withLinearErrorHandling(async () => {
1594
+ let milestoneName = null;
1168
1595
 
1169
- try {
1170
- const existing = await client.projectMilestone(milestoneId);
1171
- if (existing?.name) {
1172
- milestoneName = existing.name;
1596
+ try {
1597
+ const existing = await client.projectMilestone(milestoneId);
1598
+ if (existing?.name) {
1599
+ milestoneName = existing.name;
1600
+ }
1601
+ } catch {
1602
+ milestoneName = null;
1173
1603
  }
1174
- } catch {
1175
- milestoneName = null;
1176
- }
1177
1604
 
1178
- const result = await client.deleteProjectMilestone(milestoneId);
1605
+ const result = await client.deleteProjectMilestone(milestoneId);
1179
1606
 
1180
- return {
1181
- success: result.success,
1182
- milestoneId,
1183
- name: milestoneName,
1184
- };
1607
+ return {
1608
+ success: result.success,
1609
+ milestoneId,
1610
+ name: milestoneName,
1611
+ };
1612
+ }, 'deleteProjectMilestone');
1185
1613
  }
1186
1614
 
1187
1615
  /**
@@ -1191,21 +1619,23 @@ export async function deleteProjectMilestone(client, milestoneId) {
1191
1619
  * @returns {Promise<{success: boolean, issueId: string, identifier: string}>}
1192
1620
  */
1193
1621
  export async function deleteIssue(client, issueRef) {
1194
- const targetIssue = await resolveIssue(client, issueRef);
1622
+ return withLinearErrorHandling(async () => {
1623
+ const targetIssue = await resolveIssue(client, issueRef);
1195
1624
 
1196
- // Get SDK issue instance for delete
1197
- const sdkIssue = await client.issue(targetIssue.id);
1198
- if (!sdkIssue) {
1199
- throw new Error(`Issue not found: ${targetIssue.id}`);
1200
- }
1625
+ // Get SDK issue instance for delete
1626
+ const sdkIssue = await client.issue(targetIssue.id);
1627
+ if (!sdkIssue) {
1628
+ throw new Error(`Issue not found: ${targetIssue.id}`);
1629
+ }
1201
1630
 
1202
- const result = await sdkIssue.delete();
1631
+ const result = await sdkIssue.delete();
1203
1632
 
1204
- return {
1205
- success: result.success,
1206
- issueId: targetIssue.id,
1207
- identifier: targetIssue.identifier,
1208
- };
1633
+ return {
1634
+ success: result.success,
1635
+ issueId: targetIssue.id,
1636
+ identifier: targetIssue.identifier,
1637
+ };
1638
+ }, 'deleteIssue');
1209
1639
  }
1210
1640
 
1211
1641
  // ===== PURE HELPER FUNCTIONS (unchanged) =====