@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.
@@ -0,0 +1,781 @@
1
+ /**
2
+ * Shared handlers for Linear tools
3
+ *
4
+ * These handlers are used by both the pi extension and CLI.
5
+ * All handlers are pure functions that accept a LinearClient and parameters.
6
+ */
7
+
8
+ import { createLinearClient } from './linear-client.js';
9
+ import {
10
+ prepareIssueStart,
11
+ setIssueState,
12
+ addIssueComment,
13
+ updateIssue,
14
+ createIssue,
15
+ fetchProjects,
16
+ fetchTeams,
17
+ fetchWorkspaces,
18
+ resolveProjectRef,
19
+ resolveTeamRef,
20
+ getTeamWorkflowStates,
21
+ fetchIssueDetails,
22
+ formatIssueAsMarkdown,
23
+ fetchIssuesByProject,
24
+ fetchProjectMilestones,
25
+ fetchMilestoneDetails,
26
+ createProjectMilestone,
27
+ updateProjectMilestone,
28
+ deleteProjectMilestone,
29
+ deleteIssue,
30
+ } from './linear.js';
31
+ import { debug } from './logger.js';
32
+
33
+ function toTextResult(text, details = {}) {
34
+ return {
35
+ content: [{ type: 'text', text }],
36
+ details,
37
+ };
38
+ }
39
+
40
+ function ensureNonEmpty(value, fieldName) {
41
+ const text = String(value || '').trim();
42
+ if (!text) throw new Error(`Missing required field: ${fieldName}`);
43
+ return text;
44
+ }
45
+
46
+ // ===== GIT OPERATIONS (for issue start) =====
47
+
48
+ /**
49
+ * Run a git command using child_process
50
+ * @param {string[]} args - Git arguments
51
+ * @returns {Promise<{code: number, stdout: string, stderr: string}>}
52
+ */
53
+ async function runGitCommand(args) {
54
+ const { spawn } = await import('child_process');
55
+ return new Promise((resolve, reject) => {
56
+ const proc = spawn('git', args, { stdio: ['ignore', 'pipe', 'pipe'] });
57
+ let stdout = '';
58
+ let stderr = '';
59
+ proc.stdout.on('data', (data) => { stdout += data; });
60
+ proc.stderr.on('data', (data) => { stderr += data; });
61
+ proc.on('close', (code) => {
62
+ resolve({ code: code ?? 1, stdout, stderr });
63
+ });
64
+ proc.on('error', (err) => {
65
+ reject(err);
66
+ });
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Check if a git branch exists
72
+ * @param {string} branchName - Branch name to check
73
+ * @returns {Promise<boolean>}
74
+ */
75
+ async function gitBranchExists(branchName) {
76
+ const result = await runGitCommand(['rev-parse', '--verify', branchName]);
77
+ return result.code === 0;
78
+ }
79
+
80
+ /**
81
+ * Start a git branch for an issue
82
+ * @param {string} branchName - Desired branch name
83
+ * @param {string} fromRef - Git ref to branch from
84
+ * @param {string} onBranchExists - Action when branch exists: 'switch' or 'suffix'
85
+ * @returns {Promise<{action: string, branchName: string}>}
86
+ */
87
+ async function startGitBranch(branchName, fromRef = 'HEAD', onBranchExists = 'switch') {
88
+ const exists = await gitBranchExists(branchName);
89
+
90
+ if (!exists) {
91
+ const result = await runGitCommand(['checkout', '-b', branchName, fromRef || 'HEAD']);
92
+ if (result.code !== 0) {
93
+ const stderr = result.stderr.trim();
94
+ throw new Error(`git checkout -b failed${stderr ? `: ${stderr}` : ''}`);
95
+ }
96
+ return { action: 'created', branchName };
97
+ }
98
+
99
+ if (onBranchExists === 'suffix') {
100
+ let suffix = 1;
101
+ let nextName = `${branchName}-${suffix}`;
102
+
103
+ // eslint-disable-next-line no-await-in-loop
104
+ while (await gitBranchExists(nextName)) {
105
+ suffix += 1;
106
+ nextName = `${branchName}-${suffix}`;
107
+ }
108
+
109
+ const result = await runGitCommand(['checkout', '-b', nextName, fromRef || 'HEAD']);
110
+ if (result.code !== 0) {
111
+ const stderr = result.stderr.trim();
112
+ throw new Error(`git checkout -b failed${stderr ? `: ${stderr}` : ''}`);
113
+ }
114
+ return { action: 'created-suffix', branchName: nextName };
115
+ }
116
+
117
+ const result = await runGitCommand(['checkout', branchName]);
118
+ if (result.code !== 0) {
119
+ const stderr = result.stderr.trim();
120
+ throw new Error(`git checkout failed${stderr ? `: ${stderr}` : ''}`);
121
+ }
122
+ return { action: 'switched', branchName };
123
+ }
124
+
125
+ // ===== ISSUE HANDLERS =====
126
+
127
+ /**
128
+ * List issues in a project
129
+ */
130
+ export async function executeIssueList(client, params) {
131
+ let projectRef = params.project;
132
+ if (!projectRef) {
133
+ projectRef = process.cwd().split('/').pop();
134
+ }
135
+
136
+ const resolved = await resolveProjectRef(client, projectRef);
137
+
138
+ let assigneeId = null;
139
+ if (params.assignee === 'me') {
140
+ const viewer = await client.viewer;
141
+ assigneeId = viewer.id;
142
+ }
143
+
144
+ const { issues, truncated } = await fetchIssuesByProject(client, resolved.id, params.states || null, {
145
+ assigneeId,
146
+ limit: params.limit || 50,
147
+ });
148
+
149
+ if (issues.length === 0) {
150
+ return toTextResult(`No issues found in project "${resolved.name}"`, {
151
+ projectId: resolved.id,
152
+ projectName: resolved.name,
153
+ issueCount: 0,
154
+ });
155
+ }
156
+
157
+ const lines = [`## Issues in project "${resolved.name}" (${issues.length}${truncated ? '+' : ''})\n`];
158
+
159
+ for (const issue of issues) {
160
+ const stateLabel = issue.state?.name || 'Unknown';
161
+ const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
162
+ const priorityLabel = issue.priority !== undefined && issue.priority !== null
163
+ ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
164
+ : null;
165
+
166
+ const metaParts = [`[${stateLabel}]`, `@${assigneeLabel}`];
167
+ if (priorityLabel) metaParts.push(priorityLabel);
168
+
169
+ lines.push(`- **${issue.identifier}**: ${issue.title} _${metaParts.join(' ')}_`);
170
+ }
171
+
172
+ if (truncated) {
173
+ lines.push('\n_Results may be truncated. Use limit parameter to fetch more._');
174
+ }
175
+
176
+ return toTextResult(lines.join('\n'), {
177
+ projectId: resolved.id,
178
+ projectName: resolved.name,
179
+ issueCount: issues.length,
180
+ truncated,
181
+ });
182
+ }
183
+
184
+ /**
185
+ * View issue details
186
+ */
187
+ export async function executeIssueView(client, params) {
188
+ const issue = ensureNonEmpty(params.issue, 'issue');
189
+ const includeComments = params.includeComments !== false;
190
+
191
+ const issueData = await fetchIssueDetails(client, issue, { includeComments });
192
+ const markdown = formatIssueAsMarkdown(issueData, { includeComments });
193
+
194
+ return {
195
+ content: [{ type: 'text', text: markdown }],
196
+ details: {
197
+ issueId: issueData.id,
198
+ identifier: issueData.identifier,
199
+ title: issueData.title,
200
+ state: issueData.state,
201
+ url: issueData.url,
202
+ },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Create a new issue
208
+ */
209
+ export async function executeIssueCreate(client, params, options = {}) {
210
+ const { resolveDefaultTeam } = options;
211
+
212
+ const title = ensureNonEmpty(params.title, 'title');
213
+
214
+ let projectRef = params.project;
215
+ if (!projectRef) {
216
+ projectRef = process.cwd().split('/').pop();
217
+ }
218
+
219
+ let projectId = null;
220
+ let resolvedProject = null;
221
+ try {
222
+ resolvedProject = await resolveProjectRef(client, projectRef);
223
+ projectId = resolvedProject.id;
224
+ } catch {
225
+ // continue without project
226
+ }
227
+
228
+ let teamRef = params.team;
229
+ if (!teamRef && resolveDefaultTeam) {
230
+ teamRef = await resolveDefaultTeam(projectId);
231
+ }
232
+
233
+ if (!teamRef) {
234
+ throw new Error('Missing required field: team. Set a default with /linear-tools-config --default-team <team-key> or provide team parameter.');
235
+ }
236
+
237
+ const team = await resolveTeamRef(client, teamRef);
238
+
239
+ const createInput = {
240
+ teamId: team.id,
241
+ title,
242
+ };
243
+
244
+ if (params.description) {
245
+ createInput.description = params.description;
246
+ }
247
+
248
+ if (params.priority !== undefined && params.priority !== null) {
249
+ createInput.priority = params.priority;
250
+ }
251
+
252
+ if (params.parentId) {
253
+ createInput.parentId = params.parentId;
254
+ }
255
+
256
+ if (params.assignee === 'me') {
257
+ const viewer = await client.viewer;
258
+ createInput.assigneeId = viewer.id;
259
+ } else if (params.assignee) {
260
+ createInput.assigneeId = params.assignee;
261
+ } else if (params.assigneeId) {
262
+ createInput.assigneeId = params.assigneeId;
263
+ }
264
+
265
+ if (params.state) {
266
+ const states = await getTeamWorkflowStates(client, team.id);
267
+ const target = params.state.trim().toLowerCase();
268
+ const state = states.find((s) => s.name.toLowerCase() === target || s.id === params.state);
269
+ if (state) {
270
+ createInput.stateId = state.id;
271
+ }
272
+ }
273
+
274
+ if (resolvedProject) {
275
+ createInput.projectId = resolvedProject.id;
276
+ }
277
+
278
+ const issue = await createIssue(client, createInput);
279
+
280
+ const identifier = issue.identifier || issue.id || 'unknown';
281
+ const projectLabel = issue.project?.name || 'No project';
282
+ const priorityLabel = issue.priority !== undefined && issue.priority !== null
283
+ ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
284
+ : null;
285
+ const stateLabel = issue.state?.name || 'Unknown';
286
+ const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
287
+
288
+ const metaParts = [`Team: ${team.name}`, `Project: ${projectLabel}`, `State: ${stateLabel}`, `Assignee: ${assigneeLabel}`];
289
+ if (priorityLabel) metaParts.push(`Priority: ${priorityLabel}`);
290
+
291
+ return toTextResult(
292
+ `Created issue **${identifier}**: ${issue.title}\n${metaParts.join(' | ')}`,
293
+ {
294
+ issueId: issue.id,
295
+ identifier: issue.identifier,
296
+ title: issue.title,
297
+ team: issue.team,
298
+ project: issue.project,
299
+ state: issue.state,
300
+ assignee: issue.assignee,
301
+ url: issue.url,
302
+ }
303
+ );
304
+ }
305
+
306
+ /**
307
+ * Update an issue
308
+ */
309
+ export async function executeIssueUpdate(client, params) {
310
+ const issue = ensureNonEmpty(params.issue, 'issue');
311
+
312
+ debug('executeIssueUpdate: incoming params', {
313
+ issue,
314
+ hasTitle: params.title !== undefined,
315
+ hasDescription: params.description !== undefined,
316
+ priority: params.priority,
317
+ state: params.state,
318
+ assignee: params.assignee,
319
+ assigneeId: params.assigneeId,
320
+ milestone: params.milestone,
321
+ projectMilestoneId: params.projectMilestoneId,
322
+ subIssueOf: params.subIssueOf,
323
+ parentOfCount: Array.isArray(params.parentOf) ? params.parentOf.length : 0,
324
+ blockedByCount: Array.isArray(params.blockedBy) ? params.blockedBy.length : 0,
325
+ blockingCount: Array.isArray(params.blocking) ? params.blocking.length : 0,
326
+ relatedToCount: Array.isArray(params.relatedTo) ? params.relatedTo.length : 0,
327
+ duplicateOf: params.duplicateOf,
328
+ });
329
+
330
+ const updatePatch = {
331
+ title: params.title,
332
+ description: params.description,
333
+ priority: params.priority,
334
+ state: params.state,
335
+ milestone: params.milestone,
336
+ projectMilestoneId: params.projectMilestoneId,
337
+ subIssueOf: params.subIssueOf,
338
+ parentOf: params.parentOf,
339
+ blockedBy: params.blockedBy,
340
+ blocking: params.blocking,
341
+ relatedTo: params.relatedTo,
342
+ duplicateOf: params.duplicateOf,
343
+ };
344
+
345
+ if (params.assignee !== undefined && params.assigneeId !== undefined) {
346
+ debug('executeIssueUpdate: both assignee and assigneeId provided; assignee takes precedence', {
347
+ issue,
348
+ assignee: params.assignee,
349
+ assigneeId: params.assigneeId,
350
+ });
351
+ }
352
+
353
+ // Handle assignee parameter
354
+ if (params.assignee === 'me') {
355
+ const viewer = await client.viewer;
356
+ updatePatch.assigneeId = viewer.id;
357
+ } else if (params.assignee) {
358
+ updatePatch.assigneeId = params.assignee;
359
+ } else if (params.assigneeId) {
360
+ updatePatch.assigneeId = params.assigneeId;
361
+ }
362
+
363
+ debug('executeIssueUpdate: constructed updatePatch', {
364
+ issue,
365
+ patchKeys: Object.keys(updatePatch).filter((k) => updatePatch[k] !== undefined),
366
+ assigneeId: updatePatch.assigneeId,
367
+ milestone: updatePatch.milestone,
368
+ projectMilestoneId: updatePatch.projectMilestoneId,
369
+ });
370
+
371
+ const result = await updateIssue(client, issue, updatePatch);
372
+
373
+ const friendlyChanges = result.changed.map((field) => {
374
+ if (field === 'stateId') return 'state';
375
+ if (field === 'assigneeId') return 'assignee';
376
+ if (field === 'projectMilestoneId') return 'milestone';
377
+ if (field === 'parentId') return 'subIssueOf';
378
+ return field;
379
+ });
380
+ const changeSummaryParts = [];
381
+
382
+ if (friendlyChanges.includes('state') && result.issue?.state?.name) {
383
+ changeSummaryParts.push(`state: ${result.issue.state.name}`);
384
+ }
385
+
386
+ if (friendlyChanges.includes('assignee')) {
387
+ const assigneeLabel = result.issue?.assignee?.displayName || 'Unassigned';
388
+ changeSummaryParts.push(`assignee: ${assigneeLabel}`);
389
+ }
390
+
391
+ if (friendlyChanges.includes('milestone')) {
392
+ const milestoneLabel = result.issue?.projectMilestone?.name || 'None';
393
+ changeSummaryParts.push(`milestone: ${milestoneLabel}`);
394
+ }
395
+
396
+ if (friendlyChanges.includes('subIssueOf')) {
397
+ changeSummaryParts.push('subIssueOf');
398
+ }
399
+
400
+ for (const field of friendlyChanges) {
401
+ if (field !== 'state' && field !== 'assignee' && field !== 'milestone' && field !== 'subIssueOf') {
402
+ changeSummaryParts.push(field);
403
+ }
404
+ }
405
+
406
+ const suffix = changeSummaryParts.length > 0
407
+ ? ` (${changeSummaryParts.join(', ')})`
408
+ : '';
409
+
410
+ return toTextResult(
411
+ `Updated issue ${result.issue.identifier}${suffix}`,
412
+ {
413
+ issueId: result.issue.id,
414
+ identifier: result.issue.identifier,
415
+ changed: friendlyChanges,
416
+ state: result.issue.state,
417
+ priority: result.issue.priority,
418
+ projectMilestone: result.issue.projectMilestone,
419
+ }
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Add a comment to an issue
425
+ */
426
+ export async function executeIssueComment(client, params) {
427
+ const issue = ensureNonEmpty(params.issue, 'issue');
428
+ const body = ensureNonEmpty(params.body, 'body');
429
+ const result = await addIssueComment(client, issue, body, params.parentCommentId);
430
+
431
+ return toTextResult(
432
+ `Added comment to issue ${result.issue.identifier}`,
433
+ {
434
+ issueId: result.issue.id,
435
+ identifier: result.issue.identifier,
436
+ commentId: result.comment.id,
437
+ }
438
+ );
439
+ }
440
+
441
+ /**
442
+ * Start an issue (set to In Progress and create branch)
443
+ */
444
+ export async function executeIssueStart(client, params, options = {}) {
445
+ const { gitExecutor } = options;
446
+
447
+ const issue = ensureNonEmpty(params.issue, 'issue');
448
+ const prepared = await prepareIssueStart(client, issue);
449
+
450
+ const desiredBranch = params.branch || prepared.branchName;
451
+ if (!desiredBranch) {
452
+ throw new Error(
453
+ `No branch name resolved for issue ${prepared.issue.identifier}. Provide the 'branch' parameter explicitly.`
454
+ );
455
+ }
456
+
457
+ let gitResult;
458
+ if (gitExecutor) {
459
+ // Use provided git executor (e.g., pi.exec)
460
+ gitResult = await gitExecutor(desiredBranch, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
461
+ } else {
462
+ // Use built-in child_process git operations
463
+ gitResult = await startGitBranch(desiredBranch, params.fromRef || 'HEAD', params.onBranchExists || 'switch');
464
+ }
465
+
466
+ const updatedIssue = await setIssueState(client, prepared.issue.id, prepared.startedState.id);
467
+
468
+ const compactTitle = String(updatedIssue.title || prepared.issue?.title || '').trim().toLowerCase();
469
+ const summary = compactTitle
470
+ ? `Started issue ${updatedIssue.identifier} (${compactTitle})`
471
+ : `Started issue ${updatedIssue.identifier}`;
472
+
473
+ return toTextResult(summary, {
474
+ issueId: updatedIssue.id,
475
+ identifier: updatedIssue.identifier,
476
+ state: updatedIssue.state,
477
+ startedState: prepared.startedState,
478
+ git: gitResult,
479
+ });
480
+ }
481
+
482
+ /**
483
+ * Delete an issue
484
+ */
485
+ export async function executeIssueDelete(client, params) {
486
+ const issue = ensureNonEmpty(params.issue, 'issue');
487
+ const result = await deleteIssue(client, issue);
488
+
489
+ return toTextResult(
490
+ `Deleted issue **${result.identifier}**`,
491
+ {
492
+ issueId: result.issueId,
493
+ identifier: result.identifier,
494
+ success: result.success,
495
+ }
496
+ );
497
+ }
498
+
499
+ // ===== PROJECT HANDLERS =====
500
+
501
+ /**
502
+ * List projects
503
+ */
504
+ export async function executeProjectList(client) {
505
+ const projects = await fetchProjects(client);
506
+
507
+ if (projects.length === 0) {
508
+ return toTextResult('No projects found', { projectCount: 0 });
509
+ }
510
+
511
+ const lines = [`## Projects (${projects.length})\n`];
512
+
513
+ for (const project of projects) {
514
+ lines.push(`- **${project.name}** \`${project.id}\``);
515
+ }
516
+
517
+ return toTextResult(lines.join('\n'), {
518
+ projectCount: projects.length,
519
+ projects: projects.map((p) => ({ id: p.id, name: p.name })),
520
+ });
521
+ }
522
+
523
+ // ===== TEAM HANDLERS =====
524
+
525
+ /**
526
+ * List teams
527
+ */
528
+ export async function executeTeamList(client) {
529
+ const teams = await fetchTeams(client);
530
+
531
+ if (teams.length === 0) {
532
+ return toTextResult('No teams found', { teamCount: 0 });
533
+ }
534
+
535
+ const lines = [`## Teams (${teams.length})\n`];
536
+
537
+ for (const team of teams) {
538
+ lines.push(`- **${team.key}**: ${team.name} \`${team.id}\``);
539
+ }
540
+
541
+ return toTextResult(lines.join('\n'), {
542
+ teamCount: teams.length,
543
+ teams: teams.map((t) => ({ id: t.id, key: t.key, name: t.name })),
544
+ });
545
+ }
546
+
547
+ // ===== MILESTONE HANDLERS =====
548
+
549
+ /**
550
+ * List milestones in a project
551
+ */
552
+ export async function executeMilestoneList(client, params) {
553
+ let projectRef = params.project;
554
+ if (!projectRef) {
555
+ projectRef = process.cwd().split('/').pop();
556
+ }
557
+
558
+ const resolved = await resolveProjectRef(client, projectRef);
559
+ const milestones = await fetchProjectMilestones(client, resolved.id);
560
+
561
+ if (milestones.length === 0) {
562
+ return toTextResult(`No milestones found in project "${resolved.name}"`, {
563
+ projectId: resolved.id,
564
+ projectName: resolved.name,
565
+ milestoneCount: 0,
566
+ });
567
+ }
568
+
569
+ const lines = [`## Milestones in project "${resolved.name}" (${milestones.length})\n`];
570
+
571
+ const sorted = [...milestones].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
572
+
573
+ for (const milestone of sorted) {
574
+ const statusEmoji = {
575
+ backlogged: 'πŸ“‹',
576
+ planned: 'πŸ“…',
577
+ inProgress: 'πŸš€',
578
+ paused: '⏸️',
579
+ completed: 'βœ…',
580
+ done: 'βœ…',
581
+ cancelled: '❌',
582
+ }[milestone.status] || 'πŸ“Œ';
583
+
584
+ const progressLabel = milestone.progress !== undefined && milestone.progress !== null
585
+ ? `${milestone.progress}%`
586
+ : 'N/A';
587
+
588
+ const dateLabel = milestone.targetDate
589
+ ? ` β†’ ${milestone.targetDate.split('T')[0]}`
590
+ : '';
591
+
592
+ lines.push(`- ${statusEmoji} **${milestone.name}** _[${milestone.status}]_ (${progressLabel})${dateLabel}`);
593
+ if (milestone.description) {
594
+ lines.push(` ${milestone.description.split('\n')[0].slice(0, 100)}${milestone.description.length > 100 ? '...' : ''}`);
595
+ }
596
+ }
597
+
598
+ return toTextResult(lines.join('\n'), {
599
+ projectId: resolved.id,
600
+ projectName: resolved.name,
601
+ milestoneCount: milestones.length,
602
+ milestones: milestones.map((m) => ({ id: m.id, name: m.name, status: m.status, progress: m.progress })),
603
+ });
604
+ }
605
+
606
+ /**
607
+ * View milestone details
608
+ */
609
+ export async function executeMilestoneView(client, params) {
610
+ const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
611
+
612
+ const milestoneData = await fetchMilestoneDetails(client, milestoneId);
613
+
614
+ const lines = [];
615
+ lines.push(`# Milestone: ${milestoneData.name}`);
616
+
617
+ const metaParts = [];
618
+ if (milestoneData.project?.name) {
619
+ metaParts.push(`**Project:** ${milestoneData.project.name}`);
620
+ }
621
+ metaParts.push(`**Status:** ${milestoneData.status}`);
622
+ if (milestoneData.progress !== undefined && milestoneData.progress !== null) {
623
+ metaParts.push(`**Progress:** ${milestoneData.progress}%`);
624
+ }
625
+ if (milestoneData.targetDate) {
626
+ metaParts.push(`**Target Date:** ${milestoneData.targetDate.split('T')[0]}`);
627
+ }
628
+
629
+ if (metaParts.length > 0) {
630
+ lines.push('');
631
+ lines.push(metaParts.join(' | '));
632
+ }
633
+
634
+ if (milestoneData.description) {
635
+ lines.push('');
636
+ lines.push(milestoneData.description);
637
+ }
638
+
639
+ if (milestoneData.issues?.length > 0) {
640
+ lines.push('');
641
+ lines.push(`## Issues (${milestoneData.issues.length})`);
642
+ lines.push('');
643
+
644
+ for (const issue of milestoneData.issues) {
645
+ const stateLabel = issue.state?.name || 'Unknown';
646
+ const assigneeLabel = issue.assignee?.displayName || 'Unassigned';
647
+ const priorityLabel = issue.priority !== undefined && issue.priority !== null
648
+ ? ['None', 'Urgent', 'High', 'Medium', 'Low'][issue.priority] || `P${issue.priority}`
649
+ : null;
650
+
651
+ const meta = [`[${stateLabel}]`, `@${assigneeLabel}`];
652
+ if (priorityLabel) meta.push(priorityLabel);
653
+ if (issue.estimate !== undefined && issue.estimate !== null) meta.push(`${issue.estimate}pt`);
654
+
655
+ lines.push(`- **${issue.identifier}**: ${issue.title} _${meta.join(' ')}_`);
656
+ }
657
+ } else {
658
+ lines.push('');
659
+ lines.push('_No issues associated with this milestone._');
660
+ }
661
+
662
+ return {
663
+ content: [{ type: 'text', text: lines.join('\n') }],
664
+ details: {
665
+ milestoneId: milestoneData.id,
666
+ name: milestoneData.name,
667
+ status: milestoneData.status,
668
+ progress: milestoneData.progress,
669
+ project: milestoneData.project,
670
+ issueCount: milestoneData.issues?.length || 0,
671
+ },
672
+ };
673
+ }
674
+
675
+ /**
676
+ * Create a milestone
677
+ */
678
+ export async function executeMilestoneCreate(client, params) {
679
+ const name = ensureNonEmpty(params.name, 'name');
680
+
681
+ let projectRef = params.project;
682
+ if (!projectRef) {
683
+ projectRef = process.cwd().split('/').pop();
684
+ }
685
+
686
+ const resolved = await resolveProjectRef(client, projectRef);
687
+
688
+ const createInput = {
689
+ projectId: resolved.id,
690
+ name,
691
+ };
692
+
693
+ if (params.description) {
694
+ createInput.description = params.description;
695
+ }
696
+
697
+ if (params.targetDate) {
698
+ createInput.targetDate = params.targetDate;
699
+ }
700
+
701
+ if (params.status) {
702
+ createInput.status = params.status;
703
+ }
704
+
705
+ const milestone = await createProjectMilestone(client, createInput);
706
+
707
+ const statusEmoji = {
708
+ backlogged: 'πŸ“‹',
709
+ planned: 'πŸ“…',
710
+ inProgress: 'πŸš€',
711
+ paused: '⏸️',
712
+ completed: 'βœ…',
713
+ done: 'βœ…',
714
+ cancelled: '❌',
715
+ }[milestone.status] || 'πŸ“Œ';
716
+
717
+ return toTextResult(
718
+ `Created milestone ${statusEmoji} **${milestone.name}** _[${milestone.status}]_ in project "${resolved.name}"`,
719
+ {
720
+ milestoneId: milestone.id,
721
+ name: milestone.name,
722
+ status: milestone.status,
723
+ project: milestone.project,
724
+ }
725
+ );
726
+ }
727
+
728
+ /**
729
+ * Update a milestone
730
+ */
731
+ export async function executeMilestoneUpdate(client, params) {
732
+ const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
733
+
734
+ const result = await updateProjectMilestone(client, milestoneId, {
735
+ name: params.name,
736
+ description: params.description,
737
+ targetDate: params.targetDate,
738
+ status: params.status,
739
+ });
740
+
741
+ const friendlyChanges = result.changed;
742
+ const suffix = friendlyChanges.length > 0
743
+ ? ` (${friendlyChanges.join(', ')})`
744
+ : '';
745
+
746
+ const statusEmoji = {
747
+ backlogged: 'πŸ“‹',
748
+ planned: 'πŸ“…',
749
+ inProgress: 'πŸš€',
750
+ paused: '⏸️',
751
+ completed: 'βœ…',
752
+ done: 'βœ…',
753
+ cancelled: '❌',
754
+ }[result.milestone.status] || 'πŸ“Œ';
755
+
756
+ return toTextResult(
757
+ `Updated milestone ${statusEmoji} **${result.milestone.name}**${suffix}`,
758
+ {
759
+ milestoneId: result.milestone.id,
760
+ name: result.milestone.name,
761
+ status: result.milestone.status,
762
+ changed: friendlyChanges,
763
+ }
764
+ );
765
+ }
766
+
767
+ /**
768
+ * Delete a milestone
769
+ */
770
+ export async function executeMilestoneDelete(client, params) {
771
+ const milestoneId = ensureNonEmpty(params.milestone, 'milestone');
772
+ const result = await deleteProjectMilestone(client, milestoneId);
773
+
774
+ return toTextResult(
775
+ `Deleted milestone \`${milestoneId}\``,
776
+ {
777
+ milestoneId: result.milestoneId,
778
+ success: result.success,
779
+ }
780
+ );
781
+ }