@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/handlers.js
ADDED
|
@@ -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
|
+
}
|