specweave 1.0.520 → 1.0.521
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/bin/specweave.js +1 -1
- package/dist/plugins/specweave/lib/integrations/github/duplicate-detector.js +6 -6
- package/dist/plugins/specweave/lib/integrations/github/duplicate-detector.js.map +1 -1
- package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.d.ts.map +1 -1
- package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js +34 -9
- package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js.map +1 -1
- package/dist/plugins/specweave/lib/integrations/github/github-body-utils.d.ts +38 -0
- package/dist/plugins/specweave/lib/integrations/github/github-body-utils.d.ts.map +1 -0
- package/dist/plugins/specweave/lib/integrations/github/github-body-utils.js +50 -0
- package/dist/plugins/specweave/lib/integrations/github/github-body-utils.js.map +1 -0
- package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.d.ts +0 -2
- package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.d.ts.map +1 -1
- package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.js +194 -173
- package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.js.map +1 -1
- package/dist/src/cli/commands/create-increment.d.ts.map +1 -1
- package/dist/src/cli/commands/create-increment.js +2 -10
- package/dist/src/cli/commands/create-increment.js.map +1 -1
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +25 -0
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/cli/commands/save.js +1 -1
- package/dist/src/cli/commands/save.js.map +1 -1
- package/dist/src/cli/commands/sync-progress.d.ts.map +1 -1
- package/dist/src/cli/commands/sync-progress.js +10 -0
- package/dist/src/cli/commands/sync-progress.js.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/github-multi-repo.d.ts +1 -8
- package/dist/src/cli/helpers/issue-tracker/github-multi-repo.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/github-multi-repo.js +57 -88
- package/dist/src/cli/helpers/issue-tracker/github-multi-repo.js.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/github.d.ts.map +1 -1
- package/dist/src/cli/helpers/issue-tracker/github.js +1 -3
- package/dist/src/cli/helpers/issue-tracker/github.js.map +1 -1
- package/dist/src/core/increment/status-change-sync-trigger.d.ts.map +1 -1
- package/dist/src/core/increment/status-change-sync-trigger.js +9 -0
- package/dist/src/core/increment/status-change-sync-trigger.js.map +1 -1
- package/dist/src/core/repo-structure/prompt-consolidator.d.ts +3 -17
- package/dist/src/core/repo-structure/prompt-consolidator.d.ts.map +1 -1
- package/dist/src/core/repo-structure/prompt-consolidator.js +1 -55
- package/dist/src/core/repo-structure/prompt-consolidator.js.map +1 -1
- package/dist/src/core/repo-structure/repo-structure-manager.d.ts.map +1 -1
- package/dist/src/core/repo-structure/repo-structure-manager.js +3 -1
- package/dist/src/core/repo-structure/repo-structure-manager.js.map +1 -1
- package/dist/src/core/repo-structure/setup-state-manager.d.ts.map +1 -1
- package/dist/src/core/repo-structure/setup-state-manager.js +2 -1
- package/dist/src/core/repo-structure/setup-state-manager.js.map +1 -1
- package/dist/src/core/sync-throttle.d.ts +49 -0
- package/dist/src/core/sync-throttle.d.ts.map +1 -0
- package/dist/src/core/sync-throttle.js +94 -0
- package/dist/src/core/sync-throttle.js.map +1 -0
- package/dist/src/hooks/auto-create-external-issue.js +11 -0
- package/dist/src/hooks/auto-create-external-issue.js.map +1 -1
- package/dist/src/init/InitFlow.d.ts.map +1 -1
- package/dist/src/init/InitFlow.js +4 -8
- package/dist/src/init/InitFlow.js.map +1 -1
- package/dist/src/init/research/src/config/ConfigManager.js +1 -1
- package/dist/src/init/research/src/config/ConfigManager.js.map +1 -1
- package/dist/src/init/research/src/config/types.d.ts +0 -1
- package/dist/src/init/research/src/config/types.d.ts.map +1 -1
- package/dist/src/init/research/src/config/types.js +1 -1
- package/dist/src/init/research/src/config/types.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/lib/integrations/github/duplicate-detector.js +1 -1
- package/plugins/specweave/lib/integrations/github/duplicate-detector.ts +6 -6
- package/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js +39 -13
- package/plugins/specweave/lib/integrations/github/github-ac-comment-poster.ts +43 -13
- package/plugins/specweave/lib/integrations/github/github-body-utils.js +15 -0
- package/plugins/specweave/lib/integrations/github/github-body-utils.ts +52 -0
- package/plugins/specweave/lib/integrations/github/github-feature-sync.js +160 -136
- package/plugins/specweave/lib/integrations/github/github-feature-sync.ts +49 -29
- package/plugins/specweave/skills/increment/SKILL.md +45 -88
- package/dist/src/core/migration/consolidation-engine.d.ts +0 -59
- package/dist/src/core/migration/consolidation-engine.d.ts.map +0 -1
- package/dist/src/core/migration/consolidation-engine.js +0 -177
- package/dist/src/core/migration/consolidation-engine.js.map +0 -1
- package/dist/src/core/migration/spec-project-mapper.d.ts +0 -51
- package/dist/src/core/migration/spec-project-mapper.d.ts.map +0 -1
- package/dist/src/core/migration/spec-project-mapper.js +0 -299
- package/dist/src/core/migration/spec-project-mapper.js.map +0 -1
- package/dist/src/core/migration/types.d.ts +0 -132
- package/dist/src/core/migration/types.d.ts.map +0 -1
- package/dist/src/core/migration/types.js +0 -10
- package/dist/src/core/migration/types.js.map +0 -1
- package/dist/src/core/migration/umbrella-migrator.d.ts +0 -58
- package/dist/src/core/migration/umbrella-migrator.d.ts.map +0 -1
- package/dist/src/core/migration/umbrella-migrator.js +0 -617
- package/dist/src/core/migration/umbrella-migrator.js.map +0 -1
|
@@ -12,6 +12,7 @@ import { readFile } from 'fs/promises';
|
|
|
12
12
|
import { existsSync } from 'fs';
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import { execFileNoThrow } from '../../../../../src/utils/execFileNoThrow.js';
|
|
15
|
+
import { buildFingerprint, extractFingerprint } from './github-body-utils.js';
|
|
15
16
|
|
|
16
17
|
export interface CommentPostOptions {
|
|
17
18
|
owner: string;
|
|
@@ -87,21 +88,50 @@ export async function postACProgressComments(
|
|
|
87
88
|
const allComplete = acStates.length > 0 && acStates.every(ac => ac.completed);
|
|
88
89
|
|
|
89
90
|
if (!allComplete) {
|
|
90
|
-
const
|
|
91
|
+
const total = acStates.length;
|
|
92
|
+
const completed = acStates.filter(ac => ac.completed).length;
|
|
93
|
+
const currentFingerprint = buildFingerprint(completed, total);
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// Dedup: fetch last comment and check fingerprint before posting
|
|
96
|
+
let shouldPost = true;
|
|
97
|
+
try {
|
|
98
|
+
const commentsResult = await execFileNoThrow(
|
|
99
|
+
'gh',
|
|
100
|
+
[
|
|
101
|
+
'api',
|
|
102
|
+
`repos/${repoSlug}/issues/${link.issueNumber}/comments`,
|
|
103
|
+
'--jq', '.[-1].body',
|
|
104
|
+
],
|
|
105
|
+
env ? { env } : {},
|
|
106
|
+
);
|
|
107
|
+
if (commentsResult.success && commentsResult.stdout.trim()) {
|
|
108
|
+
const lastFingerprint = extractFingerprint(commentsResult.stdout);
|
|
109
|
+
if (lastFingerprint === `${completed}/${total}`) {
|
|
110
|
+
shouldPost = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// If comment fetch fails, proceed with posting
|
|
115
|
+
}
|
|
97
116
|
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
if (shouldPost) {
|
|
118
|
+
const commentBody = buildProgressCommentForUS(incrementId, usId, acStates)
|
|
119
|
+
+ currentFingerprint + '\n';
|
|
120
|
+
|
|
121
|
+
const execResult = await execFileNoThrow(
|
|
122
|
+
'gh',
|
|
123
|
+
['issue', 'comment', String(link.issueNumber), '--body', commentBody, '-R', repoSlug],
|
|
124
|
+
env ? { env } : {},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (execResult.success) {
|
|
128
|
+
result.posted.push({ usId, issueNumber: link.issueNumber });
|
|
129
|
+
} else {
|
|
130
|
+
result.errors.push({
|
|
131
|
+
usId,
|
|
132
|
+
error: execResult.stderr || 'Unknown error posting comment',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
105
135
|
}
|
|
106
136
|
}
|
|
107
137
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function buildFingerprint(completed, total) {
|
|
2
|
+
return `<!-- sw-progress:${completed}/${total} -->`;
|
|
3
|
+
}
|
|
4
|
+
function extractFingerprint(text) {
|
|
5
|
+
const match = text.match(/<!-- sw-progress:(\d+\/\d+) -->/);
|
|
6
|
+
return match ? match[1] : null;
|
|
7
|
+
}
|
|
8
|
+
function normalizeIssueBody(body) {
|
|
9
|
+
return body.split("\n").map((line) => line.trimEnd()).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
buildFingerprint,
|
|
13
|
+
extractFingerprint,
|
|
14
|
+
normalizeIssueBody
|
|
15
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for GitHub issue body fingerprinting, extraction,
|
|
3
|
+
* and normalization. Used by the AC comment poster and feature sync
|
|
4
|
+
* to deduplicate progress comments and skip no-op edits.
|
|
5
|
+
*
|
|
6
|
+
* Fingerprint format: `<!-- sw-progress:N/M -->` (HTML comment,
|
|
7
|
+
* invisible in rendered Markdown, consistent with JIRA fingerprint
|
|
8
|
+
* pattern in jira-status-sync.ts).
|
|
9
|
+
*
|
|
10
|
+
* @module github-body-utils
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build an HTML comment fingerprint for the current AC progress state.
|
|
15
|
+
*
|
|
16
|
+
* @param completed - Number of completed acceptance criteria
|
|
17
|
+
* @param total - Total number of acceptance criteria
|
|
18
|
+
* @returns HTML comment string, e.g. `<!-- sw-progress:3/5 -->`
|
|
19
|
+
*/
|
|
20
|
+
export function buildFingerprint(completed: number, total: number): string {
|
|
21
|
+
return `<!-- sw-progress:${completed}/${total} -->`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the progress fingerprint value from a block of text.
|
|
26
|
+
* Returns the `N/M` portion if found, or `null` if no fingerprint is present.
|
|
27
|
+
*
|
|
28
|
+
* @param text - Text to search (e.g. a GitHub comment body)
|
|
29
|
+
* @returns The fingerprint value like `"3/5"`, or `null`
|
|
30
|
+
*/
|
|
31
|
+
export function extractFingerprint(text: string): string | null {
|
|
32
|
+
const match = text.match(/<!-- sw-progress:(\d+\/\d+) -->/);
|
|
33
|
+
return match ? match[1] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize a GitHub issue body for comparison purposes.
|
|
38
|
+
* - Strips trailing whitespace from each line
|
|
39
|
+
* - Collapses multiple consecutive blank lines into a single blank line
|
|
40
|
+
* - Trims leading/trailing whitespace from the entire body
|
|
41
|
+
*
|
|
42
|
+
* @param body - Raw issue body text
|
|
43
|
+
* @returns Normalized body string
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeIssueBody(body: string): string {
|
|
46
|
+
return body
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map(line => line.trimEnd())
|
|
49
|
+
.join('\n')
|
|
50
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
@@ -7,8 +7,9 @@ import { CompletionCalculator } from "./completion-calculator.js";
|
|
|
7
7
|
import { DuplicateDetector } from "./duplicate-detector.js";
|
|
8
8
|
import { execFileNoThrow } from "../../vendor/utils/execFileNoThrow.js";
|
|
9
9
|
import { getGitHubAuthFromProject } from "../../vendor/utils/auth-helpers.js";
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { LockManager } from "../../../../../src/utils/lock-manager.js";
|
|
11
|
+
import { normalizeIssueBody } from "./github-body-utils.js";
|
|
12
|
+
class GitHubFeatureSync {
|
|
12
13
|
constructor(client, specsDir, projectRoot) {
|
|
13
14
|
// Cached default branch for the sync session (one API call per session)
|
|
14
15
|
this.defaultBranch = null;
|
|
@@ -69,14 +70,14 @@ const _GitHubFeatureSync = class _GitHubFeatureSync {
|
|
|
69
70
|
* 4. Update frontmatter with GitHub issue links
|
|
70
71
|
*/
|
|
71
72
|
async syncFeatureToGitHub(featureId, projectName) {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
const owner = this.client.getOwner();
|
|
74
|
+
const repo = this.client.getRepo();
|
|
75
|
+
const lockDir = path.join(this.projectRoot, ".specweave", "state", "locks", `github-sync-${owner}-${repo}`);
|
|
76
|
+
const lock = new LockManager(lockDir, 120);
|
|
77
|
+
const acquired = await lock.acquire();
|
|
78
|
+
if (!acquired) {
|
|
77
79
|
console.log(`
|
|
78
|
-
\u23ED\uFE0F Sync already in progress for ${featureId} (
|
|
79
|
-
console.log(` \u2139\uFE0F Sync will be available in ${secondsRemaining}s to prevent duplicates`);
|
|
80
|
+
\u23ED\uFE0F Sync already in progress for ${featureId} (lock held by another process)`);
|
|
80
81
|
console.log(` \u{1F4A1} This prevents race conditions between task completion and status change syncs`);
|
|
81
82
|
return {
|
|
82
83
|
milestoneNumber: 0,
|
|
@@ -86,123 +87,126 @@ const _GitHubFeatureSync = class _GitHubFeatureSync {
|
|
|
86
87
|
userStoriesProcessed: 0
|
|
87
88
|
};
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
try {
|
|
91
|
+
console.log(`
|
|
91
92
|
\u{1F504} Syncing Feature ${featureId} to GitHub...`);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
console.log(`
|
|
127
|
-
\u{1F4DD} Found ${userStories.length} User Stories to sync...`);
|
|
128
|
-
let issuesCreated = 0;
|
|
129
|
-
let issuesUpdated = 0;
|
|
130
|
-
const detectedBranch = await this.detectDefaultBranch();
|
|
131
|
-
console.log(` \u{1F33F} Default branch: ${detectedBranch}`);
|
|
132
|
-
for (const userStory of userStories) {
|
|
93
|
+
const featureFolder = await this.findFeatureFolder(featureId, projectName);
|
|
94
|
+
if (!featureFolder) {
|
|
95
|
+
console.log(` \u26A0\uFE0F Feature ${featureId} not found in ${this.specsDir} (no living docs and auto-create failed)`);
|
|
96
|
+
console.log(` \u{1F4A1} Run /sw:sync-docs or /sw:living-docs to generate living docs first`);
|
|
97
|
+
return {
|
|
98
|
+
milestoneNumber: 0,
|
|
99
|
+
milestoneUrl: "",
|
|
100
|
+
issuesCreated: 0,
|
|
101
|
+
issuesUpdated: 0,
|
|
102
|
+
userStoriesProcessed: 0
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const featurePath = path.join(featureFolder, "FEATURE.md");
|
|
106
|
+
const featureData = await this.parseFeatureMd(featurePath);
|
|
107
|
+
console.log(` \u{1F4E6} Feature: ${featureData.title}`);
|
|
108
|
+
console.log(` \u{1F4CA} Status: ${featureData.status}`);
|
|
109
|
+
let milestoneNumber = featureData.external_tools?.github?.id;
|
|
110
|
+
let milestoneUrl = featureData.external_tools?.github?.url;
|
|
111
|
+
if (!milestoneNumber) {
|
|
112
|
+
console.log(` \u{1F680} Creating GitHub Milestone...`);
|
|
113
|
+
const milestone = await this.createMilestone(featureData);
|
|
114
|
+
milestoneNumber = milestone.number;
|
|
115
|
+
milestoneUrl = milestone.url;
|
|
116
|
+
console.log(` \u2705 Created Milestone #${milestoneNumber}`);
|
|
117
|
+
await this.updateFeatureMd(featurePath, {
|
|
118
|
+
type: "milestone",
|
|
119
|
+
id: milestoneNumber,
|
|
120
|
+
url: milestoneUrl
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
console.log(` \u267B\uFE0F Using existing Milestone #${milestoneNumber}`);
|
|
124
|
+
milestoneUrl = featureData.external_tools?.github?.url || milestoneUrl;
|
|
125
|
+
}
|
|
126
|
+
const userStories = await this.findUserStories(featureId, projectName);
|
|
133
127
|
console.log(`
|
|
128
|
+
\u{1F4DD} Found ${userStories.length} User Stories to sync...`);
|
|
129
|
+
let issuesCreated = 0;
|
|
130
|
+
let issuesUpdated = 0;
|
|
131
|
+
const detectedBranch = await this.detectDefaultBranch();
|
|
132
|
+
console.log(` \u{1F33F} Default branch: ${detectedBranch}`);
|
|
133
|
+
for (const userStory of userStories) {
|
|
134
|
+
console.log(`
|
|
134
135
|
\u{1F539} Processing ${userStory.id}: ${userStory.title}`);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
136
|
+
const repoInfo = {
|
|
137
|
+
owner: this.client.getOwner(),
|
|
138
|
+
repo: this.client.getRepo(),
|
|
139
|
+
branch: detectedBranch
|
|
140
|
+
};
|
|
141
|
+
const builder = new UserStoryIssueBuilder(
|
|
142
|
+
userStory.filePath,
|
|
143
|
+
this.projectRoot,
|
|
144
|
+
featureId,
|
|
145
|
+
repoInfo
|
|
146
|
+
);
|
|
147
|
+
const issueContent = await builder.buildIssueBody();
|
|
148
|
+
issueContent.status = userStory.status;
|
|
149
|
+
let issueNumber;
|
|
150
|
+
let wasUpdated = false;
|
|
151
|
+
if (userStory.existingIssue) {
|
|
152
|
+
console.log(` \u267B\uFE0F Issue #${userStory.existingIssue} exists in frontmatter`);
|
|
153
|
+
try {
|
|
154
|
+
await this.client.getIssue(userStory.existingIssue);
|
|
155
|
+
await this.updateUserStoryIssue(userStory.existingIssue, issueContent, userStory.filePath);
|
|
156
|
+
issuesUpdated++;
|
|
157
|
+
console.log(` \u2705 Updated Issue #${userStory.existingIssue}`);
|
|
158
|
+
continue;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.log(` \u26A0\uFE0F Issue #${userStory.existingIssue} deleted on GitHub, creating new`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const titlePattern = `[${featureId}][${userStory.id}]`;
|
|
164
|
+
const milestoneTitle = `${featureData.id}: ${featureData.title}`;
|
|
165
|
+
console.log(` \u{1F6E1}\uFE0F Using DuplicateDetector (pattern: ${titlePattern})`);
|
|
166
|
+
const result = await DuplicateDetector.createWithProtection({
|
|
167
|
+
title: issueContent.title,
|
|
168
|
+
body: issueContent.body,
|
|
169
|
+
titlePattern,
|
|
170
|
+
incrementId: userStory.id,
|
|
171
|
+
labels: issueContent.labels,
|
|
172
|
+
milestone: milestoneTitle,
|
|
173
|
+
repo: `${this.client.getOwner()}/${this.client.getRepo()}`
|
|
174
|
+
});
|
|
175
|
+
issueNumber = result.issue.number;
|
|
176
|
+
if (result.wasReused) {
|
|
177
|
+
console.log(` \u267B\uFE0F Reused existing issue #${issueNumber} (duplicate prevented!)`);
|
|
178
|
+
wasUpdated = true;
|
|
179
|
+
} else {
|
|
180
|
+
console.log(` \u2705 Created issue #${issueNumber}`);
|
|
181
|
+
}
|
|
182
|
+
if (result.duplicatesFound > 0) {
|
|
183
|
+
console.log(` \u{1F6E1}\uFE0F Duplicates detected: ${result.duplicatesFound}, auto-closed: ${result.duplicatesClosed}`);
|
|
184
|
+
}
|
|
185
|
+
await this.updateUserStoryFrontmatter(userStory.filePath, issueNumber);
|
|
186
|
+
await this.backfillIncrementMetadata(featureId, userStory.id, issueNumber, milestoneNumber);
|
|
187
|
+
await this.updateUserStoryIssue(issueNumber, issueContent, userStory.filePath);
|
|
188
|
+
if (result.wasReused) {
|
|
155
189
|
issuesUpdated++;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
} catch (err) {
|
|
159
|
-
console.log(` \u26A0\uFE0F Issue #${userStory.existingIssue} deleted on GitHub, creating new`);
|
|
190
|
+
} else {
|
|
191
|
+
issuesCreated++;
|
|
160
192
|
}
|
|
161
193
|
}
|
|
162
|
-
|
|
163
|
-
const milestoneTitle = `${featureData.id}: ${featureData.title}`;
|
|
164
|
-
console.log(` \u{1F6E1}\uFE0F Using DuplicateDetector (pattern: ${titlePattern})`);
|
|
165
|
-
const result = await DuplicateDetector.createWithProtection({
|
|
166
|
-
title: issueContent.title,
|
|
167
|
-
body: issueContent.body,
|
|
168
|
-
titlePattern,
|
|
169
|
-
incrementId: userStory.id,
|
|
170
|
-
labels: issueContent.labels,
|
|
171
|
-
milestone: milestoneTitle,
|
|
172
|
-
repo: `${this.client.getOwner()}/${this.client.getRepo()}`
|
|
173
|
-
});
|
|
174
|
-
issueNumber = result.issue.number;
|
|
175
|
-
if (result.wasReused) {
|
|
176
|
-
console.log(` \u267B\uFE0F Reused existing issue #${issueNumber} (duplicate prevented!)`);
|
|
177
|
-
wasUpdated = true;
|
|
178
|
-
} else {
|
|
179
|
-
console.log(` \u2705 Created issue #${issueNumber}`);
|
|
180
|
-
}
|
|
181
|
-
if (result.duplicatesFound > 0) {
|
|
182
|
-
console.log(` \u{1F6E1}\uFE0F Duplicates detected: ${result.duplicatesFound}, auto-closed: ${result.duplicatesClosed}`);
|
|
183
|
-
}
|
|
184
|
-
await this.updateUserStoryFrontmatter(userStory.filePath, issueNumber);
|
|
185
|
-
await this.backfillIncrementMetadata(featureId, userStory.id, issueNumber, milestoneNumber);
|
|
186
|
-
await this.updateUserStoryIssue(issueNumber, issueContent, userStory.filePath);
|
|
187
|
-
if (result.wasReused) {
|
|
188
|
-
issuesUpdated++;
|
|
189
|
-
} else {
|
|
190
|
-
issuesCreated++;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
console.log(`
|
|
194
|
+
console.log(`
|
|
194
195
|
\u2705 Feature sync complete!`);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
196
|
+
console.log(` Milestone: ${milestoneUrl}`);
|
|
197
|
+
console.log(` User Stories: ${userStories.length}`);
|
|
198
|
+
console.log(` Issues created: ${issuesCreated}`);
|
|
199
|
+
console.log(` Issues updated: ${issuesUpdated}`);
|
|
200
|
+
return {
|
|
201
|
+
milestoneNumber,
|
|
202
|
+
milestoneUrl,
|
|
203
|
+
issuesCreated,
|
|
204
|
+
issuesUpdated,
|
|
205
|
+
userStoriesProcessed: userStories.length
|
|
206
|
+
};
|
|
207
|
+
} finally {
|
|
208
|
+
await lock.release();
|
|
209
|
+
}
|
|
206
210
|
}
|
|
207
211
|
/**
|
|
208
212
|
* Find Feature folder in specs directory.
|
|
@@ -710,17 +714,42 @@ Created: ${featureData.created}`;
|
|
|
710
714
|
*/
|
|
711
715
|
async updateUserStoryIssue(issueNumber, issueContent, userStoryPath) {
|
|
712
716
|
const repoSlug = this.getRepoSlug();
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
"
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
717
|
+
let shouldEdit = true;
|
|
718
|
+
try {
|
|
719
|
+
const viewResult = await execFileNoThrow("gh", [
|
|
720
|
+
"issue",
|
|
721
|
+
"view",
|
|
722
|
+
issueNumber.toString(),
|
|
723
|
+
"--json",
|
|
724
|
+
"body",
|
|
725
|
+
"--jq",
|
|
726
|
+
".body",
|
|
727
|
+
"-R",
|
|
728
|
+
repoSlug
|
|
729
|
+
], { env: this.getGhEnv() });
|
|
730
|
+
if (viewResult.exitCode === 0 && viewResult.stdout) {
|
|
731
|
+
const currentNormalized = normalizeIssueBody(viewResult.stdout);
|
|
732
|
+
const newNormalized = normalizeIssueBody(issueContent.body);
|
|
733
|
+
if (currentNormalized === newNormalized) {
|
|
734
|
+
shouldEdit = false;
|
|
735
|
+
console.log(` \u23ED\uFE0F Body unchanged, skipping gh issue edit for #${issueNumber}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
} catch {
|
|
739
|
+
}
|
|
740
|
+
if (shouldEdit) {
|
|
741
|
+
await execFileNoThrow("gh", [
|
|
742
|
+
"issue",
|
|
743
|
+
"edit",
|
|
744
|
+
issueNumber.toString(),
|
|
745
|
+
"--title",
|
|
746
|
+
issueContent.title,
|
|
747
|
+
"--body",
|
|
748
|
+
issueContent.body,
|
|
749
|
+
"-R",
|
|
750
|
+
repoSlug
|
|
751
|
+
], { env: this.getGhEnv() });
|
|
752
|
+
}
|
|
724
753
|
const completion = await this.calculator.calculateCompletion(userStoryPath);
|
|
725
754
|
const issueData = await this.client.getIssue(issueNumber);
|
|
726
755
|
const currentlyClosed = issueData.state === "closed";
|
|
@@ -996,12 +1025,7 @@ ${newFrontmatter}---${bodyContent}`;
|
|
|
996
1025
|
${newFrontmatter}---${bodyContent}`;
|
|
997
1026
|
await writeFile(userStoryPath, newContent, "utf-8");
|
|
998
1027
|
}
|
|
999
|
-
}
|
|
1000
|
-
// SYNC LOCK: Prevent concurrent syncs of the same feature
|
|
1001
|
-
// Maps featureId → last sync timestamp
|
|
1002
|
-
_GitHubFeatureSync.syncLocks = /* @__PURE__ */ new Map();
|
|
1003
|
-
_GitHubFeatureSync.LOCK_DURATION_MS = 3e4;
|
|
1004
|
-
let GitHubFeatureSync = _GitHubFeatureSync;
|
|
1028
|
+
}
|
|
1005
1029
|
export {
|
|
1006
1030
|
GitHubFeatureSync
|
|
1007
1031
|
};
|
|
@@ -20,6 +20,8 @@ import { CompletionCalculator } from './completion-calculator.js';
|
|
|
20
20
|
import { DuplicateDetector } from './duplicate-detector.js';
|
|
21
21
|
import { execFileNoThrow } from '../../vendor/utils/execFileNoThrow.js';
|
|
22
22
|
import { getGitHubAuthFromProject } from '../../vendor/utils/auth-helpers.js';
|
|
23
|
+
import { LockManager } from '../../../../../src/utils/lock-manager.js';
|
|
24
|
+
import { normalizeIssueBody } from './github-body-utils.js';
|
|
23
25
|
|
|
24
26
|
interface FeatureFrontmatter {
|
|
25
27
|
id: string;
|
|
@@ -57,11 +59,6 @@ export class GitHubFeatureSync {
|
|
|
57
59
|
// Cached default branch for the sync session (one API call per session)
|
|
58
60
|
private defaultBranch: string | null = null;
|
|
59
61
|
|
|
60
|
-
// SYNC LOCK: Prevent concurrent syncs of the same feature
|
|
61
|
-
// Maps featureId → last sync timestamp
|
|
62
|
-
private static syncLocks: Map<string, number> = new Map();
|
|
63
|
-
private static readonly LOCK_DURATION_MS = 30000; // 30 seconds
|
|
64
|
-
|
|
65
62
|
constructor(client: GitHubClientV2, specsDir: string, projectRoot: string) {
|
|
66
63
|
this.client = client;
|
|
67
64
|
this.specsDir = specsDir;
|
|
@@ -134,18 +131,17 @@ export class GitHubFeatureSync {
|
|
|
134
131
|
issuesUpdated: number;
|
|
135
132
|
userStoriesProcessed: number;
|
|
136
133
|
}> {
|
|
137
|
-
// SYNC LOCK
|
|
134
|
+
// SYNC LOCK: Cross-process file lock prevents concurrent syncs of the same feature+repo
|
|
138
135
|
// Root cause: Two sync paths (task completion + status change) can fire simultaneously
|
|
139
136
|
// Result: Duplicate GitHub comments due to race condition
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
console.log(`\n⏭️ Sync already in progress for ${featureId} (
|
|
148
|
-
console.log(` ℹ️ Sync will be available in ${secondsRemaining}s to prevent duplicates`);
|
|
137
|
+
const owner = this.client.getOwner();
|
|
138
|
+
const repo = this.client.getRepo();
|
|
139
|
+
const lockDir = path.join(this.projectRoot, '.specweave', 'state', 'locks', `github-sync-${owner}-${repo}`);
|
|
140
|
+
const lock = new LockManager(lockDir, 120);
|
|
141
|
+
const acquired = await lock.acquire();
|
|
142
|
+
|
|
143
|
+
if (!acquired) {
|
|
144
|
+
console.log(`\n⏭️ Sync already in progress for ${featureId} (lock held by another process)`);
|
|
149
145
|
console.log(` 💡 This prevents race conditions between task completion and status change syncs`);
|
|
150
146
|
|
|
151
147
|
// Return placeholder result (sync was skipped, not failed)
|
|
@@ -158,8 +154,7 @@ export class GitHubFeatureSync {
|
|
|
158
154
|
};
|
|
159
155
|
}
|
|
160
156
|
|
|
161
|
-
|
|
162
|
-
GitHubFeatureSync.syncLocks.set(lockKey, now);
|
|
157
|
+
try {
|
|
163
158
|
console.log(`\n🔄 Syncing Feature ${featureId} to GitHub...`);
|
|
164
159
|
|
|
165
160
|
// 1. Load Feature FEATURE.md
|
|
@@ -344,6 +339,9 @@ export class GitHubFeatureSync {
|
|
|
344
339
|
issuesUpdated,
|
|
345
340
|
userStoriesProcessed: userStories.length,
|
|
346
341
|
};
|
|
342
|
+
} finally {
|
|
343
|
+
await lock.release();
|
|
344
|
+
}
|
|
347
345
|
}
|
|
348
346
|
|
|
349
347
|
/**
|
|
@@ -1016,19 +1014,41 @@ export class GitHubFeatureSync {
|
|
|
1016
1014
|
},
|
|
1017
1015
|
userStoryPath: string
|
|
1018
1016
|
): Promise<void> {
|
|
1019
|
-
//
|
|
1017
|
+
// Body diff check: skip `gh issue edit` when body is unchanged (FS-587)
|
|
1020
1018
|
const repoSlug = this.getRepoSlug();
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
'
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1019
|
+
let shouldEdit = true;
|
|
1020
|
+
try {
|
|
1021
|
+
const viewResult = await execFileNoThrow('gh', [
|
|
1022
|
+
'issue', 'view', issueNumber.toString(),
|
|
1023
|
+
'--json', 'body', '--jq', '.body',
|
|
1024
|
+
'-R', repoSlug,
|
|
1025
|
+
], { env: this.getGhEnv() });
|
|
1026
|
+
|
|
1027
|
+
if (viewResult.exitCode === 0 && viewResult.stdout) {
|
|
1028
|
+
const currentNormalized = normalizeIssueBody(viewResult.stdout);
|
|
1029
|
+
const newNormalized = normalizeIssueBody(issueContent.body);
|
|
1030
|
+
if (currentNormalized === newNormalized) {
|
|
1031
|
+
shouldEdit = false;
|
|
1032
|
+
console.log(` ⏭️ Body unchanged, skipping gh issue edit for #${issueNumber}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
} catch {
|
|
1036
|
+
// 404 or other error fetching current body — proceed with edit
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (shouldEdit) {
|
|
1040
|
+
await execFileNoThrow('gh', [
|
|
1041
|
+
'issue',
|
|
1042
|
+
'edit',
|
|
1043
|
+
issueNumber.toString(),
|
|
1044
|
+
'--title',
|
|
1045
|
+
issueContent.title,
|
|
1046
|
+
'--body',
|
|
1047
|
+
issueContent.body,
|
|
1048
|
+
'-R',
|
|
1049
|
+
repoSlug,
|
|
1050
|
+
], { env: this.getGhEnv() });
|
|
1051
|
+
}
|
|
1032
1052
|
|
|
1033
1053
|
// ✅ VERIFICATION GATE: Calculate ACTUAL completion from checkboxes
|
|
1034
1054
|
const completion = await this.calculator.calculateCompletion(userStoryPath);
|