specweave 0.28.19 → 0.28.22

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.
Files changed (96) hide show
  1. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts +16 -0
  2. package/dist/plugins/specweave-ado/lib/ado-spec-sync.d.ts.map +1 -1
  3. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +63 -3
  4. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
  5. package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts +12 -3
  6. package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts.map +1 -1
  7. package/dist/plugins/specweave-ado/lib/ado-status-sync.js +37 -3
  8. package/dist/plugins/specweave-ado/lib/ado-status-sync.js.map +1 -1
  9. package/dist/plugins/specweave-github/lib/github-increment-sync-cli.d.ts +8 -6
  10. package/dist/plugins/specweave-github/lib/github-increment-sync-cli.d.ts.map +1 -1
  11. package/dist/plugins/specweave-github/lib/github-increment-sync-cli.js +230 -165
  12. package/dist/plugins/specweave-github/lib/github-increment-sync-cli.js.map +1 -1
  13. package/dist/plugins/specweave-github/lib/github-status-sync.d.ts +10 -0
  14. package/dist/plugins/specweave-github/lib/github-status-sync.d.ts.map +1 -1
  15. package/dist/plugins/specweave-github/lib/github-status-sync.js +40 -2
  16. package/dist/plugins/specweave-github/lib/github-status-sync.js.map +1 -1
  17. package/dist/plugins/specweave-github/lib/increment-issue-builder.d.ts +2 -0
  18. package/dist/plugins/specweave-github/lib/increment-issue-builder.d.ts.map +1 -1
  19. package/dist/plugins/specweave-github/lib/increment-issue-builder.js +25 -5
  20. package/dist/plugins/specweave-github/lib/increment-issue-builder.js.map +1 -1
  21. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts +12 -0
  22. package/dist/plugins/specweave-jira/lib/jira-spec-sync.d.ts.map +1 -1
  23. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +57 -5
  24. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
  25. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +5 -1
  26. package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -1
  27. package/dist/plugins/specweave-jira/lib/jira-status-sync.js +12 -4
  28. package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -1
  29. package/dist/src/cli/commands/init.d.ts.map +1 -1
  30. package/dist/src/cli/commands/init.js +7 -3
  31. package/dist/src/cli/commands/init.js.map +1 -1
  32. package/dist/src/cli/helpers/init/external-import.d.ts.map +1 -1
  33. package/dist/src/cli/helpers/init/external-import.js +193 -23
  34. package/dist/src/cli/helpers/init/external-import.js.map +1 -1
  35. package/dist/src/cli/helpers/init/jira-ado-auto-detect.d.ts +115 -0
  36. package/dist/src/cli/helpers/init/jira-ado-auto-detect.d.ts.map +1 -0
  37. package/dist/src/cli/helpers/init/jira-ado-auto-detect.js +590 -0
  38. package/dist/src/cli/helpers/init/jira-ado-auto-detect.js.map +1 -0
  39. package/dist/src/cli/helpers/init/language-selection.js +1 -1
  40. package/dist/src/cli/helpers/init/language-selection.js.map +1 -1
  41. package/dist/src/cli/helpers/init/repository-setup.js +1 -1
  42. package/dist/src/cli/helpers/init/repository-setup.js.map +1 -1
  43. package/dist/src/config/types.d.ts +6 -6
  44. package/dist/src/core/background/index.d.ts +11 -0
  45. package/dist/src/core/background/index.d.ts.map +1 -0
  46. package/dist/src/core/background/index.js +11 -0
  47. package/dist/src/core/background/index.js.map +1 -0
  48. package/dist/src/core/background/job-manager.d.ts +65 -0
  49. package/dist/src/core/background/job-manager.d.ts.map +1 -0
  50. package/dist/src/core/background/job-manager.js +192 -0
  51. package/dist/src/core/background/job-manager.js.map +1 -0
  52. package/dist/src/core/background/types.d.ts +59 -0
  53. package/dist/src/core/background/types.d.ts.map +1 -0
  54. package/dist/src/core/background/types.js +8 -0
  55. package/dist/src/core/background/types.js.map +1 -0
  56. package/dist/src/core/repo-structure/multi-repo-configurator.d.ts +25 -0
  57. package/dist/src/core/repo-structure/multi-repo-configurator.d.ts.map +1 -0
  58. package/dist/src/core/repo-structure/multi-repo-configurator.js +614 -0
  59. package/dist/src/core/repo-structure/multi-repo-configurator.js.map +1 -0
  60. package/dist/src/core/repo-structure/prompt-consolidator.d.ts.map +1 -1
  61. package/dist/src/core/repo-structure/prompt-consolidator.js +6 -36
  62. package/dist/src/core/repo-structure/prompt-consolidator.js.map +1 -1
  63. package/dist/src/core/repo-structure/repo-initializer.d.ts +40 -0
  64. package/dist/src/core/repo-structure/repo-initializer.d.ts.map +1 -0
  65. package/dist/src/core/repo-structure/repo-initializer.js +260 -0
  66. package/dist/src/core/repo-structure/repo-initializer.js.map +1 -0
  67. package/dist/src/core/repo-structure/repo-structure-manager.d.ts +3 -37
  68. package/dist/src/core/repo-structure/repo-structure-manager.d.ts.map +1 -1
  69. package/dist/src/core/repo-structure/repo-structure-manager.js +23 -803
  70. package/dist/src/core/repo-structure/repo-structure-manager.js.map +1 -1
  71. package/dist/src/core/types/spec-metadata.d.ts +2 -0
  72. package/dist/src/core/types/spec-metadata.d.ts.map +1 -1
  73. package/dist/src/importers/import-coordinator.d.ts +20 -0
  74. package/dist/src/importers/import-coordinator.d.ts.map +1 -1
  75. package/dist/src/importers/import-coordinator.js.map +1 -1
  76. package/dist/src/init/architecture/types.d.ts +2 -2
  77. package/dist/src/init/compliance/types.d.ts +1 -1
  78. package/package.json +1 -1
  79. package/plugins/specweave/commands/specweave-jobs.md +160 -0
  80. package/plugins/specweave-ado/lib/ado-spec-sync.js +59 -3
  81. package/plugins/specweave-ado/lib/ado-spec-sync.ts +72 -3
  82. package/plugins/specweave-ado/lib/ado-status-sync.js +35 -3
  83. package/plugins/specweave-ado/lib/ado-status-sync.ts +48 -4
  84. package/plugins/specweave-github/hooks/.specweave/logs/hooks-debug.log +6 -0
  85. package/plugins/specweave-github/lib/github-increment-sync-cli.js +268 -155
  86. package/plugins/specweave-github/lib/github-increment-sync-cli.ts +313 -209
  87. package/plugins/specweave-github/lib/github-status-sync.js +37 -1
  88. package/plugins/specweave-github/lib/github-status-sync.ts +60 -4
  89. package/plugins/specweave-github/lib/increment-issue-builder.js +26 -5
  90. package/plugins/specweave-github/lib/increment-issue-builder.ts +36 -5
  91. package/plugins/specweave-jira/lib/jira-spec-sync.js +53 -5
  92. package/plugins/specweave-jira/lib/jira-spec-sync.ts +87 -7
  93. package/plugins/specweave-jira/lib/jira-status-sync.js +9 -3
  94. package/plugins/specweave-jira/lib/jira-status-sync.ts +15 -6
  95. package/plugins/specweave-release/commands/specweave-release-npm.md +187 -8
  96. package/plugins/specweave-release/hooks/.specweave/logs/dora-tracking.log +9 -0
@@ -3,23 +3,25 @@
3
3
  * GitHub Increment Sync CLI
4
4
  *
5
5
  * For brownfield projects without living docs structure.
6
- * Creates GitHub issues directly from increment spec.md with proper format.
6
+ * Creates GitHub issues directly from increment spec.md with CORRECT format.
7
7
  *
8
- * CORRECT FORMAT:
9
- * - Single issue per increment: [FS-XXX] Increment Title
10
- * - With User Stories and ACs as sections
8
+ * CORRECT FORMAT (SpecWeave Universal Hierarchy):
9
+ * - Feature (FS-XXX) → GitHub Milestone
10
+ * - User Story (US-XXX) → GitHub Issue with [FS-XXX][US-YYY] Title
11
+ * - Tasks (T-XXX) → Checkboxes in User Story issue
12
+ * - ACs → Checkboxes in User Story issue
11
13
  *
12
14
  * Usage:
13
15
  * node github-increment-sync-cli.js <increment-id>
14
- * node github-increment-sync-cli.js 0063-fix-external-import
16
+ * node github-increment-sync-cli.js 0002-thumbnail-optimizer-mvp
15
17
  *
16
- * @see ADR-0143 (GitHub Issue Format)
18
+ * @see CLAUDE.md (GitHub Issue Format rules)
17
19
  */
18
20
 
19
21
  import { existsSync, readFileSync } from 'fs';
20
22
  import * as fs from 'fs/promises';
21
23
  import * as path from 'path';
22
- import { IncrementIssueBuilder } from './increment-issue-builder.js';
24
+ import { IncrementIssueBuilder, UserStory, Task, IncrementData } from './increment-issue-builder.js';
23
25
  import { execFileNoThrow } from '../../../src/utils/execFileNoThrow.js';
24
26
 
25
27
  interface GitHubConfig {
@@ -28,6 +30,17 @@ interface GitHubConfig {
28
30
  token: string;
29
31
  }
30
32
 
33
+ interface SyncResult {
34
+ milestoneNumber: number;
35
+ milestoneUrl: string;
36
+ issues: Array<{
37
+ userStoryId: string;
38
+ issueNumber: number;
39
+ issueUrl: string;
40
+ title: string;
41
+ }>;
42
+ }
43
+
31
44
  async function loadGitHubConfig(): Promise<GitHubConfig | null> {
32
45
  const projectRoot = process.cwd();
33
46
  const configPath = path.join(projectRoot, '.specweave/config.json');
@@ -131,164 +144,272 @@ async function findIncrementFolder(incrementId: string): Promise<string | null>
131
144
  }
132
145
 
133
146
  /**
134
- * Check if GitHub issue already exists for this increment
147
+ * Load existing GitHub links from metadata.json
135
148
  */
136
- async function findExistingIssue(
137
- owner: string,
138
- repo: string,
139
- featureId: string
140
- ): Promise<number | null> {
141
- // Search for issues with this feature ID in title
142
- const result = await execFileNoThrow('gh', [
143
- 'search', 'issues',
144
- `repo:${owner}/${repo}`,
145
- `"[${featureId}]" in:title`,
146
- 'is:open',
147
- '--json', 'number,title',
148
- '--limit', '5'
149
- ]);
149
+ function loadExistingGitHubLinks(incrementPath: string): {
150
+ milestone?: number;
151
+ userStoryIssues: Record<string, number>;
152
+ } {
153
+ const metadataPath = path.join(incrementPath, 'metadata.json');
150
154
 
151
- if (result.exitCode !== 0 || !result.stdout.trim()) {
152
- return null;
155
+ if (!existsSync(metadataPath)) {
156
+ return { userStoryIssues: {} };
153
157
  }
154
158
 
155
159
  try {
156
- const issues = JSON.parse(result.stdout);
157
- if (issues.length > 0) {
158
- return issues[0].number;
159
- }
160
+ const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
161
+
162
+ return {
163
+ milestone: metadata.github?.milestone,
164
+ userStoryIssues: metadata.github?.userStoryIssues || {}
165
+ };
160
166
  } catch {
161
- // Parse error, no existing issue
167
+ return { userStoryIssues: {} };
162
168
  }
169
+ }
163
170
 
164
- return null;
171
+ /**
172
+ * Update increment metadata with GitHub links
173
+ */
174
+ async function updateIncrementMetadata(
175
+ incrementPath: string,
176
+ milestoneNumber: number,
177
+ userStoryIssues: Record<string, number>
178
+ ): Promise<void> {
179
+ const metadataPath = path.join(incrementPath, 'metadata.json');
180
+
181
+ let metadata: Record<string, unknown> = {};
182
+
183
+ if (existsSync(metadataPath)) {
184
+ try {
185
+ metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
186
+ } catch {
187
+ // Start fresh
188
+ }
189
+ }
190
+
191
+ // Update github section
192
+ metadata.github = {
193
+ milestone: milestoneNumber,
194
+ userStoryIssues,
195
+ lastSync: new Date().toISOString()
196
+ };
197
+
198
+ await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n');
165
199
  }
166
200
 
167
201
  /**
168
- * Create GitHub issue via gh CLI
202
+ * Create or get GitHub milestone for the feature
169
203
  */
170
- async function createGitHubIssue(
204
+ async function createOrGetMilestone(
171
205
  owner: string,
172
206
  repo: string,
207
+ featureId: string,
173
208
  title: string,
174
- body: string,
175
- labels: string[]
209
+ existingMilestone?: number
176
210
  ): Promise<{ number: number; url: string }> {
177
- const args = [
178
- 'issue', 'create',
179
- '--repo', `${owner}/${repo}`,
180
- '--title', title,
181
- '--body', body
182
- ];
183
-
184
- // Add labels
185
- if (labels.length > 0) {
186
- args.push('--label', labels.join(','));
211
+ // If we have an existing milestone, verify it exists
212
+ if (existingMilestone) {
213
+ const verifyResult = await execFileNoThrow('gh', [
214
+ 'api', `repos/${owner}/${repo}/milestones/${existingMilestone}`,
215
+ '--jq', '.number'
216
+ ]);
217
+ if (verifyResult.exitCode === 0) {
218
+ return {
219
+ number: existingMilestone,
220
+ url: `https://github.com/${owner}/${repo}/milestone/${existingMilestone}`
221
+ };
222
+ }
187
223
  }
188
224
 
189
- const result = await execFileNoThrow('gh', args);
225
+ // Search for existing milestone by title
226
+ const searchResult = await execFileNoThrow('gh', [
227
+ 'api', `repos/${owner}/${repo}/milestones`,
228
+ '--jq', `.[] | select(.title | contains("${featureId}")) | .number`
229
+ ]);
190
230
 
191
- if (result.exitCode !== 0) {
192
- throw new Error(`Failed to create issue: ${result.stderr || result.stdout}`);
231
+ if (searchResult.exitCode === 0 && searchResult.stdout.trim()) {
232
+ const milestoneNumber = parseInt(searchResult.stdout.trim().split('\n')[0], 10);
233
+ return {
234
+ number: milestoneNumber,
235
+ url: `https://github.com/${owner}/${repo}/milestone/${milestoneNumber}`
236
+ };
193
237
  }
194
238
 
195
- // Parse issue URL from output
196
- const urlMatch = result.stdout.match(/https:\/\/github\.com\/[^\s]+\/issues\/(\d+)/);
197
- if (!urlMatch) {
198
- throw new Error('Could not parse issue URL from gh output');
239
+ // Create new milestone
240
+ const milestoneTitle = `[${featureId}] ${title}`;
241
+ const createResult = await execFileNoThrow('gh', [
242
+ 'api', `repos/${owner}/${repo}/milestones`,
243
+ '-X', 'POST',
244
+ '-f', `title=${milestoneTitle}`,
245
+ '-f', `description=Feature milestone for ${featureId}`,
246
+ '--jq', '.number'
247
+ ]);
248
+
249
+ if (createResult.exitCode !== 0) {
250
+ throw new Error(`Failed to create milestone: ${createResult.stderr || createResult.stdout}`);
199
251
  }
200
252
 
253
+ const milestoneNumber = parseInt(createResult.stdout.trim(), 10);
201
254
  return {
202
- number: parseInt(urlMatch[1], 10),
203
- url: urlMatch[0]
255
+ number: milestoneNumber,
256
+ url: `https://github.com/${owner}/${repo}/milestone/${milestoneNumber}`
204
257
  };
205
258
  }
206
259
 
207
260
  /**
208
- * Update existing GitHub issue
261
+ * Build issue body for a single user story
209
262
  */
210
- async function updateGitHubIssue(
211
- owner: string,
212
- repo: string,
213
- issueNumber: number,
214
- body: string
215
- ): Promise<void> {
216
- const result = await execFileNoThrow('gh', [
217
- 'issue', 'edit',
218
- String(issueNumber),
219
- '--repo', `${owner}/${repo}`,
220
- '--body', body
221
- ]);
222
-
223
- if (result.exitCode !== 0) {
224
- throw new Error(`Failed to update issue: ${result.stderr || result.stdout}`);
263
+ function buildUserStoryIssueBody(
264
+ story: UserStory,
265
+ tasks: Task[],
266
+ incrementData: IncrementData,
267
+ githubRepo: string
268
+ ): string {
269
+ const incrementId = incrementData.frontmatter.increment;
270
+ let body = '';
271
+
272
+ // Header with metadata
273
+ body += `**Feature**: ${incrementData.frontmatter.feature_id || 'N/A'}\n`;
274
+ body += `**Status**: ${story.acceptanceCriteria.every(ac => ac.completed) ? 'complete' : 'in-progress'}\n`;
275
+ body += `**Priority**: ${story.priority || incrementData.frontmatter.priority || 'P2'}\n`;
276
+
277
+ body += `\n---\n\n`;
278
+
279
+ // User Story description
280
+ body += `## User Story\n\n`;
281
+ if (story.asA && story.iWant && story.soThat) {
282
+ body += `**As a** ${story.asA}\n`;
283
+ body += `**I want** ${story.iWant}\n`;
284
+ body += `**So that** ${story.soThat}\n\n`;
285
+ } else {
286
+ body += `${story.title}\n\n`;
225
287
  }
226
- }
227
288
 
228
- /**
229
- * Load existing GitHub issue link from metadata.json
230
- * This is the PRIMARY source for existing issue detection
231
- */
232
- function loadExistingGitHubLink(incrementPath: string): { issue: number; url?: string } | null {
233
- const metadataPath = path.join(incrementPath, 'metadata.json');
289
+ body += `---\n\n`;
234
290
 
235
- if (!existsSync(metadataPath)) {
236
- return null;
291
+ // Acceptance Criteria
292
+ body += `## Acceptance Criteria\n\n`;
293
+ if (story.acceptanceCriteria.length > 0) {
294
+ const completed = story.acceptanceCriteria.filter(ac => ac.completed).length;
295
+ const total = story.acceptanceCriteria.length;
296
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
297
+ body += `Progress: ${completed}/${total} criteria met (${percentage}%)\n\n`;
298
+
299
+ for (const ac of story.acceptanceCriteria) {
300
+ const checkbox = ac.completed ? '[x]' : '[ ]';
301
+ body += `- ${checkbox} **${ac.id}**: ${ac.description}\n`;
302
+ }
303
+ body += '\n';
304
+ } else {
305
+ body += `*No acceptance criteria defined*\n\n`;
237
306
  }
238
307
 
239
- try {
240
- const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
308
+ body += `---\n\n`;
241
309
 
242
- // Check for github.issue (new format)
243
- if (metadata.github?.issue) {
244
- return {
245
- issue: metadata.github.issue,
246
- url: metadata.github.url
247
- };
248
- }
310
+ // Tasks for this user story
311
+ const storyTasks = tasks.filter(t =>
312
+ t.userStories.includes(story.id) ||
313
+ t.userStories.some(us => us.includes(story.id.replace('US-', '')))
314
+ );
249
315
 
250
- // Check for sync.issueNumber (alternative format)
251
- if (metadata.sync?.issueNumber) {
252
- return {
253
- issue: metadata.sync.issueNumber,
254
- url: metadata.sync.issueUrl
255
- };
316
+ if (storyTasks.length > 0) {
317
+ body += `## Implementation Tasks\n\n`;
318
+ const completedTasks = storyTasks.filter(t => t.completed).length;
319
+ const totalTasks = storyTasks.length;
320
+ const taskPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
321
+ body += `Progress: ${completedTasks}/${totalTasks} tasks (${taskPercentage}%)\n\n`;
322
+
323
+ for (const task of storyTasks) {
324
+ const checkbox = task.completed ? '[x]' : '[ ]';
325
+ body += `- ${checkbox} **${task.id}**: ${task.title}\n`;
256
326
  }
327
+ body += '\n';
257
328
 
258
- return null;
259
- } catch {
260
- return null;
329
+ body += `---\n\n`;
261
330
  }
331
+
332
+ // Link to increment
333
+ body += `## SpecWeave Increment\n\n`;
334
+ body += `**Increment**: [${incrementId}](https://github.com/${githubRepo}/tree/develop/.specweave/increments/${incrementId})\n\n`;
335
+
336
+ body += `---\n\n`;
337
+ body += `šŸ¤– Auto-synced by SpecWeave`;
338
+
339
+ return body;
262
340
  }
263
341
 
264
342
  /**
265
- * Update increment metadata with GitHub issue link
343
+ * Create or update GitHub issue for a user story
266
344
  */
267
- async function updateIncrementMetadata(
268
- incrementPath: string,
269
- issueNumber: number,
270
- issueUrl: string
271
- ): Promise<void> {
272
- const metadataPath = path.join(incrementPath, 'metadata.json');
345
+ async function syncUserStoryIssue(
346
+ owner: string,
347
+ repo: string,
348
+ featureId: string,
349
+ story: UserStory,
350
+ tasks: Task[],
351
+ incrementData: IncrementData,
352
+ milestoneNumber: number,
353
+ existingIssueNumber?: number
354
+ ): Promise<{ number: number; url: string }> {
355
+ // CORRECT FORMAT: [FS-XXX][US-YYY] User Story Title
356
+ const title = `[${featureId}][${story.id}] ${story.title}`;
357
+ const body = buildUserStoryIssueBody(story, tasks, incrementData, `${owner}/${repo}`);
358
+
359
+ // Labels
360
+ const labels = ['specweave', 'user-story'];
361
+ const priority = story.priority?.toLowerCase() || incrementData.frontmatter.priority?.toLowerCase() || 'p2';
362
+ labels.push(priority);
363
+
364
+ if (existingIssueNumber) {
365
+ // Update existing issue
366
+ const updateResult = await execFileNoThrow('gh', [
367
+ 'issue', 'edit',
368
+ String(existingIssueNumber),
369
+ '--repo', `${owner}/${repo}`,
370
+ '--title', title,
371
+ '--body', body
372
+ ]);
373
+
374
+ if (updateResult.exitCode !== 0) {
375
+ throw new Error(`Failed to update issue #${existingIssueNumber}: ${updateResult.stderr}`);
376
+ }
273
377
 
274
- let metadata: Record<string, unknown> = {};
378
+ return {
379
+ number: existingIssueNumber,
380
+ url: `https://github.com/${owner}/${repo}/issues/${existingIssueNumber}`
381
+ };
382
+ }
275
383
 
276
- if (existsSync(metadataPath)) {
277
- try {
278
- metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
279
- } catch {
280
- // Start fresh
281
- }
384
+ // Create new issue
385
+ const createArgs = [
386
+ 'issue', 'create',
387
+ '--repo', `${owner}/${repo}`,
388
+ '--title', title,
389
+ '--body', body,
390
+ '--milestone', String(milestoneNumber)
391
+ ];
392
+
393
+ if (labels.length > 0) {
394
+ createArgs.push('--label', labels.join(','));
282
395
  }
283
396
 
284
- // Update github section
285
- metadata.github = {
286
- issue: issueNumber,
287
- url: issueUrl,
288
- lastSync: new Date().toISOString()
289
- };
397
+ const createResult = await execFileNoThrow('gh', createArgs);
290
398
 
291
- await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n');
399
+ if (createResult.exitCode !== 0) {
400
+ throw new Error(`Failed to create issue: ${createResult.stderr || createResult.stdout}`);
401
+ }
402
+
403
+ // Parse issue URL from output
404
+ const urlMatch = createResult.stdout.match(/https:\/\/github\.com\/[^\s]+\/issues\/(\d+)/);
405
+ if (!urlMatch) {
406
+ throw new Error('Could not parse issue URL from gh output');
407
+ }
408
+
409
+ return {
410
+ number: parseInt(urlMatch[1], 10),
411
+ url: urlMatch[0]
412
+ };
292
413
  }
293
414
 
294
415
  async function main() {
@@ -297,26 +418,28 @@ async function main() {
297
418
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
298
419
  console.log('Usage: node github-increment-sync-cli.js <increment-id> [options]');
299
420
  console.log('');
421
+ console.log('Creates GitHub issues with CORRECT format:');
422
+ console.log(' - Milestone: [FS-XXX] Feature Title');
423
+ console.log(' - Issues: [FS-XXX][US-YYY] User Story Title (one per US)');
424
+ console.log('');
300
425
  console.log('Arguments:');
301
- console.log(' increment-id Increment ID (e.g., 0063 or 0063-fix-external-import)');
426
+ console.log(' increment-id Increment ID (e.g., 0002 or 0002-thumbnail-mvp)');
302
427
  console.log('');
303
428
  console.log('Options:');
304
- console.log(' --force Force create even if issue exists');
305
- console.log(' --dry-run Preview issue without creating');
429
+ console.log(' --dry-run Preview issues without creating');
306
430
  console.log('');
307
431
  console.log('Environment:');
308
432
  console.log(' GITHUB_TOKEN Required - GitHub personal access token');
309
433
  console.log('');
310
434
  console.log('Example:');
311
- console.log(' GITHUB_TOKEN=ghp_xxx node github-increment-sync-cli.js 0063');
435
+ console.log(' GITHUB_TOKEN=ghp_xxx node github-increment-sync-cli.js 0002');
312
436
  process.exit(args.length === 0 ? 1 : 0);
313
437
  }
314
438
 
315
439
  const incrementId = args[0];
316
- const force = args.includes('--force');
317
440
  const dryRun = args.includes('--dry-run');
318
441
 
319
- console.log(`\nšŸ™ GitHub Increment Sync CLI`);
442
+ console.log(`\nšŸ™ GitHub Increment Sync CLI (Per-User-Story Mode)`);
320
443
  console.log(` Increment: ${incrementId}`);
321
444
 
322
445
  // Find increment folder
@@ -339,7 +462,6 @@ async function main() {
339
462
  }
340
463
  console.log(` Repository: ${config.owner}/${config.repo}`);
341
464
  } else {
342
- // Try to detect repo for dry-run preview (non-fatal)
343
465
  config = await loadGitHubConfig().catch((): null => null);
344
466
  if (config) {
345
467
  console.log(` Repository: ${config.owner}/${config.repo}`);
@@ -348,7 +470,7 @@ async function main() {
348
470
  }
349
471
  }
350
472
 
351
- // Parse increment and build issue
473
+ // Parse increment
352
474
  const projectRoot = process.cwd();
353
475
  const builder = new IncrementIssueBuilder(incrementPath, projectRoot);
354
476
 
@@ -356,6 +478,10 @@ async function main() {
356
478
  console.log(`\nšŸ”„ Parsing increment spec.md...`);
357
479
  const incrementData = await builder.parse();
358
480
 
481
+ const featureId = incrementData.frontmatter.feature_id ||
482
+ `FS-${fullIncrementId.match(/^(\d+)/)?.[1]?.padStart(3, '0') || 'UNKNOWN'}`;
483
+
484
+ console.log(` šŸ“¦ Feature: ${featureId}`);
359
485
  console.log(` šŸ“¦ Title: ${incrementData.title}`);
360
486
  console.log(` šŸ“ User Stories: ${incrementData.userStories.length}`);
361
487
 
@@ -363,19 +489,36 @@ async function main() {
363
489
  (sum, us) => sum + us.acceptanceCriteria.length, 0
364
490
  );
365
491
  console.log(` āœ“ Acceptance Criteria: ${totalACs}`);
492
+ console.log(` šŸ”§ Tasks: ${incrementData.tasks.length}`);
366
493
 
367
- // Build issue content
368
- const githubRepo = config ? `${config.owner}/${config.repo}` : undefined;
369
- const issue = builder.buildIncrementIssue(incrementData, githubRepo);
494
+ if (incrementData.userStories.length === 0) {
495
+ console.error(`\nāŒ No user stories found in spec.md`);
496
+ console.error(' Ensure spec.md has ### US-XXX: Title sections');
497
+ process.exit(1);
498
+ }
370
499
 
371
- console.log(`\nšŸ“‹ Issue Preview:`);
372
- console.log(` Title: ${issue.title}`);
373
- console.log(` Labels: ${issue.labels.join(', ')}`);
500
+ // Preview issues
501
+ console.log(`\nšŸ“‹ Issues to Create/Update:`);
502
+ console.log(` šŸŽÆ Milestone: [${featureId}] ${incrementData.title}`);
503
+ for (const story of incrementData.userStories) {
504
+ const storyTasks = incrementData.tasks.filter(t =>
505
+ t.userStories.includes(story.id) ||
506
+ t.userStories.some(us => us.includes(story.id.replace('US-', '')))
507
+ );
508
+ console.log(` šŸ“ [${featureId}][${story.id}] ${story.title}`);
509
+ console.log(` └─ ${story.acceptanceCriteria.length} ACs, ${storyTasks.length} tasks`);
510
+ }
374
511
 
375
512
  if (dryRun) {
376
- console.log(`\nšŸ“„ Issue Body Preview:\n`);
377
- console.log(issue.body);
378
- console.log(`\nāœ… Dry run complete (no issue created)`);
513
+ console.log(`\nšŸ“„ Sample Issue Body (${incrementData.userStories[0].id}):\n`);
514
+ const sampleBody = buildUserStoryIssueBody(
515
+ incrementData.userStories[0],
516
+ incrementData.tasks,
517
+ incrementData,
518
+ config ? `${config.owner}/${config.repo}` : 'owner/repo'
519
+ );
520
+ console.log(sampleBody);
521
+ console.log(`\nāœ… Dry run complete (no issues created)`);
379
522
  process.exit(0);
380
523
  }
381
524
 
@@ -385,90 +528,51 @@ async function main() {
385
528
  process.exit(1);
386
529
  }
387
530
 
388
- // STEP 1: Check metadata.json for existing issue link (PRIMARY detection)
389
- const metadataLink = loadExistingGitHubLink(incrementPath);
390
-
391
- if (metadataLink) {
392
- console.log(`\nšŸ“Ž Found existing issue link in metadata: #${metadataLink.issue}`);
393
-
394
- // Update existing issue with new format
395
- console.log(`šŸ”„ Updating issue #${metadataLink.issue} with new format...`);
396
-
397
- // Update body
398
- await updateGitHubIssue(config.owner, config.repo, metadataLink.issue, issue.body);
399
- console.log(` āœ… Body updated with User Stories and ACs`);
400
-
401
- // Update title to new format (fix the [0002] → [FS-XXX] issue)
402
- const updateTitleResult = await execFileNoThrow('gh', [
403
- 'issue', 'edit',
404
- String(metadataLink.issue),
405
- '--repo', `${config.owner}/${config.repo}`,
406
- '--title', issue.title
407
- ]);
408
-
409
- if (updateTitleResult.exitCode === 0) {
410
- console.log(` āœ… Title updated to: ${issue.title}`);
411
- } else {
412
- console.log(` āš ļø Could not update title (may need permissions)`);
413
- }
414
-
415
- // Update metadata with lastSync
416
- await updateIncrementMetadata(
417
- incrementPath,
418
- metadataLink.issue,
419
- `https://github.com/${config.owner}/${config.repo}/issues/${metadataLink.issue}`
420
- );
421
-
422
- console.log(`\nāœ… Sync complete!`);
423
- console.log(` šŸ”— https://github.com/${config.owner}/${config.repo}/issues/${metadataLink.issue}`);
424
- process.exit(0);
425
- }
426
-
427
- // STEP 2: Search GitHub by feature ID (fallback)
428
- const featureId = incrementData.frontmatter.feature_id ||
429
- `FS-${fullIncrementId.match(/^(\d+)/)?.[1]?.padStart(3, '0') || 'UNKNOWN'}`;
430
-
431
- console.log(`\nšŸ” Searching GitHub for existing issue [${featureId}]...`);
432
- const existingIssue = await findExistingIssue(config.owner, config.repo, featureId);
433
-
434
- if (existingIssue) {
435
- console.log(` Found existing issue: #${existingIssue}`);
531
+ // Load existing links
532
+ const existingLinks = loadExistingGitHubLinks(incrementPath);
436
533
 
437
- // Update existing issue
438
- console.log(`šŸ”„ Updating issue #${existingIssue}...`);
439
- await updateGitHubIssue(config.owner, config.repo, existingIssue, issue.body);
440
- console.log(` āœ… Issue #${existingIssue} updated`);
441
-
442
- await updateIncrementMetadata(
443
- incrementPath,
444
- existingIssue,
445
- `https://github.com/${config.owner}/${config.repo}/issues/${existingIssue}`
446
- );
447
-
448
- console.log(`\nāœ… Sync complete!`);
449
- console.log(` šŸ”— https://github.com/${config.owner}/${config.repo}/issues/${existingIssue}`);
450
- process.exit(0);
451
- }
452
-
453
- // STEP 3: Create new issue (no existing found)
454
- console.log(` No existing issue found`);
455
- console.log(`\nšŸš€ Creating GitHub issue...`);
456
- const created = await createGitHubIssue(
534
+ // Create or get milestone
535
+ console.log(`\nšŸŽÆ Creating/updating milestone...`);
536
+ const milestone = await createOrGetMilestone(
457
537
  config.owner,
458
538
  config.repo,
459
- issue.title,
460
- issue.body,
461
- issue.labels
539
+ featureId,
540
+ incrementData.title,
541
+ existingLinks.milestone
462
542
  );
543
+ console.log(` āœ… Milestone #${milestone.number}: [${featureId}] ${incrementData.title}`);
544
+
545
+ // Create/update issues for each user story
546
+ console.log(`\nšŸ“ Creating/updating user story issues...`);
547
+ const userStoryIssues: Record<string, number> = {};
548
+
549
+ for (const story of incrementData.userStories) {
550
+ const existingIssue = existingLinks.userStoryIssues[story.id];
551
+
552
+ const issue = await syncUserStoryIssue(
553
+ config.owner,
554
+ config.repo,
555
+ featureId,
556
+ story,
557
+ incrementData.tasks,
558
+ incrementData,
559
+ milestone.number,
560
+ existingIssue
561
+ );
463
562
 
464
- console.log(` āœ… Issue #${created.number} created`);
563
+ userStoryIssues[story.id] = issue.number;
564
+
565
+ const action = existingIssue ? 'ā™»ļø Updated' : 'āœ… Created';
566
+ console.log(` ${action} #${issue.number}: [${featureId}][${story.id}] ${story.title}`);
567
+ }
465
568
 
466
569
  // Update metadata
467
- await updateIncrementMetadata(incrementPath, created.number, created.url);
468
- console.log(` šŸ“ Metadata updated`);
570
+ await updateIncrementMetadata(incrementPath, milestone.number, userStoryIssues);
571
+ console.log(`\nšŸ“ Metadata updated`);
469
572
 
470
573
  console.log(`\nāœ… Sync complete!`);
471
- console.log(` šŸ”— ${created.url}`);
574
+ console.log(` šŸŽÆ Milestone: ${milestone.url}`);
575
+ console.log(` šŸ“ Issues: ${Object.keys(userStoryIssues).length} user stories synced`);
472
576
 
473
577
  process.exit(0);
474
578
  } catch (error) {