claude-autopm 2.4.0 → 2.5.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/bin/autopm.js +2 -0
- package/lib/cli/commands/issue.js +520 -0
- package/lib/services/IssueService.js +591 -0
- package/package.json +1 -1
package/bin/autopm.js
CHANGED
|
@@ -182,6 +182,8 @@ function main() {
|
|
|
182
182
|
.command(require('./commands/mcp'))
|
|
183
183
|
// Epic management command (STANDALONE)
|
|
184
184
|
.command(require('../lib/cli/commands/epic'))
|
|
185
|
+
// Issue management command (STANDALONE)
|
|
186
|
+
.command(require('../lib/cli/commands/issue'))
|
|
185
187
|
// PRD management command (STANDALONE)
|
|
186
188
|
.command(require('../lib/cli/commands/prd'))
|
|
187
189
|
// Task management command (STANDALONE)
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Issue Commands
|
|
3
|
+
*
|
|
4
|
+
* Provides Issue management commands for ClaudeAutoPM.
|
|
5
|
+
* Implements subcommands for issue lifecycle management.
|
|
6
|
+
*
|
|
7
|
+
* Commands:
|
|
8
|
+
* - show <number>: Display issue details
|
|
9
|
+
* - start <number>: Start working on issue
|
|
10
|
+
* - close <number>: Close and complete issue
|
|
11
|
+
* - status <number>: Check issue status
|
|
12
|
+
* - edit <number>: Edit issue in editor
|
|
13
|
+
* - sync <number>: Sync issue with GitHub/Azure
|
|
14
|
+
*
|
|
15
|
+
* @module cli/commands/issue
|
|
16
|
+
* @requires ../../services/IssueService
|
|
17
|
+
* @requires fs-extra
|
|
18
|
+
* @requires ora
|
|
19
|
+
* @requires chalk
|
|
20
|
+
* @requires path
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const IssueService = require('../../services/IssueService');
|
|
24
|
+
const fs = require('fs-extra');
|
|
25
|
+
const ora = require('ora');
|
|
26
|
+
const chalk = require('chalk');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { spawn } = require('child_process');
|
|
29
|
+
const readline = require('readline');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get issue file path
|
|
33
|
+
* @param {number|string} issueNumber - Issue number
|
|
34
|
+
* @returns {string} Full path to issue file
|
|
35
|
+
*/
|
|
36
|
+
function getIssuePath(issueNumber) {
|
|
37
|
+
return path.join(process.cwd(), '.claude', 'issues', `${issueNumber}.md`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read issue file
|
|
42
|
+
* @param {number|string} issueNumber - Issue number
|
|
43
|
+
* @returns {Promise<string>} Issue content
|
|
44
|
+
* @throws {Error} If file doesn't exist or can't be read
|
|
45
|
+
*/
|
|
46
|
+
async function readIssueFile(issueNumber) {
|
|
47
|
+
const issuePath = getIssuePath(issueNumber);
|
|
48
|
+
|
|
49
|
+
const exists = await fs.pathExists(issuePath);
|
|
50
|
+
if (!exists) {
|
|
51
|
+
throw new Error(`Issue file not found: ${issuePath}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return await fs.readFile(issuePath, 'utf8');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Show issue details
|
|
59
|
+
* @param {Object} argv - Command arguments
|
|
60
|
+
*/
|
|
61
|
+
async function issueShow(argv) {
|
|
62
|
+
const spinner = ora(`Loading issue: #${argv.number}`).start();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const issueService = new IssueService();
|
|
66
|
+
const issue = await issueService.getLocalIssue(argv.number);
|
|
67
|
+
|
|
68
|
+
spinner.succeed(chalk.green('Issue loaded'));
|
|
69
|
+
|
|
70
|
+
// Display metadata table
|
|
71
|
+
console.log('\n' + chalk.bold('📋 Issue Details') + '\n');
|
|
72
|
+
console.log(chalk.gray('─'.repeat(50)) + '\n');
|
|
73
|
+
|
|
74
|
+
console.log(chalk.bold('ID: ') + (issue.id || argv.number));
|
|
75
|
+
console.log(chalk.bold('Title: ') + (issue.title || 'N/A'));
|
|
76
|
+
console.log(chalk.bold('Status: ') + chalk.yellow(issue.status || 'open'));
|
|
77
|
+
|
|
78
|
+
if (issue.assignee) {
|
|
79
|
+
console.log(chalk.bold('Assignee: ') + issue.assignee);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (issue.labels) {
|
|
83
|
+
console.log(chalk.bold('Labels: ') + issue.labels);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (issue.created) {
|
|
87
|
+
console.log(chalk.bold('Created: ') + new Date(issue.created).toLocaleDateString());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (issue.started) {
|
|
91
|
+
console.log(chalk.bold('Started: ') + new Date(issue.started).toLocaleDateString());
|
|
92
|
+
const duration = issueService.formatIssueDuration(issue.started);
|
|
93
|
+
console.log(chalk.bold('Duration: ') + duration);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (issue.completed) {
|
|
97
|
+
console.log(chalk.bold('Completed:') + new Date(issue.completed).toLocaleDateString());
|
|
98
|
+
const duration = issueService.formatIssueDuration(issue.started, issue.completed);
|
|
99
|
+
console.log(chalk.bold('Duration: ') + duration);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (issue.url) {
|
|
103
|
+
console.log(chalk.bold('URL: ') + chalk.cyan(issue.url));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Show issue content
|
|
107
|
+
console.log('\n' + chalk.gray('─'.repeat(80)) + '\n');
|
|
108
|
+
|
|
109
|
+
// Extract and display description (skip frontmatter)
|
|
110
|
+
const contentWithoutFrontmatter = issue.content.replace(/^---[\s\S]*?---\n\n/, '');
|
|
111
|
+
console.log(contentWithoutFrontmatter);
|
|
112
|
+
|
|
113
|
+
console.log('\n' + chalk.gray('─'.repeat(80)) + '\n');
|
|
114
|
+
|
|
115
|
+
console.log(chalk.dim(`File: ${issue.path}\n`));
|
|
116
|
+
|
|
117
|
+
} catch (error) {
|
|
118
|
+
spinner.fail(chalk.red('Failed to show issue'));
|
|
119
|
+
|
|
120
|
+
if (error.message.includes('not found')) {
|
|
121
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
122
|
+
console.error(chalk.yellow('Use: autopm issue list to see available issues'));
|
|
123
|
+
} else {
|
|
124
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Start working on issue
|
|
131
|
+
* @param {Object} argv - Command arguments
|
|
132
|
+
*/
|
|
133
|
+
async function issueStart(argv) {
|
|
134
|
+
const spinner = ora(`Starting issue: #${argv.number}`).start();
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const issueService = new IssueService();
|
|
138
|
+
|
|
139
|
+
// Check if issue exists
|
|
140
|
+
await issueService.getLocalIssue(argv.number);
|
|
141
|
+
|
|
142
|
+
// Update status to in-progress
|
|
143
|
+
await issueService.updateIssueStatus(argv.number, 'in-progress');
|
|
144
|
+
|
|
145
|
+
spinner.succeed(chalk.green('Issue started'));
|
|
146
|
+
|
|
147
|
+
console.log(chalk.green(`\n✅ Issue #${argv.number} is now in progress!`));
|
|
148
|
+
|
|
149
|
+
const issuePath = getIssuePath(argv.number);
|
|
150
|
+
console.log(chalk.cyan(`📄 File: ${issuePath}\n`));
|
|
151
|
+
|
|
152
|
+
console.log(chalk.bold('📋 What You Can Do Next:\n'));
|
|
153
|
+
console.log(` ${chalk.cyan('1.')} Check status: ${chalk.yellow('autopm issue status ' + argv.number)}`);
|
|
154
|
+
console.log(` ${chalk.cyan('2.')} Edit issue: ${chalk.yellow('autopm issue edit ' + argv.number)}`);
|
|
155
|
+
console.log(` ${chalk.cyan('3.')} Close when done: ${chalk.yellow('autopm issue close ' + argv.number)}\n`);
|
|
156
|
+
|
|
157
|
+
} catch (error) {
|
|
158
|
+
spinner.fail(chalk.red('Failed to start issue'));
|
|
159
|
+
|
|
160
|
+
if (error.message.includes('not found')) {
|
|
161
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
162
|
+
console.error(chalk.yellow('Use: autopm issue list to see available issues'));
|
|
163
|
+
} else {
|
|
164
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Close issue
|
|
171
|
+
* @param {Object} argv - Command arguments
|
|
172
|
+
*/
|
|
173
|
+
async function issueClose(argv) {
|
|
174
|
+
const spinner = ora(`Closing issue: #${argv.number}`).start();
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const issueService = new IssueService();
|
|
178
|
+
|
|
179
|
+
// Check if issue exists
|
|
180
|
+
await issueService.getLocalIssue(argv.number);
|
|
181
|
+
|
|
182
|
+
// Update status to closed
|
|
183
|
+
await issueService.updateIssueStatus(argv.number, 'closed');
|
|
184
|
+
|
|
185
|
+
spinner.succeed(chalk.green('Issue closed'));
|
|
186
|
+
|
|
187
|
+
console.log(chalk.green(`\n✅ Issue #${argv.number} completed!`));
|
|
188
|
+
|
|
189
|
+
const issuePath = getIssuePath(argv.number);
|
|
190
|
+
console.log(chalk.cyan(`📄 File: ${issuePath}\n`));
|
|
191
|
+
|
|
192
|
+
console.log(chalk.bold('📋 What You Can Do Next:\n'));
|
|
193
|
+
console.log(` ${chalk.cyan('1.')} View issue: ${chalk.yellow('autopm issue show ' + argv.number)}`);
|
|
194
|
+
console.log(` ${chalk.cyan('2.')} Check status: ${chalk.yellow('autopm issue status ' + argv.number)}\n`);
|
|
195
|
+
|
|
196
|
+
} catch (error) {
|
|
197
|
+
spinner.fail(chalk.red('Failed to close issue'));
|
|
198
|
+
|
|
199
|
+
if (error.message.includes('not found')) {
|
|
200
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
201
|
+
console.error(chalk.yellow('Use: autopm issue list to see available issues'));
|
|
202
|
+
} else {
|
|
203
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Show issue status
|
|
210
|
+
* @param {Object} argv - Command arguments
|
|
211
|
+
*/
|
|
212
|
+
async function issueStatus(argv) {
|
|
213
|
+
const spinner = ora(`Analyzing issue: #${argv.number}`).start();
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const issueService = new IssueService();
|
|
217
|
+
const issue = await issueService.getLocalIssue(argv.number);
|
|
218
|
+
|
|
219
|
+
spinner.succeed(chalk.green('Status analyzed'));
|
|
220
|
+
|
|
221
|
+
// Display status
|
|
222
|
+
console.log('\n' + chalk.bold('📊 Issue Status Report') + '\n');
|
|
223
|
+
console.log(chalk.gray('─'.repeat(50)) + '\n');
|
|
224
|
+
|
|
225
|
+
console.log(chalk.bold('Metadata:'));
|
|
226
|
+
console.log(` ID: #${issue.id || argv.number}`);
|
|
227
|
+
console.log(` Title: ${issue.title || 'N/A'}`);
|
|
228
|
+
console.log(` Status: ${chalk.yellow(issue.status || 'open')}`);
|
|
229
|
+
|
|
230
|
+
if (issue.assignee) {
|
|
231
|
+
console.log(` Assignee: ${issue.assignee}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (issue.labels) {
|
|
235
|
+
console.log(` Labels: ${issue.labels}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.log('\n' + chalk.bold('Timeline:'));
|
|
239
|
+
|
|
240
|
+
if (issue.created) {
|
|
241
|
+
console.log(` Created: ${new Date(issue.created).toLocaleString()}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (issue.started) {
|
|
245
|
+
console.log(` Started: ${new Date(issue.started).toLocaleString()}`);
|
|
246
|
+
|
|
247
|
+
if (issue.completed) {
|
|
248
|
+
const duration = issueService.formatIssueDuration(issue.started, issue.completed);
|
|
249
|
+
console.log(` Completed: ${new Date(issue.completed).toLocaleString()}`);
|
|
250
|
+
console.log(` Duration: ${duration}`);
|
|
251
|
+
} else {
|
|
252
|
+
const duration = issueService.formatIssueDuration(issue.started);
|
|
253
|
+
console.log(` Duration: ${duration} (ongoing)`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Show related files
|
|
258
|
+
const relatedFiles = await issueService.getIssueFiles(argv.number);
|
|
259
|
+
if (relatedFiles.length > 0) {
|
|
260
|
+
console.log('\n' + chalk.bold('Related Files:'));
|
|
261
|
+
relatedFiles.forEach(file => {
|
|
262
|
+
console.log(` • ${file}`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Show dependencies
|
|
267
|
+
const dependencies = await issueService.getDependencies(argv.number);
|
|
268
|
+
if (dependencies.length > 0) {
|
|
269
|
+
console.log('\n' + chalk.bold('Dependencies:'));
|
|
270
|
+
dependencies.forEach(dep => {
|
|
271
|
+
console.log(` • Issue #${dep}`);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Show sub-issues
|
|
276
|
+
const subIssues = await issueService.getSubIssues(argv.number);
|
|
277
|
+
if (subIssues.length > 0) {
|
|
278
|
+
console.log('\n' + chalk.bold('Sub-Issues:'));
|
|
279
|
+
subIssues.forEach(sub => {
|
|
280
|
+
console.log(` • Issue #${sub}`);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
console.log('\n' + chalk.gray('─'.repeat(50)) + '\n');
|
|
285
|
+
|
|
286
|
+
const issuePath = getIssuePath(argv.number);
|
|
287
|
+
console.log(chalk.dim(`File: ${issuePath}\n`));
|
|
288
|
+
|
|
289
|
+
} catch (error) {
|
|
290
|
+
spinner.fail(chalk.red('Failed to analyze status'));
|
|
291
|
+
|
|
292
|
+
if (error.message.includes('not found')) {
|
|
293
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
294
|
+
} else {
|
|
295
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Edit issue in editor
|
|
302
|
+
* @param {Object} argv - Command arguments
|
|
303
|
+
*/
|
|
304
|
+
async function issueEdit(argv) {
|
|
305
|
+
const spinner = ora(`Opening issue: #${argv.number}`).start();
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const issuePath = getIssuePath(argv.number);
|
|
309
|
+
|
|
310
|
+
// Check if file exists
|
|
311
|
+
const exists = await fs.pathExists(issuePath);
|
|
312
|
+
if (!exists) {
|
|
313
|
+
spinner.fail(chalk.red('Issue not found'));
|
|
314
|
+
console.error(chalk.red(`\nError: Issue file not found: ${issuePath}`));
|
|
315
|
+
console.error(chalk.yellow('Use: autopm issue list to see available issues'));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
spinner.succeed(chalk.green('Opening editor...'));
|
|
320
|
+
|
|
321
|
+
// Determine editor
|
|
322
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
|
|
323
|
+
|
|
324
|
+
// Spawn editor
|
|
325
|
+
const child = spawn(editor, [issuePath], {
|
|
326
|
+
stdio: 'inherit',
|
|
327
|
+
cwd: process.cwd()
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Wait for editor to close
|
|
331
|
+
await new Promise((resolve, reject) => {
|
|
332
|
+
child.on('close', (code) => {
|
|
333
|
+
if (code === 0) {
|
|
334
|
+
console.log(chalk.green('\n✓ Issue saved'));
|
|
335
|
+
resolve();
|
|
336
|
+
} else {
|
|
337
|
+
reject(new Error(`Editor exited with code ${code}`));
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
child.on('error', reject);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
} catch (error) {
|
|
344
|
+
spinner.fail(chalk.red('Failed to edit issue'));
|
|
345
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Sync issue with GitHub/Azure
|
|
351
|
+
* @param {Object} argv - Command arguments
|
|
352
|
+
*/
|
|
353
|
+
async function issueSync(argv) {
|
|
354
|
+
const spinner = ora(`Syncing issue: #${argv.number}`).start();
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const issueService = new IssueService();
|
|
358
|
+
|
|
359
|
+
// Check if issue exists
|
|
360
|
+
const issue = await issueService.getLocalIssue(argv.number);
|
|
361
|
+
|
|
362
|
+
// TODO: Implement provider integration
|
|
363
|
+
// For now, just show a message
|
|
364
|
+
spinner.info(chalk.yellow('Provider sync not yet implemented'));
|
|
365
|
+
|
|
366
|
+
console.log(chalk.yellow(`\n⚠️ GitHub/Azure sync feature coming soon!\n`));
|
|
367
|
+
|
|
368
|
+
console.log(chalk.dim('This feature will:'));
|
|
369
|
+
console.log(chalk.dim(' - Create GitHub/Azure issue if not exists'));
|
|
370
|
+
console.log(chalk.dim(' - Update existing issue'));
|
|
371
|
+
console.log(chalk.dim(' - Sync issue status and comments\n'));
|
|
372
|
+
|
|
373
|
+
console.log(chalk.bold('For now, you can:'));
|
|
374
|
+
console.log(` ${chalk.cyan('1.')} View issue: ${chalk.yellow('autopm issue show ' + argv.number)}`);
|
|
375
|
+
console.log(` ${chalk.cyan('2.')} Check status: ${chalk.yellow('autopm issue status ' + argv.number)}\n`);
|
|
376
|
+
|
|
377
|
+
} catch (error) {
|
|
378
|
+
spinner.fail(chalk.red('Failed to sync issue'));
|
|
379
|
+
|
|
380
|
+
if (error.message.includes('not found')) {
|
|
381
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
382
|
+
} else {
|
|
383
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Command builder - registers all subcommands
|
|
390
|
+
* @param {Object} yargs - Yargs instance
|
|
391
|
+
* @returns {Object} Configured yargs instance
|
|
392
|
+
*/
|
|
393
|
+
function builder(yargs) {
|
|
394
|
+
return yargs
|
|
395
|
+
.command(
|
|
396
|
+
'show <number>',
|
|
397
|
+
'Display issue details',
|
|
398
|
+
(yargs) => {
|
|
399
|
+
return yargs
|
|
400
|
+
.positional('number', {
|
|
401
|
+
describe: 'Issue number',
|
|
402
|
+
type: 'number'
|
|
403
|
+
})
|
|
404
|
+
.example('autopm issue show 123', 'Display issue #123');
|
|
405
|
+
},
|
|
406
|
+
issueShow
|
|
407
|
+
)
|
|
408
|
+
.command(
|
|
409
|
+
'start <number>',
|
|
410
|
+
'Start working on issue',
|
|
411
|
+
(yargs) => {
|
|
412
|
+
return yargs
|
|
413
|
+
.positional('number', {
|
|
414
|
+
describe: 'Issue number',
|
|
415
|
+
type: 'number'
|
|
416
|
+
})
|
|
417
|
+
.example('autopm issue start 123', 'Mark issue #123 as in-progress');
|
|
418
|
+
},
|
|
419
|
+
issueStart
|
|
420
|
+
)
|
|
421
|
+
.command(
|
|
422
|
+
'close <number>',
|
|
423
|
+
'Close and complete issue',
|
|
424
|
+
(yargs) => {
|
|
425
|
+
return yargs
|
|
426
|
+
.positional('number', {
|
|
427
|
+
describe: 'Issue number',
|
|
428
|
+
type: 'number'
|
|
429
|
+
})
|
|
430
|
+
.example('autopm issue close 123', 'Mark issue #123 as completed');
|
|
431
|
+
},
|
|
432
|
+
issueClose
|
|
433
|
+
)
|
|
434
|
+
.command(
|
|
435
|
+
'status <number>',
|
|
436
|
+
'Check issue status',
|
|
437
|
+
(yargs) => {
|
|
438
|
+
return yargs
|
|
439
|
+
.positional('number', {
|
|
440
|
+
describe: 'Issue number',
|
|
441
|
+
type: 'number'
|
|
442
|
+
})
|
|
443
|
+
.example('autopm issue status 123', 'Show status of issue #123');
|
|
444
|
+
},
|
|
445
|
+
issueStatus
|
|
446
|
+
)
|
|
447
|
+
.command(
|
|
448
|
+
'edit <number>',
|
|
449
|
+
'Edit issue in your editor',
|
|
450
|
+
(yargs) => {
|
|
451
|
+
return yargs
|
|
452
|
+
.positional('number', {
|
|
453
|
+
describe: 'Issue number',
|
|
454
|
+
type: 'number'
|
|
455
|
+
})
|
|
456
|
+
.example('autopm issue edit 123', 'Open issue #123 in editor')
|
|
457
|
+
.example('EDITOR=code autopm issue edit 123', 'Open in VS Code');
|
|
458
|
+
},
|
|
459
|
+
issueEdit
|
|
460
|
+
)
|
|
461
|
+
.command(
|
|
462
|
+
'sync <number>',
|
|
463
|
+
'Sync issue with GitHub/Azure',
|
|
464
|
+
(yargs) => {
|
|
465
|
+
return yargs
|
|
466
|
+
.positional('number', {
|
|
467
|
+
describe: 'Issue number',
|
|
468
|
+
type: 'number'
|
|
469
|
+
})
|
|
470
|
+
.option('push', {
|
|
471
|
+
describe: 'Push local changes to provider',
|
|
472
|
+
type: 'boolean',
|
|
473
|
+
default: false
|
|
474
|
+
})
|
|
475
|
+
.option('pull', {
|
|
476
|
+
describe: 'Pull updates from provider',
|
|
477
|
+
type: 'boolean',
|
|
478
|
+
default: false
|
|
479
|
+
})
|
|
480
|
+
.example('autopm issue sync 123', 'Sync issue #123 with provider')
|
|
481
|
+
.example('autopm issue sync 123 --push', 'Push local changes')
|
|
482
|
+
.example('autopm issue sync 123 --pull', 'Pull remote updates');
|
|
483
|
+
},
|
|
484
|
+
issueSync
|
|
485
|
+
)
|
|
486
|
+
.demandCommand(1, 'You must specify an issue command')
|
|
487
|
+
.strictCommands()
|
|
488
|
+
.help();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Command export
|
|
493
|
+
*/
|
|
494
|
+
module.exports = {
|
|
495
|
+
command: 'issue',
|
|
496
|
+
describe: 'Manage issues and task lifecycle',
|
|
497
|
+
builder,
|
|
498
|
+
handler: (argv) => {
|
|
499
|
+
if (!argv._.includes('issue') || argv._.length === 1) {
|
|
500
|
+
console.log(chalk.yellow('\nPlease specify an issue command\n'));
|
|
501
|
+
console.log('Usage: autopm issue <command>\n');
|
|
502
|
+
console.log('Available commands:');
|
|
503
|
+
console.log(' show <number> Display issue details');
|
|
504
|
+
console.log(' start <number> Start working on issue');
|
|
505
|
+
console.log(' close <number> Close issue');
|
|
506
|
+
console.log(' status <number> Check issue status');
|
|
507
|
+
console.log(' edit <number> Edit issue in editor');
|
|
508
|
+
console.log(' sync <number> Sync with GitHub/Azure');
|
|
509
|
+
console.log('\nUse: autopm issue <command> --help for more info\n');
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
handlers: {
|
|
513
|
+
show: issueShow,
|
|
514
|
+
start: issueStart,
|
|
515
|
+
close: issueClose,
|
|
516
|
+
status: issueStatus,
|
|
517
|
+
edit: issueEdit,
|
|
518
|
+
sync: issueSync
|
|
519
|
+
}
|
|
520
|
+
};
|
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IssueService - Issue Management Service
|
|
3
|
+
*
|
|
4
|
+
* Pure service layer for issue operations following ClaudeAutoPM patterns.
|
|
5
|
+
* Follows 3-layer architecture: Service (logic) -> Provider (I/O) -> CLI (presentation)
|
|
6
|
+
*
|
|
7
|
+
* Provides comprehensive issue lifecycle management:
|
|
8
|
+
*
|
|
9
|
+
* 1. Issue Metadata & Parsing (4 methods):
|
|
10
|
+
* - parseIssueMetadata: Parse YAML frontmatter from issue content
|
|
11
|
+
* - getLocalIssue: Read local issue file with metadata
|
|
12
|
+
* - getIssueStatus: Get current status of an issue
|
|
13
|
+
* - validateIssue: Validate issue structure and required fields
|
|
14
|
+
*
|
|
15
|
+
* 2. Issue Lifecycle Management (2 methods):
|
|
16
|
+
* - updateIssueStatus: Update status with automatic timestamps
|
|
17
|
+
* - listIssues: List all issues with optional filtering
|
|
18
|
+
*
|
|
19
|
+
* 3. Issue Relationships (3 methods):
|
|
20
|
+
* - getIssueFiles: Find all files related to an issue
|
|
21
|
+
* - getSubIssues: Get child issues
|
|
22
|
+
* - getDependencies: Get blocking issues
|
|
23
|
+
*
|
|
24
|
+
* 4. Provider Integration (2 methods):
|
|
25
|
+
* - syncIssueToProvider: Push local changes to GitHub/Azure
|
|
26
|
+
* - syncIssueFromProvider: Pull updates from provider
|
|
27
|
+
*
|
|
28
|
+
* 5. Utility Methods (4 methods):
|
|
29
|
+
* - categorizeStatus: Categorize status into standard buckets
|
|
30
|
+
* - isIssueClosed: Check if issue is closed
|
|
31
|
+
* - getIssuePath: Get file path for issue
|
|
32
|
+
* - formatIssueDuration: Format time duration
|
|
33
|
+
*
|
|
34
|
+
* Documentation Queries:
|
|
35
|
+
* - GitHub Issues API v3 best practices (2025)
|
|
36
|
+
* - Azure DevOps work items REST API patterns
|
|
37
|
+
* - Agile issue tracking workflow best practices
|
|
38
|
+
* - mcp://context7/project-management/issue-tracking - Issue lifecycle management
|
|
39
|
+
* - mcp://context7/markdown/frontmatter - YAML frontmatter patterns
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
class IssueService {
|
|
43
|
+
/**
|
|
44
|
+
* Create a new IssueService instance
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} options - Configuration options
|
|
47
|
+
* @param {Object} options.provider - Provider instance for GitHub/Azure (optional)
|
|
48
|
+
* @param {string} [options.issuesDir] - Path to issues directory (default: .claude/issues)
|
|
49
|
+
* @param {string} [options.defaultStatus] - Default issue status (default: open)
|
|
50
|
+
*/
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
// Provider for GitHub/Azure integration (optional)
|
|
53
|
+
this.provider = options.provider || null;
|
|
54
|
+
|
|
55
|
+
// CLI operation options
|
|
56
|
+
this.options = {
|
|
57
|
+
issuesDir: options.issuesDir || '.claude/issues',
|
|
58
|
+
defaultStatus: options.defaultStatus || 'open',
|
|
59
|
+
...options
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ==========================================
|
|
64
|
+
// 1. ISSUE METADATA & PARSING (4 METHODS)
|
|
65
|
+
// ==========================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse YAML frontmatter from issue content
|
|
69
|
+
*
|
|
70
|
+
* Extracts key-value pairs from YAML frontmatter block.
|
|
71
|
+
* Returns null if frontmatter is missing or malformed.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} content - Issue markdown content with frontmatter
|
|
74
|
+
* @returns {Object|null} Parsed frontmatter object or null
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* parseIssueMetadata(`---
|
|
78
|
+
* id: 123
|
|
79
|
+
* title: Fix bug
|
|
80
|
+
* status: open
|
|
81
|
+
* ---
|
|
82
|
+
* # Issue Details`)
|
|
83
|
+
* // Returns: { id: '123', title: 'Fix bug', status: 'open' }
|
|
84
|
+
*/
|
|
85
|
+
parseIssueMetadata(content) {
|
|
86
|
+
if (!content || typeof content !== 'string') {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Match frontmatter block: ---\n...\n--- or ---\n---
|
|
91
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/) ||
|
|
92
|
+
content.match(/^---\n---/);
|
|
93
|
+
|
|
94
|
+
if (!frontmatterMatch) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Empty frontmatter (---\n---)
|
|
99
|
+
if (!frontmatterMatch[1] && content.startsWith('---\n---')) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const metadata = {};
|
|
104
|
+
const lines = (frontmatterMatch[1] || '').split('\n');
|
|
105
|
+
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
// Skip empty lines
|
|
108
|
+
if (!line.trim()) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Find first colon to split key and value
|
|
113
|
+
const colonIndex = line.indexOf(':');
|
|
114
|
+
if (colonIndex === -1) {
|
|
115
|
+
continue; // Skip lines without colons
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const key = line.substring(0, colonIndex).trim();
|
|
119
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
120
|
+
|
|
121
|
+
if (key) {
|
|
122
|
+
metadata[key] = value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return metadata;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read local issue file with metadata
|
|
131
|
+
*
|
|
132
|
+
* @param {number|string} issueNumber - Issue number
|
|
133
|
+
* @returns {Promise<Object>} Issue data with metadata and content
|
|
134
|
+
* @throws {Error} If issue not found
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* await getLocalIssue(123)
|
|
138
|
+
* // Returns: { id: '123', title: '...', status: '...', content: '...' }
|
|
139
|
+
*/
|
|
140
|
+
async getLocalIssue(issueNumber) {
|
|
141
|
+
const fs = require('fs-extra');
|
|
142
|
+
|
|
143
|
+
const issuePath = this.getIssuePath(issueNumber);
|
|
144
|
+
|
|
145
|
+
// Check if issue exists
|
|
146
|
+
const exists = await fs.pathExists(issuePath);
|
|
147
|
+
if (!exists) {
|
|
148
|
+
throw new Error(`Issue not found: ${issueNumber}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Read issue content
|
|
152
|
+
const content = await fs.readFile(issuePath, 'utf8');
|
|
153
|
+
const metadata = this.parseIssueMetadata(content);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
...metadata,
|
|
157
|
+
content,
|
|
158
|
+
path: issuePath
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get current status of an issue
|
|
164
|
+
*
|
|
165
|
+
* @param {number|string} issueNumber - Issue number
|
|
166
|
+
* @returns {Promise<string>} Current status
|
|
167
|
+
* @throws {Error} If issue not found
|
|
168
|
+
*/
|
|
169
|
+
async getIssueStatus(issueNumber) {
|
|
170
|
+
try {
|
|
171
|
+
const issue = await this.getLocalIssue(issueNumber);
|
|
172
|
+
return issue.status || this.options.defaultStatus;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error.message.includes('not found')) {
|
|
175
|
+
throw new Error(`Issue not found: ${issueNumber}`);
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Validate issue structure and completeness
|
|
183
|
+
*
|
|
184
|
+
* @param {number|string} issueNumber - Issue number
|
|
185
|
+
* @returns {Promise<Object>} Validation result: { valid: boolean, issues: string[] }
|
|
186
|
+
* @throws {Error} If issue not found
|
|
187
|
+
*/
|
|
188
|
+
async validateIssue(issueNumber) {
|
|
189
|
+
const fs = require('fs-extra');
|
|
190
|
+
|
|
191
|
+
const issuePath = this.getIssuePath(issueNumber);
|
|
192
|
+
|
|
193
|
+
// Check if issue exists
|
|
194
|
+
const exists = await fs.pathExists(issuePath);
|
|
195
|
+
if (!exists) {
|
|
196
|
+
throw new Error(`Issue not found: ${issueNumber}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const issues = [];
|
|
200
|
+
|
|
201
|
+
// Read issue content
|
|
202
|
+
const content = await fs.readFile(issuePath, 'utf8');
|
|
203
|
+
|
|
204
|
+
// Check for frontmatter
|
|
205
|
+
const metadata = this.parseIssueMetadata(content);
|
|
206
|
+
if (!metadata) {
|
|
207
|
+
issues.push('Missing frontmatter');
|
|
208
|
+
} else {
|
|
209
|
+
// Check for required fields
|
|
210
|
+
if (!metadata.id) {
|
|
211
|
+
issues.push('Missing id in frontmatter');
|
|
212
|
+
}
|
|
213
|
+
if (!metadata.title) {
|
|
214
|
+
issues.push('Missing title in frontmatter');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
valid: issues.length === 0,
|
|
220
|
+
issues
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ==========================================
|
|
225
|
+
// 2. ISSUE LIFECYCLE MANAGEMENT (2 METHODS)
|
|
226
|
+
// ==========================================
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Update issue status with automatic timestamps
|
|
230
|
+
*
|
|
231
|
+
* @param {number|string} issueNumber - Issue number
|
|
232
|
+
* @param {string} newStatus - New status to set
|
|
233
|
+
* @returns {Promise<void>}
|
|
234
|
+
* @throws {Error} If issue not found
|
|
235
|
+
*/
|
|
236
|
+
async updateIssueStatus(issueNumber, newStatus) {
|
|
237
|
+
const fs = require('fs-extra');
|
|
238
|
+
|
|
239
|
+
const issuePath = this.getIssuePath(issueNumber);
|
|
240
|
+
|
|
241
|
+
// Check if issue exists
|
|
242
|
+
const exists = await fs.pathExists(issuePath);
|
|
243
|
+
if (!exists) {
|
|
244
|
+
throw new Error(`Issue not found: ${issueNumber}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Read current content
|
|
248
|
+
let content = await fs.readFile(issuePath, 'utf8');
|
|
249
|
+
|
|
250
|
+
// Update status
|
|
251
|
+
content = content.replace(/^status:\s*.+$/m, `status: ${newStatus}`);
|
|
252
|
+
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
|
|
255
|
+
// Add started timestamp when moving to in-progress
|
|
256
|
+
if (newStatus === 'in-progress' && !content.includes('started:')) {
|
|
257
|
+
// Try to add after created: field, or after status: if no created: field
|
|
258
|
+
if (content.includes('created:')) {
|
|
259
|
+
content = content.replace(/^(created:.+)$/m, `$1\nstarted: ${now}`);
|
|
260
|
+
} else {
|
|
261
|
+
content = content.replace(/^(status:.+)$/m, `$1\nstarted: ${now}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Add completed timestamp when closing
|
|
266
|
+
if (['closed', 'completed', 'done', 'resolved'].includes(newStatus.toLowerCase()) &&
|
|
267
|
+
!content.includes('completed:')) {
|
|
268
|
+
// Try to add after created: field, or after status: if no created: field
|
|
269
|
+
if (content.includes('created:')) {
|
|
270
|
+
content = content.replace(/^(created:.+)$/m, `$1\ncompleted: ${now}`);
|
|
271
|
+
} else {
|
|
272
|
+
content = content.replace(/^(status:.+)$/m, `$1\ncompleted: ${now}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Write updated content
|
|
277
|
+
await fs.writeFile(issuePath, content);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* List all issues with optional filtering
|
|
282
|
+
*
|
|
283
|
+
* @param {Object} [options] - Filter options
|
|
284
|
+
* @param {string} [options.status] - Filter by status
|
|
285
|
+
* @returns {Promise<Array<Object>>} Array of issue objects with metadata
|
|
286
|
+
*/
|
|
287
|
+
async listIssues(options = {}) {
|
|
288
|
+
const fs = require('fs-extra');
|
|
289
|
+
const path = require('path');
|
|
290
|
+
|
|
291
|
+
const issuesDir = path.join(process.cwd(), this.options.issuesDir);
|
|
292
|
+
|
|
293
|
+
// Check if issues directory exists
|
|
294
|
+
const dirExists = await fs.pathExists(issuesDir);
|
|
295
|
+
if (!dirExists) {
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Read all issue files
|
|
300
|
+
let files;
|
|
301
|
+
try {
|
|
302
|
+
files = await fs.readdir(issuesDir);
|
|
303
|
+
// Only process .md files with numeric names
|
|
304
|
+
files = files.filter(file => /^\d+\.md$/.test(file));
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const issues = [];
|
|
310
|
+
|
|
311
|
+
for (const file of files) {
|
|
312
|
+
const filePath = path.join(issuesDir, file);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
316
|
+
const metadata = this.parseIssueMetadata(content);
|
|
317
|
+
|
|
318
|
+
if (metadata) {
|
|
319
|
+
// Apply default status if missing
|
|
320
|
+
metadata.status = metadata.status || this.options.defaultStatus;
|
|
321
|
+
|
|
322
|
+
// Filter by status if specified
|
|
323
|
+
if (options.status && metadata.status !== options.status) {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
issues.push({
|
|
328
|
+
...metadata,
|
|
329
|
+
path: filePath
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
// Skip files that can't be read
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return issues;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ==========================================
|
|
342
|
+
// 3. ISSUE RELATIONSHIPS (3 METHODS)
|
|
343
|
+
// ==========================================
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Find all files related to an issue
|
|
347
|
+
*
|
|
348
|
+
* @param {number|string} issueNumber - Issue number
|
|
349
|
+
* @returns {Promise<Array<string>>} Array of related file names
|
|
350
|
+
*/
|
|
351
|
+
async getIssueFiles(issueNumber) {
|
|
352
|
+
const fs = require('fs-extra');
|
|
353
|
+
const path = require('path');
|
|
354
|
+
|
|
355
|
+
const issuesDir = path.join(process.cwd(), this.options.issuesDir);
|
|
356
|
+
|
|
357
|
+
// Check if issues directory exists
|
|
358
|
+
const dirExists = await fs.pathExists(issuesDir);
|
|
359
|
+
if (!dirExists) {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Read all files
|
|
364
|
+
try {
|
|
365
|
+
const files = await fs.readdir(issuesDir);
|
|
366
|
+
|
|
367
|
+
// Filter files that start with issue number
|
|
368
|
+
const issueStr = String(issueNumber);
|
|
369
|
+
const relatedFiles = files.filter(file => {
|
|
370
|
+
return file.startsWith(`${issueStr}.md`) ||
|
|
371
|
+
file.startsWith(`${issueStr}-`);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return relatedFiles;
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Get child issues of a parent issue
|
|
382
|
+
*
|
|
383
|
+
* @param {number|string} issueNumber - Parent issue number
|
|
384
|
+
* @returns {Promise<Array<string>>} Array of child issue numbers
|
|
385
|
+
*/
|
|
386
|
+
async getSubIssues(issueNumber) {
|
|
387
|
+
try {
|
|
388
|
+
const issue = await this.getLocalIssue(issueNumber);
|
|
389
|
+
const children = issue.children;
|
|
390
|
+
|
|
391
|
+
if (!children) {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Parse children (can be array [101, 102] or string "101, 102")
|
|
396
|
+
if (typeof children === 'string') {
|
|
397
|
+
// Remove brackets and split by comma
|
|
398
|
+
const cleaned = children.replace(/[\[\]]/g, '');
|
|
399
|
+
return cleaned.split(',').map(c => c.trim()).filter(c => c);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return [];
|
|
403
|
+
} catch (error) {
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Get blocking issues (dependencies)
|
|
410
|
+
*
|
|
411
|
+
* @param {number|string} issueNumber - Issue number
|
|
412
|
+
* @returns {Promise<Array<string>>} Array of blocking issue numbers
|
|
413
|
+
*/
|
|
414
|
+
async getDependencies(issueNumber) {
|
|
415
|
+
try {
|
|
416
|
+
const issue = await this.getLocalIssue(issueNumber);
|
|
417
|
+
const dependencies = issue.dependencies || issue.blocked_by;
|
|
418
|
+
|
|
419
|
+
if (!dependencies) {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Parse dependencies (can be array [120, 121] or string "120, 121")
|
|
424
|
+
if (typeof dependencies === 'string') {
|
|
425
|
+
// Remove brackets and split by comma
|
|
426
|
+
const cleaned = dependencies.replace(/[\[\]]/g, '');
|
|
427
|
+
return cleaned.split(',').map(d => d.trim()).filter(d => d);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return [];
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return [];
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ==========================================
|
|
437
|
+
// 4. PROVIDER INTEGRATION (2 METHODS)
|
|
438
|
+
// ==========================================
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Push local issue changes to GitHub/Azure
|
|
442
|
+
*
|
|
443
|
+
* @param {number|string} issueNumber - Issue number
|
|
444
|
+
* @returns {Promise<Object>} Sync result
|
|
445
|
+
* @throws {Error} If no provider configured
|
|
446
|
+
*/
|
|
447
|
+
async syncIssueToProvider(issueNumber) {
|
|
448
|
+
if (!this.provider || !this.provider.updateIssue) {
|
|
449
|
+
throw new Error('No provider configured for syncing');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Read local issue
|
|
453
|
+
const issue = await this.getLocalIssue(issueNumber);
|
|
454
|
+
|
|
455
|
+
// Push to provider
|
|
456
|
+
const result = await this.provider.updateIssue(issue);
|
|
457
|
+
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Pull issue updates from GitHub/Azure
|
|
463
|
+
*
|
|
464
|
+
* @param {number|string} issueNumber - Issue number
|
|
465
|
+
* @returns {Promise<Object>} Updated issue data
|
|
466
|
+
* @throws {Error} If no provider configured
|
|
467
|
+
*/
|
|
468
|
+
async syncIssueFromProvider(issueNumber) {
|
|
469
|
+
const fs = require('fs-extra');
|
|
470
|
+
|
|
471
|
+
if (!this.provider || !this.provider.getIssue) {
|
|
472
|
+
throw new Error('No provider configured for syncing');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Fetch from provider
|
|
476
|
+
const issueData = await this.provider.getIssue(String(issueNumber));
|
|
477
|
+
|
|
478
|
+
// Build issue content with frontmatter
|
|
479
|
+
const frontmatter = `---
|
|
480
|
+
id: ${issueData.id}
|
|
481
|
+
title: ${issueData.title}
|
|
482
|
+
status: ${issueData.status}
|
|
483
|
+
${issueData.assignees && issueData.assignees.length > 0 ? `assignee: ${issueData.assignees[0]}` : ''}
|
|
484
|
+
${issueData.labels && issueData.labels.length > 0 ? `labels: ${issueData.labels.join(', ')}` : ''}
|
|
485
|
+
created: ${issueData.createdAt}
|
|
486
|
+
updated: ${issueData.updatedAt}
|
|
487
|
+
${issueData.url ? `url: ${issueData.url}` : ''}
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
# ${issueData.title}
|
|
491
|
+
|
|
492
|
+
${issueData.description || ''}
|
|
493
|
+
`;
|
|
494
|
+
|
|
495
|
+
// Write to local file
|
|
496
|
+
const issuePath = this.getIssuePath(issueNumber);
|
|
497
|
+
await fs.writeFile(issuePath, frontmatter);
|
|
498
|
+
|
|
499
|
+
return issueData;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ==========================================
|
|
503
|
+
// 5. UTILITY METHODS (4 METHODS)
|
|
504
|
+
// ==========================================
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Categorize issue status into standard buckets
|
|
508
|
+
*
|
|
509
|
+
* Maps various status strings to standardized categories:
|
|
510
|
+
* - open: Not started, awaiting work
|
|
511
|
+
* - in_progress: Active development
|
|
512
|
+
* - closed: Completed/resolved
|
|
513
|
+
*
|
|
514
|
+
* @param {string} status - Raw status string
|
|
515
|
+
* @returns {string} Categorized status (open|in_progress|closed)
|
|
516
|
+
*/
|
|
517
|
+
categorizeStatus(status) {
|
|
518
|
+
const lowerStatus = (status || '').toLowerCase();
|
|
519
|
+
|
|
520
|
+
// Open statuses
|
|
521
|
+
if (['open', 'todo', 'new', ''].includes(lowerStatus)) {
|
|
522
|
+
return 'open';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// In-progress statuses
|
|
526
|
+
if (['in-progress', 'in_progress', 'active', 'started'].includes(lowerStatus)) {
|
|
527
|
+
return 'in_progress';
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Closed statuses
|
|
531
|
+
if (['closed', 'completed', 'done', 'resolved'].includes(lowerStatus)) {
|
|
532
|
+
return 'closed';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Default to open for unknown statuses
|
|
536
|
+
return 'open';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Check if issue is in closed/completed state
|
|
541
|
+
*
|
|
542
|
+
* @param {Object} issue - Issue object with status field
|
|
543
|
+
* @returns {boolean} True if issue is closed
|
|
544
|
+
*/
|
|
545
|
+
isIssueClosed(issue) {
|
|
546
|
+
if (!issue || !issue.status) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const status = (issue.status || '').toLowerCase();
|
|
551
|
+
return ['closed', 'completed', 'done', 'resolved'].includes(status);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Get file path for issue
|
|
556
|
+
*
|
|
557
|
+
* @param {number|string} issueNumber - Issue number
|
|
558
|
+
* @returns {string} Full path to issue file
|
|
559
|
+
*/
|
|
560
|
+
getIssuePath(issueNumber) {
|
|
561
|
+
const path = require('path');
|
|
562
|
+
return path.join(process.cwd(), this.options.issuesDir, `${issueNumber}.md`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Format time duration between two timestamps
|
|
567
|
+
*
|
|
568
|
+
* @param {string} startTime - ISO timestamp for start
|
|
569
|
+
* @param {string} [endTime] - ISO timestamp for end (defaults to now)
|
|
570
|
+
* @returns {string} Formatted duration (e.g., "2h 30m", "3 days 4h")
|
|
571
|
+
*/
|
|
572
|
+
formatIssueDuration(startTime, endTime = null) {
|
|
573
|
+
const start = new Date(startTime);
|
|
574
|
+
const end = endTime ? new Date(endTime) : new Date();
|
|
575
|
+
const diff = end - start;
|
|
576
|
+
|
|
577
|
+
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
578
|
+
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
579
|
+
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
580
|
+
|
|
581
|
+
if (days > 0) {
|
|
582
|
+
return `${days} day${days > 1 ? 's' : ''} ${hours}h`;
|
|
583
|
+
} else if (hours > 0) {
|
|
584
|
+
return `${hours}h ${minutes}m`;
|
|
585
|
+
} else {
|
|
586
|
+
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
module.exports = IssueService;
|