specweave 1.0.301 → 1.0.304
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/dist/plugins/specweave-github/lib/github-ac-comment-poster.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-ac-comment-poster.js +44 -25
- package/dist/plugins/specweave-github/lib/github-ac-comment-poster.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync-cli.js +6 -0
- package/dist/plugins/specweave-github/lib/github-feature-sync-cli.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts +36 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.js +266 -5
- package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
- package/dist/plugins/specweave-github/lib/user-story-content-builder.d.ts +2 -1
- package/dist/plugins/specweave-github/lib/user-story-content-builder.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/user-story-content-builder.js +6 -4
- package/dist/plugins/specweave-github/lib/user-story-content-builder.js.map +1 -1
- package/dist/plugins/specweave-github/lib/user-story-issue-builder.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/user-story-issue-builder.js +37 -17
- package/dist/plugins/specweave-github/lib/user-story-issue-builder.js.map +1 -1
- package/dist/src/cli/commands/refresh-plugins.d.ts.map +1 -1
- package/dist/src/cli/commands/refresh-plugins.js +9 -0
- package/dist/src/cli/commands/refresh-plugins.js.map +1 -1
- package/dist/src/cli/commands/sync-progress.d.ts.map +1 -1
- package/dist/src/cli/commands/sync-progress.js +72 -2
- package/dist/src/cli/commands/sync-progress.js.map +1 -1
- package/dist/src/config/types.d.ts +2 -2
- package/dist/src/core/increment/increment-utils.d.ts +27 -4
- package/dist/src/core/increment/increment-utils.d.ts.map +1 -1
- package/dist/src/core/increment/increment-utils.js +44 -17
- package/dist/src/core/increment/increment-utils.js.map +1 -1
- package/dist/src/core/increment/template-creator.d.ts +26 -0
- package/dist/src/core/increment/template-creator.d.ts.map +1 -1
- package/dist/src/core/increment/template-creator.js +179 -20
- package/dist/src/core/increment/template-creator.js.map +1 -1
- package/dist/src/importers/import-to-increment.d.ts +111 -0
- package/dist/src/importers/import-to-increment.d.ts.map +1 -0
- package/dist/src/importers/import-to-increment.js +223 -0
- package/dist/src/importers/import-to-increment.js.map +1 -0
- package/dist/src/importers/increment-external-ref-detector.d.ts +78 -0
- package/dist/src/importers/increment-external-ref-detector.d.ts.map +1 -0
- package/dist/src/importers/increment-external-ref-detector.js +130 -0
- package/dist/src/importers/increment-external-ref-detector.js.map +1 -0
- package/dist/src/init/research/types.d.ts +1 -1
- package/dist/src/sync/external-issue-auto-creator.d.ts.map +1 -1
- package/dist/src/sync/external-issue-auto-creator.js +28 -1
- package/dist/src/sync/external-issue-auto-creator.js.map +1 -1
- package/dist/src/sync/sync-coordinator.d.ts +6 -0
- package/dist/src/sync/sync-coordinator.d.ts.map +1 -1
- package/dist/src/sync/sync-coordinator.js +42 -2
- package/dist/src/sync/sync-coordinator.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/hooks/lib/update-active-increment.sh +2 -2
- package/plugins/specweave/hooks/lib/update-status-line.sh +2 -2
- package/plugins/specweave/hooks/stop-auto-v5.sh +28 -8
- package/plugins/specweave/hooks/stop-sync.sh +10 -5
- package/plugins/specweave/hooks/universal/fail-fast-wrapper.sh +8 -4
- package/plugins/specweave/hooks/user-prompt-submit.sh +130 -112
- package/plugins/specweave/hooks/v2/handlers/github-sync-handler.sh +6 -3
- package/plugins/specweave/hooks/v2/handlers/project-bridge-handler.sh +4 -3
- package/plugins/specweave/skills/auto/SKILL.md +5 -3
- package/plugins/specweave/skills/done/SKILL.md +9 -3
- package/plugins/specweave/skills/import/SKILL.md +186 -0
- package/plugins/specweave/skills/increment/SKILL.md +30 -16
- package/plugins/specweave/skills/pm/SKILL.md +29 -2
- package/plugins/specweave/skills/pm/phases/00-deep-interview.md +12 -0
- package/plugins/specweave/skills/team-lead/SKILL.md +4 -2
- package/plugins/specweave/skills/team-merge/SKILL.md +2 -2
- package/plugins/specweave-github/lib/github-ac-comment-poster.js +31 -19
- package/plugins/specweave-github/lib/github-ac-comment-poster.ts +44 -27
- package/plugins/specweave-github/lib/github-feature-sync-cli.js +5 -0
- package/plugins/specweave-github/lib/github-feature-sync-cli.ts +7 -1
- package/plugins/specweave-github/lib/github-feature-sync.js +274 -6
- package/plugins/specweave-github/lib/github-feature-sync.ts +353 -5
- package/plugins/specweave-github/lib/user-story-content-builder.js +6 -4
- package/plugins/specweave-github/lib/user-story-content-builder.ts +6 -4
- package/plugins/specweave-github/lib/user-story-issue-builder.js +26 -11
- package/plugins/specweave-github/lib/user-story-issue-builder.ts +37 -19
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Creates ONE issue PER user story file from specs/{project}/FS-XXX/us-*.md
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readdir, readFile, writeFile } from 'fs/promises';
|
|
13
|
+
import { readdir, readFile, writeFile, mkdir } from 'fs/promises';
|
|
14
14
|
import { existsSync } from 'fs';
|
|
15
15
|
import * as path from 'path';
|
|
16
16
|
import * as yaml from 'yaml';
|
|
@@ -54,6 +54,9 @@ export class GitHubFeatureSync {
|
|
|
54
54
|
private calculator: CompletionCalculator;
|
|
55
55
|
private token?: string;
|
|
56
56
|
|
|
57
|
+
// Cached default branch for the sync session (one API call per session)
|
|
58
|
+
private defaultBranch: string | null = null;
|
|
59
|
+
|
|
57
60
|
// SYNC LOCK: Prevent concurrent syncs of the same feature
|
|
58
61
|
// Maps featureId → last sync timestamp
|
|
59
62
|
private static syncLocks: Map<string, number> = new Map();
|
|
@@ -68,6 +71,33 @@ export class GitHubFeatureSync {
|
|
|
68
71
|
this.token = getGitHubAuthFromProject(projectRoot).token;
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Detect the default branch from the GitHub API.
|
|
76
|
+
* Caches the result per sync session to avoid repeated API calls.
|
|
77
|
+
* Falls back to 'main' if API call fails.
|
|
78
|
+
*/
|
|
79
|
+
private async detectDefaultBranch(): Promise<string> {
|
|
80
|
+
if (this.defaultBranch) {
|
|
81
|
+
return this.defaultBranch;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const owner = this.client.getOwner();
|
|
85
|
+
const repo = this.client.getRepo();
|
|
86
|
+
|
|
87
|
+
const result = await execFileNoThrow('gh', [
|
|
88
|
+
'api', `repos/${owner}/${repo}`, '--jq', '.default_branch'
|
|
89
|
+
], { env: this.getGhEnv() });
|
|
90
|
+
|
|
91
|
+
if (result.exitCode === 0 && result.stdout.trim()) {
|
|
92
|
+
this.defaultBranch = result.stdout.trim();
|
|
93
|
+
} else {
|
|
94
|
+
console.warn(` ⚠️ Failed to detect default branch, falling back to 'main': ${result.stderr}`);
|
|
95
|
+
this.defaultBranch = 'main';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return this.defaultBranch;
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
/**
|
|
72
102
|
* Get environment object with GH_TOKEN for gh CLI commands.
|
|
73
103
|
* This ensures the token from .env is passed to all gh operations,
|
|
@@ -163,6 +193,10 @@ export class GitHubFeatureSync {
|
|
|
163
193
|
let issuesCreated = 0;
|
|
164
194
|
let issuesUpdated = 0;
|
|
165
195
|
|
|
196
|
+
// Detect default branch once per sync session
|
|
197
|
+
const detectedBranch = await this.detectDefaultBranch();
|
|
198
|
+
console.log(` 🌿 Default branch: ${detectedBranch}`);
|
|
199
|
+
|
|
166
200
|
for (const userStory of userStories) {
|
|
167
201
|
console.log(`\n 🔹 Processing ${userStory.id}: ${userStory.title}`);
|
|
168
202
|
|
|
@@ -170,7 +204,7 @@ export class GitHubFeatureSync {
|
|
|
170
204
|
const repoInfo = {
|
|
171
205
|
owner: this.client.getOwner(),
|
|
172
206
|
repo: this.client.getRepo(),
|
|
173
|
-
branch:
|
|
207
|
+
branch: detectedBranch
|
|
174
208
|
};
|
|
175
209
|
|
|
176
210
|
const builder = new UserStoryIssueBuilder(
|
|
@@ -294,7 +328,8 @@ export class GitHubFeatureSync {
|
|
|
294
328
|
}
|
|
295
329
|
|
|
296
330
|
/**
|
|
297
|
-
* Find Feature folder in specs directory
|
|
331
|
+
* Find Feature folder in specs directory.
|
|
332
|
+
* Falls back to auto-creating from increment spec.md if living docs don't exist.
|
|
298
333
|
*/
|
|
299
334
|
private async findFeatureFolder(featureId: string): Promise<string | null> {
|
|
300
335
|
// v5.0.0+: NO _features folder - features live in project folders
|
|
@@ -315,9 +350,296 @@ export class GitHubFeatureSync {
|
|
|
315
350
|
return legacyFolder;
|
|
316
351
|
}
|
|
317
352
|
|
|
353
|
+
// FALLBACK (v1.0.302): Auto-create feature folder from increment spec.md
|
|
354
|
+
// Most increments never get living docs created, so sync silently fails.
|
|
355
|
+
// This makes sync self-healing by creating minimal living docs on-the-fly.
|
|
356
|
+
console.log(` ℹ️ Feature folder not found in living docs, attempting auto-create from spec.md...`);
|
|
357
|
+
const created = await this.createFeatureFolderFromSpec(featureId, projectFolders);
|
|
358
|
+
if (created) {
|
|
359
|
+
return created;
|
|
360
|
+
}
|
|
361
|
+
|
|
318
362
|
return null;
|
|
319
363
|
}
|
|
320
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Find the increment folder for a given feature ID.
|
|
367
|
+
* Converts FS-271 -> finds 0271-xxx-xxx/ in .specweave/increments/
|
|
368
|
+
*/
|
|
369
|
+
private async findIncrementFolder(featureId: string): Promise<string | null> {
|
|
370
|
+
const numMatch = featureId.match(/FS-0*(\d+)E?/i);
|
|
371
|
+
if (!numMatch) return null;
|
|
372
|
+
|
|
373
|
+
const num = parseInt(numMatch[1], 10);
|
|
374
|
+
const paddedNum = String(num).padStart(4, '0');
|
|
375
|
+
|
|
376
|
+
const incrementsDir = path.join(this.projectRoot, '.specweave/increments');
|
|
377
|
+
if (!existsSync(incrementsDir)) return null;
|
|
378
|
+
|
|
379
|
+
const entries = await readdir(incrementsDir);
|
|
380
|
+
const match = entries.find(e => e.startsWith(paddedNum + '-'));
|
|
381
|
+
if (!match) return null;
|
|
382
|
+
|
|
383
|
+
return path.join(incrementsDir, match);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Auto-create a feature folder (FEATURE.md + us-NNN.md files) from an
|
|
388
|
+
* increment's spec.md. This enables GitHub sync even when the living docs
|
|
389
|
+
* builder hasn't run yet.
|
|
390
|
+
*/
|
|
391
|
+
private async createFeatureFolderFromSpec(
|
|
392
|
+
featureId: string,
|
|
393
|
+
projectFolders: string[]
|
|
394
|
+
): Promise<string | null> {
|
|
395
|
+
try {
|
|
396
|
+
const incrementFolder = await this.findIncrementFolder(featureId);
|
|
397
|
+
if (!incrementFolder) {
|
|
398
|
+
console.log(` ⚠️ No increment folder found for ${featureId}`);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const specPath = path.join(incrementFolder, 'spec.md');
|
|
403
|
+
if (!existsSync(specPath)) {
|
|
404
|
+
console.log(` ⚠️ No spec.md found in ${path.basename(incrementFolder)}`);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const specContent = await readFile(specPath, 'utf-8');
|
|
409
|
+
|
|
410
|
+
// Parse frontmatter
|
|
411
|
+
const fmMatch = specContent.match(/^---\n([\s\S]*?)\n---/);
|
|
412
|
+
if (!fmMatch) {
|
|
413
|
+
console.log(` ⚠️ spec.md has no YAML frontmatter`);
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const frontmatter = yaml.parse(fmMatch[1]);
|
|
418
|
+
const title = frontmatter.title || path.basename(incrementFolder).replace(/^\d+-/, '');
|
|
419
|
+
const status = frontmatter.status || 'active';
|
|
420
|
+
const priority = frontmatter.priority || 'P2';
|
|
421
|
+
const created = frontmatter.created || new Date().toISOString().split('T')[0];
|
|
422
|
+
const incrementId = frontmatter.increment || path.basename(incrementFolder);
|
|
423
|
+
|
|
424
|
+
// Determine target project folder from spec.md user stories or first available
|
|
425
|
+
let targetProjectFolder = projectFolders[0]; // Default: first project folder
|
|
426
|
+
const projectMatch = specContent.match(/\*\*Project\*\*:\s*(\S+)/);
|
|
427
|
+
if (projectMatch) {
|
|
428
|
+
const projectName = projectMatch[1];
|
|
429
|
+
const matchingFolder = projectFolders.find(f => path.basename(f) === projectName);
|
|
430
|
+
if (matchingFolder) {
|
|
431
|
+
targetProjectFolder = matchingFolder;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!targetProjectFolder) {
|
|
436
|
+
console.log(` ⚠️ No project folder available for feature creation`);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Create feature folder
|
|
441
|
+
const featureFolder = path.join(targetProjectFolder, featureId);
|
|
442
|
+
await mkdir(featureFolder, { recursive: true });
|
|
443
|
+
|
|
444
|
+
// Parse user stories from spec.md body
|
|
445
|
+
const userStories = this.parseUserStoriesFromSpec(specContent, featureId);
|
|
446
|
+
|
|
447
|
+
// Create FEATURE.md
|
|
448
|
+
const featureMd = this.buildFeatureMd(featureId, title, status, priority, created, incrementId, userStories);
|
|
449
|
+
await writeFile(path.join(featureFolder, 'FEATURE.md'), featureMd, 'utf-8');
|
|
450
|
+
|
|
451
|
+
// Create us-NNN.md files
|
|
452
|
+
for (const us of userStories) {
|
|
453
|
+
const usFilename = `us-${us.id.replace('US-', '').padStart(3, '0')}-${this.slugify(us.title)}.md`;
|
|
454
|
+
const usMd = this.buildUserStoryMd(us, featureId, incrementId);
|
|
455
|
+
await writeFile(path.join(featureFolder, usFilename), usMd, 'utf-8');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
console.log(` ✅ Auto-created feature folder with ${userStories.length} user stories`);
|
|
459
|
+
return featureFolder;
|
|
460
|
+
} catch (error) {
|
|
461
|
+
console.log(` ⚠️ Failed to auto-create feature folder: ${(error as Error).message}`);
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Parse user stories from spec.md markdown content.
|
|
468
|
+
*/
|
|
469
|
+
private parseUserStoriesFromSpec(
|
|
470
|
+
specContent: string,
|
|
471
|
+
featureId: string
|
|
472
|
+
): Array<{
|
|
473
|
+
id: string;
|
|
474
|
+
title: string;
|
|
475
|
+
priority: string;
|
|
476
|
+
project: string;
|
|
477
|
+
storyText: string;
|
|
478
|
+
acceptanceCriteria: string[];
|
|
479
|
+
status: string;
|
|
480
|
+
}> {
|
|
481
|
+
const stories: Array<{
|
|
482
|
+
id: string;
|
|
483
|
+
title: string;
|
|
484
|
+
priority: string;
|
|
485
|
+
project: string;
|
|
486
|
+
storyText: string;
|
|
487
|
+
acceptanceCriteria: string[];
|
|
488
|
+
status: string;
|
|
489
|
+
}> = [];
|
|
490
|
+
|
|
491
|
+
// Match ### US-NNN: Title (Priority) sections
|
|
492
|
+
const usRegex = /### (US-\d+):\s*(.+?)(?:\s*\((P\d)\))?\s*\n([\s\S]*?)(?=\n### US-|\n## |\n---\s*\n### US-|$)/g;
|
|
493
|
+
let match;
|
|
494
|
+
|
|
495
|
+
while ((match = usRegex.exec(specContent)) !== null) {
|
|
496
|
+
const usId = match[1];
|
|
497
|
+
const rawTitle = match[2].trim();
|
|
498
|
+
const priority = match[3] || 'P2';
|
|
499
|
+
const body = match[4];
|
|
500
|
+
|
|
501
|
+
// Skip template placeholders
|
|
502
|
+
if (rawTitle === '[Story Title]') continue;
|
|
503
|
+
|
|
504
|
+
// Extract project
|
|
505
|
+
const projectMatch = body.match(/\*\*Project\*\*:\s*(\S+)/);
|
|
506
|
+
const project = projectMatch ? projectMatch[1] : 'specweave';
|
|
507
|
+
|
|
508
|
+
// Extract story text (As a... I want... So that...)
|
|
509
|
+
const storyMatch = body.match(/\*\*As a\*\*\s+([\s\S]*?)(?=\n\*\*Acceptance Criteria|$)/);
|
|
510
|
+
const storyText = storyMatch ? storyMatch[1].trim() : '';
|
|
511
|
+
|
|
512
|
+
// Extract acceptance criteria
|
|
513
|
+
const acs: string[] = [];
|
|
514
|
+
const acRegex = /- \[[ x]\] \*\*AC-[^*]+\*\*:\s*(.+)/g;
|
|
515
|
+
let acMatch;
|
|
516
|
+
while ((acMatch = acRegex.exec(body)) !== null) {
|
|
517
|
+
acs.push(acMatch[0]);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Determine status from ACs
|
|
521
|
+
const totalAcs = acs.length;
|
|
522
|
+
const completedAcs = acs.filter(ac => ac.startsWith('- [x]')).length;
|
|
523
|
+
let status = 'not-started';
|
|
524
|
+
if (totalAcs > 0 && completedAcs === totalAcs) status = 'complete';
|
|
525
|
+
else if (completedAcs > 0) status = 'active';
|
|
526
|
+
|
|
527
|
+
stories.push({
|
|
528
|
+
id: usId,
|
|
529
|
+
title: rawTitle,
|
|
530
|
+
priority,
|
|
531
|
+
project,
|
|
532
|
+
storyText,
|
|
533
|
+
acceptanceCriteria: acs,
|
|
534
|
+
status,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return stories;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Build FEATURE.md content matching the living docs format.
|
|
543
|
+
*/
|
|
544
|
+
private buildFeatureMd(
|
|
545
|
+
featureId: string,
|
|
546
|
+
title: string,
|
|
547
|
+
status: string,
|
|
548
|
+
priority: string,
|
|
549
|
+
created: string,
|
|
550
|
+
incrementId: string,
|
|
551
|
+
userStories: Array<{ id: string; title: string }>
|
|
552
|
+
): string {
|
|
553
|
+
const now = new Date().toISOString().split('T')[0];
|
|
554
|
+
const mappedStatus = status === 'planned' ? 'planning'
|
|
555
|
+
: status === 'completed' || status === 'done' ? 'complete'
|
|
556
|
+
: status === 'active' || status === 'in-progress' ? 'active'
|
|
557
|
+
: 'planning';
|
|
558
|
+
|
|
559
|
+
const fm: Record<string, unknown> = {
|
|
560
|
+
id: featureId,
|
|
561
|
+
title,
|
|
562
|
+
type: 'feature',
|
|
563
|
+
status: mappedStatus,
|
|
564
|
+
priority,
|
|
565
|
+
created,
|
|
566
|
+
lastUpdated: now,
|
|
567
|
+
tldr: title,
|
|
568
|
+
complexity: 'medium',
|
|
569
|
+
auto_created: true,
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const yamlFm = yaml.stringify(fm);
|
|
573
|
+
|
|
574
|
+
let body = `\n# ${title}\n\n## TL;DR\n\n**What**: ${title}\n**Status**: ${mappedStatus} | **Priority**: ${priority}\n**User Stories**: ${userStories.length}\n\n## Overview\n\n${title}\n\n## Implementation History\n\n| Increment | Status |\n|-----------|--------|\n| [${incrementId}](../../../../../increments/${incrementId}/spec.md) | ${mappedStatus} |\n\n## User Stories\n`;
|
|
575
|
+
|
|
576
|
+
for (const us of userStories) {
|
|
577
|
+
body += `\n- [${us.id}: ${us.title}](./${us.id.toLowerCase()}.md)`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return `---\n${yamlFm}---${body}\n`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Build us-NNN.md content matching the living docs format.
|
|
585
|
+
*/
|
|
586
|
+
private buildUserStoryMd(
|
|
587
|
+
us: {
|
|
588
|
+
id: string;
|
|
589
|
+
title: string;
|
|
590
|
+
priority: string;
|
|
591
|
+
project: string;
|
|
592
|
+
storyText: string;
|
|
593
|
+
acceptanceCriteria: string[];
|
|
594
|
+
status: string;
|
|
595
|
+
},
|
|
596
|
+
featureId: string,
|
|
597
|
+
incrementId: string
|
|
598
|
+
): string {
|
|
599
|
+
const now = new Date().toISOString().split('T')[0];
|
|
600
|
+
|
|
601
|
+
const fm: Record<string, unknown> = {
|
|
602
|
+
id: us.id,
|
|
603
|
+
feature: featureId,
|
|
604
|
+
title: us.title,
|
|
605
|
+
status: us.status,
|
|
606
|
+
priority: us.priority,
|
|
607
|
+
created: now,
|
|
608
|
+
project: us.project,
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const yamlFm = yaml.stringify(fm);
|
|
612
|
+
|
|
613
|
+
let body = `\n# ${us.id}: ${us.title}\n\n**Feature**: [${featureId}](./FEATURE.md)\n\n`;
|
|
614
|
+
|
|
615
|
+
if (us.storyText) {
|
|
616
|
+
body += `${us.storyText}\n\n`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
body += `---\n\n## Acceptance Criteria\n\n`;
|
|
620
|
+
|
|
621
|
+
if (us.acceptanceCriteria.length > 0) {
|
|
622
|
+
body += us.acceptanceCriteria.join('\n') + '\n';
|
|
623
|
+
} else {
|
|
624
|
+
body += `- [ ] **AC-${us.id.replace('US-', 'US')}-01**: Pending specification\n`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
body += `\n---\n\n## Implementation\n\n**Increment**: [${incrementId}](../../../../../increments/${incrementId}/spec.md)\n`;
|
|
628
|
+
|
|
629
|
+
return `---\n${yamlFm}---${body}\n`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Convert a title to a URL-safe slug.
|
|
634
|
+
*/
|
|
635
|
+
private slugify(text: string): string {
|
|
636
|
+
return text
|
|
637
|
+
.toLowerCase()
|
|
638
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
639
|
+
.replace(/^-+|-+$/g, '')
|
|
640
|
+
.substring(0, 60);
|
|
641
|
+
}
|
|
642
|
+
|
|
321
643
|
/**
|
|
322
644
|
* Backfill increment metadata.json with GitHub issue reference (v1.0.240)
|
|
323
645
|
*
|
|
@@ -467,9 +789,13 @@ export class GitHubFeatureSync {
|
|
|
467
789
|
// CRITICAL: Check if milestone already exists before creating
|
|
468
790
|
// NOTE: Must use per_page=100 to handle repos with 30+ milestones (GitHub default is 30)
|
|
469
791
|
// BUG FIX: Without pagination, milestone #31+ won't be found → false "not found" → HTTP 422 duplicate error
|
|
792
|
+
// FIX (v1.0.302): Use explicit owner/repo from config, not :owner/:repo which resolves from git remote
|
|
793
|
+
const owner = this.client.getOwner();
|
|
794
|
+
const repo = this.client.getRepo();
|
|
795
|
+
|
|
470
796
|
const existingResult = await execFileNoThrow('gh', [
|
|
471
797
|
'api',
|
|
472
|
-
|
|
798
|
+
`repos/${owner}/${repo}/milestones?per_page=100&state=all`,
|
|
473
799
|
'--jq',
|
|
474
800
|
`.[] | select(.title == "${title}") | {number, html_url}`,
|
|
475
801
|
], { env: this.getGhEnv() });
|
|
@@ -496,7 +822,7 @@ export class GitHubFeatureSync {
|
|
|
496
822
|
|
|
497
823
|
const result = await execFileNoThrow('gh', [
|
|
498
824
|
'api',
|
|
499
|
-
|
|
825
|
+
`repos/${owner}/${repo}/milestones`,
|
|
500
826
|
'-X',
|
|
501
827
|
'POST',
|
|
502
828
|
'-f',
|
|
@@ -677,7 +1003,9 @@ export class GitHubFeatureSync {
|
|
|
677
1003
|
acsPercentage: number;
|
|
678
1004
|
tasksPercentage: number;
|
|
679
1005
|
acsTotal?: number;
|
|
1006
|
+
acsCompleted?: number;
|
|
680
1007
|
tasksTotal?: number;
|
|
1008
|
+
tasksCompleted?: number;
|
|
681
1009
|
frontmatterStatus?: string;
|
|
682
1010
|
}
|
|
683
1011
|
): Promise<void> {
|
|
@@ -759,6 +1087,26 @@ export class GitHubFeatureSync {
|
|
|
759
1087
|
} else {
|
|
760
1088
|
console.warn(` ⚠️ Failed to add label ${newStatusLabel}: ${result.stderr}`);
|
|
761
1089
|
}
|
|
1090
|
+
|
|
1091
|
+
// Step 3: Auto-close issue when status:complete and issue still OPEN
|
|
1092
|
+
// This ensures closure happens atomically with the label update,
|
|
1093
|
+
// preventing issues like #1198 where label is applied but issue stays open
|
|
1094
|
+
if (newStatusLabel === 'status:complete' && issueData.state.toLowerCase() !== 'closed') {
|
|
1095
|
+
try {
|
|
1096
|
+
const completionComment = this.calculator.buildCompletionComment(completion as any);
|
|
1097
|
+
await execFileNoThrow('gh', [
|
|
1098
|
+
'issue',
|
|
1099
|
+
'close',
|
|
1100
|
+
issueNumber.toString(),
|
|
1101
|
+
'--comment',
|
|
1102
|
+
completionComment,
|
|
1103
|
+
], { env: this.getGhEnv() });
|
|
1104
|
+
console.log(` ✅ Auto-closed issue #${issueNumber} (status:complete)`);
|
|
1105
|
+
} catch (closeError) {
|
|
1106
|
+
// Non-blocking: close failure shouldn't break sync
|
|
1107
|
+
console.warn(` ⚠️ Failed to auto-close issue #${issueNumber}: ${(closeError as Error).message}`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
762
1110
|
} catch (error) {
|
|
763
1111
|
// Non-blocking: Label update failure shouldn't break sync
|
|
764
1112
|
console.warn(` ⚠️ Failed to update status labels: ${(error as Error).message}`);
|
|
@@ -36,11 +36,13 @@ class UserStoryContentBuilder {
|
|
|
36
36
|
* Build GitHub issue body from user story content
|
|
37
37
|
*
|
|
38
38
|
* @param githubRepo Optional GitHub repo in format "owner/repo" for generating URLs
|
|
39
|
+
* @param branch Optional branch name for URL generation (defaults to 'main')
|
|
39
40
|
*/
|
|
40
|
-
async buildIssueBody(githubRepo) {
|
|
41
|
+
async buildIssueBody(githubRepo, branch) {
|
|
41
42
|
const content = await this.parse();
|
|
42
43
|
let body = "";
|
|
43
44
|
const repo = githubRepo || await this.detectGitHubRepo();
|
|
45
|
+
const branchName = branch || "main";
|
|
44
46
|
const completedACs = content.acceptanceCriteria.filter((ac) => ac.completed).length;
|
|
45
47
|
const totalACs = content.acceptanceCriteria.length;
|
|
46
48
|
const completedTasks = content.tasks.filter((t) => t.status).length;
|
|
@@ -83,7 +85,7 @@ class UserStoryContentBuilder {
|
|
|
83
85
|
const usFilename = path.basename(this.userStoryPath);
|
|
84
86
|
if (repo) {
|
|
85
87
|
const relativePath = this.userStoryPath.replace(this.projectRoot, "").replace(/^\//, "");
|
|
86
|
-
body += `\u{1F4C4} View full story: [\`${usFilename}\`](https://github.com/${repo}/tree
|
|
88
|
+
body += `\u{1F4C4} View full story: [\`${usFilename}\`](https://github.com/${repo}/tree/${branchName}/${relativePath})
|
|
87
89
|
|
|
88
90
|
`;
|
|
89
91
|
}
|
|
@@ -128,7 +130,7 @@ class UserStoryContentBuilder {
|
|
|
128
130
|
`;
|
|
129
131
|
if (content.incrementId) {
|
|
130
132
|
if (repo) {
|
|
131
|
-
body += `**Increment**: [${content.incrementId}](https://github.com/${repo}/tree
|
|
133
|
+
body += `**Increment**: [${content.incrementId}](https://github.com/${repo}/tree/${branchName}/.specweave/increments/${content.incrementId})
|
|
132
134
|
|
|
133
135
|
`;
|
|
134
136
|
} else {
|
|
@@ -142,7 +144,7 @@ class UserStoryContentBuilder {
|
|
|
142
144
|
let taskLink = task.link;
|
|
143
145
|
if (repo && taskLink.startsWith("../../")) {
|
|
144
146
|
const relativePath = taskLink.replace(/^\.\.\/\.\.\//, ".specweave/");
|
|
145
|
-
taskLink = `https://github.com/${repo}/tree
|
|
147
|
+
taskLink = `https://github.com/${repo}/tree/${branchName}/${relativePath}`;
|
|
146
148
|
}
|
|
147
149
|
body += `- ${checkbox} [${task.id}: ${task.title}](${taskLink})
|
|
148
150
|
`;
|
|
@@ -110,14 +110,16 @@ export class UserStoryContentBuilder {
|
|
|
110
110
|
* Build GitHub issue body from user story content
|
|
111
111
|
*
|
|
112
112
|
* @param githubRepo Optional GitHub repo in format "owner/repo" for generating URLs
|
|
113
|
+
* @param branch Optional branch name for URL generation (defaults to 'main')
|
|
113
114
|
*/
|
|
114
|
-
async buildIssueBody(githubRepo?: string): Promise<string> {
|
|
115
|
+
async buildIssueBody(githubRepo?: string, branch?: string): Promise<string> {
|
|
115
116
|
const content = await this.parse();
|
|
116
117
|
|
|
117
118
|
let body = '';
|
|
118
119
|
|
|
119
120
|
// Detect GitHub repo from git remote if not provided
|
|
120
121
|
const repo = githubRepo || await this.detectGitHubRepo();
|
|
122
|
+
const branchName = branch || 'main';
|
|
121
123
|
|
|
122
124
|
// ❌ REMOVED: Metadata header (Feature, Status, Priority)
|
|
123
125
|
// WHY: GitHub has NATIVE fields for this (labels, milestones)
|
|
@@ -160,7 +162,7 @@ export class UserStoryContentBuilder {
|
|
|
160
162
|
const relativePath = this.userStoryPath
|
|
161
163
|
.replace(this.projectRoot, '')
|
|
162
164
|
.replace(/^\//, '');
|
|
163
|
-
body += `📄 View full story: [\`${usFilename}\`](https://github.com/${repo}/tree
|
|
165
|
+
body += `📄 View full story: [\`${usFilename}\`](https://github.com/${repo}/tree/${branchName}/${relativePath})\n\n`;
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
body += `---\n\n`;
|
|
@@ -196,7 +198,7 @@ export class UserStoryContentBuilder {
|
|
|
196
198
|
|
|
197
199
|
if (content.incrementId) {
|
|
198
200
|
if (repo) {
|
|
199
|
-
body += `**Increment**: [${content.incrementId}](https://github.com/${repo}/tree
|
|
201
|
+
body += `**Increment**: [${content.incrementId}](https://github.com/${repo}/tree/${branchName}/.specweave/increments/${content.incrementId})\n\n`;
|
|
200
202
|
} else {
|
|
201
203
|
body += `**Increment**: ${content.incrementId}\n\n`;
|
|
202
204
|
}
|
|
@@ -208,7 +210,7 @@ export class UserStoryContentBuilder {
|
|
|
208
210
|
let taskLink = task.link;
|
|
209
211
|
if (repo && taskLink.startsWith('../../')) {
|
|
210
212
|
const relativePath = taskLink.replace(/^\.\.\/\.\.\//, '.specweave/');
|
|
211
|
-
taskLink = `https://github.com/${repo}/tree
|
|
213
|
+
taskLink = `https://github.com/${repo}/tree/${branchName}/${relativePath}`;
|
|
212
214
|
}
|
|
213
215
|
body += `- ${checkbox} [${task.id}: ${task.title}](${taskLink})\n`;
|
|
214
216
|
}
|
|
@@ -322,21 +322,36 @@ User Story ID: ${frontmatter.id}`
|
|
|
322
322
|
sections.push("");
|
|
323
323
|
if (this.repoOwner && this.repoName) {
|
|
324
324
|
const baseUrl = `https://github.com/${this.repoOwner}/${this.repoName}/blob/${this.branch}`;
|
|
325
|
-
const pathMatch = this.userStoryPath.match(/specs\/([^/]+)\/FS-\d+\//);
|
|
326
|
-
const projectFolder = pathMatch ? pathMatch[1] : "default";
|
|
327
|
-
sections.push(`- **Feature Spec**: [${this.featureId}](${baseUrl}/.specweave/docs/internal/specs/${projectFolder}/${this.featureId}/FEATURE.md)`);
|
|
328
|
-
const relativeUSPath = path.relative(this.projectRoot, this.userStoryPath);
|
|
329
|
-
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](${baseUrl}/${relativeUSPath})`);
|
|
330
325
|
const incrementMatch = implMatch?.[1]?.match(/\*\*Increment\*\*:\s*\[([^\]]+)\]/);
|
|
331
|
-
|
|
332
|
-
|
|
326
|
+
const incrementId = incrementMatch ? incrementMatch[1] : null;
|
|
327
|
+
if (incrementId) {
|
|
328
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](${baseUrl}/.specweave/increments/${incrementId}/spec.md)`);
|
|
329
|
+
} else {
|
|
330
|
+
const pathMatch = this.userStoryPath.match(/specs\/([^/]+)\/FS-\d+\//);
|
|
331
|
+
const projectFolder = pathMatch ? pathMatch[1] : "default";
|
|
332
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](${baseUrl}/.specweave/docs/internal/specs/${projectFolder}/${this.featureId}/FEATURE.md)`);
|
|
333
|
+
}
|
|
334
|
+
if (incrementId) {
|
|
335
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](${baseUrl}/.specweave/increments/${incrementId}/spec.md)`);
|
|
336
|
+
} else {
|
|
337
|
+
const relativeUSPath = path.relative(this.projectRoot, this.userStoryPath);
|
|
338
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](${baseUrl}/${relativeUSPath})`);
|
|
339
|
+
}
|
|
340
|
+
if (incrementId) {
|
|
333
341
|
sections.push(`- **Increment**: [${incrementId}](${baseUrl}/.specweave/increments/${incrementId})`);
|
|
334
342
|
}
|
|
335
343
|
} else {
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
344
|
+
const incrementMatch = implMatch?.[1]?.match(/\*\*Increment\*\*:\s*\[([^\]]+)\]/);
|
|
345
|
+
const incrementId = incrementMatch ? incrementMatch[1] : null;
|
|
346
|
+
if (incrementId) {
|
|
347
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](.specweave/increments/${incrementId}/spec.md)`);
|
|
348
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](.specweave/increments/${incrementId}/spec.md)`);
|
|
349
|
+
} else {
|
|
350
|
+
const pathMatch = this.userStoryPath.match(/specs\/([^/]+)\/FS-\d+\//);
|
|
351
|
+
const projectFolder = pathMatch ? pathMatch[1] : "default";
|
|
352
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](../.specweave/docs/internal/specs/${projectFolder}/${this.featureId}/FEATURE.md)`);
|
|
353
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](${this.userStoryPath})`);
|
|
354
|
+
}
|
|
340
355
|
}
|
|
341
356
|
sections.push("");
|
|
342
357
|
sections.push("---");
|
|
@@ -520,34 +520,52 @@ export class UserStoryIssueBuilder {
|
|
|
520
520
|
sections.push('');
|
|
521
521
|
|
|
522
522
|
// Generate proper GitHub blob URLs
|
|
523
|
-
//
|
|
523
|
+
// v1.0.302 FIX: Use increment paths instead of living docs paths.
|
|
524
|
+
// Living docs (/.specweave/docs/internal/specs/) only exist in the umbrella repo,
|
|
525
|
+
// so links to them return 404 in the target repo. Increment spec.md is always pushed.
|
|
524
526
|
if (this.repoOwner && this.repoName) {
|
|
525
527
|
const baseUrl = `https://github.com/${this.repoOwner}/${this.repoName}/blob/${this.branch}`;
|
|
526
528
|
|
|
527
|
-
// Extract
|
|
528
|
-
const
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
// Feature Spec link
|
|
532
|
-
|
|
529
|
+
// Extract increment ID from Implementation section (if available)
|
|
530
|
+
const incrementMatch = implMatch?.[1]?.match(/\*\*Increment\*\*:\s*\[([^\]]+)\]/);
|
|
531
|
+
const incrementId = incrementMatch ? incrementMatch[1] : null;
|
|
532
|
+
|
|
533
|
+
// Feature Spec link: point to increment spec.md (always exists in target repo)
|
|
534
|
+
if (incrementId) {
|
|
535
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](${baseUrl}/.specweave/increments/${incrementId}/spec.md)`);
|
|
536
|
+
} else {
|
|
537
|
+
// Fallback to living docs path when no increment ID available
|
|
538
|
+
const pathMatch = this.userStoryPath.match(/specs\/([^/]+)\/FS-\d+\//);
|
|
539
|
+
const projectFolder = pathMatch ? pathMatch[1] : 'default';
|
|
540
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](${baseUrl}/.specweave/docs/internal/specs/${projectFolder}/${this.featureId}/FEATURE.md)`);
|
|
541
|
+
}
|
|
533
542
|
|
|
534
|
-
// User Story File link
|
|
535
|
-
|
|
536
|
-
|
|
543
|
+
// User Story File link: point to increment spec.md (always exists in target repo)
|
|
544
|
+
if (incrementId) {
|
|
545
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](${baseUrl}/.specweave/increments/${incrementId}/spec.md)`);
|
|
546
|
+
} else {
|
|
547
|
+
const relativeUSPath = path.relative(this.projectRoot, this.userStoryPath);
|
|
548
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](${baseUrl}/${relativeUSPath})`);
|
|
549
|
+
}
|
|
537
550
|
|
|
538
|
-
// Increment link (
|
|
539
|
-
|
|
540
|
-
if (incrementMatch) {
|
|
541
|
-
const incrementId = incrementMatch[1];
|
|
551
|
+
// Increment link (always points to the increment folder in the target repo)
|
|
552
|
+
if (incrementId) {
|
|
542
553
|
sections.push(`- **Increment**: [${incrementId}](${baseUrl}/.specweave/increments/${incrementId})`);
|
|
543
554
|
}
|
|
544
555
|
} else {
|
|
545
556
|
// Fallback to relative links if repo info not provided
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
557
|
+
const incrementMatch = implMatch?.[1]?.match(/\*\*Increment\*\*:\s*\[([^\]]+)\]/);
|
|
558
|
+
const incrementId = incrementMatch ? incrementMatch[1] : null;
|
|
559
|
+
|
|
560
|
+
if (incrementId) {
|
|
561
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](.specweave/increments/${incrementId}/spec.md)`);
|
|
562
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](.specweave/increments/${incrementId}/spec.md)`);
|
|
563
|
+
} else {
|
|
564
|
+
const pathMatch = this.userStoryPath.match(/specs\/([^/]+)\/FS-\d+\//);
|
|
565
|
+
const projectFolder = pathMatch ? pathMatch[1] : 'default';
|
|
566
|
+
sections.push(`- **Feature Spec**: [${this.featureId}](../.specweave/docs/internal/specs/${projectFolder}/${this.featureId}/FEATURE.md)`);
|
|
567
|
+
sections.push(`- **User Story File**: [${path.basename(this.userStoryPath)}](${this.userStoryPath})`);
|
|
568
|
+
}
|
|
551
569
|
}
|
|
552
570
|
|
|
553
571
|
sections.push('');
|