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.
Files changed (86) hide show
  1. package/bin/specweave.js +1 -1
  2. package/dist/plugins/specweave/lib/integrations/github/duplicate-detector.js +6 -6
  3. package/dist/plugins/specweave/lib/integrations/github/duplicate-detector.js.map +1 -1
  4. package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.d.ts.map +1 -1
  5. package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js +34 -9
  6. package/dist/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js.map +1 -1
  7. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.d.ts +38 -0
  8. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.d.ts.map +1 -0
  9. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.js +50 -0
  10. package/dist/plugins/specweave/lib/integrations/github/github-body-utils.js.map +1 -0
  11. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.d.ts +0 -2
  12. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.d.ts.map +1 -1
  13. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.js +194 -173
  14. package/dist/plugins/specweave/lib/integrations/github/github-feature-sync.js.map +1 -1
  15. package/dist/src/cli/commands/create-increment.d.ts.map +1 -1
  16. package/dist/src/cli/commands/create-increment.js +2 -10
  17. package/dist/src/cli/commands/create-increment.js.map +1 -1
  18. package/dist/src/cli/commands/init.d.ts.map +1 -1
  19. package/dist/src/cli/commands/init.js +25 -0
  20. package/dist/src/cli/commands/init.js.map +1 -1
  21. package/dist/src/cli/commands/save.js +1 -1
  22. package/dist/src/cli/commands/save.js.map +1 -1
  23. package/dist/src/cli/commands/sync-progress.d.ts.map +1 -1
  24. package/dist/src/cli/commands/sync-progress.js +10 -0
  25. package/dist/src/cli/commands/sync-progress.js.map +1 -1
  26. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.d.ts +1 -8
  27. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.d.ts.map +1 -1
  28. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.js +57 -88
  29. package/dist/src/cli/helpers/issue-tracker/github-multi-repo.js.map +1 -1
  30. package/dist/src/cli/helpers/issue-tracker/github.d.ts.map +1 -1
  31. package/dist/src/cli/helpers/issue-tracker/github.js +1 -3
  32. package/dist/src/cli/helpers/issue-tracker/github.js.map +1 -1
  33. package/dist/src/core/increment/status-change-sync-trigger.d.ts.map +1 -1
  34. package/dist/src/core/increment/status-change-sync-trigger.js +9 -0
  35. package/dist/src/core/increment/status-change-sync-trigger.js.map +1 -1
  36. package/dist/src/core/repo-structure/prompt-consolidator.d.ts +3 -17
  37. package/dist/src/core/repo-structure/prompt-consolidator.d.ts.map +1 -1
  38. package/dist/src/core/repo-structure/prompt-consolidator.js +1 -55
  39. package/dist/src/core/repo-structure/prompt-consolidator.js.map +1 -1
  40. package/dist/src/core/repo-structure/repo-structure-manager.d.ts.map +1 -1
  41. package/dist/src/core/repo-structure/repo-structure-manager.js +3 -1
  42. package/dist/src/core/repo-structure/repo-structure-manager.js.map +1 -1
  43. package/dist/src/core/repo-structure/setup-state-manager.d.ts.map +1 -1
  44. package/dist/src/core/repo-structure/setup-state-manager.js +2 -1
  45. package/dist/src/core/repo-structure/setup-state-manager.js.map +1 -1
  46. package/dist/src/core/sync-throttle.d.ts +49 -0
  47. package/dist/src/core/sync-throttle.d.ts.map +1 -0
  48. package/dist/src/core/sync-throttle.js +94 -0
  49. package/dist/src/core/sync-throttle.js.map +1 -0
  50. package/dist/src/hooks/auto-create-external-issue.js +11 -0
  51. package/dist/src/hooks/auto-create-external-issue.js.map +1 -1
  52. package/dist/src/init/InitFlow.d.ts.map +1 -1
  53. package/dist/src/init/InitFlow.js +4 -8
  54. package/dist/src/init/InitFlow.js.map +1 -1
  55. package/dist/src/init/research/src/config/ConfigManager.js +1 -1
  56. package/dist/src/init/research/src/config/ConfigManager.js.map +1 -1
  57. package/dist/src/init/research/src/config/types.d.ts +0 -1
  58. package/dist/src/init/research/src/config/types.d.ts.map +1 -1
  59. package/dist/src/init/research/src/config/types.js +1 -1
  60. package/dist/src/init/research/src/config/types.js.map +1 -1
  61. package/package.json +1 -1
  62. package/plugins/specweave/lib/integrations/github/duplicate-detector.js +1 -1
  63. package/plugins/specweave/lib/integrations/github/duplicate-detector.ts +6 -6
  64. package/plugins/specweave/lib/integrations/github/github-ac-comment-poster.js +39 -13
  65. package/plugins/specweave/lib/integrations/github/github-ac-comment-poster.ts +43 -13
  66. package/plugins/specweave/lib/integrations/github/github-body-utils.js +15 -0
  67. package/plugins/specweave/lib/integrations/github/github-body-utils.ts +52 -0
  68. package/plugins/specweave/lib/integrations/github/github-feature-sync.js +160 -136
  69. package/plugins/specweave/lib/integrations/github/github-feature-sync.ts +49 -29
  70. package/plugins/specweave/skills/increment/SKILL.md +45 -88
  71. package/dist/src/core/migration/consolidation-engine.d.ts +0 -59
  72. package/dist/src/core/migration/consolidation-engine.d.ts.map +0 -1
  73. package/dist/src/core/migration/consolidation-engine.js +0 -177
  74. package/dist/src/core/migration/consolidation-engine.js.map +0 -1
  75. package/dist/src/core/migration/spec-project-mapper.d.ts +0 -51
  76. package/dist/src/core/migration/spec-project-mapper.d.ts.map +0 -1
  77. package/dist/src/core/migration/spec-project-mapper.js +0 -299
  78. package/dist/src/core/migration/spec-project-mapper.js.map +0 -1
  79. package/dist/src/core/migration/types.d.ts +0 -132
  80. package/dist/src/core/migration/types.d.ts.map +0 -1
  81. package/dist/src/core/migration/types.js +0 -10
  82. package/dist/src/core/migration/types.js.map +0 -1
  83. package/dist/src/core/migration/umbrella-migrator.d.ts +0 -58
  84. package/dist/src/core/migration/umbrella-migrator.d.ts.map +0 -1
  85. package/dist/src/core/migration/umbrella-migrator.js +0 -617
  86. 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 commentBody = buildProgressCommentForUS(incrementId, usId, acStates);
91
+ const total = acStates.length;
92
+ const completed = acStates.filter(ac => ac.completed).length;
93
+ const currentFingerprint = buildFingerprint(completed, total);
91
94
 
92
- const execResult = await execFileNoThrow(
93
- 'gh',
94
- ['issue', 'comment', String(link.issueNumber), '--body', commentBody, '-R', repoSlug],
95
- env ? { env } : {},
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 (execResult.success) {
99
- result.posted.push({ usId, issueNumber: link.issueNumber });
100
- } else {
101
- result.errors.push({
102
- usId,
103
- error: execResult.stderr || 'Unknown error posting comment',
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
- const _GitHubFeatureSync = class _GitHubFeatureSync {
11
- // 30 seconds
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 lockKey = `${this.client.getOwner()}/${this.client.getRepo()}:${featureId}`;
73
- const now = Date.now();
74
- const lastSync = _GitHubFeatureSync.syncLocks.get(lockKey);
75
- if (lastSync && now - lastSync < _GitHubFeatureSync.LOCK_DURATION_MS) {
76
- const secondsRemaining = Math.ceil((_GitHubFeatureSync.LOCK_DURATION_MS - (now - lastSync)) / 1e3);
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} (or completed ${Math.floor((now - lastSync) / 1e3)}s ago)`);
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
- _GitHubFeatureSync.syncLocks.set(lockKey, now);
90
- console.log(`
90
+ try {
91
+ console.log(`
91
92
  \u{1F504} Syncing Feature ${featureId} to GitHub...`);
92
- const featureFolder = await this.findFeatureFolder(featureId, projectName);
93
- if (!featureFolder) {
94
- console.log(` \u26A0\uFE0F Feature ${featureId} not found in ${this.specsDir} (no living docs and auto-create failed)`);
95
- console.log(` \u{1F4A1} Run /sw:sync-docs or /sw:living-docs to generate living docs first`);
96
- return {
97
- milestoneNumber: 0,
98
- milestoneUrl: "",
99
- issuesCreated: 0,
100
- issuesUpdated: 0,
101
- userStoriesProcessed: 0
102
- };
103
- }
104
- const featurePath = path.join(featureFolder, "FEATURE.md");
105
- const featureData = await this.parseFeatureMd(featurePath);
106
- console.log(` \u{1F4E6} Feature: ${featureData.title}`);
107
- console.log(` \u{1F4CA} Status: ${featureData.status}`);
108
- let milestoneNumber = featureData.external_tools?.github?.id;
109
- let milestoneUrl = featureData.external_tools?.github?.url;
110
- if (!milestoneNumber) {
111
- console.log(` \u{1F680} Creating GitHub Milestone...`);
112
- const milestone = await this.createMilestone(featureData);
113
- milestoneNumber = milestone.number;
114
- milestoneUrl = milestone.url;
115
- console.log(` \u2705 Created Milestone #${milestoneNumber}`);
116
- await this.updateFeatureMd(featurePath, {
117
- type: "milestone",
118
- id: milestoneNumber,
119
- url: milestoneUrl
120
- });
121
- } else {
122
- console.log(` \u267B\uFE0F Using existing Milestone #${milestoneNumber}`);
123
- milestoneUrl = featureData.external_tools?.github?.url || milestoneUrl;
124
- }
125
- const userStories = await this.findUserStories(featureId, projectName);
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
- const repoInfo = {
136
- owner: this.client.getOwner(),
137
- repo: this.client.getRepo(),
138
- branch: detectedBranch
139
- };
140
- const builder = new UserStoryIssueBuilder(
141
- userStory.filePath,
142
- this.projectRoot,
143
- featureId,
144
- repoInfo
145
- );
146
- const issueContent = await builder.buildIssueBody();
147
- issueContent.status = userStory.status;
148
- let issueNumber;
149
- let wasUpdated = false;
150
- if (userStory.existingIssue) {
151
- console.log(` \u267B\uFE0F Issue #${userStory.existingIssue} exists in frontmatter`);
152
- try {
153
- await this.client.getIssue(userStory.existingIssue);
154
- await this.updateUserStoryIssue(userStory.existingIssue, issueContent, userStory.filePath);
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
- console.log(` \u2705 Updated Issue #${userStory.existingIssue}`);
157
- continue;
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
- const titlePattern = `[${featureId}][${userStory.id}]`;
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
- console.log(` Milestone: ${milestoneUrl}`);
196
- console.log(` User Stories: ${userStories.length}`);
197
- console.log(` Issues created: ${issuesCreated}`);
198
- console.log(` Issues updated: ${issuesUpdated}`);
199
- return {
200
- milestoneNumber,
201
- milestoneUrl,
202
- issuesCreated,
203
- issuesUpdated,
204
- userStoriesProcessed: userStories.length
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
- await execFileNoThrow("gh", [
714
- "issue",
715
- "edit",
716
- issueNumber.toString(),
717
- "--title",
718
- issueContent.title,
719
- "--body",
720
- issueContent.body,
721
- "-R",
722
- repoSlug
723
- ], { env: this.getGhEnv() });
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 CHECK: Prevent concurrent/rapid syncs of the same feature+repo
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
- // Key includes owner/repo so cross-project syncs (same featureId, different repos) aren't throttled
141
- const lockKey = `${this.client.getOwner()}/${this.client.getRepo()}:${featureId}`;
142
- const now = Date.now();
143
- const lastSync = GitHubFeatureSync.syncLocks.get(lockKey);
144
-
145
- if (lastSync && (now - lastSync) < GitHubFeatureSync.LOCK_DURATION_MS) {
146
- const secondsRemaining = Math.ceil((GitHubFeatureSync.LOCK_DURATION_MS - (now - lastSync)) / 1000);
147
- console.log(`\n⏭️ Sync already in progress for ${featureId} (or completed ${Math.floor((now - lastSync) / 1000)}s ago)`);
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
- // Acquire lock
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
- // Update issue body
1017
+ // Body diff check: skip `gh issue edit` when body is unchanged (FS-587)
1020
1018
  const repoSlug = this.getRepoSlug();
1021
- await execFileNoThrow('gh', [
1022
- 'issue',
1023
- 'edit',
1024
- issueNumber.toString(),
1025
- '--title',
1026
- issueContent.title,
1027
- '--body',
1028
- issueContent.body,
1029
- '-R',
1030
- repoSlug,
1031
- ], { env: this.getGhEnv() });
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);