@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/CHANGELOG.md +42 -0
- package/extensions/pi-linear-tools.js +167 -134
- package/index.js +167 -134
- package/package.json +2 -2
- package/src/auth/callback-server.js +1 -1
- package/src/auth/constants.js +9 -0
- package/src/auth/index.js +50 -7
- package/src/auth/oauth.js +6 -5
- package/src/auth/pkce.js +16 -53
- package/src/auth/token-refresh.js +5 -7
- package/src/cli.js +1 -13
- package/src/error-hints.js +24 -0
- package/src/handlers.js +50 -45
- package/src/linear-client.js +252 -11
- package/src/linear.js +1066 -636
- package/src/logger.js +56 -16
- package/src/settings.js +5 -4
- package/src/shared.js +112 -0
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
|
-
*
|
|
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,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
|
-
|
|
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
|
+
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
585
|
-
teamId
|
|
586
|
-
|
|
587
|
-
|
|
993
|
+
const teamId = String(input.teamId || '').trim();
|
|
994
|
+
if (!teamId) {
|
|
995
|
+
throw new Error('Missing required field: teamId');
|
|
996
|
+
}
|
|
588
997
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
998
|
+
const createInput = {
|
|
999
|
+
teamId,
|
|
1000
|
+
title,
|
|
1001
|
+
};
|
|
592
1002
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
1003
|
+
if (input.description !== undefined) {
|
|
1004
|
+
createInput.description = String(input.description);
|
|
1005
|
+
}
|
|
596
1006
|
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1019
|
+
if (input.assigneeId !== undefined) {
|
|
1020
|
+
createInput.assigneeId = input.assigneeId;
|
|
1021
|
+
}
|
|
612
1022
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
1023
|
+
if (input.parentId !== undefined) {
|
|
1024
|
+
createInput.parentId = input.parentId;
|
|
1025
|
+
}
|
|
616
1026
|
|
|
617
|
-
|
|
1027
|
+
if (input.stateId !== undefined) {
|
|
1028
|
+
createInput.stateId = input.stateId;
|
|
1029
|
+
}
|
|
618
1030
|
|
|
619
|
-
|
|
620
|
-
throw new Error('Failed to create issue');
|
|
621
|
-
}
|
|
1031
|
+
const result = await client.createIssue(createInput);
|
|
622
1032
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|| result._issue?.id
|
|
627
|
-
|| null;
|
|
1033
|
+
if (!result.success) {
|
|
1034
|
+
throw new Error('Failed to create issue');
|
|
1035
|
+
}
|
|
628
1036
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
1086
|
+
const targetIssue = await resolveIssue(client, issueRef);
|
|
671
1087
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1088
|
+
const input = {
|
|
1089
|
+
issueId: targetIssue.id,
|
|
1090
|
+
body: commentBody,
|
|
1091
|
+
};
|
|
676
1092
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1093
|
+
if (parentCommentId) {
|
|
1094
|
+
input.parentId = parentCommentId;
|
|
1095
|
+
}
|
|
680
1096
|
|
|
681
|
-
|
|
1097
|
+
const result = await client.createComment(input);
|
|
682
1098
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1099
|
+
if (!result.success) {
|
|
1100
|
+
throw new Error('Failed to create comment');
|
|
1101
|
+
}
|
|
686
1102
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1142
|
+
if (patch.title !== undefined) {
|
|
1143
|
+
updateInput.title = String(patch.title);
|
|
1144
|
+
}
|
|
731
1145
|
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1165
|
+
const states = await getTeamWorkflowStates(client, team.id);
|
|
1166
|
+
updateInput.stateId = resolveStateIdFromInput(states, patch.state);
|
|
1167
|
+
}
|
|
754
1168
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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 (
|
|
762
|
-
updateInput.projectMilestoneId =
|
|
763
|
-
} else {
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
793
|
-
const
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
1285
|
+
const updatedIssue = await transformIssue(updatedSdkIssue);
|
|
868
1286
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
970
|
-
|
|
1389
|
+
const result = await project.projectMilestones();
|
|
1390
|
+
const nodes = result.nodes || [];
|
|
971
1391
|
|
|
972
|
-
|
|
1392
|
+
const milestones = await Promise.all(nodes.map(transformMilestone));
|
|
973
1393
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
);
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1474
|
+
const projectId = String(input.projectId || '').trim();
|
|
1475
|
+
if (!projectId) {
|
|
1476
|
+
throw new Error('Missing required field: projectId');
|
|
1477
|
+
}
|
|
1055
1478
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1479
|
+
const createInput = {
|
|
1480
|
+
projectId,
|
|
1481
|
+
name,
|
|
1482
|
+
};
|
|
1060
1483
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1484
|
+
if (input.description !== undefined) {
|
|
1485
|
+
createInput.description = String(input.description);
|
|
1486
|
+
}
|
|
1064
1487
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1488
|
+
if (input.targetDate !== undefined) {
|
|
1489
|
+
createInput.targetDate = input.targetDate;
|
|
1490
|
+
}
|
|
1068
1491
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1501
|
+
const result = await client.createProjectMilestone(createInput);
|
|
1079
1502
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1503
|
+
if (!result.success) {
|
|
1504
|
+
throw new Error('Failed to create milestone');
|
|
1505
|
+
}
|
|
1083
1506
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1507
|
+
// The payload has projectMilestone
|
|
1508
|
+
const created = result.projectMilestone || result._projectMilestone;
|
|
1086
1509
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
|
|
1550
|
+
const updateInput = {};
|
|
1126
1551
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1552
|
+
if (patch.name !== undefined) {
|
|
1553
|
+
updateInput.name = String(patch.name);
|
|
1554
|
+
}
|
|
1130
1555
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1556
|
+
if (patch.description !== undefined) {
|
|
1557
|
+
updateInput.description = String(patch.description);
|
|
1558
|
+
}
|
|
1134
1559
|
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1560
|
+
if (patch.targetDate !== undefined) {
|
|
1561
|
+
updateInput.targetDate = patch.targetDate;
|
|
1562
|
+
}
|
|
1138
1563
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1568
|
+
if (Object.keys(updateInput).length === 0) {
|
|
1569
|
+
throw new Error('No update fields provided');
|
|
1570
|
+
}
|
|
1146
1571
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1572
|
+
const result = await milestone.update(updateInput);
|
|
1573
|
+
if (!result.success) {
|
|
1574
|
+
throw new Error('Failed to update milestone');
|
|
1575
|
+
}
|
|
1151
1576
|
|
|
1152
|
-
|
|
1577
|
+
const updatedMilestone = await transformMilestone(result.projectMilestone || result._projectMilestone || milestone);
|
|
1153
1578
|
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
1593
|
+
return withLinearErrorHandling(async () => {
|
|
1594
|
+
let milestoneName = null;
|
|
1168
1595
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
1605
|
+
const result = await client.deleteProjectMilestone(milestoneId);
|
|
1179
1606
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1622
|
+
return withLinearErrorHandling(async () => {
|
|
1623
|
+
const targetIssue = await resolveIssue(client, issueRef);
|
|
1195
1624
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1631
|
+
const result = await sdkIssue.delete();
|
|
1203
1632
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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) =====
|