specweave 1.0.464 → 1.0.466
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.d.ts +16 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js +139 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.d.ts +111 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js +304 -0
- package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.d.ts +115 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js +359 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.d.ts +121 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js +273 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.d.ts +72 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js +237 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.d.ts +52 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js +281 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.d.ts +278 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js +925 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.d.ts +113 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js +317 -0
- package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.d.ts +442 -0
- package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js +246 -0
- package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.d.ts +64 -0
- package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js +228 -0
- package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.d.ts +95 -0
- package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js +300 -0
- package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/config.d.ts +73 -0
- package/dist/plugins/specweave/lib/vendor/sync/config.js +132 -0
- package/dist/plugins/specweave/lib/vendor/sync/config.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.d.ts +163 -0
- package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js +898 -0
- package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/provider-router.d.ts +86 -0
- package/dist/plugins/specweave/lib/vendor/sync/provider-router.js +147 -0
- package/dist/plugins/specweave/lib/vendor/sync/provider-router.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/sync/status-mapper.d.ts +120 -0
- package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js +164 -0
- package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
- package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
- package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.d.ts +38 -0
- package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js +118 -0
- package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/clean-env.d.ts +47 -0
- package/dist/plugins/specweave/lib/vendor/utils/clean-env.js +63 -0
- package/dist/plugins/specweave/lib/vendor/utils/clean-env.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/credential-masker.d.ts +118 -0
- package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js +275 -0
- package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.d.ts +99 -0
- package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js +149 -0
- package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.d.ts +63 -0
- package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js +85 -0
- package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/fs-native.d.ts +219 -0
- package/dist/plugins/specweave/lib/vendor/utils/fs-native.js +397 -0
- package/dist/plugins/specweave/lib/vendor/utils/fs-native.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/logger.d.ts +56 -0
- package/dist/plugins/specweave/lib/vendor/utils/logger.js +123 -0
- package/dist/plugins/specweave/lib/vendor/utils/logger.js.map +1 -0
- package/dist/plugins/specweave/lib/vendor/utils/translation.d.ts +187 -0
- package/dist/plugins/specweave/lib/vendor/utils/translation.js +414 -0
- package/dist/plugins/specweave/lib/vendor/utils/translation.js.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
- package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js.map +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
- package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js +2 -2
- package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-feature-sync.js +13 -4
- package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
- package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js.map +1 -1
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
- package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
- package/dist/src/sync/spec-to-living-docs-sync.js +1 -1
- package/dist/src/sync/spec-to-living-docs-sync.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
- package/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
- package/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
- package/plugins/specweave/skills/team-lead/SKILL.md +150 -56
- package/plugins/specweave/skills/team-lead/agents/backend.md +13 -9
- package/plugins/specweave/skills/team-lead/agents/database.md +13 -9
- package/plugins/specweave/skills/team-lead/agents/frontend.md +12 -8
- package/plugins/specweave/skills/team-lead/agents/security.md +13 -9
- package/plugins/specweave/skills/team-lead/agents/testing.md +12 -8
- package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
- package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +1 -1
- package/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
- package/plugins/specweave-ado/lib/ado-spec-sync.ts +1 -1
- package/plugins/specweave-github/lib/github-ac-checkbox-sync.js +1 -1
- package/plugins/specweave-github/lib/github-ac-checkbox-sync.ts +2 -2
- package/plugins/specweave-github/lib/github-feature-sync.js +11 -3
- package/plugins/specweave-github/lib/github-feature-sync.ts +13 -4
- package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
- package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.ts +1 -1
- package/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
- package/plugins/specweave-jira/lib/jira-spec-sync.ts +1 -1
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Reconciler (NEW in v0.28.33)
|
|
3
|
+
*
|
|
4
|
+
* Reconciles GitHub issue states with increment metadata.json statuses.
|
|
5
|
+
* Fixes drift between local SpecWeave state and GitHub:
|
|
6
|
+
* - Closes issues for completed increments that are still open
|
|
7
|
+
* - Reopens issues for in-progress increments that are closed
|
|
8
|
+
*
|
|
9
|
+
* Triggered by:
|
|
10
|
+
* - /specweave-github:reconcile command (manual)
|
|
11
|
+
* - SessionStart hook (automatic, if configured)
|
|
12
|
+
* - post-increment-status-change.sh (on resume/abandon)
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs, existsSync } from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { GitHubClientV2 } from '../../plugins/specweave-github/lib/github-client-v2.js';
|
|
17
|
+
import { consoleLogger } from '../utils/logger.js';
|
|
18
|
+
import { resolvePermissions } from './config.js';
|
|
19
|
+
import { isProviderEnabled } from './status-mapper.js';
|
|
20
|
+
import { deriveFeatureId } from '../utils/feature-id-derivation.js';
|
|
21
|
+
function hasAnyGitHubSyncData(metadata) {
|
|
22
|
+
if (metadata.github?.issue || metadata.github?.milestone)
|
|
23
|
+
return true;
|
|
24
|
+
if (Array.isArray(metadata.github?.issues) && metadata.github.issues.length > 0)
|
|
25
|
+
return true;
|
|
26
|
+
const ext = metadata.externalLinks?.github;
|
|
27
|
+
if (!ext)
|
|
28
|
+
return false;
|
|
29
|
+
if (ext.milestone || ext.issueNumber)
|
|
30
|
+
return true;
|
|
31
|
+
if (ext.issues && typeof ext.issues === 'object' && Object.keys(ext.issues).length > 0)
|
|
32
|
+
return true;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
export class GitHubReconciler {
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.client = null;
|
|
38
|
+
this.configCache = null;
|
|
39
|
+
this.projectRoot = options.projectRoot;
|
|
40
|
+
this.dryRun = options.dryRun ?? false;
|
|
41
|
+
this.logger = options.logger ?? consoleLogger;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Main reconciliation entry point
|
|
45
|
+
*/
|
|
46
|
+
async reconcile() {
|
|
47
|
+
const result = {
|
|
48
|
+
scanned: 0,
|
|
49
|
+
mismatches: 0,
|
|
50
|
+
closed: 0,
|
|
51
|
+
reopened: 0,
|
|
52
|
+
errors: [],
|
|
53
|
+
details: [],
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
// 1. Check if GitHub sync is enabled
|
|
57
|
+
const config = await this.loadConfig();
|
|
58
|
+
// v1.0.240 FIX: Honor preset when explicit settings absent
|
|
59
|
+
const syncAny = config.sync;
|
|
60
|
+
const permissions = resolvePermissions(syncAny?.preset, undefined, config.sync?.settings);
|
|
61
|
+
const canUpdate = config.sync?.settings?.canUpdateExternalItems ?? permissions.canUpsert;
|
|
62
|
+
const githubEnabled = isProviderEnabled(config, 'github');
|
|
63
|
+
if (!canUpdate || !githubEnabled) {
|
|
64
|
+
this.logger.log('ℹ️ GitHub sync is disabled - skipping reconciliation');
|
|
65
|
+
this.logger.log(' Enable with: canUpdateExternalItems=true AND sync.github.enabled=true');
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
// 2. Initialize GitHub client
|
|
69
|
+
await this.initClient(config);
|
|
70
|
+
if (!this.client) {
|
|
71
|
+
result.errors.push('Failed to initialize GitHub client');
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
// 3. Scan all non-archived increments
|
|
75
|
+
const increments = await this.scanIncrements();
|
|
76
|
+
result.scanned = increments.length;
|
|
77
|
+
this.logger.log(`\n📊 Scanning ${increments.length} increment(s) for GitHub state drift...\n`);
|
|
78
|
+
// 4. Check and fix each increment
|
|
79
|
+
for (const inc of increments) {
|
|
80
|
+
await this.reconcileIncrement(inc, result);
|
|
81
|
+
}
|
|
82
|
+
// 5. Report summary
|
|
83
|
+
this.logger.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
84
|
+
this.logger.log('📊 RECONCILIATION SUMMARY');
|
|
85
|
+
this.logger.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
86
|
+
this.logger.log(` Increments scanned: ${result.scanned}`);
|
|
87
|
+
this.logger.log(` Mismatches found: ${result.mismatches}`);
|
|
88
|
+
this.logger.log(` Issues closed: ${result.closed}`);
|
|
89
|
+
this.logger.log(` Issues reopened: ${result.reopened}`);
|
|
90
|
+
this.logger.log(` Errors: ${result.errors.length}`);
|
|
91
|
+
if (this.dryRun) {
|
|
92
|
+
this.logger.log('\n ⚠️ DRY RUN - No changes were made');
|
|
93
|
+
}
|
|
94
|
+
this.logger.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
result.errors.push(`Reconciliation error: ${error.message}`);
|
|
99
|
+
this.logger.error('❌ Reconciliation failed:', error.message);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reconcile a single increment
|
|
105
|
+
*/
|
|
106
|
+
async reconcileIncrement(inc, result) {
|
|
107
|
+
const status = inc.metadataStatus;
|
|
108
|
+
// Determine expected GitHub state
|
|
109
|
+
const shouldBeClosed = status === 'completed' || status === 'abandoned';
|
|
110
|
+
const shouldBeOpen = status === 'active' || status === 'planning' || status === 'backlog' || status === 'ready_for_review' || status === 'paused';
|
|
111
|
+
if (!shouldBeClosed && !shouldBeOpen) {
|
|
112
|
+
this.logger.log(` ⚠️ Unknown increment status '${status}' for ${inc.incrementId} — skipping`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Check main issue
|
|
116
|
+
if (inc.mainIssue) {
|
|
117
|
+
await this.reconcileIssue(inc.incrementId, inc.mainIssue.number, shouldBeClosed, shouldBeOpen, status, result);
|
|
118
|
+
}
|
|
119
|
+
// Check User Story issues
|
|
120
|
+
for (const us of inc.userStoryIssues) {
|
|
121
|
+
await this.reconcileIssue(`${inc.incrementId}/${us.userStoryId}`, us.issueNumber, shouldBeClosed, shouldBeOpen, status, result);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Reconcile a single issue
|
|
126
|
+
*/
|
|
127
|
+
async reconcileIssue(context, issueNumber, shouldBeClosed, shouldBeOpen, metadataStatus, result) {
|
|
128
|
+
try {
|
|
129
|
+
// Get current GitHub state
|
|
130
|
+
const issue = await this.client.getIssue(issueNumber);
|
|
131
|
+
const isCurrentlyClosed = issue.state === 'closed';
|
|
132
|
+
// Check for mismatch
|
|
133
|
+
if (shouldBeClosed && !isCurrentlyClosed) {
|
|
134
|
+
// Should be closed but is open
|
|
135
|
+
result.mismatches++;
|
|
136
|
+
this.logger.log(` ❌ Issue #${issueNumber} (${context}): OPEN but should be CLOSED (status=${metadataStatus})`);
|
|
137
|
+
if (!this.dryRun) {
|
|
138
|
+
const comment = `## 🔄 Auto-Reconciled
|
|
139
|
+
|
|
140
|
+
This issue was closed by SpecWeave reconciliation.
|
|
141
|
+
|
|
142
|
+
**Reason**: Increment status is \`${metadataStatus}\` but GitHub issue was still open.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
🤖 Auto-reconciled by SpecWeave`;
|
|
146
|
+
await this.client.closeIssue(issueNumber, comment);
|
|
147
|
+
result.closed++;
|
|
148
|
+
this.logger.log(` ✅ Closed issue #${issueNumber}`);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
this.logger.log(` [DRY RUN] Would close issue #${issueNumber}`);
|
|
152
|
+
}
|
|
153
|
+
result.details.push({
|
|
154
|
+
incrementId: context,
|
|
155
|
+
action: this.dryRun ? 'skip' : 'close',
|
|
156
|
+
issueNumber,
|
|
157
|
+
reason: `Status=${metadataStatus}, GH=open`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
else if (shouldBeOpen && isCurrentlyClosed) {
|
|
161
|
+
// Should be open but is closed
|
|
162
|
+
result.mismatches++;
|
|
163
|
+
this.logger.log(` ❌ Issue #${issueNumber} (${context}): CLOSED but should be OPEN (status=${metadataStatus})`);
|
|
164
|
+
if (!this.dryRun) {
|
|
165
|
+
const comment = `## 🔄 Auto-Reopened
|
|
166
|
+
|
|
167
|
+
This issue was reopened by SpecWeave reconciliation.
|
|
168
|
+
|
|
169
|
+
**Reason**: Increment status is \`${metadataStatus}\` but GitHub issue was closed.
|
|
170
|
+
|
|
171
|
+
This typically happens when:
|
|
172
|
+
- Increment was resumed after being paused/completed
|
|
173
|
+
- Manual status change in metadata.json
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
🤖 Auto-reconciled by SpecWeave`;
|
|
177
|
+
await this.client.reopenIssue(issueNumber, comment);
|
|
178
|
+
result.reopened++;
|
|
179
|
+
this.logger.log(` ✅ Reopened issue #${issueNumber}`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
this.logger.log(` [DRY RUN] Would reopen issue #${issueNumber}`);
|
|
183
|
+
}
|
|
184
|
+
result.details.push({
|
|
185
|
+
incrementId: context,
|
|
186
|
+
action: this.dryRun ? 'skip' : 'reopen',
|
|
187
|
+
issueNumber,
|
|
188
|
+
reason: `Status=${metadataStatus}, GH=closed`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// State matches - no action needed
|
|
193
|
+
this.logger.log(` ✅ Issue #${issueNumber} (${context}): State matches (${isCurrentlyClosed ? 'closed' : 'open'})`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
result.errors.push(`Issue #${issueNumber}: ${error.message}`);
|
|
198
|
+
result.details.push({
|
|
199
|
+
incrementId: context,
|
|
200
|
+
action: 'error',
|
|
201
|
+
issueNumber,
|
|
202
|
+
reason: error.message,
|
|
203
|
+
});
|
|
204
|
+
this.logger.error(` ⚠️ Error checking issue #${issueNumber}: ${error.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Scan all increments (active + archived + abandoned) and extract GitHub state.
|
|
209
|
+
* Archived/abandoned increments are included so the reconciler can close their
|
|
210
|
+
* stale open issues.
|
|
211
|
+
*/
|
|
212
|
+
async scanIncrements() {
|
|
213
|
+
const incrementsDir = path.join(this.projectRoot, '.specweave/increments');
|
|
214
|
+
const results = [];
|
|
215
|
+
if (!existsSync(incrementsDir)) {
|
|
216
|
+
return results;
|
|
217
|
+
}
|
|
218
|
+
// Collect all directories to scan: active + archive + abandoned
|
|
219
|
+
const dirsToScan = [incrementsDir];
|
|
220
|
+
for (const sub of ['_archive', '_abandoned']) {
|
|
221
|
+
const subPath = path.join(incrementsDir, sub);
|
|
222
|
+
if (existsSync(subPath))
|
|
223
|
+
dirsToScan.push(subPath);
|
|
224
|
+
}
|
|
225
|
+
for (const dir of dirsToScan) {
|
|
226
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
227
|
+
const isArchiveDir = dir !== incrementsDir;
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.')) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const incrementPath = path.join(dir, entry.name);
|
|
233
|
+
const metadataPath = path.join(incrementPath, 'metadata.json');
|
|
234
|
+
if (!existsSync(metadataPath)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
239
|
+
const state = this.extractGitHubState(entry.name, incrementPath, metadata);
|
|
240
|
+
// For archived increments, skip the expensive GitHub API search fallback —
|
|
241
|
+
// only use metadata-based issue references (they're already stored)
|
|
242
|
+
if (!isArchiveDir && state.userStoryIssues.length === 0 && state.featureId && this.client) {
|
|
243
|
+
await this.searchGitHubForIssues(state);
|
|
244
|
+
}
|
|
245
|
+
if (state.mainIssue || state.userStoryIssues.length > 0) {
|
|
246
|
+
results.push(state);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
this.logger.log(` ⚠️ Skipping ${entry.name}: Invalid metadata.json`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return results;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Extract GitHub state from increment metadata (both old and new formats)
|
|
258
|
+
*/
|
|
259
|
+
extractGitHubState(incrementId, incrementPath, metadata) {
|
|
260
|
+
const state = {
|
|
261
|
+
incrementId,
|
|
262
|
+
incrementPath,
|
|
263
|
+
metadataStatus: metadata.status || 'unknown',
|
|
264
|
+
featureId: metadata.feature_id,
|
|
265
|
+
userStoryIssues: [],
|
|
266
|
+
};
|
|
267
|
+
// Extract main issue
|
|
268
|
+
if (metadata.github?.issue) {
|
|
269
|
+
state.mainIssue = {
|
|
270
|
+
number: metadata.github.issue,
|
|
271
|
+
url: metadata.github.url,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// OLD format: metadata.github.issues[]
|
|
275
|
+
if (metadata.github?.issues && Array.isArray(metadata.github.issues)) {
|
|
276
|
+
for (const issue of metadata.github.issues) {
|
|
277
|
+
if (issue.userStory && issue.number) {
|
|
278
|
+
state.userStoryIssues.push({
|
|
279
|
+
userStoryId: issue.userStory,
|
|
280
|
+
issueNumber: issue.number,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// NEW format: externalLinks.github.issues{} (keyed by US-ID)
|
|
286
|
+
if (state.userStoryIssues.length === 0 && metadata.externalLinks?.github?.issues) {
|
|
287
|
+
const newFormatIssues = metadata.externalLinks.github.issues;
|
|
288
|
+
for (const [usId, issueData] of Object.entries(newFormatIssues)) {
|
|
289
|
+
if (issueData && typeof issueData === 'object' && 'issueNumber' in issueData) {
|
|
290
|
+
state.userStoryIssues.push({
|
|
291
|
+
userStoryId: usId,
|
|
292
|
+
issueNumber: issueData.issueNumber,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Auto-derive featureId when metadata.feature_id is null
|
|
298
|
+
if (!state.featureId) {
|
|
299
|
+
try {
|
|
300
|
+
state.featureId = deriveFeatureId(incrementId) || undefined;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Non-critical
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return state;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Fallback: search GitHub API for issues when metadata has no references.
|
|
310
|
+
* Only used for active (non-archived) increments to avoid excessive API calls.
|
|
311
|
+
*/
|
|
312
|
+
async searchGitHubForIssues(state) {
|
|
313
|
+
if (!state.featureId || !this.client)
|
|
314
|
+
return;
|
|
315
|
+
this.logger.log(` 🔍 Searching GitHub for ${state.featureId} issues (not in metadata)...`);
|
|
316
|
+
try {
|
|
317
|
+
const foundIssues = await this.client.searchIssuesByFeature(state.featureId);
|
|
318
|
+
for (const issue of foundIssues) {
|
|
319
|
+
const match = issue.title.match(/\[([A-Z]+-\d+)\]\[([A-Z]+-\d+)\]/);
|
|
320
|
+
if (match && match[1] === state.featureId) {
|
|
321
|
+
state.userStoryIssues.push({
|
|
322
|
+
userStoryId: match[2],
|
|
323
|
+
issueNumber: issue.number,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (state.userStoryIssues.length > 0) {
|
|
328
|
+
this.logger.log(` Found ${state.userStoryIssues.length} issue(s) via GitHub search`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
this.logger.log(` ⚠️ GitHub search failed: ${error.message}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Initialize GitHub client
|
|
337
|
+
*
|
|
338
|
+
* Prefers sync.github.owner/repo from config (critical for umbrella repos
|
|
339
|
+
* where git remote points to a different repo than where issues live).
|
|
340
|
+
* Falls back to git remote detection.
|
|
341
|
+
*/
|
|
342
|
+
async initClient(config) {
|
|
343
|
+
const cfg = config ?? await this.loadConfig();
|
|
344
|
+
const ghConfig = cfg.sync?.github;
|
|
345
|
+
if (ghConfig?.owner && ghConfig?.repo) {
|
|
346
|
+
this.client = GitHubClientV2.fromRepo(ghConfig.owner, ghConfig.repo);
|
|
347
|
+
this.logger.log(`🔗 GitHub repository: ${ghConfig.owner}/${ghConfig.repo} (from config)`);
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const repoInfo = await GitHubClientV2.detectRepo(this.projectRoot);
|
|
351
|
+
if (!repoInfo) {
|
|
352
|
+
throw new Error('Could not detect GitHub repository. Ensure sync.github.owner/repo is configured or a git remote exists.');
|
|
353
|
+
}
|
|
354
|
+
this.client = GitHubClientV2.fromRepo(repoInfo.owner, repoInfo.repo);
|
|
355
|
+
this.logger.log(`🔗 GitHub repository: ${repoInfo.owner}/${repoInfo.repo} (from git remote)`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Load config (cached after first read)
|
|
360
|
+
*/
|
|
361
|
+
async loadConfig() {
|
|
362
|
+
if (this.configCache !== null) {
|
|
363
|
+
return this.configCache;
|
|
364
|
+
}
|
|
365
|
+
const configPath = path.join(this.projectRoot, '.specweave/config.json');
|
|
366
|
+
if (!existsSync(configPath)) {
|
|
367
|
+
this.configCache = {};
|
|
368
|
+
return this.configCache;
|
|
369
|
+
}
|
|
370
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
371
|
+
this.configCache = JSON.parse(content);
|
|
372
|
+
return this.configCache;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Resolve GitHub owner/repo from config, falling back to git remote.
|
|
376
|
+
* Critical for umbrella repos where git remote != issue target repo.
|
|
377
|
+
*/
|
|
378
|
+
static async resolveRepoInfo(projectRoot) {
|
|
379
|
+
try {
|
|
380
|
+
const configPath = path.join(projectRoot, '.specweave/config.json');
|
|
381
|
+
if (existsSync(configPath)) {
|
|
382
|
+
const config = JSON.parse(await fs.readFile(configPath, 'utf-8'));
|
|
383
|
+
const ghConfig = config.sync?.github;
|
|
384
|
+
if (ghConfig?.owner && ghConfig?.repo) {
|
|
385
|
+
return { owner: ghConfig.owner, repo: ghConfig.repo };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// Fall through to git detection
|
|
391
|
+
}
|
|
392
|
+
return GitHubClientV2.detectRepo(projectRoot);
|
|
393
|
+
}
|
|
394
|
+
// ==========================================================================
|
|
395
|
+
// Static helpers for single-increment operations (used by hooks)
|
|
396
|
+
// ==========================================================================
|
|
397
|
+
/**
|
|
398
|
+
* Reopen all GitHub issues for an increment
|
|
399
|
+
* Called by post-increment-status-change.sh when resuming
|
|
400
|
+
*/
|
|
401
|
+
static async reopenIncrementIssues(projectRoot, incrementId, reason, logger) {
|
|
402
|
+
const log = logger ?? consoleLogger;
|
|
403
|
+
const result = { reopened: 0, errors: [] };
|
|
404
|
+
try {
|
|
405
|
+
// Load metadata
|
|
406
|
+
const metadataPath = path.join(projectRoot, '.specweave/increments', incrementId, 'metadata.json');
|
|
407
|
+
if (!existsSync(metadataPath)) {
|
|
408
|
+
result.errors.push('metadata.json not found');
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
412
|
+
// Initialize client (prefer config over git remote for umbrella repos)
|
|
413
|
+
const repoInfo = await GitHubReconciler.resolveRepoInfo(projectRoot);
|
|
414
|
+
if (!repoInfo) {
|
|
415
|
+
result.errors.push('Could not detect GitHub repository');
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
const client = GitHubClientV2.fromRepo(repoInfo.owner, repoInfo.repo);
|
|
419
|
+
const comment = `## ▶️ Increment Resumed
|
|
420
|
+
|
|
421
|
+
This issue was reopened because increment \`${incrementId}\` was resumed.
|
|
422
|
+
|
|
423
|
+
**Reason**: ${reason}
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
🤖 Auto-reopened by SpecWeave`;
|
|
427
|
+
// Reopen main issue
|
|
428
|
+
if (metadata.github?.issue) {
|
|
429
|
+
try {
|
|
430
|
+
const issue = await client.getIssue(metadata.github.issue);
|
|
431
|
+
if (issue.state === 'closed') {
|
|
432
|
+
await client.reopenIssue(metadata.github.issue, comment);
|
|
433
|
+
result.reopened++;
|
|
434
|
+
log.log(` ✅ Reopened main issue #${metadata.github.issue}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
result.errors.push(`Main issue: ${error.message}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Reopen User Story issues
|
|
442
|
+
if (metadata.github?.issues && Array.isArray(metadata.github.issues)) {
|
|
443
|
+
for (const usIssue of metadata.github.issues) {
|
|
444
|
+
if (usIssue.number) {
|
|
445
|
+
try {
|
|
446
|
+
const issue = await client.getIssue(usIssue.number);
|
|
447
|
+
if (issue.state === 'closed') {
|
|
448
|
+
await client.reopenIssue(usIssue.number, comment);
|
|
449
|
+
result.reopened++;
|
|
450
|
+
log.log(` ✅ Reopened User Story issue #${usIssue.number}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
result.errors.push(`Issue #${usIssue.number}: ${error.message}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
result.errors.push(error.message);
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Close all GitHub issues for an abandoned increment
|
|
468
|
+
* Called by post-increment-status-change.sh when abandoning
|
|
469
|
+
*/
|
|
470
|
+
static async closeAbandonedIncrementIssues(projectRoot, incrementId, reason, logger) {
|
|
471
|
+
const log = logger ?? consoleLogger;
|
|
472
|
+
const result = { closed: 0, errors: [] };
|
|
473
|
+
try {
|
|
474
|
+
// Load metadata
|
|
475
|
+
const metadataPath = path.join(projectRoot, '.specweave/increments', incrementId, 'metadata.json');
|
|
476
|
+
if (!existsSync(metadataPath)) {
|
|
477
|
+
result.errors.push('metadata.json not found');
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
481
|
+
// Initialize client (prefer config over git remote for umbrella repos)
|
|
482
|
+
const repoInfo = await GitHubReconciler.resolveRepoInfo(projectRoot);
|
|
483
|
+
if (!repoInfo) {
|
|
484
|
+
result.errors.push('Could not detect GitHub repository');
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
const client = GitHubClientV2.fromRepo(repoInfo.owner, repoInfo.repo);
|
|
488
|
+
const comment = `## 🗑️ Increment Abandoned
|
|
489
|
+
|
|
490
|
+
This issue was closed because increment \`${incrementId}\` was abandoned.
|
|
491
|
+
|
|
492
|
+
**Reason**: ${reason}
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
🤖 Auto-closed by SpecWeave`;
|
|
496
|
+
// Close main issue
|
|
497
|
+
if (metadata.github?.issue) {
|
|
498
|
+
try {
|
|
499
|
+
const issue = await client.getIssue(metadata.github.issue);
|
|
500
|
+
if (issue.state === 'open') {
|
|
501
|
+
await client.closeIssue(metadata.github.issue, comment);
|
|
502
|
+
result.closed++;
|
|
503
|
+
log.log(` ✅ Closed main issue #${metadata.github.issue}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
result.errors.push(`Main issue: ${error.message}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Close User Story issues
|
|
511
|
+
if (metadata.github?.issues && Array.isArray(metadata.github.issues)) {
|
|
512
|
+
for (const usIssue of metadata.github.issues) {
|
|
513
|
+
if (usIssue.number) {
|
|
514
|
+
try {
|
|
515
|
+
const issue = await client.getIssue(usIssue.number);
|
|
516
|
+
if (issue.state === 'open') {
|
|
517
|
+
await client.closeIssue(usIssue.number, comment);
|
|
518
|
+
result.closed++;
|
|
519
|
+
log.log(` ✅ Closed User Story issue #${usIssue.number}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
result.errors.push(`Issue #${usIssue.number}: ${error.message}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return result;
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
result.errors.push(error.message);
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Close all GitHub issues for a completed increment.
|
|
537
|
+
*
|
|
538
|
+
* Fallback path that reads issue numbers directly from metadata.json
|
|
539
|
+
* (does NOT depend on living docs files existing).
|
|
540
|
+
*
|
|
541
|
+
* Reads from BOTH metadata formats:
|
|
542
|
+
* - metadata.externalLinks.github.issues (keyed by US-ID, has issueNumber)
|
|
543
|
+
* - metadata.github.issues (array, has number)
|
|
544
|
+
*
|
|
545
|
+
* Idempotent: skips already-closed issues.
|
|
546
|
+
* Also closes the milestone if present in metadata.
|
|
547
|
+
*/
|
|
548
|
+
static async closeCompletedIncrementIssues(projectRoot, incrementId, logger) {
|
|
549
|
+
const log = typeof logger === 'function'
|
|
550
|
+
? { log: logger }
|
|
551
|
+
: (logger ?? consoleLogger);
|
|
552
|
+
const result = { closed: 0, milestoneClose: false, errors: [] };
|
|
553
|
+
const closedNumbers = new Set();
|
|
554
|
+
try {
|
|
555
|
+
const metadataPath = path.join(projectRoot, '.specweave/increments', incrementId, 'metadata.json');
|
|
556
|
+
if (!existsSync(metadataPath)) {
|
|
557
|
+
return result; // No metadata = nothing to close
|
|
558
|
+
}
|
|
559
|
+
let metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
560
|
+
// Auto-recovery: if no sync data at all, run full sync before closure
|
|
561
|
+
if (!hasAnyGitHubSyncData(metadata)) {
|
|
562
|
+
try {
|
|
563
|
+
const { LivingDocsSync } = await import('../core/living-docs/living-docs-sync.js');
|
|
564
|
+
const sync = new LivingDocsSync(projectRoot);
|
|
565
|
+
await sync.syncIncrement(incrementId);
|
|
566
|
+
// Re-read metadata after sync
|
|
567
|
+
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// Non-critical: sync may fail if no living docs exist yet
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Collect issues grouped by owner/repo for cross-repo closure
|
|
574
|
+
// Map key: "owner/repo" → issue numbers
|
|
575
|
+
const repoIssues = new Map();
|
|
576
|
+
const issueRepoMap = new Map(); // issueNum → "owner/repo"
|
|
577
|
+
// Helper to extract owner/repo from GitHub issue URL
|
|
578
|
+
const parseRepoFromUrl = (url) => {
|
|
579
|
+
const match = url?.match(/github\.com\/([^/]+\/[^/]+)\/issues\//);
|
|
580
|
+
return match ? match[1] : null;
|
|
581
|
+
};
|
|
582
|
+
// Fallback repo from global config
|
|
583
|
+
const globalRepoInfo = await GitHubReconciler.resolveRepoInfo(projectRoot);
|
|
584
|
+
const globalRepoSlug = globalRepoInfo ? `${globalRepoInfo.owner}/${globalRepoInfo.repo}` : null;
|
|
585
|
+
// Format 1: externalLinks.github.issues (keyed by US-ID)
|
|
586
|
+
const extIssues = metadata.externalLinks?.github?.issues;
|
|
587
|
+
if (extIssues && typeof extIssues === 'object') {
|
|
588
|
+
for (const usId of Object.keys(extIssues)) {
|
|
589
|
+
const entry = extIssues[usId];
|
|
590
|
+
const num = entry?.issueNumber;
|
|
591
|
+
if (typeof num !== 'number')
|
|
592
|
+
continue;
|
|
593
|
+
const repoSlug = parseRepoFromUrl(entry?.issueUrl) || globalRepoSlug;
|
|
594
|
+
if (!repoSlug)
|
|
595
|
+
continue;
|
|
596
|
+
if (!repoIssues.has(repoSlug))
|
|
597
|
+
repoIssues.set(repoSlug, new Set());
|
|
598
|
+
repoIssues.get(repoSlug).add(num);
|
|
599
|
+
issueRepoMap.set(num, repoSlug);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Format 2: github.issues (array with number field)
|
|
603
|
+
if (Array.isArray(metadata.github?.issues)) {
|
|
604
|
+
for (const entry of metadata.github.issues) {
|
|
605
|
+
if (typeof entry.number !== 'number')
|
|
606
|
+
continue;
|
|
607
|
+
const repoSlug = parseRepoFromUrl(entry?.url) || globalRepoSlug;
|
|
608
|
+
if (!repoSlug)
|
|
609
|
+
continue;
|
|
610
|
+
if (!repoIssues.has(repoSlug))
|
|
611
|
+
repoIssues.set(repoSlug, new Set());
|
|
612
|
+
repoIssues.get(repoSlug).add(entry.number);
|
|
613
|
+
issueRepoMap.set(entry.number, repoSlug);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const totalIssues = Array.from(repoIssues.values()).reduce((sum, s) => sum + s.size, 0);
|
|
617
|
+
if (totalIssues === 0)
|
|
618
|
+
return result;
|
|
619
|
+
const comment = `## Increment Completed
|
|
620
|
+
|
|
621
|
+
All tasks for increment \`${incrementId}\` have been completed.
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
Auto-closed by SpecWeave`;
|
|
625
|
+
// Close issues per-repo (supports cross-repo distributed sync)
|
|
626
|
+
for (const [repoSlug, nums] of repoIssues) {
|
|
627
|
+
const [owner, repo] = repoSlug.split('/');
|
|
628
|
+
const client = GitHubClientV2.fromRepo(owner, repo);
|
|
629
|
+
for (const num of nums) {
|
|
630
|
+
try {
|
|
631
|
+
const issue = await client.getIssue(num);
|
|
632
|
+
if (issue.state === 'open') {
|
|
633
|
+
await client.closeIssue(num, comment);
|
|
634
|
+
closedNumbers.add(num);
|
|
635
|
+
result.closed++;
|
|
636
|
+
log.log(` Closed GitHub issue ${repoSlug}#${num}`);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
log.log(` Issue ${repoSlug}#${num} already closed`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
result.errors.push(`Issue ${repoSlug}#${num}: ${error.message}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Close milestone if present (uses global repo — milestones are on the main repo)
|
|
648
|
+
const milestoneNum = metadata.externalLinks?.github?.milestone ?? metadata.github?.milestone;
|
|
649
|
+
if (typeof milestoneNum === 'number' && globalRepoSlug) {
|
|
650
|
+
try {
|
|
651
|
+
const { execFileNoThrow } = await import('../utils/execFileNoThrow.js');
|
|
652
|
+
const msResult = await execFileNoThrow('gh', [
|
|
653
|
+
'api',
|
|
654
|
+
'-X',
|
|
655
|
+
'PATCH',
|
|
656
|
+
`repos/${globalRepoSlug}/milestones/${milestoneNum}`,
|
|
657
|
+
'-f',
|
|
658
|
+
'state=closed',
|
|
659
|
+
]);
|
|
660
|
+
if (msResult.exitCode === 0) {
|
|
661
|
+
result.milestoneClose = true;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
result.errors.push(`Milestone #${milestoneNum}: ${error.message}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Update metadata externalLinks status (only for actually-closed issues)
|
|
669
|
+
if (closedNumbers.size > 0 && extIssues) {
|
|
670
|
+
let changed = false;
|
|
671
|
+
for (const usId of Object.keys(extIssues)) {
|
|
672
|
+
const num = extIssues[usId]?.issueNumber;
|
|
673
|
+
if (typeof num === 'number' && closedNumbers.has(num) && extIssues[usId]?.status !== 'closed') {
|
|
674
|
+
extIssues[usId].status = 'closed';
|
|
675
|
+
changed = true;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (changed) {
|
|
679
|
+
metadata.externalLinks.github.syncedAt = new Date().toISOString();
|
|
680
|
+
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2) + '\n');
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return result;
|
|
684
|
+
}
|
|
685
|
+
catch (error) {
|
|
686
|
+
result.errors.push(error.message);
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Reconcile stale and duplicate milestones on GitHub.
|
|
692
|
+
*
|
|
693
|
+
* - Closes open milestones with 0 open issues and 1+ closed issues (stale)
|
|
694
|
+
* - Closes open milestones matching completed local increments
|
|
695
|
+
* - Detects duplicate milestones (same FS-XXX prefix) and closes the
|
|
696
|
+
* smaller/empty one, keeping the one with the most issues
|
|
697
|
+
*
|
|
698
|
+
* @param projectRoot - Project root directory
|
|
699
|
+
* @param dryRun - If true, only report what would happen
|
|
700
|
+
* @param logger - Logger instance
|
|
701
|
+
*/
|
|
702
|
+
static async reconcileMilestones(projectRoot, dryRun = false, logger) {
|
|
703
|
+
const log = logger ?? consoleLogger;
|
|
704
|
+
const result = { staleClosed: 0, duplicatesClosed: 0, errors: [] };
|
|
705
|
+
try {
|
|
706
|
+
// Resolve repo info
|
|
707
|
+
const repoInfo = await GitHubReconciler.resolveRepoInfo(projectRoot);
|
|
708
|
+
if (!repoInfo) {
|
|
709
|
+
result.errors.push('Could not detect GitHub repository');
|
|
710
|
+
return result;
|
|
711
|
+
}
|
|
712
|
+
const { execFileNoThrow } = await import('../utils/execFileNoThrow.js');
|
|
713
|
+
const repoSlug = `${repoInfo.owner}/${repoInfo.repo}`;
|
|
714
|
+
// 1. Fetch all open milestones from GitHub
|
|
715
|
+
log.log(' Fetching open milestones from GitHub...');
|
|
716
|
+
const msResult = await execFileNoThrow('gh', [
|
|
717
|
+
'api',
|
|
718
|
+
`repos/${repoSlug}/milestones`,
|
|
719
|
+
'--paginate',
|
|
720
|
+
'-q',
|
|
721
|
+
'.[] | {number, title, open_issues, closed_issues, state}',
|
|
722
|
+
]);
|
|
723
|
+
if (!msResult.success) {
|
|
724
|
+
result.errors.push(`Failed to list milestones: ${msResult.stderr}`);
|
|
725
|
+
return result;
|
|
726
|
+
}
|
|
727
|
+
const milestones = [];
|
|
728
|
+
for (const line of msResult.stdout.trim().split('\n')) {
|
|
729
|
+
if (!line.trim())
|
|
730
|
+
continue;
|
|
731
|
+
try {
|
|
732
|
+
milestones.push(JSON.parse(line));
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
// Skip unparseable lines
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (milestones.length === 0) {
|
|
739
|
+
log.log(' No open milestones found on GitHub');
|
|
740
|
+
return result;
|
|
741
|
+
}
|
|
742
|
+
log.log(` Found ${milestones.length} open milestone(s)`);
|
|
743
|
+
// 2. Collect completed local increment FS-IDs for cross-reference
|
|
744
|
+
const completedFsIds = await GitHubReconciler.collectCompletedIncrementFsIds(projectRoot);
|
|
745
|
+
// 3. T-014: Close stale milestones
|
|
746
|
+
// A milestone is stale if:
|
|
747
|
+
// (a) it has 0 open issues AND 1+ closed issues, OR
|
|
748
|
+
// (b) it matches a completed local increment (FS-XXX prefix)
|
|
749
|
+
const closedNumbers = new Set();
|
|
750
|
+
for (const ms of milestones) {
|
|
751
|
+
const fsMatch = ms.title.match(/^(FS-\d+)/);
|
|
752
|
+
const fsId = fsMatch ? fsMatch[1] : null;
|
|
753
|
+
const isAllIssuesClosed = ms.open_issues === 0 && ms.closed_issues > 0;
|
|
754
|
+
const matchesCompletedIncrement = fsId ? completedFsIds.has(fsId) : false;
|
|
755
|
+
if (isAllIssuesClosed || matchesCompletedIncrement) {
|
|
756
|
+
const reason = isAllIssuesClosed
|
|
757
|
+
? `0 open / ${ms.closed_issues} closed issues`
|
|
758
|
+
: `matches completed increment ${fsId}`;
|
|
759
|
+
if (!dryRun) {
|
|
760
|
+
const closeResult = await execFileNoThrow('gh', [
|
|
761
|
+
'api',
|
|
762
|
+
'-X', 'PATCH',
|
|
763
|
+
`repos/${repoSlug}/milestones/${ms.number}`,
|
|
764
|
+
'-f', 'state=closed',
|
|
765
|
+
]);
|
|
766
|
+
if (closeResult.success) {
|
|
767
|
+
result.staleClosed++;
|
|
768
|
+
closedNumbers.add(ms.number);
|
|
769
|
+
log.log(` Closed stale milestone #${ms.number} "${ms.title}" (${reason})`);
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
result.errors.push(`Failed to close milestone #${ms.number}: ${closeResult.stderr}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
result.staleClosed++;
|
|
777
|
+
closedNumbers.add(ms.number);
|
|
778
|
+
log.log(` [DRY-RUN] Would close stale milestone #${ms.number} "${ms.title}" (${reason})`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// 4. T-015: Detect and close duplicate milestones
|
|
783
|
+
// Group remaining open milestones by FS-XXX prefix
|
|
784
|
+
const fsPrefixGroups = new Map();
|
|
785
|
+
for (const ms of milestones) {
|
|
786
|
+
// Skip milestones we already closed in step 3
|
|
787
|
+
if (closedNumbers.has(ms.number))
|
|
788
|
+
continue;
|
|
789
|
+
const fsMatch = ms.title.match(/^(FS-\d+)/);
|
|
790
|
+
if (!fsMatch)
|
|
791
|
+
continue;
|
|
792
|
+
const prefix = fsMatch[1];
|
|
793
|
+
const group = fsPrefixGroups.get(prefix) || [];
|
|
794
|
+
group.push(ms);
|
|
795
|
+
fsPrefixGroups.set(prefix, group);
|
|
796
|
+
}
|
|
797
|
+
for (const [prefix, group] of fsPrefixGroups) {
|
|
798
|
+
if (group.length < 2)
|
|
799
|
+
continue;
|
|
800
|
+
// Sort by total issue count descending — keep the one with most issues
|
|
801
|
+
const sorted = [...group].sort((a, b) => {
|
|
802
|
+
const totalA = a.open_issues + a.closed_issues;
|
|
803
|
+
const totalB = b.open_issues + b.closed_issues;
|
|
804
|
+
return totalB - totalA;
|
|
805
|
+
});
|
|
806
|
+
const keep = sorted[0];
|
|
807
|
+
const toClose = sorted.slice(1);
|
|
808
|
+
for (const dup of toClose) {
|
|
809
|
+
if (!dryRun) {
|
|
810
|
+
const closeResult = await execFileNoThrow('gh', [
|
|
811
|
+
'api',
|
|
812
|
+
'-X', 'PATCH',
|
|
813
|
+
`repos/${repoSlug}/milestones/${dup.number}`,
|
|
814
|
+
'-f', 'state=closed',
|
|
815
|
+
]);
|
|
816
|
+
if (closeResult.success) {
|
|
817
|
+
result.duplicatesClosed++;
|
|
818
|
+
log.log(` Closed duplicate milestone #${dup.number} "${dup.title}" (keeping #${keep.number} with ${keep.open_issues + keep.closed_issues} issues)`);
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
result.errors.push(`Failed to close duplicate milestone #${dup.number}: ${closeResult.stderr}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
result.duplicatesClosed++;
|
|
826
|
+
log.log(` [DRY-RUN] Would close duplicate milestone #${dup.number} "${dup.title}" (keeping #${keep.number})`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return result;
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
result.errors.push(error.message);
|
|
834
|
+
return result;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Collect FS-IDs of all completed/archived/abandoned increments.
|
|
839
|
+
* Returns a Set of strings like "FS-400", "FS-391", etc.
|
|
840
|
+
*/
|
|
841
|
+
static async collectCompletedIncrementFsIds(projectRoot) {
|
|
842
|
+
const fsIds = new Set();
|
|
843
|
+
const incrementsDir = path.join(projectRoot, '.specweave/increments');
|
|
844
|
+
if (!existsSync(incrementsDir))
|
|
845
|
+
return fsIds;
|
|
846
|
+
// Check archive and abandoned dirs for completed increments
|
|
847
|
+
for (const sub of ['_archive', '_abandoned']) {
|
|
848
|
+
const subDir = path.join(incrementsDir, sub);
|
|
849
|
+
if (!existsSync(subDir))
|
|
850
|
+
continue;
|
|
851
|
+
const entries = await fs.readdir(subDir, { withFileTypes: true });
|
|
852
|
+
for (const entry of entries) {
|
|
853
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
854
|
+
continue;
|
|
855
|
+
const match = entry.name.match(/^(\d{4})/);
|
|
856
|
+
if (match) {
|
|
857
|
+
const num = parseInt(match[1], 10);
|
|
858
|
+
fsIds.add(`FS-${String(num).padStart(3, '0')}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Also check active increments with completed/abandoned status
|
|
863
|
+
const entries = await fs.readdir(incrementsDir, { withFileTypes: true });
|
|
864
|
+
for (const entry of entries) {
|
|
865
|
+
if (!entry.isDirectory() || entry.name.startsWith('_') || entry.name.startsWith('.'))
|
|
866
|
+
continue;
|
|
867
|
+
const metadataPath = path.join(incrementsDir, entry.name, 'metadata.json');
|
|
868
|
+
if (!existsSync(metadataPath))
|
|
869
|
+
continue;
|
|
870
|
+
try {
|
|
871
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
872
|
+
if (metadata.status === 'completed' || metadata.status === 'abandoned') {
|
|
873
|
+
const match = entry.name.match(/^(\d{4})/);
|
|
874
|
+
if (match) {
|
|
875
|
+
const num = parseInt(match[1], 10);
|
|
876
|
+
fsIds.add(`FS-${String(num).padStart(3, '0')}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
// Skip invalid metadata
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return fsIds;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
// ==========================================================================
|
|
888
|
+
// Milestone reconciliation (T-014, T-015)
|
|
889
|
+
// ==========================================================================
|
|
890
|
+
/**
|
|
891
|
+
* Result from milestone reconciliation
|
|
892
|
+
*/
|
|
893
|
+
GitHubReconciler.MILESTONE_RECONCILE_RESULT_TEMPLATE = {
|
|
894
|
+
staleClosed: 0,
|
|
895
|
+
duplicatesClosed: 0,
|
|
896
|
+
errors: [],
|
|
897
|
+
};
|
|
898
|
+
//# sourceMappingURL=github-reconciler.js.map
|