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