@fink-andreas/pi-linear-tools 0.2.1 → 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/CHANGELOG.md +19 -0
- package/extensions/pi-linear-tools.js +165 -40
- package/package.json +1 -1
- package/src/handlers.js +45 -42
- package/src/linear-client.js +133 -2
- package/src/linear.js +1071 -636
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
|
-
*
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
537
|
+
if (assigneeId) {
|
|
538
|
+
filter.assignee = { id: { eq: assigneeId } };
|
|
539
|
+
}
|
|
143
540
|
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
547
|
+
const nodes = data?.issues?.nodes || [];
|
|
548
|
+
const pageInfo = data?.issues?.pageInfo;
|
|
549
|
+
const hasNextPage = pageInfo?.hasNextPage ?? false;
|
|
149
550
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
587
|
+
return withLinearErrorHandling(async () => {
|
|
588
|
+
const { assigneeId = null, limit = 20 } = options;
|
|
190
589
|
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
594
|
+
if (states && states.length > 0) {
|
|
595
|
+
filter.state = { name: { in: states } };
|
|
596
|
+
}
|
|
202
597
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
});
|
|
598
|
+
if (assigneeId) {
|
|
599
|
+
filter.assignee = { id: { eq: assigneeId } };
|
|
600
|
+
}
|
|
207
601
|
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
609
|
+
const nodes = data?.issues?.nodes || [];
|
|
610
|
+
const pageInfo = data?.issues?.pageInfo;
|
|
611
|
+
const hasNextPage = pageInfo?.hasNextPage ?? false;
|
|
213
612
|
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
243
|
-
|
|
645
|
+
return withLinearErrorHandling(async () => {
|
|
646
|
+
const result = await client.projects();
|
|
647
|
+
const nodes = result.nodes ?? [];
|
|
244
648
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
664
|
+
return withLinearErrorHandling(async () => {
|
|
665
|
+
const viewer = await client.viewer;
|
|
666
|
+
const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
|
|
261
667
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
668
|
+
if (!organization) {
|
|
669
|
+
debug('No organization available from viewer context');
|
|
670
|
+
return [];
|
|
671
|
+
}
|
|
266
672
|
|
|
267
|
-
|
|
673
|
+
const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
|
|
268
674
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
675
|
+
debug('Fetched Linear workspace from viewer organization', {
|
|
676
|
+
workspace,
|
|
677
|
+
});
|
|
272
678
|
|
|
273
|
-
|
|
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
|
-
|
|
283
|
-
|
|
689
|
+
return withLinearErrorHandling(async () => {
|
|
690
|
+
const result = await client.teams();
|
|
691
|
+
const nodes = result.nodes ?? [];
|
|
284
692
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
756
|
+
return withLinearErrorHandling(async () => {
|
|
757
|
+
const lookup = normalizeIssueLookupInput(issueRef);
|
|
348
758
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
843
|
+
return withLinearErrorHandling(async () => {
|
|
844
|
+
const { includeComments = true } = options;
|
|
431
845
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
850
|
+
if (!sdkIssue) {
|
|
851
|
+
throw new Error(`Issue not found: ${lookup}`);
|
|
852
|
+
}
|
|
439
853
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
585
|
-
teamId
|
|
586
|
-
|
|
587
|
-
|
|
997
|
+
const teamId = String(input.teamId || '').trim();
|
|
998
|
+
if (!teamId) {
|
|
999
|
+
throw new Error('Missing required field: teamId');
|
|
1000
|
+
}
|
|
588
1001
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
1002
|
+
const createInput = {
|
|
1003
|
+
teamId,
|
|
1004
|
+
title,
|
|
1005
|
+
};
|
|
592
1006
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1007
|
+
if (input.description !== undefined) {
|
|
1008
|
+
createInput.description = String(input.description);
|
|
1009
|
+
}
|
|
596
1010
|
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1023
|
+
if (input.assigneeId !== undefined) {
|
|
1024
|
+
createInput.assigneeId = input.assigneeId;
|
|
1025
|
+
}
|
|
612
1026
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1027
|
+
if (input.parentId !== undefined) {
|
|
1028
|
+
createInput.parentId = input.parentId;
|
|
1029
|
+
}
|
|
616
1030
|
|
|
617
|
-
|
|
1031
|
+
if (input.stateId !== undefined) {
|
|
1032
|
+
createInput.stateId = input.stateId;
|
|
1033
|
+
}
|
|
618
1034
|
|
|
619
|
-
|
|
620
|
-
throw new Error('Failed to create issue');
|
|
621
|
-
}
|
|
1035
|
+
const result = await client.createIssue(createInput);
|
|
622
1036
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|| result._issue?.id
|
|
627
|
-
|| null;
|
|
1037
|
+
if (!result.success) {
|
|
1038
|
+
throw new Error('Failed to create issue');
|
|
1039
|
+
}
|
|
628
1040
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
1090
|
+
const targetIssue = await resolveIssue(client, issueRef);
|
|
671
1091
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1092
|
+
const input = {
|
|
1093
|
+
issueId: targetIssue.id,
|
|
1094
|
+
body: commentBody,
|
|
1095
|
+
};
|
|
676
1096
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1097
|
+
if (parentCommentId) {
|
|
1098
|
+
input.parentId = parentCommentId;
|
|
1099
|
+
}
|
|
680
1100
|
|
|
681
|
-
|
|
1101
|
+
const result = await client.createComment(input);
|
|
682
1102
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1103
|
+
if (!result.success) {
|
|
1104
|
+
throw new Error('Failed to create comment');
|
|
1105
|
+
}
|
|
686
1106
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1146
|
+
if (patch.title !== undefined) {
|
|
1147
|
+
updateInput.title = String(patch.title);
|
|
1148
|
+
}
|
|
731
1149
|
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1169
|
+
const states = await getTeamWorkflowStates(client, team.id);
|
|
1170
|
+
updateInput.stateId = resolveStateIdFromInput(states, patch.state);
|
|
1171
|
+
}
|
|
754
1172
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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 (
|
|
762
|
-
updateInput.projectMilestoneId =
|
|
763
|
-
} else {
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
793
|
-
const
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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
1288
|
|
|
867
|
-
|
|
1289
|
+
const updatedIssue = await transformIssue(updatedSdkIssue);
|
|
868
1290
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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');
|
|
875
1297
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
970
|
-
|
|
1393
|
+
const result = await project.projectMilestones();
|
|
1394
|
+
const nodes = result.nodes || [];
|
|
971
1395
|
|
|
972
|
-
|
|
1396
|
+
const milestones = await Promise.all(nodes.map(transformMilestone));
|
|
973
1397
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1479
|
+
const projectId = String(input.projectId || '').trim();
|
|
1480
|
+
if (!projectId) {
|
|
1481
|
+
throw new Error('Missing required field: projectId');
|
|
1482
|
+
}
|
|
1055
1483
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1484
|
+
const createInput = {
|
|
1485
|
+
projectId,
|
|
1486
|
+
name,
|
|
1487
|
+
};
|
|
1060
1488
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1489
|
+
if (input.description !== undefined) {
|
|
1490
|
+
createInput.description = String(input.description);
|
|
1491
|
+
}
|
|
1064
1492
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1493
|
+
if (input.targetDate !== undefined) {
|
|
1494
|
+
createInput.targetDate = input.targetDate;
|
|
1495
|
+
}
|
|
1068
1496
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1506
|
+
const result = await client.createProjectMilestone(createInput);
|
|
1079
1507
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1508
|
+
if (!result.success) {
|
|
1509
|
+
throw new Error('Failed to create milestone');
|
|
1510
|
+
}
|
|
1083
1511
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1512
|
+
// The payload has projectMilestone
|
|
1513
|
+
const created = result.projectMilestone || result._projectMilestone;
|
|
1086
1514
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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,44 +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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1549
|
+
return withLinearErrorHandling(async () => {
|
|
1550
|
+
const milestone = await client.projectMilestone(milestoneId);
|
|
1551
|
+
if (!milestone) {
|
|
1552
|
+
throw new Error(`Milestone not found: ${milestoneId}`);
|
|
1553
|
+
}
|
|
1124
1554
|
|
|
1125
|
-
|
|
1555
|
+
const updateInput = {};
|
|
1126
1556
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1557
|
+
if (patch.name !== undefined) {
|
|
1558
|
+
updateInput.name = String(patch.name);
|
|
1559
|
+
}
|
|
1130
1560
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1561
|
+
if (patch.description !== undefined) {
|
|
1562
|
+
updateInput.description = String(patch.description);
|
|
1563
|
+
}
|
|
1134
1564
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1565
|
+
if (patch.targetDate !== undefined) {
|
|
1566
|
+
updateInput.targetDate = patch.targetDate;
|
|
1567
|
+
}
|
|
1138
1568
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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.
|
|
1142
1572
|
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1573
|
+
if (Object.keys(updateInput).length === 0) {
|
|
1574
|
+
throw new Error('No update fields provided');
|
|
1575
|
+
}
|
|
1146
1576
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1577
|
+
const result = await milestone.update(updateInput);
|
|
1578
|
+
if (!result.success) {
|
|
1579
|
+
throw new Error('Failed to update milestone');
|
|
1580
|
+
}
|
|
1151
1581
|
|
|
1152
|
-
|
|
1582
|
+
const updatedMilestone = await transformMilestone(result.projectMilestone || result._projectMilestone || milestone);
|
|
1153
1583
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1584
|
+
return {
|
|
1585
|
+
milestone: updatedMilestone,
|
|
1586
|
+
changed: Object.keys(updateInput),
|
|
1587
|
+
};
|
|
1588
|
+
}, 'updateProjectMilestone');
|
|
1158
1589
|
}
|
|
1159
1590
|
|
|
1160
1591
|
/**
|
|
@@ -1164,24 +1595,26 @@ export async function updateProjectMilestone(client, milestoneId, patch = {}) {
|
|
|
1164
1595
|
* @returns {Promise<{success: boolean, milestoneId: string, name: string|null}>}
|
|
1165
1596
|
*/
|
|
1166
1597
|
export async function deleteProjectMilestone(client, milestoneId) {
|
|
1167
|
-
|
|
1598
|
+
return withLinearErrorHandling(async () => {
|
|
1599
|
+
let milestoneName = null;
|
|
1168
1600
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1601
|
+
try {
|
|
1602
|
+
const existing = await client.projectMilestone(milestoneId);
|
|
1603
|
+
if (existing?.name) {
|
|
1604
|
+
milestoneName = existing.name;
|
|
1605
|
+
}
|
|
1606
|
+
} catch {
|
|
1607
|
+
milestoneName = null;
|
|
1173
1608
|
}
|
|
1174
|
-
} catch {
|
|
1175
|
-
milestoneName = null;
|
|
1176
|
-
}
|
|
1177
1609
|
|
|
1178
|
-
|
|
1610
|
+
const result = await client.deleteProjectMilestone(milestoneId);
|
|
1179
1611
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1612
|
+
return {
|
|
1613
|
+
success: result.success,
|
|
1614
|
+
milestoneId,
|
|
1615
|
+
name: milestoneName,
|
|
1616
|
+
};
|
|
1617
|
+
}, 'deleteProjectMilestone');
|
|
1185
1618
|
}
|
|
1186
1619
|
|
|
1187
1620
|
/**
|
|
@@ -1191,21 +1624,23 @@ export async function deleteProjectMilestone(client, milestoneId) {
|
|
|
1191
1624
|
* @returns {Promise<{success: boolean, issueId: string, identifier: string}>}
|
|
1192
1625
|
*/
|
|
1193
1626
|
export async function deleteIssue(client, issueRef) {
|
|
1194
|
-
|
|
1627
|
+
return withLinearErrorHandling(async () => {
|
|
1628
|
+
const targetIssue = await resolveIssue(client, issueRef);
|
|
1195
1629
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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
|
+
}
|
|
1201
1635
|
|
|
1202
|
-
|
|
1636
|
+
const result = await sdkIssue.delete();
|
|
1203
1637
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1638
|
+
return {
|
|
1639
|
+
success: result.success,
|
|
1640
|
+
issueId: targetIssue.id,
|
|
1641
|
+
identifier: targetIssue.identifier,
|
|
1642
|
+
};
|
|
1643
|
+
}, 'deleteIssue');
|
|
1209
1644
|
}
|
|
1210
1645
|
|
|
1211
1646
|
// ===== PURE HELPER FUNCTIONS (unchanged) =====
|