claude-autopm 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Epic Sync - Complete GitHub sync for epics
|
|
4
|
+
*
|
|
5
|
+
* Replaces all 4 Bash scripts with clean, testable JavaScript:
|
|
6
|
+
* - create-epic-issue.sh → createEpicIssue()
|
|
7
|
+
* - create-task-issues.sh → createTaskIssues()
|
|
8
|
+
* - update-epic-file.sh → updateEpicFile()
|
|
9
|
+
* - update-references.sh → updateTaskReferences()
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { execSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse frontmatter and body from markdown file
|
|
18
|
+
*/
|
|
19
|
+
function parseMarkdownFile(filePath) {
|
|
20
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
21
|
+
const lines = content.split('\n');
|
|
22
|
+
|
|
23
|
+
let inFrontmatter = false;
|
|
24
|
+
let frontmatterLines = [];
|
|
25
|
+
let bodyLines = [];
|
|
26
|
+
let frontmatterCount = 0;
|
|
27
|
+
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
if (line === '---') {
|
|
30
|
+
frontmatterCount++;
|
|
31
|
+
if (frontmatterCount === 1) {
|
|
32
|
+
inFrontmatter = true;
|
|
33
|
+
continue;
|
|
34
|
+
} else if (frontmatterCount === 2) {
|
|
35
|
+
inFrontmatter = false;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (inFrontmatter) {
|
|
41
|
+
frontmatterLines.push(line);
|
|
42
|
+
} else if (frontmatterCount === 2) {
|
|
43
|
+
bodyLines.push(line);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse frontmatter
|
|
48
|
+
const frontmatter = {};
|
|
49
|
+
for (const line of frontmatterLines) {
|
|
50
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
51
|
+
if (match) {
|
|
52
|
+
const [, key, value] = match;
|
|
53
|
+
frontmatter[key] = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
frontmatter,
|
|
59
|
+
frontmatterLines,
|
|
60
|
+
body: bodyLines.join('\n').trim(),
|
|
61
|
+
fullContent: content
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Update frontmatter field in markdown content
|
|
67
|
+
*/
|
|
68
|
+
function updateFrontmatter(content, updates) {
|
|
69
|
+
const lines = content.split('\n');
|
|
70
|
+
const result = [];
|
|
71
|
+
let inFrontmatter = false;
|
|
72
|
+
let frontmatterCount = 0;
|
|
73
|
+
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (line === '---') {
|
|
76
|
+
frontmatterCount++;
|
|
77
|
+
result.push(line);
|
|
78
|
+
if (frontmatterCount === 1) {
|
|
79
|
+
inFrontmatter = true;
|
|
80
|
+
} else if (frontmatterCount === 2) {
|
|
81
|
+
inFrontmatter = false;
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (inFrontmatter) {
|
|
87
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
88
|
+
if (match) {
|
|
89
|
+
const [, key] = match;
|
|
90
|
+
if (updates[key] !== undefined) {
|
|
91
|
+
result.push(`${key}: ${updates[key]}`);
|
|
92
|
+
} else {
|
|
93
|
+
result.push(line);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
result.push(line);
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
result.push(line);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get current timestamp in ISO format
|
|
108
|
+
*/
|
|
109
|
+
function getTimestamp() {
|
|
110
|
+
return new Date().toISOString();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get repository info from gh CLI
|
|
115
|
+
*/
|
|
116
|
+
function getRepoInfo() {
|
|
117
|
+
try {
|
|
118
|
+
const result = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
121
|
+
});
|
|
122
|
+
return result.trim();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return 'unknown/repo';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create epic GitHub issue
|
|
130
|
+
*/
|
|
131
|
+
function createEpicIssue(epicPath) {
|
|
132
|
+
const epicFile = path.join(process.cwd(), '.claude/epics', epicPath, 'epic.md');
|
|
133
|
+
|
|
134
|
+
if (!fs.existsSync(epicFile)) {
|
|
135
|
+
throw new Error(`Epic file not found: ${epicFile}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { body } = parseMarkdownFile(epicFile);
|
|
139
|
+
|
|
140
|
+
// Count tasks
|
|
141
|
+
const epicDir = path.dirname(epicFile);
|
|
142
|
+
const taskFiles = fs.readdirSync(epicDir)
|
|
143
|
+
.filter(f => /^\d+\.md$/.test(f));
|
|
144
|
+
const taskCount = taskFiles.length;
|
|
145
|
+
|
|
146
|
+
// Detect epic type
|
|
147
|
+
const isFeature = body.toLowerCase().includes('feature') ||
|
|
148
|
+
body.toLowerCase().includes('implement') ||
|
|
149
|
+
!body.toLowerCase().includes('bug');
|
|
150
|
+
const labels = isFeature ? 'epic,feature' : 'epic,bug';
|
|
151
|
+
|
|
152
|
+
console.log(`📝 Creating epic issue: ${epicPath}`);
|
|
153
|
+
console.log(` Tasks: ${taskCount}`);
|
|
154
|
+
console.log(` Labels: ${labels}`);
|
|
155
|
+
|
|
156
|
+
// Create issue body
|
|
157
|
+
const issueBody = `
|
|
158
|
+
${body}
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
**Epic Statistics:**
|
|
163
|
+
- Tasks: ${taskCount}
|
|
164
|
+
- Status: Planning
|
|
165
|
+
- Created: ${getTimestamp()}
|
|
166
|
+
`.trim();
|
|
167
|
+
|
|
168
|
+
// Escape for shell
|
|
169
|
+
const escapedTitle = `Epic: ${epicPath}`.replace(/"/g, '\\"');
|
|
170
|
+
const escapedBody = issueBody.replace(/"/g, '\\"').replace(/`/g, '\\`');
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const result = execSync(
|
|
174
|
+
`gh issue create --title "${escapedTitle}" --body "${escapedBody}" --label "${labels}"`,
|
|
175
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const match = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
|
|
179
|
+
if (match) {
|
|
180
|
+
const issueNumber = parseInt(match[1]);
|
|
181
|
+
console.log(`✅ Created epic issue #${issueNumber}`);
|
|
182
|
+
return issueNumber;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
throw new Error('Could not extract issue number from gh output');
|
|
186
|
+
} catch (error) {
|
|
187
|
+
throw new Error(`Failed to create epic issue: ${error.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create task issues for an epic
|
|
193
|
+
*/
|
|
194
|
+
function createTaskIssues(epicPath, epicIssueNumber) {
|
|
195
|
+
const epicDir = path.join(process.cwd(), '.claude/epics', epicPath);
|
|
196
|
+
|
|
197
|
+
if (!fs.existsSync(epicDir)) {
|
|
198
|
+
throw new Error(`Epic directory not found: ${epicDir}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Find all task files
|
|
202
|
+
const taskFiles = fs.readdirSync(epicDir)
|
|
203
|
+
.filter(f => /^\d+\.md$/.test(f))
|
|
204
|
+
.sort();
|
|
205
|
+
|
|
206
|
+
if (taskFiles.length === 0) {
|
|
207
|
+
throw new Error(`No task files found in ${epicDir}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(`\n📋 Creating ${taskFiles.length} task issues for epic #${epicIssueNumber}`);
|
|
211
|
+
|
|
212
|
+
const mapping = [];
|
|
213
|
+
let successCount = 0;
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < taskFiles.length; i++) {
|
|
216
|
+
const taskFile = taskFiles[i];
|
|
217
|
+
const taskNumber = taskFile.replace('.md', '');
|
|
218
|
+
const taskPath = path.join(epicDir, taskFile);
|
|
219
|
+
|
|
220
|
+
console.log(`[${i + 1}/${taskFiles.length}] Creating issue for task ${taskNumber}...`);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const { frontmatter, body } = parseMarkdownFile(taskPath);
|
|
224
|
+
const title = frontmatter.name || 'Untitled Task';
|
|
225
|
+
|
|
226
|
+
// Create issue body
|
|
227
|
+
const issueBody = `
|
|
228
|
+
Part of Epic #${epicIssueNumber}
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
${body}
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
**Epic Path:** \`${epicPath}\`
|
|
237
|
+
**Task Number:** ${taskNumber}
|
|
238
|
+
`.trim();
|
|
239
|
+
|
|
240
|
+
// Escape for shell
|
|
241
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
242
|
+
const escapedBody = issueBody.replace(/"/g, '\\"').replace(/`/g, '\\`');
|
|
243
|
+
|
|
244
|
+
const result = execSync(
|
|
245
|
+
`gh issue create --title "${escapedTitle}" --body "${escapedBody}" --label "task"`,
|
|
246
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const match = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
|
|
250
|
+
if (match) {
|
|
251
|
+
const issueNumber = parseInt(match[1]);
|
|
252
|
+
console.log(` ✅ Created issue #${issueNumber}: ${title}`);
|
|
253
|
+
mapping.push({ oldName: taskNumber, newNumber: issueNumber });
|
|
254
|
+
successCount++;
|
|
255
|
+
}
|
|
256
|
+
} catch (error) {
|
|
257
|
+
console.error(` ❌ Error: ${error.message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Save mapping file
|
|
262
|
+
const mappingFile = path.join(epicDir, '.task-mapping.txt');
|
|
263
|
+
const mappingContent = mapping.map(m => `${m.oldName} ${m.newNumber}`).join('\n');
|
|
264
|
+
fs.writeFileSync(mappingFile, mappingContent, 'utf8');
|
|
265
|
+
|
|
266
|
+
console.log(`\n📊 Summary:`);
|
|
267
|
+
console.log(` ✅ Success: ${successCount}`);
|
|
268
|
+
console.log(` ❌ Failed: ${taskFiles.length - successCount}`);
|
|
269
|
+
console.log(` 📝 Total: ${taskFiles.length}`);
|
|
270
|
+
|
|
271
|
+
return mapping;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Update epic file with GitHub URL and task references
|
|
276
|
+
*/
|
|
277
|
+
function updateEpicFile(epicPath, epicIssueNumber, taskMapping) {
|
|
278
|
+
const epicFile = path.join(process.cwd(), '.claude/epics', epicPath, 'epic.md');
|
|
279
|
+
|
|
280
|
+
if (!fs.existsSync(epicFile)) {
|
|
281
|
+
throw new Error(`Epic file not found: ${epicFile}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
console.log(`\n📄 Updating epic file: ${epicFile}`);
|
|
285
|
+
|
|
286
|
+
const repo = getRepoInfo();
|
|
287
|
+
const epicUrl = `https://github.com/${repo}/issues/${epicIssueNumber}`;
|
|
288
|
+
|
|
289
|
+
// Read current content
|
|
290
|
+
let content = fs.readFileSync(epicFile, 'utf8');
|
|
291
|
+
|
|
292
|
+
// Update frontmatter
|
|
293
|
+
content = updateFrontmatter(content, {
|
|
294
|
+
github: epicUrl,
|
|
295
|
+
updated: getTimestamp()
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Update task references in body
|
|
299
|
+
if (taskMapping && taskMapping.length > 0) {
|
|
300
|
+
console.log(' Updating task references...');
|
|
301
|
+
|
|
302
|
+
for (const { oldName, newNumber } of taskMapping) {
|
|
303
|
+
// Update checkbox items
|
|
304
|
+
content = content.replace(
|
|
305
|
+
new RegExp(`- \\[ \\] ${oldName}\\b`, 'g'),
|
|
306
|
+
`- [ ] #${newNumber}`
|
|
307
|
+
);
|
|
308
|
+
content = content.replace(
|
|
309
|
+
new RegExp(`- \\[x\\] ${oldName}\\b`, 'g'),
|
|
310
|
+
`- [x] #${newNumber}`
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Update task links
|
|
314
|
+
content = content.replace(
|
|
315
|
+
new RegExp(`Task ${oldName}\\b`, 'g'),
|
|
316
|
+
`Task #${newNumber}`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Write updated content
|
|
322
|
+
fs.writeFileSync(epicFile, content, 'utf8');
|
|
323
|
+
|
|
324
|
+
console.log('✅ Epic file updated');
|
|
325
|
+
console.log(` GitHub: ${epicUrl}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Update task files with GitHub URLs and rename to issue numbers
|
|
330
|
+
*/
|
|
331
|
+
function updateTaskReferences(epicPath, taskMapping) {
|
|
332
|
+
const epicDir = path.join(process.cwd(), '.claude/epics', epicPath);
|
|
333
|
+
|
|
334
|
+
if (!fs.existsSync(epicDir)) {
|
|
335
|
+
throw new Error(`Epic directory not found: ${epicDir}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(`\n🔗 Updating task references and renaming files`);
|
|
339
|
+
|
|
340
|
+
const repo = getRepoInfo();
|
|
341
|
+
|
|
342
|
+
for (const { oldName, newNumber } of taskMapping) {
|
|
343
|
+
const oldFile = path.join(epicDir, `${oldName}.md`);
|
|
344
|
+
const newFile = path.join(epicDir, `${newNumber}.md`);
|
|
345
|
+
|
|
346
|
+
if (!fs.existsSync(oldFile)) {
|
|
347
|
+
console.log(` ⚠️ File not found: ${oldName}.md (skipping)`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log(` Renaming ${oldName}.md → ${newNumber}.md...`);
|
|
352
|
+
|
|
353
|
+
// Read and update content
|
|
354
|
+
let content = fs.readFileSync(oldFile, 'utf8');
|
|
355
|
+
|
|
356
|
+
const githubUrl = `https://github.com/${repo}/issues/${newNumber}`;
|
|
357
|
+
content = updateFrontmatter(content, {
|
|
358
|
+
github: githubUrl,
|
|
359
|
+
updated: getTimestamp()
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Write to new file
|
|
363
|
+
fs.writeFileSync(newFile, content, 'utf8');
|
|
364
|
+
|
|
365
|
+
// Remove old file
|
|
366
|
+
fs.unlinkSync(oldFile);
|
|
367
|
+
|
|
368
|
+
console.log(` ✓`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
console.log('\n✅ Task files renamed and frontmatter updated');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Full epic sync workflow
|
|
376
|
+
*/
|
|
377
|
+
function syncEpic(epicPath) {
|
|
378
|
+
console.log(`🚀 Starting full epic sync: ${epicPath}\n`);
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
// Step 1: Create epic issue
|
|
382
|
+
const epicIssueNumber = createEpicIssue(epicPath);
|
|
383
|
+
|
|
384
|
+
// Step 2: Create task issues
|
|
385
|
+
const taskMapping = createTaskIssues(epicPath, epicIssueNumber);
|
|
386
|
+
|
|
387
|
+
// Step 3: Update epic file
|
|
388
|
+
updateEpicFile(epicPath, epicIssueNumber, taskMapping);
|
|
389
|
+
|
|
390
|
+
// Step 4: Update task references
|
|
391
|
+
updateTaskReferences(epicPath, taskMapping);
|
|
392
|
+
|
|
393
|
+
console.log(`\n✅ Epic sync complete!`);
|
|
394
|
+
console.log(` Epic: #${epicIssueNumber}`);
|
|
395
|
+
console.log(` Tasks: ${taskMapping.length} created and synced`);
|
|
396
|
+
|
|
397
|
+
return { epicIssueNumber, taskMapping };
|
|
398
|
+
} catch (error) {
|
|
399
|
+
console.error(`\n❌ Epic sync failed: ${error.message}`);
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* CLI interface
|
|
406
|
+
*/
|
|
407
|
+
function main() {
|
|
408
|
+
const args = process.argv.slice(2);
|
|
409
|
+
const command = args[0];
|
|
410
|
+
|
|
411
|
+
if (!command) {
|
|
412
|
+
console.log('Usage:');
|
|
413
|
+
console.log(' epicSync.js sync <epic-path> - Full sync workflow');
|
|
414
|
+
console.log(' epicSync.js create-epic <epic-path> - Create epic issue only');
|
|
415
|
+
console.log(' epicSync.js create-tasks <epic-path> <epic-number> - Create task issues only');
|
|
416
|
+
console.log(' epicSync.js update-epic <epic-path> <epic-number> - Update epic file only');
|
|
417
|
+
console.log('');
|
|
418
|
+
console.log('Examples:');
|
|
419
|
+
console.log(' epicSync.js sync fullstack/01-infrastructure');
|
|
420
|
+
console.log(' epicSync.js create-epic fullstack/01-infrastructure');
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
switch (command) {
|
|
425
|
+
case 'sync': {
|
|
426
|
+
const epicPath = args[1];
|
|
427
|
+
if (!epicPath) {
|
|
428
|
+
console.error('Error: epic-path required');
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
syncEpic(epicPath);
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
case 'create-epic': {
|
|
436
|
+
const epicPath = args[1];
|
|
437
|
+
if (!epicPath) {
|
|
438
|
+
console.error('Error: epic-path required');
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
const epicNumber = createEpicIssue(epicPath);
|
|
442
|
+
console.log(`\nEpic issue number: ${epicNumber}`);
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
case 'create-tasks': {
|
|
447
|
+
const epicPath = args[1];
|
|
448
|
+
const epicNumber = parseInt(args[2]);
|
|
449
|
+
if (!epicPath || !epicNumber) {
|
|
450
|
+
console.error('Error: epic-path and epic-number required');
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
createTaskIssues(epicPath, epicNumber);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
case 'update-epic': {
|
|
458
|
+
const epicPath = args[1];
|
|
459
|
+
const epicNumber = parseInt(args[2]);
|
|
460
|
+
if (!epicPath || !epicNumber) {
|
|
461
|
+
console.error('Error: epic-path and epic-number required');
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
// Load mapping file
|
|
465
|
+
const mappingFile = path.join(process.cwd(), '.claude/epics', epicPath, '.task-mapping.txt');
|
|
466
|
+
let mapping = [];
|
|
467
|
+
if (fs.existsSync(mappingFile)) {
|
|
468
|
+
const content = fs.readFileSync(mappingFile, 'utf8');
|
|
469
|
+
mapping = content.split('\n')
|
|
470
|
+
.filter(line => line.trim())
|
|
471
|
+
.map(line => {
|
|
472
|
+
const [oldName, newNumber] = line.split(' ');
|
|
473
|
+
return { oldName, newNumber: parseInt(newNumber) };
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
updateEpicFile(epicPath, epicNumber, mapping);
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
default:
|
|
481
|
+
console.error(`Unknown command: ${command}`);
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (require.main === module) {
|
|
487
|
+
main();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
module.exports = {
|
|
491
|
+
parseMarkdownFile,
|
|
492
|
+
updateFrontmatter,
|
|
493
|
+
createEpicIssue,
|
|
494
|
+
createTaskIssues,
|
|
495
|
+
updateEpicFile,
|
|
496
|
+
updateTaskReferences,
|
|
497
|
+
syncEpic
|
|
498
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Epic Sync Tasks - Create GitHub issues for all tasks in an epic
|
|
4
|
+
*
|
|
5
|
+
* Modern Node.js replacement for bash scripts that had parsing issues.
|
|
6
|
+
* Uses simple, testable code instead of complex shell heredocs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { execSync } = require('child_process');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse task file frontmatter and content
|
|
15
|
+
*/
|
|
16
|
+
function parseTaskFile(filePath) {
|
|
17
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
18
|
+
const lines = content.split('\n');
|
|
19
|
+
|
|
20
|
+
let inFrontmatter = false;
|
|
21
|
+
let frontmatterLines = [];
|
|
22
|
+
let bodyLines = [];
|
|
23
|
+
let frontmatterCount = 0;
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (line === '---') {
|
|
27
|
+
frontmatterCount++;
|
|
28
|
+
if (frontmatterCount === 1) {
|
|
29
|
+
inFrontmatter = true;
|
|
30
|
+
continue;
|
|
31
|
+
} else if (frontmatterCount === 2) {
|
|
32
|
+
inFrontmatter = false;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (inFrontmatter) {
|
|
38
|
+
frontmatterLines.push(line);
|
|
39
|
+
} else if (frontmatterCount === 2) {
|
|
40
|
+
bodyLines.push(line);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Parse frontmatter
|
|
45
|
+
const frontmatter = {};
|
|
46
|
+
for (const line of frontmatterLines) {
|
|
47
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
48
|
+
if (match) {
|
|
49
|
+
const [, key, value] = match;
|
|
50
|
+
frontmatter[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
frontmatter,
|
|
56
|
+
body: bodyLines.join('\n').trim(),
|
|
57
|
+
title: frontmatter.name || 'Untitled Task'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create GitHub issue for a task
|
|
63
|
+
*/
|
|
64
|
+
function createTaskIssue(taskData, epicIssueNumber, epicPath, taskNumber) {
|
|
65
|
+
const { title, body } = taskData;
|
|
66
|
+
|
|
67
|
+
// Create issue body with epic reference
|
|
68
|
+
const issueBody = `
|
|
69
|
+
Part of Epic #${epicIssueNumber}
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
${body}
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
**Epic Path:** \`${epicPath}\`
|
|
78
|
+
**Task Number:** ${taskNumber}
|
|
79
|
+
`.trim();
|
|
80
|
+
|
|
81
|
+
// Escape quotes for shell
|
|
82
|
+
const escapedTitle = title.replace(/"/g, '\\"');
|
|
83
|
+
const escapedBody = issueBody.replace(/"/g, '\\"').replace(/`/g, '\\`');
|
|
84
|
+
|
|
85
|
+
// Create GitHub issue
|
|
86
|
+
try {
|
|
87
|
+
const result = execSync(
|
|
88
|
+
`gh issue create --title "${escapedTitle}" --body "${escapedBody}" --label "task"`,
|
|
89
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const issueMatch = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/);
|
|
93
|
+
if (issueMatch) {
|
|
94
|
+
return parseInt(issueMatch[1]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error(`❌ Failed to create issue for task ${taskNumber}:`, error.message);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Update task file with GitHub issue number
|
|
106
|
+
*/
|
|
107
|
+
function updateTaskFrontmatter(filePath, issueNumber) {
|
|
108
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
109
|
+
const updatedContent = content.replace(
|
|
110
|
+
/^github:.*$/m,
|
|
111
|
+
`github: "#${issueNumber}"`
|
|
112
|
+
);
|
|
113
|
+
fs.writeFileSync(filePath, updatedContent, 'utf8');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Main function
|
|
118
|
+
*/
|
|
119
|
+
function main() {
|
|
120
|
+
const args = process.argv.slice(2);
|
|
121
|
+
|
|
122
|
+
if (args.length < 2) {
|
|
123
|
+
console.error('Usage: epicSyncTasks.js <epic-path> <epic-issue-number>');
|
|
124
|
+
console.error('Example: epicSyncTasks.js fullstack/01-infrastructure 2');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const [epicPath, epicIssueNumber] = args;
|
|
129
|
+
const epicDir = path.join(process.cwd(), '.claude/epics', epicPath);
|
|
130
|
+
|
|
131
|
+
if (!fs.existsSync(epicDir)) {
|
|
132
|
+
console.error(`❌ Epic directory not found: ${epicDir}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Find all task files (numbered .md files)
|
|
137
|
+
const taskFiles = fs.readdirSync(epicDir)
|
|
138
|
+
.filter(f => /^\d+\.md$/.test(f))
|
|
139
|
+
.sort();
|
|
140
|
+
|
|
141
|
+
if (taskFiles.length === 0) {
|
|
142
|
+
console.error(`❌ No task files found in ${epicDir}`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`📋 Creating ${taskFiles.length} task issues for epic #${epicIssueNumber}`);
|
|
147
|
+
console.log(`📂 Epic path: ${epicPath}\n`);
|
|
148
|
+
|
|
149
|
+
let successCount = 0;
|
|
150
|
+
let failCount = 0;
|
|
151
|
+
|
|
152
|
+
for (const taskFile of taskFiles) {
|
|
153
|
+
const taskNumber = taskFile.replace('.md', '');
|
|
154
|
+
const taskPath = path.join(epicDir, taskFile);
|
|
155
|
+
|
|
156
|
+
console.log(`[${successCount + failCount + 1}/${taskFiles.length}] Creating issue for task ${taskNumber}...`);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const taskData = parseTaskFile(taskPath);
|
|
160
|
+
const issueNumber = createTaskIssue(taskData, epicIssueNumber, epicPath, taskNumber);
|
|
161
|
+
|
|
162
|
+
if (issueNumber) {
|
|
163
|
+
updateTaskFrontmatter(taskPath, issueNumber);
|
|
164
|
+
console.log(` ✅ Created issue #${issueNumber}: ${taskData.title}`);
|
|
165
|
+
successCount++;
|
|
166
|
+
} else {
|
|
167
|
+
console.log(` ⚠️ Issue created but number not found`);
|
|
168
|
+
failCount++;
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(` ❌ Error: ${error.message}`);
|
|
172
|
+
failCount++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log(`\n📊 Summary:`);
|
|
177
|
+
console.log(` ✅ Success: ${successCount}`);
|
|
178
|
+
console.log(` ❌ Failed: ${failCount}`);
|
|
179
|
+
console.log(` 📝 Total: ${taskFiles.length}`);
|
|
180
|
+
|
|
181
|
+
if (failCount > 0) {
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (require.main === module) {
|
|
187
|
+
main();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { parseTaskFile, createTaskIssue, updateTaskFrontmatter };
|
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Issue Sync - Complete GitHub issue synchronization
|
|
4
|
+
*
|
|
5
|
+
* Replaces 5 Bash scripts with clean, testable JavaScript:
|
|
6
|
+
* - gather-updates.sh → gatherUpdates()
|
|
7
|
+
* - format-comment.sh → formatComment()
|
|
8
|
+
* - post-comment.sh → postComment()
|
|
9
|
+
* - update-frontmatter.sh → updateFrontmatter()
|
|
10
|
+
* - preflight-validation.sh → preflightValidation()
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { execSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse frontmatter from markdown file
|
|
19
|
+
*/
|
|
20
|
+
function parseFrontmatter(filePath) {
|
|
21
|
+
if (!fs.existsSync(filePath)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
26
|
+
const lines = content.split('\n');
|
|
27
|
+
|
|
28
|
+
let inFrontmatter = false;
|
|
29
|
+
let frontmatterCount = 0;
|
|
30
|
+
const frontmatter = {};
|
|
31
|
+
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
if (line === '---') {
|
|
34
|
+
frontmatterCount++;
|
|
35
|
+
if (frontmatterCount === 1) {
|
|
36
|
+
inFrontmatter = true;
|
|
37
|
+
} else if (frontmatterCount === 2) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (inFrontmatter) {
|
|
44
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
45
|
+
if (match) {
|
|
46
|
+
const [, key, value] = match;
|
|
47
|
+
frontmatter[key] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return frontmatter;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Update frontmatter field
|
|
57
|
+
*/
|
|
58
|
+
function updateFrontmatterField(filePath, field, value) {
|
|
59
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
60
|
+
const lines = content.split('\n');
|
|
61
|
+
const result = [];
|
|
62
|
+
|
|
63
|
+
let inFrontmatter = false;
|
|
64
|
+
let frontmatterCount = 0;
|
|
65
|
+
let fieldUpdated = false;
|
|
66
|
+
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (line === '---') {
|
|
69
|
+
frontmatterCount++;
|
|
70
|
+
result.push(line);
|
|
71
|
+
if (frontmatterCount === 1) {
|
|
72
|
+
inFrontmatter = true;
|
|
73
|
+
} else if (frontmatterCount === 2) {
|
|
74
|
+
inFrontmatter = false;
|
|
75
|
+
// Add field if not found
|
|
76
|
+
if (!fieldUpdated && inFrontmatter === false) {
|
|
77
|
+
result.splice(result.length - 1, 0, `${field}: ${value}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (inFrontmatter) {
|
|
84
|
+
const match = line.match(/^(\w+):/);
|
|
85
|
+
if (match && match[1] === field) {
|
|
86
|
+
result.push(`${field}: ${value}`);
|
|
87
|
+
fieldUpdated = true;
|
|
88
|
+
} else {
|
|
89
|
+
result.push(line);
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
result.push(line);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(filePath, result.join('\n'), 'utf8');
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get current ISO timestamp
|
|
102
|
+
*/
|
|
103
|
+
function getTimestamp() {
|
|
104
|
+
return new Date().toISOString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Strip frontmatter and return body only
|
|
109
|
+
*/
|
|
110
|
+
function stripFrontmatter(filePath) {
|
|
111
|
+
if (!fs.existsSync(filePath)) {
|
|
112
|
+
return '';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
116
|
+
const lines = content.split('\n');
|
|
117
|
+
|
|
118
|
+
let frontmatterCount = 0;
|
|
119
|
+
const bodyLines = [];
|
|
120
|
+
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (line === '---') {
|
|
123
|
+
frontmatterCount++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (frontmatterCount >= 2) {
|
|
128
|
+
bodyLines.push(line);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return bodyLines.join('\n').trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Gather updates from various sources
|
|
137
|
+
*/
|
|
138
|
+
function gatherUpdates(issueNumber, updatesDir, lastSync = null) {
|
|
139
|
+
console.log(`\n📋 Gathering updates for issue #${issueNumber}`);
|
|
140
|
+
|
|
141
|
+
if (!fs.existsSync(updatesDir)) {
|
|
142
|
+
throw new Error(`Updates directory not found: ${updatesDir}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const updates = {
|
|
146
|
+
progress: '',
|
|
147
|
+
notes: '',
|
|
148
|
+
commits: '',
|
|
149
|
+
acceptanceCriteria: '',
|
|
150
|
+
nextSteps: '',
|
|
151
|
+
blockers: ''
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Gather progress
|
|
155
|
+
const progressFile = path.join(updatesDir, 'progress.md');
|
|
156
|
+
if (fs.existsSync(progressFile)) {
|
|
157
|
+
updates.progress = stripFrontmatter(progressFile);
|
|
158
|
+
const frontmatter = parseFrontmatter(progressFile);
|
|
159
|
+
if (frontmatter && frontmatter.completion) {
|
|
160
|
+
updates.progress += `\n\n**Current Progress:** ${frontmatter.completion}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Gather notes
|
|
165
|
+
const notesFile = path.join(updatesDir, 'notes.md');
|
|
166
|
+
if (fs.existsSync(notesFile)) {
|
|
167
|
+
updates.notes = stripFrontmatter(notesFile);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Gather commits
|
|
171
|
+
const commitsFile = path.join(updatesDir, 'commits.md');
|
|
172
|
+
if (fs.existsSync(commitsFile)) {
|
|
173
|
+
updates.commits = stripFrontmatter(commitsFile);
|
|
174
|
+
} else {
|
|
175
|
+
// Auto-gather recent commits
|
|
176
|
+
updates.commits = gatherRecentCommits(lastSync);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Gather acceptance criteria
|
|
180
|
+
const criteriaFile = path.join(updatesDir, 'acceptance-criteria.md');
|
|
181
|
+
if (fs.existsSync(criteriaFile)) {
|
|
182
|
+
updates.acceptanceCriteria = stripFrontmatter(criteriaFile);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Gather next steps
|
|
186
|
+
const nextStepsFile = path.join(updatesDir, 'next-steps.md');
|
|
187
|
+
if (fs.existsSync(nextStepsFile)) {
|
|
188
|
+
updates.nextSteps = stripFrontmatter(nextStepsFile);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Gather blockers
|
|
192
|
+
const blockersFile = path.join(updatesDir, 'blockers.md');
|
|
193
|
+
if (fs.existsSync(blockersFile)) {
|
|
194
|
+
updates.blockers = stripFrontmatter(blockersFile);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log('✅ Updates gathered');
|
|
198
|
+
return updates;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Auto-gather recent commits
|
|
203
|
+
*/
|
|
204
|
+
function gatherRecentCommits(since = null) {
|
|
205
|
+
try {
|
|
206
|
+
const sinceArg = since || '24 hours ago';
|
|
207
|
+
const result = execSync(
|
|
208
|
+
`git log --since="${sinceArg}" --oneline --no-merges`,
|
|
209
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (result.trim()) {
|
|
213
|
+
const commits = result.trim().split('\n').map(line => `- ${line}`);
|
|
214
|
+
return `**Recent Commits:**\n${commits.join('\n')}`;
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
// Git not available or no commits
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return 'No recent commits found';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Format comment from gathered updates
|
|
225
|
+
*/
|
|
226
|
+
function formatComment(issueNumber, updates, isCompletion = false) {
|
|
227
|
+
console.log(`\n📝 Formatting comment for issue #${issueNumber}`);
|
|
228
|
+
|
|
229
|
+
const sections = [];
|
|
230
|
+
|
|
231
|
+
if (isCompletion) {
|
|
232
|
+
sections.push('# ✅ Task Completed\n');
|
|
233
|
+
sections.push(`*Completed at: ${getTimestamp()}*\n`);
|
|
234
|
+
} else {
|
|
235
|
+
sections.push('# 📊 Progress Update\n');
|
|
236
|
+
sections.push(`*Updated at: ${getTimestamp()}*\n`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Add sections with content
|
|
240
|
+
if (updates.progress && !updates.progress.includes('No progress')) {
|
|
241
|
+
sections.push('## Progress Updates\n');
|
|
242
|
+
sections.push(updates.progress + '\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (updates.notes && !updates.notes.includes('No technical notes')) {
|
|
246
|
+
sections.push('## Technical Notes\n');
|
|
247
|
+
sections.push(updates.notes + '\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (updates.commits && !updates.commits.includes('No recent commits')) {
|
|
251
|
+
sections.push('## Recent Commits\n');
|
|
252
|
+
sections.push(updates.commits + '\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (updates.acceptanceCriteria && !updates.acceptanceCriteria.includes('No acceptance')) {
|
|
256
|
+
sections.push('## Acceptance Criteria\n');
|
|
257
|
+
sections.push(updates.acceptanceCriteria + '\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (updates.nextSteps && !updates.nextSteps.includes('No specific next steps')) {
|
|
261
|
+
sections.push('## Next Steps\n');
|
|
262
|
+
sections.push(updates.nextSteps + '\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (updates.blockers && !updates.blockers.includes('No current blockers')) {
|
|
266
|
+
sections.push('## Blockers\n');
|
|
267
|
+
sections.push(updates.blockers + '\n');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const comment = sections.join('\n');
|
|
271
|
+
console.log('✅ Comment formatted');
|
|
272
|
+
|
|
273
|
+
return comment;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Post comment to GitHub issue
|
|
278
|
+
*/
|
|
279
|
+
function postComment(issueNumber, comment, isDryRun = false) {
|
|
280
|
+
console.log(`\n💬 Posting comment to issue #${issueNumber}`);
|
|
281
|
+
|
|
282
|
+
if (isDryRun) {
|
|
283
|
+
console.log('🔸 DRY RUN - Comment preview:');
|
|
284
|
+
console.log(comment.split('\n').slice(0, 20).join('\n'));
|
|
285
|
+
console.log('...');
|
|
286
|
+
return 'https://github.com/DRYRUN/issues/' + issueNumber + '#issuecomment-DRYRUN';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Write comment to temp file
|
|
290
|
+
const tempFile = `/tmp/issue-comment-${issueNumber}-${Date.now()}.md`;
|
|
291
|
+
fs.writeFileSync(tempFile, comment, 'utf8');
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Post via gh CLI
|
|
295
|
+
const result = execSync(
|
|
296
|
+
`gh issue comment ${issueNumber} --body-file "${tempFile}"`,
|
|
297
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Extract URL from result
|
|
301
|
+
const match = result.match(/https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+#issuecomment-\d+/);
|
|
302
|
+
if (match) {
|
|
303
|
+
const url = match[0];
|
|
304
|
+
console.log(`✅ Comment posted: ${url}`);
|
|
305
|
+
|
|
306
|
+
// Cleanup temp file
|
|
307
|
+
fs.unlinkSync(tempFile);
|
|
308
|
+
|
|
309
|
+
return url;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
console.log('⚠️ Comment posted but URL not found in response');
|
|
313
|
+
return null;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error(`❌ Failed to post comment: ${error.message}`);
|
|
316
|
+
console.log(`Temp comment file preserved: ${tempFile}`);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Update frontmatter after successful sync
|
|
323
|
+
*/
|
|
324
|
+
function updateFrontmatterAfterSync(progressFile, commentUrl, isCompletion = false) {
|
|
325
|
+
console.log(`\n📄 Updating frontmatter: ${progressFile}`);
|
|
326
|
+
|
|
327
|
+
if (!fs.existsSync(progressFile)) {
|
|
328
|
+
throw new Error(`Progress file not found: ${progressFile}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Create backup
|
|
332
|
+
const backupFile = `${progressFile}.backup.${Date.now()}`;
|
|
333
|
+
fs.copyFileSync(progressFile, backupFile);
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
// Update last_sync
|
|
337
|
+
updateFrontmatterField(progressFile, 'last_sync', getTimestamp());
|
|
338
|
+
|
|
339
|
+
// Update comment URL if provided
|
|
340
|
+
if (commentUrl) {
|
|
341
|
+
updateFrontmatterField(progressFile, 'last_comment_url', commentUrl);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Update completion if needed
|
|
345
|
+
if (isCompletion) {
|
|
346
|
+
updateFrontmatterField(progressFile, 'completion', '100');
|
|
347
|
+
updateFrontmatterField(progressFile, 'status', 'completed');
|
|
348
|
+
updateFrontmatterField(progressFile, 'completed_at', getTimestamp());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Update issue state from GitHub
|
|
352
|
+
try {
|
|
353
|
+
const issueState = execSync('gh issue view --json state -q .state', {
|
|
354
|
+
encoding: 'utf8',
|
|
355
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
356
|
+
}).trim();
|
|
357
|
+
|
|
358
|
+
if (issueState) {
|
|
359
|
+
updateFrontmatterField(progressFile, 'issue_state', issueState);
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// GitHub CLI not available or issue not found
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
console.log('✅ Frontmatter updated');
|
|
366
|
+
|
|
367
|
+
// Cleanup old backups (keep last 5)
|
|
368
|
+
cleanupOldBackups(progressFile, 5);
|
|
369
|
+
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error('❌ Frontmatter update failed, restoring backup');
|
|
372
|
+
fs.copyFileSync(backupFile, progressFile);
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Cleanup old backup files
|
|
379
|
+
*/
|
|
380
|
+
function cleanupOldBackups(originalFile, keepCount = 5) {
|
|
381
|
+
const dir = path.dirname(originalFile);
|
|
382
|
+
const basename = path.basename(originalFile);
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const backups = fs.readdirSync(dir)
|
|
386
|
+
.filter(f => f.startsWith(basename + '.backup.'))
|
|
387
|
+
.map(f => ({
|
|
388
|
+
name: f,
|
|
389
|
+
path: path.join(dir, f),
|
|
390
|
+
time: fs.statSync(path.join(dir, f)).mtime.getTime()
|
|
391
|
+
}))
|
|
392
|
+
.sort((a, b) => b.time - a.time);
|
|
393
|
+
|
|
394
|
+
// Remove old backups
|
|
395
|
+
for (let i = keepCount; i < backups.length; i++) {
|
|
396
|
+
fs.unlinkSync(backups[i].path);
|
|
397
|
+
}
|
|
398
|
+
} catch (error) {
|
|
399
|
+
// Ignore cleanup errors
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Preflight validation
|
|
405
|
+
*/
|
|
406
|
+
function preflightValidation(issueNumber, updatesDir) {
|
|
407
|
+
console.log(`\n🔍 Preflight validation for issue #${issueNumber}`);
|
|
408
|
+
|
|
409
|
+
const errors = [];
|
|
410
|
+
|
|
411
|
+
// Check GitHub auth
|
|
412
|
+
try {
|
|
413
|
+
execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
414
|
+
} catch (error) {
|
|
415
|
+
errors.push('GitHub CLI not authenticated. Run: gh auth login');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Check if issue exists
|
|
419
|
+
try {
|
|
420
|
+
const state = execSync(`gh issue view ${issueNumber} --json state -q .state`, {
|
|
421
|
+
encoding: 'utf8',
|
|
422
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
423
|
+
}).trim();
|
|
424
|
+
|
|
425
|
+
if (state === 'CLOSED') {
|
|
426
|
+
console.log(`⚠️ Issue #${issueNumber} is closed`);
|
|
427
|
+
}
|
|
428
|
+
} catch (error) {
|
|
429
|
+
errors.push(`Issue #${issueNumber} not found`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Check updates directory
|
|
433
|
+
if (!fs.existsSync(updatesDir)) {
|
|
434
|
+
errors.push(`Updates directory not found: ${updatesDir}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (errors.length > 0) {
|
|
438
|
+
console.error('\n❌ Preflight validation failed:');
|
|
439
|
+
errors.forEach(err => console.error(` - ${err}`));
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
console.log('✅ Preflight validation passed');
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Full sync workflow
|
|
449
|
+
*/
|
|
450
|
+
function syncIssue(issueNumber, updatesDir, isCompletion = false, isDryRun = false) {
|
|
451
|
+
console.log(`\n🚀 Starting issue sync: #${issueNumber}`);
|
|
452
|
+
|
|
453
|
+
// Preflight validation
|
|
454
|
+
if (!preflightValidation(issueNumber, updatesDir)) {
|
|
455
|
+
throw new Error('Preflight validation failed');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Gather updates
|
|
459
|
+
const progressFile = path.join(updatesDir, 'progress.md');
|
|
460
|
+
const frontmatter = parseFrontmatter(progressFile);
|
|
461
|
+
const lastSync = frontmatter ? frontmatter.last_sync : null;
|
|
462
|
+
|
|
463
|
+
const updates = gatherUpdates(issueNumber, updatesDir, lastSync);
|
|
464
|
+
|
|
465
|
+
// Format comment
|
|
466
|
+
const comment = formatComment(issueNumber, updates, isCompletion);
|
|
467
|
+
|
|
468
|
+
// Post comment
|
|
469
|
+
const commentUrl = postComment(issueNumber, comment, isDryRun);
|
|
470
|
+
|
|
471
|
+
// Update frontmatter
|
|
472
|
+
if (!isDryRun && fs.existsSync(progressFile)) {
|
|
473
|
+
updateFrontmatterAfterSync(progressFile, commentUrl, isCompletion);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
console.log(`\n✅ Issue sync complete!`);
|
|
477
|
+
console.log(` Issue: #${issueNumber}`);
|
|
478
|
+
if (commentUrl) {
|
|
479
|
+
console.log(` Comment: ${commentUrl}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { commentUrl, updates };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* CLI interface
|
|
487
|
+
*/
|
|
488
|
+
function main() {
|
|
489
|
+
const args = process.argv.slice(2);
|
|
490
|
+
const command = args[0];
|
|
491
|
+
|
|
492
|
+
if (!command) {
|
|
493
|
+
console.log('Usage:');
|
|
494
|
+
console.log(' issueSync.js sync <issue-number> <updates-dir> [--complete] [--dry-run]');
|
|
495
|
+
console.log(' issueSync.js gather <issue-number> <updates-dir>');
|
|
496
|
+
console.log(' issueSync.js format <issue-number> <updates-dir> [--complete]');
|
|
497
|
+
console.log(' issueSync.js post <issue-number> <comment-file> [--dry-run]');
|
|
498
|
+
console.log(' issueSync.js update <progress-file> <comment-url> [--complete]');
|
|
499
|
+
console.log('');
|
|
500
|
+
console.log('Examples:');
|
|
501
|
+
console.log(' issueSync.js sync 123 .claude/epics/auth/updates/123');
|
|
502
|
+
console.log(' issueSync.js sync 456 ./updates --complete');
|
|
503
|
+
console.log(' issueSync.js sync 789 ./updates --dry-run');
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const issueNumber = args[1];
|
|
508
|
+
const isComplete = args.includes('--complete');
|
|
509
|
+
const isDryRun = args.includes('--dry-run');
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
switch (command) {
|
|
513
|
+
case 'sync': {
|
|
514
|
+
const updatesDir = args[2];
|
|
515
|
+
if (!issueNumber || !updatesDir) {
|
|
516
|
+
console.error('Error: issue-number and updates-dir required');
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
syncIssue(issueNumber, updatesDir, isComplete, isDryRun);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
case 'gather': {
|
|
524
|
+
const updatesDir = args[2];
|
|
525
|
+
if (!issueNumber || !updatesDir) {
|
|
526
|
+
console.error('Error: issue-number and updates-dir required');
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
const updates = gatherUpdates(issueNumber, updatesDir);
|
|
530
|
+
console.log(JSON.stringify(updates, null, 2));
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
case 'format': {
|
|
535
|
+
const updatesDir = args[2];
|
|
536
|
+
if (!issueNumber || !updatesDir) {
|
|
537
|
+
console.error('Error: issue-number and updates-dir required');
|
|
538
|
+
process.exit(1);
|
|
539
|
+
}
|
|
540
|
+
const updates = gatherUpdates(issueNumber, updatesDir);
|
|
541
|
+
const comment = formatComment(issueNumber, updates, isComplete);
|
|
542
|
+
console.log(comment);
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
case 'post': {
|
|
547
|
+
const commentFile = args[2];
|
|
548
|
+
if (!issueNumber || !commentFile) {
|
|
549
|
+
console.error('Error: issue-number and comment-file required');
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
const comment = fs.readFileSync(commentFile, 'utf8');
|
|
553
|
+
const url = postComment(issueNumber, comment, isDryRun);
|
|
554
|
+
console.log(url);
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
case 'update': {
|
|
559
|
+
const progressFile = args[2];
|
|
560
|
+
const commentUrl = args[3];
|
|
561
|
+
if (!progressFile) {
|
|
562
|
+
console.error('Error: progress-file required');
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
updateFrontmatterAfterSync(progressFile, commentUrl, isComplete);
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
default:
|
|
570
|
+
console.error(`Unknown command: ${command}`);
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
console.error(`\n❌ Error: ${error.message}`);
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (require.main === module) {
|
|
580
|
+
main();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
module.exports = {
|
|
584
|
+
parseFrontmatter,
|
|
585
|
+
updateFrontmatterField,
|
|
586
|
+
stripFrontmatter,
|
|
587
|
+
gatherUpdates,
|
|
588
|
+
formatComment,
|
|
589
|
+
postComment,
|
|
590
|
+
updateFrontmatterAfterSync,
|
|
591
|
+
preflightValidation,
|
|
592
|
+
syncIssue
|
|
593
|
+
};
|