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 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-autopm",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Autonomous Project Management Framework for Claude Code - Advanced AI-powered development automation",
5
5
  "main": "bin/autopm.js",
6
6
  "bin": {