claude-autopm 1.25.0 → 1.27.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/README.md +111 -0
- package/autopm/.claude/agents/frameworks/e2e-test-engineer.md +1 -18
- package/autopm/.claude/agents/frameworks/nats-messaging-expert.md +1 -18
- package/autopm/.claude/agents/frameworks/react-frontend-engineer.md +1 -18
- package/autopm/.claude/agents/frameworks/react-ui-expert.md +1 -18
- package/autopm/.claude/agents/frameworks/tailwindcss-expert.md +1 -18
- package/autopm/.claude/agents/frameworks/ux-design-expert.md +1 -18
- package/autopm/.claude/agents/languages/bash-scripting-expert.md +1 -18
- package/autopm/.claude/agents/languages/javascript-frontend-engineer.md +1 -18
- package/autopm/.claude/agents/languages/nodejs-backend-engineer.md +1 -18
- package/autopm/.claude/agents/languages/python-backend-engineer.md +1 -18
- package/autopm/.claude/agents/languages/python-backend-expert.md +1 -18
- package/autopm/.claude/commands/pm/epic-decompose.md +19 -5
- package/autopm/.claude/commands/pm/prd-new.md +14 -1
- package/autopm/.claude/includes/task-creation-excellence.md +18 -0
- package/autopm/.claude/lib/ai-task-generator.js +84 -0
- package/autopm/.claude/lib/cli-parser.js +148 -0
- package/autopm/.claude/lib/commands/pm/epicStatus.js +263 -0
- package/autopm/.claude/lib/dependency-analyzer.js +157 -0
- package/autopm/.claude/lib/frontmatter.js +224 -0
- package/autopm/.claude/lib/task-utils.js +64 -0
- package/autopm/.claude/scripts/pm-epic-decompose-local.js +158 -0
- package/autopm/.claude/scripts/pm-epic-list-local.js +103 -0
- package/autopm/.claude/scripts/pm-epic-show-local.js +70 -0
- package/autopm/.claude/scripts/pm-epic-update-local.js +56 -0
- package/autopm/.claude/scripts/pm-prd-list-local.js +111 -0
- package/autopm/.claude/scripts/pm-prd-new-local.js +196 -0
- package/autopm/.claude/scripts/pm-prd-parse-local.js +360 -0
- package/autopm/.claude/scripts/pm-prd-show-local.js +101 -0
- package/autopm/.claude/scripts/pm-prd-update-local.js +153 -0
- package/autopm/.claude/scripts/pm-sync-download-local.js +424 -0
- package/autopm/.claude/scripts/pm-sync-upload-local.js +473 -0
- package/autopm/.claude/scripts/pm-task-list-local.js +86 -0
- package/autopm/.claude/scripts/pm-task-show-local.js +92 -0
- package/autopm/.claude/scripts/pm-task-update-local.js +109 -0
- package/autopm/.claude/scripts/setup-local-mode.js +127 -0
- package/package.json +5 -3
- package/scripts/create-task-issues.sh +26 -0
- package/scripts/fix-invalid-command-refs.sh +4 -3
- package/scripts/fix-invalid-refs-simple.sh +8 -3
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local PRD Show Command
|
|
3
|
+
*
|
|
4
|
+
* Displays a specific Product Requirements Document (PRD) by ID.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* /pm:prd-show --local <id>
|
|
8
|
+
*
|
|
9
|
+
* @module pm-prd-show-local
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs').promises;
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { parseFrontmatter } = require('../lib/frontmatter');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shows a specific PRD by ID
|
|
18
|
+
*
|
|
19
|
+
* @param {string} id - PRD ID to display
|
|
20
|
+
* @returns {Promise<Object>} PRD data including frontmatter and body
|
|
21
|
+
* @throws {Error} If PRD not found or ID is invalid
|
|
22
|
+
*/
|
|
23
|
+
async function showLocalPRD(id) {
|
|
24
|
+
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
|
25
|
+
throw new Error('PRD ID is required');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const prdsDir = path.join(process.cwd(), '.claude', 'prds');
|
|
29
|
+
|
|
30
|
+
// Ensure directory exists
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(prdsDir);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err.code === 'ENOENT') {
|
|
35
|
+
throw new Error(`PRD not found: ${id}`);
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Read all PRD files
|
|
41
|
+
const files = await fs.readdir(prdsDir);
|
|
42
|
+
|
|
43
|
+
// Search for PRD by ID
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
if (!file.endsWith('.md')) continue;
|
|
46
|
+
|
|
47
|
+
const filepath = path.join(prdsDir, file);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const content = await fs.readFile(filepath, 'utf8');
|
|
51
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
52
|
+
|
|
53
|
+
if (frontmatter && frontmatter.id === id) {
|
|
54
|
+
return {
|
|
55
|
+
filepath,
|
|
56
|
+
filename: file,
|
|
57
|
+
frontmatter,
|
|
58
|
+
body,
|
|
59
|
+
content
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Skip files that can't be parsed
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// PRD not found
|
|
69
|
+
throw new Error(`PRD not found: ${id}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Formats PRD for display
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} prd - PRD object from showLocalPRD
|
|
76
|
+
* @returns {string} Formatted PRD for display
|
|
77
|
+
*/
|
|
78
|
+
function formatPRD(prd) {
|
|
79
|
+
const { frontmatter, body } = prd;
|
|
80
|
+
|
|
81
|
+
const header = [
|
|
82
|
+
'',
|
|
83
|
+
`PRD: ${frontmatter.title}`,
|
|
84
|
+
`ID: ${frontmatter.id}`,
|
|
85
|
+
`Status: ${frontmatter.status}`,
|
|
86
|
+
`Priority: ${frontmatter.priority}`,
|
|
87
|
+
`Created: ${frontmatter.created}`,
|
|
88
|
+
`Author: ${frontmatter.author}`,
|
|
89
|
+
`Version: ${frontmatter.version}`,
|
|
90
|
+
'',
|
|
91
|
+
'─'.repeat(80),
|
|
92
|
+
''
|
|
93
|
+
].join('\n');
|
|
94
|
+
|
|
95
|
+
return header + body;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
showLocalPRD,
|
|
100
|
+
formatPRD
|
|
101
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local PRD Update Command
|
|
3
|
+
*
|
|
4
|
+
* Updates frontmatter fields in a Product Requirements Document (PRD).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* /pm:prd-update --local <id> <field> <value>
|
|
8
|
+
*
|
|
9
|
+
* @module pm-prd-update-local
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs').promises;
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { parseFrontmatter, stringifyFrontmatter } = require('../lib/frontmatter');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Updates a frontmatter field in a PRD
|
|
18
|
+
*
|
|
19
|
+
* @param {string} id - PRD ID
|
|
20
|
+
* @param {string} field - Frontmatter field to update
|
|
21
|
+
* @param {*} value - New value for the field
|
|
22
|
+
* @returns {Promise<Object>} Updated PRD data
|
|
23
|
+
* @throws {Error} If PRD not found or parameters invalid
|
|
24
|
+
*/
|
|
25
|
+
async function updateLocalPRD(id, field, value) {
|
|
26
|
+
// Validate parameters
|
|
27
|
+
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
|
28
|
+
throw new Error('PRD ID is required');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!field || typeof field !== 'string' || field.trim().length === 0) {
|
|
32
|
+
throw new Error('Field is required');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const prdsDir = path.join(process.cwd(), '.claude', 'prds');
|
|
36
|
+
|
|
37
|
+
// Ensure directory exists
|
|
38
|
+
try {
|
|
39
|
+
await fs.access(prdsDir);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === 'ENOENT') {
|
|
42
|
+
throw new Error(`PRD not found: ${id}`);
|
|
43
|
+
}
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Read all PRD files to find the target
|
|
48
|
+
const files = await fs.readdir(prdsDir);
|
|
49
|
+
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
if (!file.endsWith('.md')) continue;
|
|
52
|
+
|
|
53
|
+
const filepath = path.join(prdsDir, file);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const content = await fs.readFile(filepath, 'utf8');
|
|
57
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
58
|
+
|
|
59
|
+
if (frontmatter && frontmatter.id === id) {
|
|
60
|
+
// Update the field
|
|
61
|
+
const updatedFrontmatter = {
|
|
62
|
+
...frontmatter,
|
|
63
|
+
[field]: value
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Reconstruct the file
|
|
67
|
+
const updatedContent = stringifyFrontmatter(updatedFrontmatter, body);
|
|
68
|
+
|
|
69
|
+
// Write back to file
|
|
70
|
+
await fs.writeFile(filepath, updatedContent, 'utf8');
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
filepath,
|
|
74
|
+
filename: file,
|
|
75
|
+
frontmatter: updatedFrontmatter,
|
|
76
|
+
body
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Skip files that can't be parsed
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// PRD not found
|
|
86
|
+
throw new Error(`PRD not found: ${id}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Updates multiple fields at once
|
|
91
|
+
*
|
|
92
|
+
* @param {string} id - PRD ID
|
|
93
|
+
* @param {Object} updates - Object with field: value pairs
|
|
94
|
+
* @returns {Promise<Object>} Updated PRD data
|
|
95
|
+
*/
|
|
96
|
+
async function updateMultipleFields(id, updates) {
|
|
97
|
+
// Validate parameters
|
|
98
|
+
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
|
99
|
+
throw new Error('PRD ID is required');
|
|
100
|
+
}
|
|
101
|
+
if (!updates || typeof updates !== 'object') {
|
|
102
|
+
throw new Error('Updates must be an object');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const prdsDir = path.join(process.cwd(), '.claude', 'prds');
|
|
106
|
+
let files;
|
|
107
|
+
try {
|
|
108
|
+
files = await fs.readdir(prdsDir);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err.code === 'ENOENT') {
|
|
111
|
+
throw new Error(`PRD not found: ${id}`);
|
|
112
|
+
}
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
if (!file.endsWith('.md')) continue;
|
|
118
|
+
|
|
119
|
+
const filepath = path.join(prdsDir, file);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const content = await fs.readFile(filepath, 'utf8');
|
|
123
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
124
|
+
|
|
125
|
+
if (frontmatter && frontmatter.id === id) {
|
|
126
|
+
// Apply all updates
|
|
127
|
+
const updatedFrontmatter = {
|
|
128
|
+
...frontmatter,
|
|
129
|
+
...updates
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const updatedContent = stringifyFrontmatter(updatedFrontmatter, body);
|
|
133
|
+
await fs.writeFile(filepath, updatedContent, 'utf8');
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
filepath,
|
|
137
|
+
filename: file,
|
|
138
|
+
frontmatter: updatedFrontmatter,
|
|
139
|
+
body
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
throw new Error(`PRD not found: ${id}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
updateLocalPRD,
|
|
152
|
+
updateMultipleFields
|
|
153
|
+
};
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Sync Download - Local Mode
|
|
3
|
+
*
|
|
4
|
+
* Downloads GitHub Issues to local PRDs, Epics, and Tasks
|
|
5
|
+
* with intelligent conflict resolution and mapping.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const { syncFromGitHub } = require('./pm-sync-download-local');
|
|
9
|
+
*
|
|
10
|
+
* await syncFromGitHub({
|
|
11
|
+
* basePath: '.claude',
|
|
12
|
+
* owner: 'user',
|
|
13
|
+
* repo: 'repository',
|
|
14
|
+
* octokit: octokitInstance,
|
|
15
|
+
* dryRun: false
|
|
16
|
+
* });
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs').promises;
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { parseFrontmatter, stringifyFrontmatter } = require('../lib/frontmatter');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Download PRD from GitHub Issue
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} issue - GitHub issue object
|
|
27
|
+
* @param {string} prdsDir - PRDs directory path
|
|
28
|
+
* @param {Object} reverseMap - Reverse mapping (GitHub → local)
|
|
29
|
+
* @param {boolean} dryRun - Dry run mode
|
|
30
|
+
* @param {string} conflictMode - Conflict resolution: 'merge', 'github', 'local'
|
|
31
|
+
* @returns {Promise<Object>} Download result
|
|
32
|
+
*/
|
|
33
|
+
async function downloadPRDFromGitHub(issue, prdsDir, reverseMap, dryRun = false, conflictMode = 'merge') {
|
|
34
|
+
// Parse title to get PRD name
|
|
35
|
+
const title = issue.title.replace(/^\[PRD\]\s*/, '');
|
|
36
|
+
|
|
37
|
+
if (dryRun) {
|
|
38
|
+
console.log(` [DRY-RUN] Would download: [PRD] ${title} (#${issue.number})`);
|
|
39
|
+
return { action: 'dry-run', title };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if PRD already exists locally
|
|
43
|
+
const existingPrdId = reverseMap[issue.number];
|
|
44
|
+
let prdPath;
|
|
45
|
+
let frontmatter;
|
|
46
|
+
let body;
|
|
47
|
+
|
|
48
|
+
if (existingPrdId) {
|
|
49
|
+
// Update existing PRD
|
|
50
|
+
const files = await fs.readdir(prdsDir);
|
|
51
|
+
const existingFile = files.find(f => f.startsWith(`${existingPrdId}-`));
|
|
52
|
+
|
|
53
|
+
if (existingFile) {
|
|
54
|
+
prdPath = path.join(prdsDir, existingFile);
|
|
55
|
+
const existingContent = await fs.readFile(prdPath, 'utf8');
|
|
56
|
+
const parsed = parseFrontmatter(existingContent);
|
|
57
|
+
|
|
58
|
+
// Check for conflicts
|
|
59
|
+
let hasConflict = false;
|
|
60
|
+
if (parsed.frontmatter.updated && issue.updated_at) {
|
|
61
|
+
const localUpdated = new Date(parsed.frontmatter.updated);
|
|
62
|
+
const githubUpdated = new Date(issue.updated_at);
|
|
63
|
+
|
|
64
|
+
if (localUpdated > githubUpdated) {
|
|
65
|
+
hasConflict = true;
|
|
66
|
+
|
|
67
|
+
if (conflictMode === 'local') {
|
|
68
|
+
console.log(` ⚠️ Conflict: Local PRD newer than GitHub (#${issue.number}) - Keeping local`);
|
|
69
|
+
return { action: 'conflict-skipped', conflict: true, title };
|
|
70
|
+
} else if (conflictMode === 'merge') {
|
|
71
|
+
console.log(` ⚠️ Conflict: Local PRD newer than GitHub (#${issue.number}) - Merging`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
frontmatter = parsed.frontmatter;
|
|
77
|
+
body = parsed.body;
|
|
78
|
+
|
|
79
|
+
// If conflict in merge mode, return conflict indicator
|
|
80
|
+
if (hasConflict && conflictMode === 'merge') {
|
|
81
|
+
// Continue with update but flag conflict
|
|
82
|
+
const { metadata, content } = parseIssueBody(issue.body);
|
|
83
|
+
frontmatter.title = title;
|
|
84
|
+
frontmatter.status = metadata.status || frontmatter.status;
|
|
85
|
+
frontmatter.priority = extractPriority(issue.labels);
|
|
86
|
+
frontmatter.github_issue = issue.number;
|
|
87
|
+
frontmatter.updated = new Date().toISOString();
|
|
88
|
+
|
|
89
|
+
body = content;
|
|
90
|
+
|
|
91
|
+
const updatedContent = stringifyFrontmatter(frontmatter, body);
|
|
92
|
+
await fs.writeFile(prdPath, updatedContent);
|
|
93
|
+
|
|
94
|
+
console.log(` ✅ Updated PRD (conflict resolved): ${title} (#${issue.number})`);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
action: 'conflict-merged',
|
|
98
|
+
prdId: frontmatter.id,
|
|
99
|
+
title,
|
|
100
|
+
conflict: true
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// File was deleted locally, recreate
|
|
105
|
+
return await createNewPRD(issue, prdsDir, reverseMap);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
// Create new PRD
|
|
109
|
+
return await createNewPRD(issue, prdsDir, reverseMap);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Update frontmatter from GitHub issue
|
|
113
|
+
const { metadata, content } = parseIssueBody(issue.body);
|
|
114
|
+
|
|
115
|
+
frontmatter.title = title;
|
|
116
|
+
frontmatter.status = metadata.status || 'draft';
|
|
117
|
+
frontmatter.priority = extractPriority(issue.labels);
|
|
118
|
+
frontmatter.github_issue = issue.number;
|
|
119
|
+
frontmatter.updated = new Date().toISOString();
|
|
120
|
+
|
|
121
|
+
// Update body
|
|
122
|
+
body = content;
|
|
123
|
+
|
|
124
|
+
// Write updated PRD
|
|
125
|
+
const updatedContent = stringifyFrontmatter(frontmatter, body);
|
|
126
|
+
await fs.writeFile(prdPath, updatedContent);
|
|
127
|
+
|
|
128
|
+
console.log(` ✅ Updated PRD: ${title} (#${issue.number})`);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
action: 'updated',
|
|
132
|
+
prdId: frontmatter.id,
|
|
133
|
+
title,
|
|
134
|
+
conflict: false
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create new PRD from GitHub issue
|
|
140
|
+
*/
|
|
141
|
+
async function createNewPRD(issue, prdsDir, reverseMap) {
|
|
142
|
+
const title = issue.title.replace(/^\[PRD\]\s*/, '');
|
|
143
|
+
const { metadata, content } = parseIssueBody(issue.body);
|
|
144
|
+
|
|
145
|
+
// Generate PRD ID
|
|
146
|
+
const existingFiles = await fs.readdir(prdsDir);
|
|
147
|
+
const prdNumbers = existingFiles
|
|
148
|
+
.filter(f => f.startsWith('prd-'))
|
|
149
|
+
.map(f => parseInt(f.match(/prd-(\d+)/)?.[1] || '0'))
|
|
150
|
+
.filter(n => !isNaN(n));
|
|
151
|
+
|
|
152
|
+
const nextNum = prdNumbers.length > 0 ? Math.max(...prdNumbers) + 1 : 1;
|
|
153
|
+
const prdId = `prd-${String(nextNum).padStart(3, '0')}`;
|
|
154
|
+
|
|
155
|
+
// Build frontmatter
|
|
156
|
+
const frontmatter = {
|
|
157
|
+
id: prdId,
|
|
158
|
+
title,
|
|
159
|
+
status: metadata.status || 'draft',
|
|
160
|
+
priority: extractPriority(issue.labels),
|
|
161
|
+
created: metadata.created || new Date(issue.created_at).toISOString().split('T')[0],
|
|
162
|
+
github_issue: issue.number
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Create PRD file
|
|
166
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
167
|
+
const prdFilename = `${prdId}-${slug}.md`;
|
|
168
|
+
const prdPath = path.join(prdsDir, prdFilename);
|
|
169
|
+
|
|
170
|
+
const prdContent = stringifyFrontmatter(frontmatter, content);
|
|
171
|
+
await fs.writeFile(prdPath, prdContent);
|
|
172
|
+
|
|
173
|
+
// Update reverse map
|
|
174
|
+
reverseMap[issue.number] = prdId;
|
|
175
|
+
|
|
176
|
+
console.log(` ✅ Created PRD: ${title} (#${issue.number})`);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
action: 'created',
|
|
180
|
+
prdId,
|
|
181
|
+
title
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Download Epic from GitHub Issue
|
|
187
|
+
*
|
|
188
|
+
* @param {Object} issue - GitHub issue object
|
|
189
|
+
* @param {string} epicsDir - Epics directory path
|
|
190
|
+
* @param {Object} reverseMap - Reverse mapping
|
|
191
|
+
* @param {boolean} dryRun - Dry run mode
|
|
192
|
+
* @returns {Promise<Object>} Download result
|
|
193
|
+
*/
|
|
194
|
+
async function downloadEpicFromGitHub(issue, epicsDir, reverseMap, dryRun = false) {
|
|
195
|
+
const title = issue.title.replace(/^\[EPIC\]\s*/, '');
|
|
196
|
+
|
|
197
|
+
if (dryRun) {
|
|
198
|
+
console.log(` [DRY-RUN] Would download: [EPIC] ${title} (#${issue.number})`);
|
|
199
|
+
return { action: 'dry-run', title };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Parse issue body
|
|
203
|
+
const { metadata, content } = parseIssueBody(issue.body);
|
|
204
|
+
|
|
205
|
+
// Extract parent PRD from metadata
|
|
206
|
+
const prdMatch = issue.body.match(/\*\*Parent PRD:\*\*\s*#(\d+)/);
|
|
207
|
+
const parentPrdIssue = prdMatch ? parseInt(prdMatch[1]) : null;
|
|
208
|
+
const prdId = parentPrdIssue && reverseMap[parentPrdIssue] ? reverseMap[parentPrdIssue] : null;
|
|
209
|
+
|
|
210
|
+
// Generate Epic ID
|
|
211
|
+
const existingDirs = await fs.readdir(epicsDir).catch(() => []);
|
|
212
|
+
const epicNumbers = existingDirs
|
|
213
|
+
.filter(d => d.startsWith('epic-'))
|
|
214
|
+
.map(d => parseInt(d.match(/epic-(\d+)/)?.[1] || '0'))
|
|
215
|
+
.filter(n => !isNaN(n));
|
|
216
|
+
|
|
217
|
+
const nextNum = epicNumbers.length > 0 ? Math.max(...epicNumbers) + 1 : 1;
|
|
218
|
+
const epicId = `epic-${String(nextNum).padStart(3, '0')}`;
|
|
219
|
+
|
|
220
|
+
// Build frontmatter
|
|
221
|
+
const frontmatter = {
|
|
222
|
+
id: epicId,
|
|
223
|
+
title,
|
|
224
|
+
status: metadata.status || 'pending',
|
|
225
|
+
priority: extractPriority(issue.labels),
|
|
226
|
+
created: new Date(issue.created_at).toISOString().split('T')[0],
|
|
227
|
+
github_issue: issue.number
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (prdId) {
|
|
231
|
+
frontmatter.prd_id = prdId;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Parse progress if present
|
|
235
|
+
const progressMatch = issue.body.match(/\*\*Progress:\*\*\s*(\d+)\/(\d+)/);
|
|
236
|
+
if (progressMatch) {
|
|
237
|
+
frontmatter.tasks_completed = parseInt(progressMatch[1]);
|
|
238
|
+
frontmatter.tasks_total = parseInt(progressMatch[2]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Create epic directory and file
|
|
242
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
243
|
+
const epicDirName = `${epicId}-${slug}`;
|
|
244
|
+
const epicDirPath = path.join(epicsDir, epicDirName);
|
|
245
|
+
|
|
246
|
+
await fs.mkdir(epicDirPath, { recursive: true });
|
|
247
|
+
|
|
248
|
+
const epicFilePath = path.join(epicDirPath, 'epic.md');
|
|
249
|
+
const epicContent = stringifyFrontmatter(frontmatter, content);
|
|
250
|
+
await fs.writeFile(epicFilePath, epicContent);
|
|
251
|
+
|
|
252
|
+
// Update reverse map
|
|
253
|
+
reverseMap[issue.number] = epicId;
|
|
254
|
+
|
|
255
|
+
console.log(` ✅ Created Epic: ${title} (#${issue.number})`);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
action: 'created',
|
|
259
|
+
epicId,
|
|
260
|
+
title
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Download Task from GitHub Issue
|
|
266
|
+
*
|
|
267
|
+
* @param {Object} issue - GitHub issue object
|
|
268
|
+
* @param {string} epicsDir - Epics directory path
|
|
269
|
+
* @param {Object} reverseMap - Reverse mapping
|
|
270
|
+
* @param {boolean} dryRun - Dry run mode
|
|
271
|
+
* @returns {Promise<Object>} Download result
|
|
272
|
+
*/
|
|
273
|
+
async function downloadTaskFromGitHub(issue, epicsDir, reverseMap, dryRun = false) {
|
|
274
|
+
const title = issue.title.replace(/^\[TASK\]\s*/, '');
|
|
275
|
+
|
|
276
|
+
if (dryRun) {
|
|
277
|
+
console.log(` [DRY-RUN] Would download: [TASK] ${title} (#${issue.number})`);
|
|
278
|
+
return { action: 'dry-run', title };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Parse issue body
|
|
282
|
+
const { metadata, content } = parseIssueBody(issue.body);
|
|
283
|
+
|
|
284
|
+
// Extract parent epic
|
|
285
|
+
const epicMatch = issue.body.match(/\*\*Parent Epic:\*\*\s*#(\d+)/);
|
|
286
|
+
const parentEpicIssue = epicMatch ? parseInt(epicMatch[1]) : null;
|
|
287
|
+
const epicId = parentEpicIssue && reverseMap[parentEpicIssue] ? reverseMap[parentEpicIssue] : null;
|
|
288
|
+
|
|
289
|
+
if (!epicId) {
|
|
290
|
+
console.log(` ⚠️ Skipped task: No parent epic found for #${issue.number}`);
|
|
291
|
+
return { action: 'skipped', reason: 'no-parent-epic' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Find epic directory
|
|
295
|
+
const epicDirs = await fs.readdir(epicsDir);
|
|
296
|
+
const epicDirName = epicDirs.find(d => d.startsWith(`${epicId}-`));
|
|
297
|
+
|
|
298
|
+
if (!epicDirName) {
|
|
299
|
+
console.log(` ⚠️ Skipped task: Epic directory not found for ${epicId}`);
|
|
300
|
+
return { action: 'skipped', reason: 'epic-not-found' };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const epicDirPath = path.join(epicsDir, epicDirName);
|
|
304
|
+
|
|
305
|
+
// Generate task number
|
|
306
|
+
const existingTasks = await fs.readdir(epicDirPath);
|
|
307
|
+
const taskNumbers = existingTasks
|
|
308
|
+
.filter(f => f.startsWith('task-'))
|
|
309
|
+
.map(f => parseInt(f.match(/task-(\d+)/)?.[1] || '0'))
|
|
310
|
+
.filter(n => !isNaN(n));
|
|
311
|
+
|
|
312
|
+
const nextNum = taskNumbers.length > 0 ? Math.max(...taskNumbers) + 1 : 1;
|
|
313
|
+
const taskNum = String(nextNum).padStart(3, '0');
|
|
314
|
+
const taskId = `task-${epicId}-${taskNum}`;
|
|
315
|
+
|
|
316
|
+
// Build frontmatter
|
|
317
|
+
const frontmatter = {
|
|
318
|
+
id: taskId,
|
|
319
|
+
epic_id: epicId,
|
|
320
|
+
title,
|
|
321
|
+
status: metadata.status || 'pending',
|
|
322
|
+
priority: extractPriority(issue.labels),
|
|
323
|
+
estimated_hours: metadata.estimated_hours ? parseInt(metadata.estimated_hours) : 4,
|
|
324
|
+
created: new Date(issue.created_at).toISOString().split('T')[0],
|
|
325
|
+
github_issue: issue.number,
|
|
326
|
+
dependencies: []
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
if (metadata.dependencies) {
|
|
330
|
+
frontmatter.dependencies = metadata.dependencies.split(',').map(d => d.trim());
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Create task file
|
|
334
|
+
const taskFilePath = path.join(epicDirPath, `task-${taskNum}.md`);
|
|
335
|
+
const taskContent = stringifyFrontmatter(frontmatter, content);
|
|
336
|
+
await fs.writeFile(taskFilePath, taskContent);
|
|
337
|
+
|
|
338
|
+
// Update reverse map
|
|
339
|
+
reverseMap[issue.number] = taskId;
|
|
340
|
+
|
|
341
|
+
console.log(` ✅ Created Task: ${title} (#${issue.number})`);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
action: 'created',
|
|
345
|
+
taskId,
|
|
346
|
+
title
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Parse GitHub issue body into metadata and content
|
|
352
|
+
*/
|
|
353
|
+
function parseIssueBody(body) {
|
|
354
|
+
const lines = body.split('\n');
|
|
355
|
+
const metadata = {};
|
|
356
|
+
let content = '';
|
|
357
|
+
let inMetadata = true;
|
|
358
|
+
|
|
359
|
+
for (const line of lines) {
|
|
360
|
+
if (line.trim() === '---') {
|
|
361
|
+
inMetadata = false;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (inMetadata) {
|
|
366
|
+
// Parse metadata line
|
|
367
|
+
const match = line.match(/\*\*(.+?):\*\*\s*(.+)/);
|
|
368
|
+
if (match) {
|
|
369
|
+
const key = match[1].toLowerCase().replace(/\s+/g, '_');
|
|
370
|
+
metadata[key] = match[2].trim();
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
content += line + '\n';
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
metadata,
|
|
379
|
+
content: content.trim()
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Extract priority from issue labels
|
|
385
|
+
*/
|
|
386
|
+
function extractPriority(labels) {
|
|
387
|
+
const priorityLabels = ['critical', 'high', 'medium', 'low'];
|
|
388
|
+
|
|
389
|
+
for (const label of labels) {
|
|
390
|
+
const labelName = typeof label === 'string' ? label : label.name;
|
|
391
|
+
if (priorityLabels.includes(labelName)) {
|
|
392
|
+
return labelName;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return 'medium'; // default
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Load sync map from file
|
|
401
|
+
*/
|
|
402
|
+
async function loadSyncMap(syncMapPath) {
|
|
403
|
+
try {
|
|
404
|
+
const content = await fs.readFile(syncMapPath, 'utf8');
|
|
405
|
+
return JSON.parse(content);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Save sync map to file
|
|
413
|
+
*/
|
|
414
|
+
async function saveSyncMap(syncMapPath, syncMap) {
|
|
415
|
+
await fs.writeFile(syncMapPath, JSON.stringify(syncMap, null, 2), 'utf8');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
module.exports = {
|
|
419
|
+
downloadPRDFromGitHub,
|
|
420
|
+
downloadEpicFromGitHub,
|
|
421
|
+
downloadTaskFromGitHub,
|
|
422
|
+
loadSyncMap,
|
|
423
|
+
saveSyncMap
|
|
424
|
+
};
|