@fink-andreas/pi-linear-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/linear.js ADDED
@@ -0,0 +1,1433 @@
1
+ /**
2
+ * Linear SDK API wrapper
3
+ *
4
+ * Provides high-level functions for interacting with Linear API via @linear/sdk.
5
+ * All functions receive a LinearClient instance as the first parameter.
6
+ */
7
+
8
+ import { warn, info, debug } from './logger.js';
9
+
10
+ // ===== HELPERS =====
11
+
12
+ /**
13
+ * Check if a value looks like a Linear UUID
14
+ */
15
+ function isLinearId(value) {
16
+ return typeof value === 'string' && /^[0-9a-fA-F-]{16,}$/.test(value);
17
+ }
18
+
19
+ /**
20
+ * Normalize issue lookup input
21
+ */
22
+ function normalizeIssueLookupInput(issue) {
23
+ const value = String(issue || '').trim();
24
+ if (!value) throw new Error('Missing required issue identifier');
25
+ return value;
26
+ }
27
+
28
+ /**
29
+ * Transform SDK issue object to plain object for consumers
30
+ * Handles both SDK Issue objects and already-resolved plain objects
31
+ */
32
+ async function transformIssue(sdkIssue) {
33
+ if (!sdkIssue) return null;
34
+
35
+ // Handle SDK issue with lazy-loaded relations
36
+ const [state, team, project, assignee, projectMilestone] = await Promise.all([
37
+ sdkIssue.state?.catch?.(() => null) ?? sdkIssue.state,
38
+ sdkIssue.team?.catch?.(() => null) ?? sdkIssue.team,
39
+ sdkIssue.project?.catch?.(() => null) ?? sdkIssue.project,
40
+ sdkIssue.assignee?.catch?.(() => null) ?? sdkIssue.assignee,
41
+ sdkIssue.projectMilestone?.catch?.(() => null) ?? sdkIssue.projectMilestone,
42
+ ]);
43
+
44
+ return {
45
+ id: sdkIssue.id,
46
+ identifier: sdkIssue.identifier,
47
+ title: sdkIssue.title,
48
+ description: sdkIssue.description,
49
+ url: sdkIssue.url,
50
+ branchName: sdkIssue.branchName,
51
+ priority: sdkIssue.priority,
52
+ state: state ? { id: state.id, name: state.name, type: state.type } : null,
53
+ team: team ? { id: team.id, key: team.key, name: team.name } : null,
54
+ project: project ? { id: project.id, name: project.name } : null,
55
+ projectMilestone: projectMilestone ? { id: projectMilestone.id, name: projectMilestone.name } : null,
56
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Resolve state ID from state input (ID, name, or type)
62
+ */
63
+ function resolveStateIdFromInput(states, stateInput) {
64
+ if (!stateInput) return null;
65
+ const target = String(stateInput).trim();
66
+ if (!target) return null;
67
+
68
+ const byId = states.find((s) => s.id === target);
69
+ if (byId) return byId.id;
70
+
71
+ const lower = target.toLowerCase();
72
+ const byName = states.find((s) => String(s.name || '').toLowerCase() === lower);
73
+ if (byName) return byName.id;
74
+
75
+ const byType = states.find((s) => String(s.type || '').toLowerCase() === lower);
76
+ if (byType) return byType.id;
77
+
78
+ throw new Error(`State not found in team workflow: ${target}`);
79
+ }
80
+
81
+ function resolveProjectMilestoneIdFromInput(milestones, milestoneInput) {
82
+ const target = String(milestoneInput || '').trim();
83
+ if (!target) return null;
84
+
85
+ const byId = milestones.find((m) => m.id === target);
86
+ if (byId) return byId.id;
87
+
88
+ const lower = target.toLowerCase();
89
+ const byName = milestones.find((m) => String(m.name || '').toLowerCase() === lower);
90
+ if (byName) return byName.id;
91
+
92
+ throw new Error(`Milestone not found in project: ${target}`);
93
+ }
94
+
95
+ function normalizeIssueRefList(value) {
96
+ if (value === undefined || value === null) return [];
97
+ if (Array.isArray(value)) {
98
+ return value.map((v) => String(v || '').trim()).filter(Boolean);
99
+ }
100
+ return String(value)
101
+ .split(',')
102
+ .map((v) => v.trim())
103
+ .filter(Boolean);
104
+ }
105
+
106
+ // ===== QUERY FUNCTIONS =====
107
+
108
+ /**
109
+ * Fetch the current authenticated viewer
110
+ * @param {LinearClient} client - Linear SDK client
111
+ * @returns {Promise<{id: string, name: string}>}
112
+ */
113
+ export async function fetchViewer(client) {
114
+ const viewer = await client.viewer;
115
+ return {
116
+ id: viewer.id,
117
+ name: viewer.name,
118
+ displayName: viewer.displayName,
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Fetch issues in specific states, optionally filtered by assignee
124
+ * @param {LinearClient} client - Linear SDK client
125
+ * @param {string|null} assigneeId - Assignee ID to filter by (null = all assignees)
126
+ * @param {Array<string>} openStates - List of state names to include
127
+ * @param {number} limit - Maximum number of issues to fetch
128
+ * @returns {Promise<{issues: Array, truncated: boolean}>}
129
+ */
130
+ export async function fetchIssues(client, assigneeId, openStates, limit) {
131
+ const filter = {
132
+ state: { name: { in: openStates } },
133
+ };
134
+
135
+ if (assigneeId) {
136
+ filter.assignee = { id: { eq: assigneeId } };
137
+ }
138
+
139
+ const result = await client.issues({
140
+ first: limit,
141
+ filter,
142
+ });
143
+
144
+ const nodes = result.nodes || [];
145
+ const hasNextPage = result.pageInfo?.hasNextPage ?? false;
146
+
147
+ // Transform SDK issues to plain objects
148
+ const issues = await Promise.all(nodes.map(transformIssue));
149
+
150
+ // DEBUG: Log issues delivered by Linear API
151
+ debug('Issues delivered by Linear API', {
152
+ issueCount: issues.length,
153
+ issues: issues.map(issue => ({
154
+ id: issue.id,
155
+ title: issue.title,
156
+ state: issue.state?.name,
157
+ assigneeId: issue.assignee?.id,
158
+ project: issue.project?.name,
159
+ projectId: issue.project?.id,
160
+ })),
161
+ });
162
+
163
+ const truncated = hasNextPage || nodes.length >= limit;
164
+ if (truncated) {
165
+ warn('Linear issues query may be truncated by LINEAR_PAGE_LIMIT', {
166
+ limit,
167
+ returned: nodes.length,
168
+ hasNextPage,
169
+ });
170
+ }
171
+
172
+ return {
173
+ issues,
174
+ truncated,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Fetch issues by project and optional state filter
180
+ * @param {LinearClient} client - Linear SDK client
181
+ * @param {string} projectId - Project ID to filter by
182
+ * @param {Array<string>|null} states - List of state names to include (null = all states)
183
+ * @param {Object} options
184
+ * @param {string|null} options.assigneeId - Assignee ID to filter by (null = all assignees)
185
+ * @param {number} options.limit - Maximum number of issues to fetch
186
+ * @returns {Promise<{issues: Array, truncated: boolean}>}
187
+ */
188
+ export async function fetchIssuesByProject(client, projectId, states, options = {}) {
189
+ const { assigneeId = null, limit = 50 } = options;
190
+
191
+ const filter = {
192
+ project: { id: { eq: projectId } },
193
+ };
194
+
195
+ if (states && states.length > 0) {
196
+ filter.state = { name: { in: states } };
197
+ }
198
+
199
+ if (assigneeId) {
200
+ filter.assignee = { id: { eq: assigneeId } };
201
+ }
202
+
203
+ const result = await client.issues({
204
+ first: limit,
205
+ filter,
206
+ });
207
+
208
+ const nodes = result.nodes || [];
209
+ const hasNextPage = result.pageInfo?.hasNextPage ?? false;
210
+
211
+ // Transform SDK issues to plain objects
212
+ const issues = await Promise.all(nodes.map(transformIssue));
213
+
214
+ debug('Fetched issues by project', {
215
+ projectId,
216
+ stateCount: states?.length ?? 0,
217
+ issueCount: issues.length,
218
+ truncated: hasNextPage,
219
+ });
220
+
221
+ const truncated = hasNextPage || nodes.length >= limit;
222
+ if (truncated) {
223
+ warn('Issues query may be truncated', {
224
+ limit,
225
+ returned: nodes.length,
226
+ hasNextPage,
227
+ });
228
+ }
229
+
230
+ return {
231
+ issues,
232
+ truncated,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Fetch all accessible projects from Linear API
238
+ * @param {LinearClient} client - Linear SDK client
239
+ * @returns {Promise<Array<{id: string, name: string}>>}
240
+ */
241
+ export async function fetchProjects(client) {
242
+ const result = await client.projects();
243
+ const nodes = result.nodes ?? [];
244
+
245
+ debug('Fetched Linear projects', {
246
+ projectCount: nodes.length,
247
+ projects: nodes.map((p) => ({ id: p.id, name: p.name })),
248
+ });
249
+
250
+ return nodes.map(p => ({ id: p.id, name: p.name }));
251
+ }
252
+
253
+ /**
254
+ * Fetch available workspaces (organization context) from Linear API
255
+ * @param {LinearClient} client - Linear SDK client
256
+ * @returns {Promise<Array<{id: string, name: string}>>}
257
+ */
258
+ export async function fetchWorkspaces(client) {
259
+ const viewer = await client.viewer;
260
+ const organization = await (viewer?.organization?.catch?.(() => null) ?? viewer?.organization ?? null);
261
+
262
+ if (!organization) {
263
+ debug('No organization available from viewer context');
264
+ return [];
265
+ }
266
+
267
+ const workspace = { id: organization.id, name: organization.name || organization.urlKey || 'Workspace' };
268
+
269
+ debug('Fetched Linear workspace from viewer organization', {
270
+ workspace,
271
+ });
272
+
273
+ return [workspace];
274
+ }
275
+
276
+ /**
277
+ * Fetch all accessible teams from Linear API
278
+ * @param {LinearClient} client - Linear SDK client
279
+ * @returns {Promise<Array<{id: string, key: string, name: string}>>}
280
+ */
281
+ export async function fetchTeams(client) {
282
+ const result = await client.teams();
283
+ const nodes = result.nodes ?? [];
284
+
285
+ debug('Fetched Linear teams', {
286
+ teamCount: nodes.length,
287
+ teams: nodes.map((t) => ({ id: t.id, key: t.key, name: t.name })),
288
+ });
289
+
290
+ return nodes.map(t => ({ id: t.id, key: t.key, name: t.name }));
291
+ }
292
+
293
+ /**
294
+ * Resolve a team reference (key, name, or ID) to a team object
295
+ * @param {LinearClient} client - Linear SDK client
296
+ * @param {string} teamRef - Team key, name, or ID
297
+ * @returns {Promise<{id: string, key: string, name: string}>}
298
+ */
299
+ export async function resolveTeamRef(client, teamRef) {
300
+ const ref = String(teamRef || '').trim();
301
+ if (!ref) {
302
+ throw new Error('Missing team reference');
303
+ }
304
+
305
+ const teams = await fetchTeams(client);
306
+
307
+ // If it looks like a Linear ID (UUID), try direct lookup first
308
+ if (isLinearId(ref)) {
309
+ const byId = teams.find((t) => t.id === ref);
310
+ if (byId) {
311
+ return byId;
312
+ }
313
+ throw new Error(`Team not found with ID: ${ref}`);
314
+ }
315
+
316
+ // Try exact key match (e.g., "ENG")
317
+ const byKey = teams.find((t) => t.key === ref);
318
+ if (byKey) {
319
+ return byKey;
320
+ }
321
+
322
+ // Try exact name match
323
+ const exactName = teams.find((t) => t.name === ref);
324
+ if (exactName) {
325
+ return exactName;
326
+ }
327
+
328
+ // Try case-insensitive key or name match
329
+ const lowerRef = ref.toLowerCase();
330
+ const insensitiveMatch = teams.find(
331
+ (t) => t.key?.toLowerCase() === lowerRef || t.name?.toLowerCase() === lowerRef
332
+ );
333
+ if (insensitiveMatch) {
334
+ return insensitiveMatch;
335
+ }
336
+
337
+ throw new Error(`Team not found: ${ref}. Available teams: ${teams.map((t) => `${t.key} (${t.name})`).join(', ')}`);
338
+ }
339
+
340
+ /**
341
+ * Resolve an issue by ID or identifier
342
+ * @param {LinearClient} client - Linear SDK client
343
+ * @param {string} issueRef - Issue identifier (ABC-123) or Linear issue ID
344
+ * @returns {Promise<Object>} Resolved issue object
345
+ */
346
+ export async function resolveIssue(client, issueRef) {
347
+ const lookup = normalizeIssueLookupInput(issueRef);
348
+
349
+ // The SDK's client.issue() method accepts both UUIDs and identifiers (ABC-123)
350
+ try {
351
+ const issue = await client.issue(lookup);
352
+ if (issue) {
353
+ return transformIssue(issue);
354
+ }
355
+ } catch (err) {
356
+ // Fall through to error
357
+ }
358
+
359
+ throw new Error(`Issue not found: ${lookup}`);
360
+ }
361
+
362
+ /**
363
+ * Get workflow states for a team
364
+ * @param {LinearClient} client - Linear SDK client
365
+ * @param {string} teamRef - Team ID or key
366
+ * @returns {Promise<Array<{id: string, name: string, type: string}>>}
367
+ */
368
+ export async function getTeamWorkflowStates(client, teamRef) {
369
+ const team = await client.team(teamRef);
370
+ if (!team) {
371
+ throw new Error(`Team not found: ${teamRef}`);
372
+ }
373
+
374
+ const states = await team.states();
375
+ return (states.nodes || []).map(s => ({
376
+ id: s.id,
377
+ name: s.name,
378
+ type: s.type,
379
+ }));
380
+ }
381
+
382
+ /**
383
+ * Resolve a project reference (name or ID) to a project object
384
+ * @param {LinearClient} client - Linear SDK client
385
+ * @param {string} projectRef - Project name or ID
386
+ * @returns {Promise<{id: string, name: string}>}
387
+ */
388
+ export async function resolveProjectRef(client, projectRef) {
389
+ const ref = String(projectRef || '').trim();
390
+ if (!ref) {
391
+ throw new Error('Missing project reference');
392
+ }
393
+
394
+ const projects = await fetchProjects(client);
395
+
396
+ // If it looks like a Linear ID (UUID), try direct lookup first
397
+ if (isLinearId(ref)) {
398
+ const byId = projects.find((p) => p.id === ref);
399
+ if (byId) {
400
+ return byId;
401
+ }
402
+ throw new Error(`Project not found with ID: ${ref}`);
403
+ }
404
+
405
+ // Try exact name match
406
+ const exactName = projects.find((p) => p.name === ref);
407
+ if (exactName) {
408
+ return exactName;
409
+ }
410
+
411
+ // Try case-insensitive name match
412
+ const lowerRef = ref.toLowerCase();
413
+ const insensitiveName = projects.find((p) => p.name?.toLowerCase() === lowerRef);
414
+ if (insensitiveName) {
415
+ return insensitiveName;
416
+ }
417
+
418
+ throw new Error(`Project not found: ${ref}. Available projects: ${projects.map((p) => p.name).join(', ')}`);
419
+ }
420
+
421
+ /**
422
+ * Fetch detailed issue information including comments, parent, children, and attachments
423
+ * @param {LinearClient} client - Linear SDK client
424
+ * @param {string} issueRef - Issue identifier (ABC-123) or Linear issue ID
425
+ * @param {Object} options
426
+ * @param {boolean} [options.includeComments=true] - Include comments in response
427
+ * @returns {Promise<Object>} Issue details
428
+ */
429
+ export async function fetchIssueDetails(client, issueRef, options = {}) {
430
+ const { includeComments = true } = options;
431
+
432
+ // Resolve issue - client.issue() accepts both UUIDs and identifiers
433
+ const lookup = normalizeIssueLookupInput(issueRef);
434
+ const sdkIssue = await client.issue(lookup);
435
+
436
+ if (!sdkIssue) {
437
+ throw new Error(`Issue not found: ${lookup}`);
438
+ }
439
+
440
+ // Fetch all nested relations in parallel
441
+ const [
442
+ state,
443
+ team,
444
+ project,
445
+ projectMilestone,
446
+ assignee,
447
+ creator,
448
+ labelsResult,
449
+ parent,
450
+ childrenResult,
451
+ commentsResult,
452
+ attachmentsResult,
453
+ ] = await Promise.all([
454
+ sdkIssue.state?.catch?.(() => null) ?? sdkIssue.state,
455
+ sdkIssue.team?.catch?.(() => null) ?? sdkIssue.team,
456
+ sdkIssue.project?.catch?.(() => null) ?? sdkIssue.project,
457
+ sdkIssue.projectMilestone?.catch?.(() => null) ?? sdkIssue.projectMilestone,
458
+ sdkIssue.assignee?.catch?.(() => null) ?? sdkIssue.assignee,
459
+ sdkIssue.creator?.catch?.(() => null) ?? sdkIssue.creator,
460
+ sdkIssue.labels?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.labels?.() ?? { nodes: [] },
461
+ sdkIssue.parent?.catch?.(() => null) ?? sdkIssue.parent,
462
+ sdkIssue.children?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.children?.() ?? { nodes: [] },
463
+ includeComments ? (sdkIssue.comments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.comments?.() ?? { nodes: [] }) : Promise.resolve({ nodes: [] }),
464
+ sdkIssue.attachments?.()?.catch?.(() => ({ nodes: [] })) ?? sdkIssue.attachments?.() ?? { nodes: [] },
465
+ ]);
466
+
467
+ // Transform parent if exists
468
+ let transformedParent = null;
469
+ if (parent) {
470
+ const parentState = await parent.state?.catch?.(() => null) ?? parent.state;
471
+ transformedParent = {
472
+ identifier: parent.identifier,
473
+ title: parent.title,
474
+ state: parentState ? { name: parentState.name, color: parentState.color } : null,
475
+ };
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
+ };
535
+ }
536
+
537
+ // ===== MUTATION FUNCTIONS =====
538
+
539
+ /**
540
+ * Set issue state
541
+ * @param {LinearClient} client - Linear SDK client
542
+ * @param {string} issueId - Issue ID (UUID)
543
+ * @param {string} stateId - Target state ID
544
+ * @returns {Promise<Object>} Updated issue
545
+ */
546
+ export async function setIssueState(client, issueId, stateId) {
547
+ const issue = await client.issue(issueId);
548
+ if (!issue) {
549
+ throw new Error(`Issue not found: ${issueId}`);
550
+ }
551
+
552
+ const result = await issue.update({ stateId });
553
+ if (!result.success) {
554
+ throw new Error('Failed to update issue state');
555
+ }
556
+
557
+ return transformIssue(result.issue);
558
+ }
559
+
560
+ /**
561
+ * Create a new issue
562
+ * @param {LinearClient} client - Linear SDK client
563
+ * @param {Object} input - Issue creation input
564
+ * @param {string} input.teamId - Team ID (required)
565
+ * @param {string} input.title - Issue title (required)
566
+ * @param {string} [input.description] - Issue description
567
+ * @param {string} [input.projectId] - Project ID
568
+ * @param {string} [input.priority] - Priority 0-4
569
+ * @param {string} [input.assigneeId] - Assignee ID
570
+ * @param {string} [input.parentId] - Parent issue ID for sub-issues
571
+ * @returns {Promise<Object>} Created issue
572
+ */
573
+ export async function createIssue(client, input) {
574
+ const title = String(input.title || '').trim();
575
+ if (!title) {
576
+ throw new Error('Missing required field: title');
577
+ }
578
+
579
+ const teamId = String(input.teamId || '').trim();
580
+ if (!teamId) {
581
+ throw new Error('Missing required field: teamId');
582
+ }
583
+
584
+ const createInput = {
585
+ teamId,
586
+ title,
587
+ };
588
+
589
+ if (input.description !== undefined) {
590
+ createInput.description = String(input.description);
591
+ }
592
+
593
+ if (input.projectId !== undefined) {
594
+ createInput.projectId = input.projectId;
595
+ }
596
+
597
+ if (input.priority !== undefined) {
598
+ const parsed = Number.parseInt(String(input.priority), 10);
599
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
600
+ throw new Error(`Invalid priority: ${input.priority}. Valid range: 0..4`);
601
+ }
602
+ createInput.priority = parsed;
603
+ }
604
+
605
+ if (input.assigneeId !== undefined) {
606
+ createInput.assigneeId = input.assigneeId;
607
+ }
608
+
609
+ if (input.parentId !== undefined) {
610
+ createInput.parentId = input.parentId;
611
+ }
612
+
613
+ if (input.stateId !== undefined) {
614
+ createInput.stateId = input.stateId;
615
+ }
616
+
617
+ const result = await client.createIssue(createInput);
618
+
619
+ if (!result.success) {
620
+ throw new Error('Failed to create issue');
621
+ }
622
+
623
+ // Prefer official data path: resolve created issue ID then refetch full issue.
624
+ const createdIssueId =
625
+ result.issue?.id
626
+ || result._issue?.id
627
+ || null;
628
+
629
+ if (createdIssueId) {
630
+ try {
631
+ const fullIssue = await client.issue(createdIssueId);
632
+ if (fullIssue) {
633
+ const transformed = await transformIssue(fullIssue);
634
+ return transformed;
635
+ }
636
+ } catch {
637
+ // continue to fallback
638
+ }
639
+ }
640
+
641
+ // Minimal fallback when SDK payload does not expose a resolvable issue ID.
642
+ return {
643
+ id: createdIssueId,
644
+ identifier: null,
645
+ title,
646
+ description: input.description ?? null,
647
+ url: null,
648
+ priority: input.priority ?? null,
649
+ state: null,
650
+ team: null,
651
+ project: null,
652
+ assignee: null,
653
+ };
654
+ }
655
+
656
+ /**
657
+ * Add a comment to an issue
658
+ * @param {LinearClient} client - Linear SDK client
659
+ * @param {string} issueRef - Issue identifier or ID
660
+ * @param {string} body - Comment body
661
+ * @param {string} [parentCommentId] - Parent comment ID for replies
662
+ * @returns {Promise<{issue: Object, comment: Object}>}
663
+ */
664
+ export async function addIssueComment(client, issueRef, body, parentCommentId) {
665
+ const commentBody = String(body || '').trim();
666
+ if (!commentBody) {
667
+ throw new Error('Missing required comment body');
668
+ }
669
+
670
+ const targetIssue = await resolveIssue(client, issueRef);
671
+
672
+ const input = {
673
+ issueId: targetIssue.id,
674
+ body: commentBody,
675
+ };
676
+
677
+ if (parentCommentId) {
678
+ input.parentId = parentCommentId;
679
+ }
680
+
681
+ const result = await client.createComment(input);
682
+
683
+ if (!result.success) {
684
+ throw new Error('Failed to create comment');
685
+ }
686
+
687
+ return {
688
+ issue: targetIssue,
689
+ comment: result.comment,
690
+ };
691
+ }
692
+
693
+ /**
694
+ * Update an issue
695
+ * @param {LinearClient} client - Linear SDK client
696
+ * @param {string} issueRef - Issue identifier or ID
697
+ * @param {Object} patch - Fields to update
698
+ * @returns {Promise<{issue: Object, changed: Array<string>}>}
699
+ */
700
+ export async function updateIssue(client, issueRef, patch = {}) {
701
+ const targetIssue = await resolveIssue(client, issueRef);
702
+ const updateInput = {};
703
+
704
+ debug('updateIssue: received patch', {
705
+ issueRef,
706
+ resolvedIssueId: targetIssue?.id,
707
+ resolvedIdentifier: targetIssue?.identifier,
708
+ patchKeys: Object.keys(patch || {}),
709
+ hasTitle: patch.title !== undefined,
710
+ hasDescription: patch.description !== undefined,
711
+ priority: patch.priority,
712
+ state: patch.state,
713
+ assigneeId: patch.assigneeId,
714
+ milestone: patch.milestone,
715
+ projectMilestoneId: patch.projectMilestoneId,
716
+ subIssueOf: patch.subIssueOf,
717
+ parentOf: patch.parentOf,
718
+ blockedBy: patch.blockedBy,
719
+ blocking: patch.blocking,
720
+ relatedTo: patch.relatedTo,
721
+ duplicateOf: patch.duplicateOf,
722
+ });
723
+
724
+ if (patch.title !== undefined) {
725
+ updateInput.title = String(patch.title);
726
+ }
727
+
728
+ if (patch.description !== undefined) {
729
+ updateInput.description = String(patch.description);
730
+ }
731
+
732
+ if (patch.priority !== undefined) {
733
+ const parsed = Number.parseInt(String(patch.priority), 10);
734
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 4) {
735
+ throw new Error(`Invalid priority: ${patch.priority}. Valid range: 0..4`);
736
+ }
737
+ updateInput.priority = parsed;
738
+ }
739
+
740
+ if (patch.state !== undefined) {
741
+ // Need to resolve state ID from team's workflow states
742
+ const team = targetIssue.team;
743
+ if (!team?.id) {
744
+ throw new Error(`Issue ${targetIssue.identifier} has no team assigned`);
745
+ }
746
+
747
+ const states = await getTeamWorkflowStates(client, team.id);
748
+ updateInput.stateId = resolveStateIdFromInput(states, patch.state);
749
+ }
750
+
751
+ if (patch.assigneeId !== undefined) {
752
+ updateInput.assigneeId = patch.assigneeId;
753
+ }
754
+
755
+ if (patch.projectMilestoneId !== undefined) {
756
+ updateInput.projectMilestoneId = patch.projectMilestoneId;
757
+ } else if (patch.milestone !== undefined) {
758
+ const milestoneRef = String(patch.milestone || '').trim();
759
+ const clearMilestoneValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
760
+
761
+ if (clearMilestoneValues.has(milestoneRef.toLowerCase())) {
762
+ updateInput.projectMilestoneId = null;
763
+ } else {
764
+ const projectId = targetIssue.project?.id;
765
+ if (!projectId) {
766
+ throw new Error(`Issue ${targetIssue.identifier} has no project; cannot resolve milestone by name`);
767
+ }
768
+
769
+ const milestones = await fetchProjectMilestones(client, projectId);
770
+ updateInput.projectMilestoneId = resolveProjectMilestoneIdFromInput(milestones, milestoneRef);
771
+ }
772
+ }
773
+
774
+ if (patch.subIssueOf !== undefined) {
775
+ const parentRef = String(patch.subIssueOf || '').trim();
776
+ const clearParentValues = new Set(['', 'none', 'null', 'unassigned', 'clear']);
777
+ if (clearParentValues.has(parentRef.toLowerCase())) {
778
+ updateInput.parentId = null;
779
+ } else {
780
+ const parentIssue = await resolveIssue(client, parentRef);
781
+ updateInput.parentId = parentIssue.id;
782
+ }
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
+
792
+ for (const childRef of parentOfRefs) {
793
+ const childIssue = await resolveIssue(client, childRef);
794
+ const childSdkIssue = await client.issue(childIssue.id);
795
+ if (!childSdkIssue) {
796
+ throw new Error(`Issue not found: ${childRef}`);
797
+ }
798
+ const rel = await childSdkIssue.update({ parentId: targetIssue.id });
799
+ if (!rel.success) {
800
+ throw new Error(`Failed to set parent for issue: ${childRef}`);
801
+ }
802
+ }
803
+
804
+ for (const blockerRef of blockedByRefs) {
805
+ const blocker = await resolveIssue(client, blockerRef);
806
+ relationCreates.push({ issueId: blocker.id, relatedIssueId: targetIssue.id, type: 'blocks' });
807
+ }
808
+
809
+ for (const blockedRef of blockingRefs) {
810
+ const blocked = await resolveIssue(client, blockedRef);
811
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: blocked.id, type: 'blocks' });
812
+ }
813
+
814
+ for (const relatedRef of relatedToRefs) {
815
+ const related = await resolveIssue(client, relatedRef);
816
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: related.id, type: 'related' });
817
+ }
818
+
819
+ if (duplicateOfRef) {
820
+ const duplicateTarget = await resolveIssue(client, duplicateOfRef);
821
+ relationCreates.push({ issueId: targetIssue.id, relatedIssueId: duplicateTarget.id, type: 'duplicate' });
822
+ }
823
+
824
+ debug('updateIssue: computed update input', {
825
+ issueRef,
826
+ resolvedIdentifier: targetIssue?.identifier,
827
+ updateKeys: Object.keys(updateInput),
828
+ updateInput,
829
+ relationCreateCount: relationCreates.length,
830
+ parentOfCount: parentOfRefs.length,
831
+ });
832
+
833
+ if (Object.keys(updateInput).length === 0
834
+ && relationCreates.length === 0
835
+ && parentOfRefs.length === 0) {
836
+ throw new Error('No update fields provided');
837
+ }
838
+
839
+ if (Object.keys(updateInput).length > 0) {
840
+ // Get fresh issue instance for update
841
+ const sdkIssue = await client.issue(targetIssue.id);
842
+ if (!sdkIssue) {
843
+ throw new Error(`Issue not found: ${targetIssue.id}`);
844
+ }
845
+
846
+ const result = await sdkIssue.update(updateInput);
847
+ if (!result.success) {
848
+ throw new Error('Failed to update issue');
849
+ }
850
+ }
851
+
852
+ for (const relationInput of relationCreates) {
853
+ const relationResult = await client.createIssueRelation(relationInput);
854
+ if (!relationResult.success) {
855
+ throw new Error(`Failed to create issue relation (${relationInput.type})`);
856
+ }
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
+
867
+ const updatedIssue = await transformIssue(updatedSdkIssue);
868
+
869
+ const changed = [...Object.keys(updateInput)];
870
+ if (parentOfRefs.length > 0) changed.push('parentOf');
871
+ if (blockedByRefs.length > 0) changed.push('blockedBy');
872
+ if (blockingRefs.length > 0) changed.push('blocking');
873
+ if (relatedToRefs.length > 0) changed.push('relatedTo');
874
+ if (duplicateOfRef) changed.push('duplicateOf');
875
+
876
+ return {
877
+ issue: updatedIssue || targetIssue,
878
+ changed,
879
+ };
880
+ }
881
+
882
+ /**
883
+ * Prepare issue for starting (get started state)
884
+ * @param {LinearClient} client - Linear SDK client
885
+ * @param {string} issueRef - Issue identifier or ID
886
+ * @returns {Promise<{issue: Object, startedState: Object, branchName: string|null}>}
887
+ */
888
+ export async function prepareIssueStart(client, issueRef) {
889
+ const targetIssue = await resolveIssue(client, issueRef);
890
+
891
+ const teamRef = targetIssue.team?.key || targetIssue.team?.id;
892
+ if (!teamRef) {
893
+ throw new Error(`Issue ${targetIssue.identifier} has no team assigned`);
894
+ }
895
+
896
+ const states = await getTeamWorkflowStates(client, teamRef);
897
+
898
+ // Find a "started" type state, or "In Progress" by name
899
+ const started = states.find((s) => s.type === 'started')
900
+ || states.find((s) => String(s.name || '').toLowerCase() === 'in progress');
901
+
902
+ if (!started?.id) {
903
+ throw new Error(`Could not resolve a started workflow state for team ${teamRef}`);
904
+ }
905
+
906
+ return {
907
+ issue: targetIssue,
908
+ startedState: started,
909
+ branchName: targetIssue.branchName || null,
910
+ };
911
+ }
912
+
913
+ /**
914
+ * Start an issue (set to "In Progress" state)
915
+ * @param {LinearClient} client - Linear SDK client
916
+ * @param {string} issueRef - Issue identifier or ID
917
+ * @returns {Promise<{issue: Object, startedState: Object, branchName: string|null}>}
918
+ */
919
+ export async function startIssue(client, issueRef) {
920
+ const prepared = await prepareIssueStart(client, issueRef);
921
+ const updated = await setIssueState(client, prepared.issue.id, prepared.startedState.id);
922
+
923
+ return {
924
+ issue: updated,
925
+ startedState: prepared.startedState,
926
+ branchName: prepared.branchName,
927
+ };
928
+ }
929
+
930
+ // ===== MILESTONE FUNCTIONS =====
931
+
932
+ /**
933
+ * Transform SDK milestone object to plain object for consumers
934
+ * @param {Object} sdkMilestone - SDK milestone object
935
+ * @returns {Promise<Object>} Plain milestone object
936
+ */
937
+ async function transformMilestone(sdkMilestone) {
938
+ if (!sdkMilestone) return null;
939
+
940
+ // Handle SDK milestone with lazy-loaded relations
941
+ const [project] = await Promise.all([
942
+ sdkMilestone.project?.catch?.(() => null) ?? sdkMilestone.project,
943
+ ]);
944
+
945
+ return {
946
+ id: sdkMilestone.id,
947
+ name: sdkMilestone.name,
948
+ description: sdkMilestone.description,
949
+ progress: sdkMilestone.progress,
950
+ order: sdkMilestone.order,
951
+ targetDate: sdkMilestone.targetDate,
952
+ status: sdkMilestone.status,
953
+ project: project ? { id: project.id, name: project.name } : null,
954
+ };
955
+ }
956
+
957
+ /**
958
+ * Fetch milestones for a project
959
+ * @param {LinearClient} client - Linear SDK client
960
+ * @param {string} projectId - Project ID
961
+ * @returns {Promise<Array<Object>>} Array of milestones
962
+ */
963
+ export async function fetchProjectMilestones(client, projectId) {
964
+ const project = await client.project(projectId);
965
+ if (!project) {
966
+ throw new Error(`Project not found: ${projectId}`);
967
+ }
968
+
969
+ const result = await project.projectMilestones();
970
+ const nodes = result.nodes || [];
971
+
972
+ const milestones = await Promise.all(nodes.map(transformMilestone));
973
+
974
+ debug('Fetched project milestones', {
975
+ projectId,
976
+ milestoneCount: milestones.length,
977
+ milestones: milestones.map((m) => ({ id: m.id, name: m.name, status: m.status })),
978
+ });
979
+
980
+ return milestones;
981
+ }
982
+
983
+ /**
984
+ * Fetch milestone details including associated issues
985
+ * @param {LinearClient} client - Linear SDK client
986
+ * @param {string} milestoneId - Milestone ID
987
+ * @returns {Promise<Object>} Milestone details with issues
988
+ */
989
+ export async function fetchMilestoneDetails(client, milestoneId) {
990
+ const milestone = await client.projectMilestone(milestoneId);
991
+ if (!milestone) {
992
+ throw new Error(`Milestone not found: ${milestoneId}`);
993
+ }
994
+
995
+ // Fetch project and issues in parallel
996
+ const [project, issuesResult] = await Promise.all([
997
+ milestone.project?.catch?.(() => null) ?? milestone.project,
998
+ milestone.issues?.()?.catch?.(() => ({ nodes: [] })) ?? milestone.issues?.() ?? { nodes: [] },
999
+ ]);
1000
+
1001
+ // Transform issues
1002
+ const issues = await Promise.all(
1003
+ (issuesResult.nodes || []).map(async (issue) => {
1004
+ const [state, assignee] = await Promise.all([
1005
+ issue.state?.catch?.(() => null) ?? issue.state,
1006
+ issue.assignee?.catch?.(() => null) ?? issue.assignee,
1007
+ ]);
1008
+
1009
+ return {
1010
+ id: issue.id,
1011
+ identifier: issue.identifier,
1012
+ title: issue.title,
1013
+ state: state ? { name: state.name, color: state.color, type: state.type } : null,
1014
+ assignee: assignee ? { id: assignee.id, name: assignee.name, displayName: assignee.displayName } : null,
1015
+ priority: issue.priority,
1016
+ estimate: issue.estimate,
1017
+ };
1018
+ })
1019
+ );
1020
+
1021
+ return {
1022
+ id: milestone.id,
1023
+ name: milestone.name,
1024
+ description: milestone.description,
1025
+ progress: milestone.progress,
1026
+ order: milestone.order,
1027
+ targetDate: milestone.targetDate,
1028
+ status: milestone.status,
1029
+ project: project ? { id: project.id, name: project.name } : null,
1030
+ issues,
1031
+ };
1032
+ }
1033
+
1034
+ /**
1035
+ * Create a new project milestone
1036
+ * @param {LinearClient} client - Linear SDK client
1037
+ * @param {Object} input - Milestone creation input
1038
+ * @param {string} input.projectId - Project ID (required)
1039
+ * @param {string} input.name - Milestone name (required)
1040
+ * @param {string} [input.description] - Milestone description
1041
+ * @param {string} [input.targetDate] - Target completion date (ISO string)
1042
+ * @param {string} [input.status] - Milestone status (backlogged, planned, inProgress, paused, completed, cancelled)
1043
+ * @returns {Promise<Object>} Created milestone
1044
+ */
1045
+ export async function createProjectMilestone(client, input) {
1046
+ const name = String(input.name || '').trim();
1047
+ if (!name) {
1048
+ throw new Error('Missing required field: name');
1049
+ }
1050
+
1051
+ const projectId = String(input.projectId || '').trim();
1052
+ if (!projectId) {
1053
+ throw new Error('Missing required field: projectId');
1054
+ }
1055
+
1056
+ const createInput = {
1057
+ projectId,
1058
+ name,
1059
+ };
1060
+
1061
+ if (input.description !== undefined) {
1062
+ createInput.description = String(input.description);
1063
+ }
1064
+
1065
+ if (input.targetDate !== undefined) {
1066
+ createInput.targetDate = input.targetDate;
1067
+ }
1068
+
1069
+ if (input.status !== undefined) {
1070
+ const validStatuses = ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'];
1071
+ const status = String(input.status);
1072
+ if (!validStatuses.includes(status)) {
1073
+ throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
1074
+ }
1075
+ createInput.status = status;
1076
+ }
1077
+
1078
+ const result = await client.createProjectMilestone(createInput);
1079
+
1080
+ if (!result.success) {
1081
+ throw new Error('Failed to create milestone');
1082
+ }
1083
+
1084
+ // The payload has projectMilestone
1085
+ const created = result.projectMilestone || result._projectMilestone;
1086
+
1087
+ // Try to fetch the full milestone
1088
+ try {
1089
+ if (created?.id) {
1090
+ const fullMilestone = await client.projectMilestone(created.id);
1091
+ if (fullMilestone) {
1092
+ return transformMilestone(fullMilestone);
1093
+ }
1094
+ }
1095
+ } catch {
1096
+ // Continue with fallback
1097
+ }
1098
+
1099
+ // Fallback: Build response from create result
1100
+ return {
1101
+ id: created?.id || null,
1102
+ name: created?.name || name,
1103
+ description: created?.description ?? input.description ?? null,
1104
+ progress: created?.progress ?? 0,
1105
+ order: created?.order ?? null,
1106
+ targetDate: created?.targetDate ?? input.targetDate ?? null,
1107
+ status: created?.status ?? input.status ?? 'backlogged',
1108
+ project: null,
1109
+ };
1110
+ }
1111
+
1112
+ /**
1113
+ * Update a project milestone
1114
+ * @param {LinearClient} client - Linear SDK client
1115
+ * @param {string} milestoneId - Milestone ID
1116
+ * @param {Object} patch - Fields to update
1117
+ * @returns {Promise<{milestone: Object, changed: Array<string>}>}
1118
+ */
1119
+ export async function updateProjectMilestone(client, milestoneId, patch = {}) {
1120
+ const milestone = await client.projectMilestone(milestoneId);
1121
+ if (!milestone) {
1122
+ throw new Error(`Milestone not found: ${milestoneId}`);
1123
+ }
1124
+
1125
+ const updateInput = {};
1126
+
1127
+ if (patch.name !== undefined) {
1128
+ updateInput.name = String(patch.name);
1129
+ }
1130
+
1131
+ if (patch.description !== undefined) {
1132
+ updateInput.description = String(patch.description);
1133
+ }
1134
+
1135
+ if (patch.targetDate !== undefined) {
1136
+ updateInput.targetDate = patch.targetDate;
1137
+ }
1138
+
1139
+ if (patch.status !== undefined) {
1140
+ const validStatuses = ['backlogged', 'planned', 'inProgress', 'paused', 'completed', 'done', 'cancelled'];
1141
+ const status = String(patch.status);
1142
+ if (!validStatuses.includes(status)) {
1143
+ throw new Error(`Invalid status: ${status}. Valid values: ${validStatuses.join(', ')}`);
1144
+ }
1145
+ updateInput.status = status;
1146
+ }
1147
+
1148
+ if (Object.keys(updateInput).length === 0) {
1149
+ throw new Error('No update fields provided');
1150
+ }
1151
+
1152
+ const result = await milestone.update(updateInput);
1153
+ if (!result.success) {
1154
+ throw new Error('Failed to update milestone');
1155
+ }
1156
+
1157
+ const updatedMilestone = await transformMilestone(result.projectMilestone || result._projectMilestone || milestone);
1158
+
1159
+ return {
1160
+ milestone: updatedMilestone,
1161
+ changed: Object.keys(updateInput),
1162
+ };
1163
+ }
1164
+
1165
+ /**
1166
+ * Delete a project milestone
1167
+ * @param {LinearClient} client - Linear SDK client
1168
+ * @param {string} milestoneId - Milestone ID
1169
+ * @returns {Promise<{success: boolean, milestoneId: string}>}
1170
+ */
1171
+ export async function deleteProjectMilestone(client, milestoneId) {
1172
+ const result = await client.deleteProjectMilestone(milestoneId);
1173
+
1174
+ return {
1175
+ success: result.success,
1176
+ milestoneId,
1177
+ };
1178
+ }
1179
+
1180
+ /**
1181
+ * Delete (archive) an issue
1182
+ * @param {LinearClient} client - Linear SDK client
1183
+ * @param {string} issueRef - Issue identifier or ID
1184
+ * @returns {Promise<{success: boolean, issueId: string, identifier: string}>}
1185
+ */
1186
+ export async function deleteIssue(client, issueRef) {
1187
+ const targetIssue = await resolveIssue(client, issueRef);
1188
+
1189
+ // Get SDK issue instance for delete
1190
+ const sdkIssue = await client.issue(targetIssue.id);
1191
+ if (!sdkIssue) {
1192
+ throw new Error(`Issue not found: ${targetIssue.id}`);
1193
+ }
1194
+
1195
+ const result = await sdkIssue.delete();
1196
+
1197
+ return {
1198
+ success: result.success,
1199
+ issueId: targetIssue.id,
1200
+ identifier: targetIssue.identifier,
1201
+ };
1202
+ }
1203
+
1204
+ // ===== PURE HELPER FUNCTIONS (unchanged) =====
1205
+
1206
+ /**
1207
+ * Group issues by project
1208
+ * @param {Array<Object>} issues - Array of issues
1209
+ * @returns {Map<string, {projectName: string, issueCount: number, issues: Array}>}
1210
+ */
1211
+ export function groupIssuesByProject(issues) {
1212
+ const map = new Map();
1213
+ let ignoredNoProject = 0;
1214
+
1215
+ for (const issue of issues) {
1216
+ const project = issue?.project;
1217
+ const projectId = project?.id;
1218
+
1219
+ if (!projectId) {
1220
+ ignoredNoProject += 1;
1221
+ debug('Ignoring issue with no project', {
1222
+ issueId: issue?.id,
1223
+ title: issue?.title,
1224
+ state: issue?.state?.name,
1225
+ });
1226
+ continue;
1227
+ }
1228
+
1229
+ const existing = map.get(projectId);
1230
+ if (existing) {
1231
+ existing.issueCount += 1;
1232
+ existing.issues.push(issue);
1233
+ } else {
1234
+ map.set(projectId, {
1235
+ projectName: project?.name,
1236
+ issueCount: 1,
1237
+ issues: [issue],
1238
+ });
1239
+ }
1240
+ }
1241
+
1242
+ info('Grouped issues by project', {
1243
+ issueCount: issues.length,
1244
+ projectCount: map.size,
1245
+ ignoredNoProject,
1246
+ });
1247
+
1248
+ return map;
1249
+ }
1250
+
1251
+ /**
1252
+ * Format relative time from ISO date string
1253
+ * @param {string} isoDate - ISO date string
1254
+ * @returns {string} Human-readable relative time
1255
+ */
1256
+ function formatRelativeTime(isoDate) {
1257
+ if (!isoDate) return 'unknown';
1258
+
1259
+ const date = new Date(isoDate);
1260
+ const now = new Date();
1261
+ const diffMs = now.getTime() - date.getTime();
1262
+ const diffSeconds = Math.floor(diffMs / 1000);
1263
+ const diffMinutes = Math.floor(diffSeconds / 60);
1264
+ const diffHours = Math.floor(diffMinutes / 60);
1265
+ const diffDays = Math.floor(diffHours / 24);
1266
+ const diffWeeks = Math.floor(diffDays / 7);
1267
+ const diffMonths = Math.floor(diffDays / 30);
1268
+ const diffYears = Math.floor(diffDays / 365);
1269
+
1270
+ if (diffSeconds < 60) return 'just now';
1271
+ if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
1272
+ if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
1273
+ if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
1274
+ if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks > 1 ? 's' : ''} ago`;
1275
+ if (diffMonths < 12) return `${diffMonths} month${diffMonths > 1 ? 's' : ''} ago`;
1276
+ return `${diffYears} year${diffYears > 1 ? 's' : ''} ago`;
1277
+ }
1278
+
1279
+ /**
1280
+ * Format issue details as markdown
1281
+ * @param {Object} issueData - Issue data from fetchIssueDetails
1282
+ * @param {Object} options
1283
+ * @param {boolean} [options.includeComments=true] - Include comments in markdown
1284
+ * @returns {string} Markdown formatted issue
1285
+ */
1286
+ export function formatIssueAsMarkdown(issueData, options = {}) {
1287
+ const { includeComments = true } = options;
1288
+ const lines = [];
1289
+
1290
+ // Title
1291
+ lines.push(`# ${issueData.identifier}: ${issueData.title}`);
1292
+
1293
+ // Meta information
1294
+ const metaParts = [];
1295
+ if (issueData.state?.name) {
1296
+ metaParts.push(`**State:** ${issueData.state.name}`);
1297
+ }
1298
+ if (issueData.team?.name) {
1299
+ metaParts.push(`**Team:** ${issueData.team.name}`);
1300
+ }
1301
+ if (issueData.project?.name) {
1302
+ metaParts.push(`**Project:** ${issueData.project.name}`);
1303
+ }
1304
+ if (issueData.projectMilestone?.name) {
1305
+ metaParts.push(`**Milestone:** ${issueData.projectMilestone.name}`);
1306
+ }
1307
+ if (issueData.assignee?.displayName) {
1308
+ metaParts.push(`**Assignee:** ${issueData.assignee.displayName}`);
1309
+ }
1310
+ if (issueData.priority !== undefined && issueData.priority !== null) {
1311
+ const priorityNames = ['No priority', 'Urgent', 'High', 'Medium', 'Low'];
1312
+ metaParts.push(`**Priority:** ${priorityNames[issueData.priority] || issueData.priority}`);
1313
+ }
1314
+ if (issueData.estimate !== undefined && issueData.estimate !== null) {
1315
+ metaParts.push(`**Estimate:** ${issueData.estimate}`);
1316
+ }
1317
+ if (issueData.labels?.length > 0) {
1318
+ const labelNames = issueData.labels.map((l) => l.name).join(', ');
1319
+ metaParts.push(`**Labels:** ${labelNames}`);
1320
+ }
1321
+
1322
+ if (metaParts.length > 0) {
1323
+ lines.push('');
1324
+ lines.push(metaParts.join(' | '));
1325
+ }
1326
+
1327
+ // URLs
1328
+ if (issueData.url) {
1329
+ lines.push('');
1330
+ lines.push(`**URL:** ${issueData.url}`);
1331
+ }
1332
+ if (issueData.branchName) {
1333
+ lines.push(`**Branch:** ${issueData.branchName}`);
1334
+ }
1335
+
1336
+ // Description
1337
+ if (issueData.description) {
1338
+ lines.push('');
1339
+ lines.push(issueData.description);
1340
+ }
1341
+
1342
+ // Parent issue
1343
+ if (issueData.parent) {
1344
+ lines.push('');
1345
+ lines.push('## Parent');
1346
+ lines.push('');
1347
+ lines.push(`- **${issueData.parent.identifier}**: ${issueData.parent.title} _[${issueData.parent.state?.name || 'unknown'}]_`);
1348
+ }
1349
+
1350
+ // Sub-issues
1351
+ if (issueData.children?.length > 0) {
1352
+ lines.push('');
1353
+ lines.push('## Sub-issues');
1354
+ lines.push('');
1355
+ for (const child of issueData.children) {
1356
+ lines.push(`- **${child.identifier}**: ${child.title} _[${child.state?.name || 'unknown'}]_`);
1357
+ }
1358
+ }
1359
+
1360
+ // Attachments
1361
+ if (issueData.attachments?.length > 0) {
1362
+ lines.push('');
1363
+ lines.push('## Attachments');
1364
+ lines.push('');
1365
+ for (const attachment of issueData.attachments) {
1366
+ const sourceLabel = attachment.sourceType ? ` _[${attachment.sourceType}]_` : '';
1367
+ lines.push(`- **${attachment.title}**: ${attachment.url}${sourceLabel}`);
1368
+ if (attachment.subtitle) {
1369
+ lines.push(` _${attachment.subtitle}_`);
1370
+ }
1371
+ }
1372
+ }
1373
+
1374
+ // Comments
1375
+ if (includeComments && issueData.comments?.length > 0) {
1376
+ lines.push('');
1377
+ lines.push('## Comments');
1378
+ lines.push('');
1379
+
1380
+ // Separate root comments from replies
1381
+ const rootComments = issueData.comments.filter((c) => !c.parent);
1382
+ const replies = issueData.comments.filter((c) => c.parent);
1383
+
1384
+ // Create a map of parent ID to replies
1385
+ const repliesMap = new Map();
1386
+ replies.forEach((reply) => {
1387
+ const parentId = reply.parent.id;
1388
+ if (!repliesMap.has(parentId)) {
1389
+ repliesMap.set(parentId, []);
1390
+ }
1391
+ repliesMap.get(parentId).push(reply);
1392
+ });
1393
+
1394
+ // Sort root comments by creation date (newest first)
1395
+ const sortedRootComments = rootComments.slice().reverse();
1396
+
1397
+ for (const rootComment of sortedRootComments) {
1398
+ const threadReplies = repliesMap.get(rootComment.id) || [];
1399
+
1400
+ // Sort replies by creation date (oldest first within thread)
1401
+ threadReplies.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
1402
+
1403
+ const rootAuthor = rootComment.user?.displayName
1404
+ || rootComment.user?.name
1405
+ || rootComment.externalUser?.displayName
1406
+ || rootComment.externalUser?.name
1407
+ || 'Unknown';
1408
+ const rootDate = formatRelativeTime(rootComment.createdAt);
1409
+
1410
+ lines.push(`- **@${rootAuthor}** - _${rootDate}_`);
1411
+ lines.push('');
1412
+ lines.push(` ${rootComment.body.split('\n').join('\n ')}`);
1413
+ lines.push('');
1414
+
1415
+ // Format replies
1416
+ for (const reply of threadReplies) {
1417
+ const replyAuthor = reply.user?.displayName
1418
+ || reply.user?.name
1419
+ || reply.externalUser?.displayName
1420
+ || reply.externalUser?.name
1421
+ || 'Unknown';
1422
+ const replyDate = formatRelativeTime(reply.createdAt);
1423
+
1424
+ lines.push(` - **@${replyAuthor}** - _${replyDate}_`);
1425
+ lines.push('');
1426
+ lines.push(` ${reply.body.split('\n').join('\n ')}`);
1427
+ lines.push('');
1428
+ }
1429
+ }
1430
+ }
1431
+
1432
+ return lines.join('\n');
1433
+ }