specweave 0.17.16 → 0.17.19
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/CLAUDE.md +405 -2495
- package/README.md +92 -2
- package/dist/locales/de/.gitkeep +0 -0
- package/dist/locales/de/cli.json +108 -0
- package/dist/locales/en/cli.json +287 -0
- package/dist/locales/en/errors.json +7 -0
- package/dist/locales/en/templates.json +6 -0
- package/dist/locales/es/.gitkeep +0 -0
- package/dist/locales/es/cli.json +41 -0
- package/dist/locales/fr/.gitkeep +0 -0
- package/dist/locales/fr/cli.json +108 -0
- package/dist/locales/ja/.gitkeep +0 -0
- package/dist/locales/ja/cli.json +108 -0
- package/dist/locales/ko/.gitkeep +0 -0
- package/dist/locales/ko/cli.json +108 -0
- package/dist/locales/pt/.gitkeep +0 -0
- package/dist/locales/pt/cli.json +108 -0
- package/dist/locales/ru/.gitkeep +0 -0
- package/dist/locales/ru/cli.json +269 -0
- package/dist/locales/zh/.gitkeep +0 -0
- package/dist/locales/zh/cli.json +108 -0
- package/dist/plugins/specweave/lib/hooks/sync-living-docs.d.ts.map +1 -1
- package/dist/plugins/specweave/lib/hooks/sync-living-docs.js +188 -36
- package/dist/plugins/specweave/lib/hooks/sync-living-docs.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-content-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-content-sync.js +65 -6
- package/dist/plugins/specweave-ado/lib/ado-spec-content-sync.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts +54 -0
- package/dist/plugins/specweave-ado/lib/ado-status-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-status-sync.js +86 -0
- package/dist/plugins/specweave-ado/lib/ado-status-sync.js.map +1 -0
- package/dist/plugins/specweave-ado/lib/enhanced-ado-sync.d.ts +25 -0
- package/dist/plugins/specweave-ado/lib/enhanced-ado-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-ado/lib/enhanced-ado-sync.js +191 -0
- package/dist/plugins/specweave-ado/lib/enhanced-ado-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.d.ts +139 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.js +389 -0
- package/dist/plugins/specweave-github/lib/duplicate-detector.js.map +1 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.d.ts +26 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.js +249 -0
- package/dist/plugins/specweave-github/lib/enhanced-github-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/epic-content-builder.d.ts +63 -0
- package/dist/plugins/specweave-github/lib/epic-content-builder.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/epic-content-builder.js +216 -0
- package/dist/plugins/specweave-github/lib/epic-content-builder.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-client.d.ts +1 -1
- package/dist/plugins/specweave-github/lib/github-client.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-client.js +25 -13
- package/dist/plugins/specweave-github/lib/github-client.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-epic-sync.d.ts +83 -0
- package/dist/plugins/specweave-github/lib/github-epic-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-epic-sync.js +466 -0
- package/dist/plugins/specweave-github/lib/github-epic-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.d.ts +43 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.js +82 -0
- package/dist/plugins/specweave-github/lib/github-status-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/task-sync.d.ts +5 -0
- package/dist/plugins/specweave-github/lib/task-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/task-sync.js +38 -2
- package/dist/plugins/specweave-github/lib/task-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.d.ts +28 -0
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.js +156 -0
- package/dist/plugins/specweave-jira/lib/enhanced-jira-sync.js.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts +66 -0
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.js +274 -0
- package/dist/plugins/specweave-jira/lib/jira-epic-sync.js.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts +56 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.js +93 -0
- package/dist/plugins/specweave-jira/lib/jira-status-sync.js.map +1 -0
- package/dist/spec-parser.js +629 -0
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +107 -3
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/index.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/index.js +48 -3
- package/dist/src/cli/helpers/issue-tracker/index.js.map +1 -1
- package/dist/src/core/deduplication/command-deduplicator.d.ts +166 -0
- package/dist/src/core/deduplication/command-deduplicator.d.ts.map +1 -0
- package/dist/src/core/deduplication/command-deduplicator.js +254 -0
- package/dist/src/core/deduplication/command-deduplicator.js.map +1 -0
- package/dist/src/core/living-docs/hierarchy-mapper.d.ts +142 -0
- package/dist/src/core/living-docs/hierarchy-mapper.d.ts.map +1 -0
- package/dist/src/core/living-docs/hierarchy-mapper.js +453 -0
- package/dist/src/core/living-docs/hierarchy-mapper.js.map +1 -0
- package/dist/src/core/living-docs/index.d.ts +10 -84
- package/dist/src/core/living-docs/index.d.ts.map +1 -1
- package/dist/src/core/living-docs/index.js +10 -164
- package/dist/src/core/living-docs/index.js.map +1 -1
- package/dist/src/core/living-docs/spec-distributor.d.ts +106 -0
- package/dist/src/core/living-docs/spec-distributor.d.ts.map +1 -0
- package/dist/src/core/living-docs/spec-distributor.js +823 -0
- package/dist/src/core/living-docs/spec-distributor.js.map +1 -0
- package/dist/src/core/living-docs/types.d.ts +201 -0
- package/dist/src/core/living-docs/types.d.ts.map +1 -0
- package/dist/src/core/living-docs/types.js +15 -0
- package/dist/src/core/living-docs/types.js.map +1 -0
- package/dist/src/core/logging/prompt-logger.d.ts +70 -0
- package/dist/src/core/logging/prompt-logger.d.ts.map +1 -0
- package/dist/src/core/logging/prompt-logger.js +247 -0
- package/dist/src/core/logging/prompt-logger.js.map +1 -0
- package/dist/src/core/status-line/status-line-manager.d.ts +15 -24
- package/dist/src/core/status-line/status-line-manager.d.ts.map +1 -1
- package/dist/src/core/status-line/status-line-manager.js +33 -70
- package/dist/src/core/status-line/status-line-manager.js.map +1 -1
- package/dist/src/core/status-line/types.d.ts +19 -31
- package/dist/src/core/status-line/types.d.ts.map +1 -1
- package/dist/src/core/status-line/types.js +5 -9
- package/dist/src/core/status-line/types.js.map +1 -1
- package/dist/src/core/sync/conflict-resolver.d.ts +66 -0
- package/dist/src/core/sync/conflict-resolver.d.ts.map +1 -0
- package/dist/src/core/sync/conflict-resolver.js +108 -0
- package/dist/src/core/sync/conflict-resolver.js.map +1 -0
- package/dist/src/core/sync/enhanced-content-builder.d.ts +55 -0
- package/dist/src/core/sync/enhanced-content-builder.d.ts.map +1 -0
- package/dist/src/core/sync/enhanced-content-builder.js +202 -0
- package/dist/src/core/sync/enhanced-content-builder.js.map +1 -0
- package/dist/src/core/sync/label-detector.d.ts +66 -0
- package/dist/src/core/sync/label-detector.d.ts.map +1 -0
- package/dist/src/core/sync/label-detector.js +211 -0
- package/dist/src/core/sync/label-detector.js.map +1 -0
- package/dist/src/core/sync/retry-logic.d.ts +64 -0
- package/dist/src/core/sync/retry-logic.d.ts.map +1 -0
- package/dist/src/core/sync/retry-logic.js +165 -0
- package/dist/src/core/sync/retry-logic.js.map +1 -0
- package/dist/src/core/sync/spec-content-sync.d.ts +88 -0
- package/dist/src/core/sync/spec-content-sync.d.ts.map +1 -0
- package/dist/src/core/sync/spec-content-sync.js +5 -0
- package/dist/src/core/sync/spec-content-sync.js.map +1 -0
- package/dist/src/core/sync/spec-increment-mapper.d.ts +100 -0
- package/dist/src/core/sync/spec-increment-mapper.d.ts.map +1 -0
- package/dist/src/core/sync/spec-increment-mapper.js +424 -0
- package/dist/src/core/sync/spec-increment-mapper.js.map +1 -0
- package/dist/src/core/sync/status-cache.d.ts +91 -0
- package/dist/src/core/sync/status-cache.d.ts.map +1 -0
- package/dist/src/core/sync/status-cache.js +140 -0
- package/dist/src/core/sync/status-cache.js.map +1 -0
- package/dist/src/core/sync/status-mapper.d.ts +69 -0
- package/dist/src/core/sync/status-mapper.d.ts.map +1 -0
- package/dist/src/core/sync/status-mapper.js +90 -0
- package/dist/src/core/sync/status-mapper.js.map +1 -0
- package/dist/src/core/sync/status-sync-engine.d.ts +162 -0
- package/dist/src/core/sync/status-sync-engine.d.ts.map +1 -0
- package/dist/src/core/sync/status-sync-engine.js +347 -0
- package/dist/src/core/sync/status-sync-engine.js.map +1 -0
- package/dist/src/core/sync/sync-event-logger.d.ts +99 -0
- package/dist/src/core/sync/sync-event-logger.d.ts.map +1 -0
- package/dist/src/core/sync/sync-event-logger.js +103 -0
- package/dist/src/core/sync/sync-event-logger.js.map +1 -0
- package/dist/src/core/sync/types.d.ts +52 -0
- package/dist/src/core/sync/types.d.ts.map +1 -0
- package/dist/src/core/sync/types.js +5 -0
- package/dist/src/core/sync/types.js.map +1 -0
- package/dist/src/core/sync/workflow-detector.d.ts +95 -0
- package/dist/src/core/sync/workflow-detector.d.ts.map +1 -0
- package/dist/src/core/sync/workflow-detector.js +175 -0
- package/dist/src/core/sync/workflow-detector.js.map +1 -0
- package/dist/src/core/types/config.d.ts +51 -0
- package/dist/src/core/types/config.d.ts.map +1 -1
- package/dist/src/core/types/config.js +47 -0
- package/dist/src/core/types/config.js.map +1 -1
- package/dist/src/core/types/increment-metadata.d.ts +4 -0
- package/dist/src/core/types/increment-metadata.d.ts.map +1 -1
- package/dist/src/core/types/increment-metadata.js.map +1 -1
- package/dist/src/utils/github-url.d.ts +53 -0
- package/dist/src/utils/github-url.d.ts.map +1 -0
- package/dist/src/utils/github-url.js +90 -0
- package/dist/src/utils/github-url.js.map +1 -0
- package/dist/src/utils/spec-parser.d.ts +145 -0
- package/dist/src/utils/spec-parser.d.ts.map +1 -0
- package/dist/src/utils/spec-parser.js +640 -0
- package/dist/src/utils/spec-parser.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +1 -1
- package/plugins/specweave/agents/pm/AGENT.md +160 -13
- package/plugins/specweave/agents/pm/templates/increment-spec.md +158 -0
- package/plugins/specweave/agents/pm/templates/living-docs-spec.md +113 -0
- package/plugins/specweave/commands/specweave-done.md +163 -0
- package/plugins/specweave/commands/specweave.md +70 -405
- package/plugins/specweave/hooks/hooks.json +4 -0
- package/plugins/specweave/hooks/lib/sync-spec-content.sh +2 -2
- package/plugins/specweave/hooks/lib/update-status-line.sh +79 -111
- package/plugins/specweave/hooks/post-increment-planning.sh +133 -37
- package/plugins/specweave/hooks/pre-command-deduplication.sh +86 -0
- package/plugins/specweave/lib/hooks/sync-living-docs.js +139 -34
- package/plugins/specweave/lib/hooks/sync-living-docs.ts +234 -38
- package/plugins/specweave/skills/SKILLS-INDEX.md +4 -24
- package/plugins/specweave/skills/increment-planner/SKILL.md +94 -0
- package/plugins/specweave/skills/increment-work-router/SKILL.md +466 -0
- package/plugins/specweave-ado/commands/specweave-ado-sync-spec.md +1 -1
- package/plugins/specweave-ado/lib/ado-spec-content-sync.js +49 -5
- package/plugins/specweave-ado/lib/ado-spec-content-sync.ts +72 -6
- package/plugins/specweave-ado/lib/ado-status-sync.js +80 -0
- package/plugins/specweave-ado/lib/ado-status-sync.ts +121 -0
- package/plugins/specweave-ado/lib/enhanced-ado-sync.js +170 -0
- package/plugins/specweave-github/commands/specweave-github-cleanup-duplicates.md +205 -0
- package/plugins/specweave-github/commands/specweave-github-sync-epic.md +248 -0
- package/plugins/specweave-github/commands/specweave-github-sync-spec.md +1 -1
- package/plugins/specweave-github/hooks/post-task-completion.sh +32 -0
- package/plugins/specweave-github/lib/duplicate-detector.js +370 -0
- package/plugins/specweave-github/lib/duplicate-detector.ts +525 -0
- package/plugins/specweave-github/lib/enhanced-github-sync.js +220 -0
- package/plugins/specweave-github/lib/enhanced-github-sync.ts +322 -0
- package/plugins/specweave-github/lib/epic-content-builder.js +227 -0
- package/plugins/specweave-github/lib/epic-content-builder.ts +317 -0
- package/plugins/specweave-github/lib/github-client.js +21 -10
- package/plugins/specweave-github/lib/github-client.ts +27 -16
- package/plugins/specweave-github/lib/github-epic-sync.js +488 -0
- package/plugins/specweave-github/lib/github-epic-sync.ts +715 -0
- package/plugins/specweave-github/lib/github-status-sync.js +71 -0
- package/plugins/specweave-github/lib/github-status-sync.ts +107 -0
- package/plugins/specweave-github/lib/task-sync.js +33 -2
- package/plugins/specweave-github/lib/task-sync.ts +44 -2
- package/plugins/specweave-jira/commands/specweave-jira-sync-epic.md +267 -0
- package/plugins/specweave-jira/commands/specweave-jira-sync-spec.md +1 -1
- package/plugins/specweave-jira/lib/enhanced-jira-sync.js +134 -0
- package/plugins/specweave-jira/lib/enhanced-jira-sync.ts +196 -0
- package/plugins/specweave-jira/lib/jira-epic-sync.js +304 -0
- package/plugins/specweave-jira/lib/jira-epic-sync.ts +459 -0
- package/plugins/specweave-jira/lib/jira-status-sync.js +79 -0
- package/plugins/specweave-jira/lib/jira-status-sync.ts +139 -0
- package/plugins/specweave-release/commands/specweave-release-platform.md +1 -1
- package/plugins/specweave-release/hooks/post-task-completion.sh +2 -2
- package/src/templates/AGENTS.md.template +88 -1
- package/src/templates/CLAUDE.md.template +49 -0
- package/plugins/specweave/skills/increment-quality-judge/SKILL.md +0 -524
- package/plugins/specweave/skills/plugin-installer/SKILL.md +0 -353
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as yaml from "yaml";
|
|
5
|
+
class EpicContentBuilder {
|
|
6
|
+
constructor(epicFolder, projectRoot) {
|
|
7
|
+
this.epicFolder = epicFolder;
|
|
8
|
+
this.projectRoot = projectRoot;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build hierarchical GitHub issue body
|
|
12
|
+
*
|
|
13
|
+
* Format:
|
|
14
|
+
* - Epic overview
|
|
15
|
+
* - User Stories section (checkable, with status + increment)
|
|
16
|
+
* - Tasks section (grouped by User Story)
|
|
17
|
+
*/
|
|
18
|
+
async buildIssueBody() {
|
|
19
|
+
const epicData = await this.readEpicMetadata();
|
|
20
|
+
const userStories = await this.readUserStories();
|
|
21
|
+
const overview = this.buildOverviewSection(epicData);
|
|
22
|
+
const userStoriesSection = this.buildUserStoriesSection(userStories);
|
|
23
|
+
const tasksSection = this.buildTasksSection(userStories);
|
|
24
|
+
return `${overview}
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
${userStoriesSection}
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
${tasksSection}
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
\u{1F916} Auto-created by SpecWeave Epic Sync`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read Epic FEATURE.md frontmatter
|
|
40
|
+
*/
|
|
41
|
+
async readEpicMetadata() {
|
|
42
|
+
const featurePath = path.join(this.epicFolder, "FEATURE.md");
|
|
43
|
+
const content = await readFile(featurePath, "utf-8");
|
|
44
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
45
|
+
if (!match) {
|
|
46
|
+
throw new Error("FEATURE.md missing YAML frontmatter");
|
|
47
|
+
}
|
|
48
|
+
return yaml.parse(match[1]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Read all user stories from us-*.md files
|
|
52
|
+
*/
|
|
53
|
+
async readUserStories() {
|
|
54
|
+
const files = await readdir(this.epicFolder);
|
|
55
|
+
const usFiles = files.filter((f) => f.startsWith("us-") && f.endsWith(".md"));
|
|
56
|
+
const userStories = [];
|
|
57
|
+
for (const file of usFiles.sort()) {
|
|
58
|
+
const filePath = path.join(this.epicFolder, file);
|
|
59
|
+
const content = await readFile(filePath, "utf-8");
|
|
60
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
61
|
+
if (!match) {
|
|
62
|
+
console.warn(` \u26A0\uFE0F ${file} missing frontmatter, skipping`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const frontmatter = yaml.parse(match[1]);
|
|
66
|
+
const bodyContent = content.slice(match[0].length).trim();
|
|
67
|
+
const incrementMatch = bodyContent.match(/\*\*Increment\*\*:\s*\[([^\]]+)\]/);
|
|
68
|
+
const increment = incrementMatch ? incrementMatch[1] : null;
|
|
69
|
+
const tasks = await this.extractTasksForUserStory(
|
|
70
|
+
frontmatter.id,
|
|
71
|
+
increment,
|
|
72
|
+
bodyContent
|
|
73
|
+
);
|
|
74
|
+
userStories.push({
|
|
75
|
+
id: frontmatter.id,
|
|
76
|
+
title: frontmatter.title,
|
|
77
|
+
status: this.normalizeStatus(frontmatter.status),
|
|
78
|
+
increment,
|
|
79
|
+
tasks
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return userStories;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Extract tasks for a user story from its Implementation section
|
|
86
|
+
*/
|
|
87
|
+
async extractTasksForUserStory(userStoryId, incrementId, content) {
|
|
88
|
+
if (!incrementId) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const incrementFolder = path.join(
|
|
92
|
+
this.projectRoot,
|
|
93
|
+
".specweave",
|
|
94
|
+
"increments",
|
|
95
|
+
incrementId
|
|
96
|
+
);
|
|
97
|
+
if (!existsSync(incrementFolder)) {
|
|
98
|
+
console.warn(` \u26A0\uFE0F Increment folder not found: ${incrementId}`);
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const tasksPath = path.join(incrementFolder, "tasks.md");
|
|
102
|
+
if (!existsSync(tasksPath)) {
|
|
103
|
+
console.warn(` \u26A0\uFE0F tasks.md not found in ${incrementId}`);
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const tasksContent = await readFile(tasksPath, "utf-8");
|
|
107
|
+
const taskLinkPattern = /- \[([T-\d]+):\s*([^\]]+)\]/g;
|
|
108
|
+
const taskLinks = [];
|
|
109
|
+
let match;
|
|
110
|
+
while ((match = taskLinkPattern.exec(content)) !== null) {
|
|
111
|
+
taskLinks.push({
|
|
112
|
+
id: match[1],
|
|
113
|
+
// e.g., "T-001"
|
|
114
|
+
title: match[2].trim()
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const tasks = [];
|
|
118
|
+
for (const taskLink of taskLinks) {
|
|
119
|
+
const taskPattern = new RegExp(
|
|
120
|
+
`###\\s+${taskLink.id}:\\s*([^\\n]+)[\\s\\S]*?\\*\\*Status\\*\\*:\\s*\\[([x\\s])\\]`,
|
|
121
|
+
"i"
|
|
122
|
+
);
|
|
123
|
+
const taskMatch = tasksContent.match(taskPattern);
|
|
124
|
+
const isCompleted = taskMatch ? taskMatch[2] === "x" : false;
|
|
125
|
+
tasks.push({
|
|
126
|
+
id: taskLink.id,
|
|
127
|
+
title: taskLink.title,
|
|
128
|
+
status: isCompleted,
|
|
129
|
+
userStoryId
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return tasks;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Build overview section
|
|
136
|
+
*/
|
|
137
|
+
buildOverviewSection(epic) {
|
|
138
|
+
return `# [${epic.id}] ${epic.title}
|
|
139
|
+
|
|
140
|
+
**Status**: ${epic.status}
|
|
141
|
+
**Created**: ${epic.created}
|
|
142
|
+
**Last Updated**: ${epic.last_updated}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Build User Stories section
|
|
146
|
+
*/
|
|
147
|
+
buildUserStoriesSection(userStories) {
|
|
148
|
+
const total = userStories.length;
|
|
149
|
+
const completed = userStories.filter((us) => us.status === "complete").length;
|
|
150
|
+
const percentage = total > 0 ? Math.round(completed / total * 100) : 0;
|
|
151
|
+
let section = `## User Stories
|
|
152
|
+
|
|
153
|
+
Progress: ${completed}/${total} user stories complete (${percentage}%)
|
|
154
|
+
|
|
155
|
+
`;
|
|
156
|
+
for (const us of userStories) {
|
|
157
|
+
const checkbox = us.status === "complete" ? "[x]" : "[ ]";
|
|
158
|
+
const statusEmoji = this.getStatusEmoji(us.status);
|
|
159
|
+
const incrementLink = us.increment ? `[${us.increment}](../../increments/${us.increment}/)` : "TBD";
|
|
160
|
+
section += `- ${checkbox} **${us.id}: ${us.title}** (${statusEmoji} ${us.status} | Increment: ${incrementLink})
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
return section;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build Tasks section (grouped by User Story)
|
|
167
|
+
*/
|
|
168
|
+
buildTasksSection(userStories) {
|
|
169
|
+
const totalTasks = userStories.reduce((sum, us) => sum + us.tasks.length, 0);
|
|
170
|
+
const completedTasks = userStories.reduce(
|
|
171
|
+
(sum, us) => sum + us.tasks.filter((t) => t.status).length,
|
|
172
|
+
0
|
|
173
|
+
);
|
|
174
|
+
const percentage = totalTasks > 0 ? Math.round(completedTasks / totalTasks * 100) : 0;
|
|
175
|
+
let section = `## Tasks by User Story
|
|
176
|
+
|
|
177
|
+
Progress: ${completedTasks}/${totalTasks} tasks complete (${percentage}%)
|
|
178
|
+
|
|
179
|
+
`;
|
|
180
|
+
for (const us of userStories) {
|
|
181
|
+
if (us.tasks.length === 0) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const incrementLink = us.increment ? `[${us.increment}](../../increments/${us.increment}/tasks.md)` : "TBD";
|
|
185
|
+
section += `### ${us.id}: ${us.title} (Increment: ${incrementLink})
|
|
186
|
+
|
|
187
|
+
`;
|
|
188
|
+
for (const task of us.tasks) {
|
|
189
|
+
const checkbox = task.status ? "[x]" : "[ ]";
|
|
190
|
+
section += `- ${checkbox} ${task.id}: ${task.title}
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
section += "\n";
|
|
194
|
+
}
|
|
195
|
+
return section;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Normalize status values
|
|
199
|
+
*/
|
|
200
|
+
normalizeStatus(status) {
|
|
201
|
+
const normalized = status.toLowerCase();
|
|
202
|
+
if (normalized === "complete" || normalized === "completed") return "complete";
|
|
203
|
+
if (normalized === "active" || normalized === "in-progress") return "active";
|
|
204
|
+
if (normalized === "planning") return "planning";
|
|
205
|
+
return "not-started";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Get status emoji
|
|
209
|
+
*/
|
|
210
|
+
getStatusEmoji(status) {
|
|
211
|
+
switch (status) {
|
|
212
|
+
case "complete":
|
|
213
|
+
return "\u2705";
|
|
214
|
+
case "active":
|
|
215
|
+
return "\u{1F6A7}";
|
|
216
|
+
case "planning":
|
|
217
|
+
return "\u{1F4CB}";
|
|
218
|
+
case "not-started":
|
|
219
|
+
return "\u23F3";
|
|
220
|
+
default:
|
|
221
|
+
return "\u2753";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export {
|
|
226
|
+
EpicContentBuilder
|
|
227
|
+
};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epic Content Builder - Hierarchical GitHub issue content for Feature Specs
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* - Reads FS-* folder (FEATURE.md + us-*.md files)
|
|
6
|
+
* - Reads increment tasks.md files to map tasks to user stories
|
|
7
|
+
* - Generates hierarchical issue body:
|
|
8
|
+
* 1. User Stories section (with status + increment)
|
|
9
|
+
* 2. Tasks section (grouped by User Story)
|
|
10
|
+
*
|
|
11
|
+
* Key Features:
|
|
12
|
+
* - NO single "Increment" field (epics span multiple increments)
|
|
13
|
+
* - User Stories are checkable with status and increment link
|
|
14
|
+
* - Tasks grouped under their User Story
|
|
15
|
+
* - Shows which increment each US/task belongs to
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdir, readFile } from 'fs/promises';
|
|
19
|
+
import { existsSync } from 'fs';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import * as yaml from 'yaml';
|
|
22
|
+
|
|
23
|
+
interface UserStory {
|
|
24
|
+
id: string;
|
|
25
|
+
title: string;
|
|
26
|
+
status: 'complete' | 'active' | 'planning' | 'not-started';
|
|
27
|
+
increment: string | null; // e.g., "0031-external-tool-status-sync"
|
|
28
|
+
tasks: Task[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Task {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
status: boolean; // true = completed, false = not started
|
|
35
|
+
userStoryId: string; // e.g., "US-001"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface EpicFrontmatter {
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
status: string;
|
|
42
|
+
created: string;
|
|
43
|
+
last_updated: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface UserStoryFrontmatter {
|
|
47
|
+
id: string;
|
|
48
|
+
epic: string;
|
|
49
|
+
title: string;
|
|
50
|
+
status: string;
|
|
51
|
+
created: string;
|
|
52
|
+
completed?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class EpicContentBuilder {
|
|
56
|
+
private epicFolder: string;
|
|
57
|
+
private projectRoot: string;
|
|
58
|
+
|
|
59
|
+
constructor(epicFolder: string, projectRoot: string) {
|
|
60
|
+
this.epicFolder = epicFolder;
|
|
61
|
+
this.projectRoot = projectRoot;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build hierarchical GitHub issue body
|
|
66
|
+
*
|
|
67
|
+
* Format:
|
|
68
|
+
* - Epic overview
|
|
69
|
+
* - User Stories section (checkable, with status + increment)
|
|
70
|
+
* - Tasks section (grouped by User Story)
|
|
71
|
+
*/
|
|
72
|
+
async buildIssueBody(): Promise<string> {
|
|
73
|
+
// 1. Read Epic metadata
|
|
74
|
+
const epicData = await this.readEpicMetadata();
|
|
75
|
+
|
|
76
|
+
// 2. Read User Stories
|
|
77
|
+
const userStories = await this.readUserStories();
|
|
78
|
+
|
|
79
|
+
// 3. Build sections
|
|
80
|
+
const overview = this.buildOverviewSection(epicData);
|
|
81
|
+
const userStoriesSection = this.buildUserStoriesSection(userStories);
|
|
82
|
+
const tasksSection = this.buildTasksSection(userStories);
|
|
83
|
+
|
|
84
|
+
// 4. Combine
|
|
85
|
+
return `${overview}\n\n---\n\n${userStoriesSection}\n\n---\n\n${tasksSection}\n\n---\n\n🤖 Auto-created by SpecWeave Epic Sync`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read Epic FEATURE.md frontmatter
|
|
90
|
+
*/
|
|
91
|
+
private async readEpicMetadata(): Promise<EpicFrontmatter> {
|
|
92
|
+
const featurePath = path.join(this.epicFolder, 'FEATURE.md');
|
|
93
|
+
const content = await readFile(featurePath, 'utf-8');
|
|
94
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
95
|
+
|
|
96
|
+
if (!match) {
|
|
97
|
+
throw new Error('FEATURE.md missing YAML frontmatter');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return yaml.parse(match[1]) as EpicFrontmatter;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read all user stories from us-*.md files
|
|
105
|
+
*/
|
|
106
|
+
private async readUserStories(): Promise<UserStory[]> {
|
|
107
|
+
const files = await readdir(this.epicFolder);
|
|
108
|
+
const usFiles = files.filter((f) => f.startsWith('us-') && f.endsWith('.md'));
|
|
109
|
+
|
|
110
|
+
const userStories: UserStory[] = [];
|
|
111
|
+
|
|
112
|
+
for (const file of usFiles.sort()) {
|
|
113
|
+
const filePath = path.join(this.epicFolder, file);
|
|
114
|
+
const content = await readFile(filePath, 'utf-8');
|
|
115
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
116
|
+
|
|
117
|
+
if (!match) {
|
|
118
|
+
console.warn(` ⚠️ ${file} missing frontmatter, skipping`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const frontmatter = yaml.parse(match[1]) as UserStoryFrontmatter;
|
|
123
|
+
const bodyContent = content.slice(match[0].length).trim();
|
|
124
|
+
|
|
125
|
+
// Extract increment from Implementation section
|
|
126
|
+
const incrementMatch = bodyContent.match(/\*\*Increment\*\*:\s*\[([^\]]+)\]/);
|
|
127
|
+
const increment = incrementMatch ? incrementMatch[1] : null;
|
|
128
|
+
|
|
129
|
+
// Extract tasks from Implementation section
|
|
130
|
+
const tasks = await this.extractTasksForUserStory(
|
|
131
|
+
frontmatter.id,
|
|
132
|
+
increment,
|
|
133
|
+
bodyContent
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
userStories.push({
|
|
137
|
+
id: frontmatter.id,
|
|
138
|
+
title: frontmatter.title,
|
|
139
|
+
status: this.normalizeStatus(frontmatter.status),
|
|
140
|
+
increment,
|
|
141
|
+
tasks,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return userStories;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Extract tasks for a user story from its Implementation section
|
|
150
|
+
*/
|
|
151
|
+
private async extractTasksForUserStory(
|
|
152
|
+
userStoryId: string,
|
|
153
|
+
incrementId: string | null,
|
|
154
|
+
content: string
|
|
155
|
+
): Promise<Task[]> {
|
|
156
|
+
if (!incrementId) {
|
|
157
|
+
return []; // No increment yet, no tasks
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Find increment folder
|
|
161
|
+
const incrementFolder = path.join(
|
|
162
|
+
this.projectRoot,
|
|
163
|
+
'.specweave',
|
|
164
|
+
'increments',
|
|
165
|
+
incrementId
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (!existsSync(incrementFolder)) {
|
|
169
|
+
console.warn(` ⚠️ Increment folder not found: ${incrementId}`);
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tasksPath = path.join(incrementFolder, 'tasks.md');
|
|
174
|
+
if (!existsSync(tasksPath)) {
|
|
175
|
+
console.warn(` ⚠️ tasks.md not found in ${incrementId}`);
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Read tasks.md
|
|
180
|
+
const tasksContent = await readFile(tasksPath, 'utf-8');
|
|
181
|
+
|
|
182
|
+
// Extract task links from user story's Implementation section
|
|
183
|
+
// Format: - [T-001: Title](link#t-001-title)
|
|
184
|
+
const taskLinkPattern = /- \[([T-\d]+):\s*([^\]]+)\]/g;
|
|
185
|
+
const taskLinks: Array<{ id: string; title: string }> = [];
|
|
186
|
+
|
|
187
|
+
let match;
|
|
188
|
+
while ((match = taskLinkPattern.exec(content)) !== null) {
|
|
189
|
+
taskLinks.push({
|
|
190
|
+
id: match[1], // e.g., "T-001"
|
|
191
|
+
title: match[2].trim(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Parse tasks from tasks.md to get completion status
|
|
196
|
+
const tasks: Task[] = [];
|
|
197
|
+
|
|
198
|
+
for (const taskLink of taskLinks) {
|
|
199
|
+
// Find task in tasks.md by heading pattern: ### T-001: Title
|
|
200
|
+
const taskPattern = new RegExp(
|
|
201
|
+
`###\\s+${taskLink.id}:\\s*([^\\n]+)[\\s\\S]*?\\*\\*Status\\*\\*:\\s*\\[([x\\s])\\]`,
|
|
202
|
+
'i'
|
|
203
|
+
);
|
|
204
|
+
const taskMatch = tasksContent.match(taskPattern);
|
|
205
|
+
|
|
206
|
+
const isCompleted = taskMatch ? taskMatch[2] === 'x' : false;
|
|
207
|
+
|
|
208
|
+
tasks.push({
|
|
209
|
+
id: taskLink.id,
|
|
210
|
+
title: taskLink.title,
|
|
211
|
+
status: isCompleted,
|
|
212
|
+
userStoryId,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return tasks;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Build overview section
|
|
221
|
+
*/
|
|
222
|
+
private buildOverviewSection(epic: EpicFrontmatter): string {
|
|
223
|
+
return `# [${epic.id}] ${epic.title}\n\n**Status**: ${epic.status}\n**Created**: ${epic.created}\n**Last Updated**: ${epic.last_updated}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build User Stories section
|
|
228
|
+
*/
|
|
229
|
+
private buildUserStoriesSection(userStories: UserStory[]): string {
|
|
230
|
+
const total = userStories.length;
|
|
231
|
+
const completed = userStories.filter((us) => us.status === 'complete').length;
|
|
232
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
233
|
+
|
|
234
|
+
let section = `## User Stories\n\nProgress: ${completed}/${total} user stories complete (${percentage}%)\n\n`;
|
|
235
|
+
|
|
236
|
+
for (const us of userStories) {
|
|
237
|
+
const checkbox = us.status === 'complete' ? '[x]' : '[ ]';
|
|
238
|
+
const statusEmoji = this.getStatusEmoji(us.status);
|
|
239
|
+
const incrementLink = us.increment
|
|
240
|
+
? `[${us.increment}](../../increments/${us.increment}/)`
|
|
241
|
+
: 'TBD';
|
|
242
|
+
|
|
243
|
+
section += `- ${checkbox} **${us.id}: ${us.title}** (${statusEmoji} ${us.status} | Increment: ${incrementLink})\n`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return section;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build Tasks section (grouped by User Story)
|
|
251
|
+
*/
|
|
252
|
+
private buildTasksSection(userStories: UserStory[]): string {
|
|
253
|
+
const totalTasks = userStories.reduce((sum, us) => sum + us.tasks.length, 0);
|
|
254
|
+
const completedTasks = userStories.reduce(
|
|
255
|
+
(sum, us) => sum + us.tasks.filter((t) => t.status).length,
|
|
256
|
+
0
|
|
257
|
+
);
|
|
258
|
+
const percentage =
|
|
259
|
+
totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
|
260
|
+
|
|
261
|
+
let section = `## Tasks by User Story\n\nProgress: ${completedTasks}/${totalTasks} tasks complete (${percentage}%)\n\n`;
|
|
262
|
+
|
|
263
|
+
for (const us of userStories) {
|
|
264
|
+
if (us.tasks.length === 0) {
|
|
265
|
+
continue; // Skip user stories with no tasks yet
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const incrementLink = us.increment
|
|
269
|
+
? `[${us.increment}](../../increments/${us.increment}/tasks.md)`
|
|
270
|
+
: 'TBD';
|
|
271
|
+
|
|
272
|
+
section += `### ${us.id}: ${us.title} (Increment: ${incrementLink})\n\n`;
|
|
273
|
+
|
|
274
|
+
for (const task of us.tasks) {
|
|
275
|
+
const checkbox = task.status ? '[x]' : '[ ]';
|
|
276
|
+
section += `- ${checkbox} ${task.id}: ${task.title}\n`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
section += '\n';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return section;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Normalize status values
|
|
287
|
+
*/
|
|
288
|
+
private normalizeStatus(
|
|
289
|
+
status: string
|
|
290
|
+
): 'complete' | 'active' | 'planning' | 'not-started' {
|
|
291
|
+
const normalized = status.toLowerCase();
|
|
292
|
+
if (normalized === 'complete' || normalized === 'completed') return 'complete';
|
|
293
|
+
if (normalized === 'active' || normalized === 'in-progress') return 'active';
|
|
294
|
+
if (normalized === 'planning') return 'planning';
|
|
295
|
+
return 'not-started';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get status emoji
|
|
300
|
+
*/
|
|
301
|
+
private getStatusEmoji(
|
|
302
|
+
status: 'complete' | 'active' | 'planning' | 'not-started'
|
|
303
|
+
): string {
|
|
304
|
+
switch (status) {
|
|
305
|
+
case 'complete':
|
|
306
|
+
return '✅';
|
|
307
|
+
case 'active':
|
|
308
|
+
return '🚧';
|
|
309
|
+
case 'planning':
|
|
310
|
+
return '📋';
|
|
311
|
+
case 'not-started':
|
|
312
|
+
return '⏳';
|
|
313
|
+
default:
|
|
314
|
+
return '❓';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
+
import { DuplicateDetector } from "./duplicate-detector.js";
|
|
2
3
|
class GitHubClient {
|
|
3
4
|
constructor(repo) {
|
|
4
5
|
this.repo = repo || this.detectRepo();
|
|
@@ -70,21 +71,31 @@ class GitHubClient {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
|
-
* Create epic issue (increment-level)
|
|
74
|
+
* Create epic issue (increment-level) with FULL DUPLICATE PROTECTION
|
|
74
75
|
*/
|
|
75
76
|
async createEpicIssue(title, body, milestone, labels = []) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const titlePattern = DuplicateDetector.extractTitlePattern(title);
|
|
78
|
+
if (!titlePattern) {
|
|
79
|
+
throw new Error(`Epic issue title must start with pattern like [FS-XXX] or [INC-XXXX]: ${title}`);
|
|
80
|
+
}
|
|
79
81
|
try {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
const result = await DuplicateDetector.createWithProtection({
|
|
83
|
+
title,
|
|
84
|
+
body,
|
|
85
|
+
titlePattern,
|
|
86
|
+
labels: labels.length > 0 ? labels : ["specweave", "increment"],
|
|
87
|
+
milestone: milestone?.toString(),
|
|
88
|
+
repo: this.repo
|
|
89
|
+
});
|
|
90
|
+
const viewCmd = `gh issue view ${result.issue.number} --repo ${this.repo} --json number,title,body,state,url,labels,milestone`;
|
|
86
91
|
const output = execSync(viewCmd, { encoding: "utf-8" });
|
|
87
92
|
const issue = JSON.parse(output);
|
|
93
|
+
if (result.wasReused) {
|
|
94
|
+
console.log(` \u267B\uFE0F Reused existing issue #${result.issue.number} (duplicate prevention)`);
|
|
95
|
+
}
|
|
96
|
+
if (result.duplicatesFound > 0) {
|
|
97
|
+
console.log(` \u{1F6E1}\uFE0F Duplicates detected: ${result.duplicatesFound} (auto-closed: ${result.duplicatesClosed})`);
|
|
98
|
+
}
|
|
88
99
|
return {
|
|
89
100
|
...issue,
|
|
90
101
|
html_url: issue.url,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
import { GitHubIssue, GitHubMilestone } from './types';
|
|
8
|
+
import { DuplicateDetector } from './duplicate-detector.js';
|
|
8
9
|
|
|
9
10
|
export class GitHubClient {
|
|
10
11
|
private repo: string;
|
|
@@ -96,7 +97,7 @@ export class GitHubClient {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/**
|
|
99
|
-
* Create epic issue (increment-level)
|
|
100
|
+
* Create epic issue (increment-level) with FULL DUPLICATE PROTECTION
|
|
100
101
|
*/
|
|
101
102
|
async createEpicIssue(
|
|
102
103
|
title: string,
|
|
@@ -104,27 +105,37 @@ export class GitHubClient {
|
|
|
104
105
|
milestone?: number | string,
|
|
105
106
|
labels: string[] = []
|
|
106
107
|
): Promise<GitHubIssue> {
|
|
107
|
-
|
|
108
|
-
const
|
|
108
|
+
// Extract title pattern for duplicate detection (e.g., "[FS-031]" from "[FS-031] Feature Title")
|
|
109
|
+
const titlePattern = DuplicateDetector.extractTitlePattern(title);
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
if (!titlePattern) {
|
|
112
|
+
throw new Error(`Epic issue title must start with pattern like [FS-XXX] or [INC-XXXX]: ${title}`);
|
|
113
|
+
}
|
|
112
114
|
|
|
113
115
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
116
|
+
// Use DuplicateDetector for full 3-phase protection
|
|
117
|
+
const result = await DuplicateDetector.createWithProtection({
|
|
118
|
+
title,
|
|
119
|
+
body,
|
|
120
|
+
titlePattern,
|
|
121
|
+
labels: labels.length > 0 ? labels : ['specweave', 'increment'],
|
|
122
|
+
milestone: milestone?.toString(),
|
|
123
|
+
repo: this.repo
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Fetch full issue details (DuplicateDetector returns minimal info)
|
|
127
|
+
const viewCmd = `gh issue view ${result.issue.number} --repo ${this.repo} --json number,title,body,state,url,labels,milestone`;
|
|
125
128
|
const output = execSync(viewCmd, { encoding: 'utf-8' });
|
|
126
129
|
const issue = JSON.parse(output);
|
|
127
130
|
|
|
131
|
+
// Log duplicate detection results if any
|
|
132
|
+
if (result.wasReused) {
|
|
133
|
+
console.log(` ♻️ Reused existing issue #${result.issue.number} (duplicate prevention)`);
|
|
134
|
+
}
|
|
135
|
+
if (result.duplicatesFound > 0) {
|
|
136
|
+
console.log(` 🛡️ Duplicates detected: ${result.duplicatesFound} (auto-closed: ${result.duplicatesClosed})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
return {
|
|
129
140
|
...issue,
|
|
130
141
|
html_url: issue.url,
|