@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/CHANGELOG.md +16 -0
- package/FUNCTIONALITY.md +57 -0
- package/LICENSE +21 -0
- package/POST_RELEASE_CHECKLIST.md +30 -0
- package/README.md +157 -0
- package/RELEASE.md +50 -0
- package/bin/pi-linear-tools.js +8 -0
- package/extensions/pi-linear-tools.js +582 -0
- package/index.js +8 -0
- package/package.json +49 -0
- package/settings.json.example +12 -0
- package/src/cli.js +729 -0
- package/src/handlers.js +781 -0
- package/src/linear-client.js +43 -0
- package/src/linear.js +1433 -0
- package/src/logger.js +128 -0
- package/src/settings.js +173 -0
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
|
+
}
|