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.
Files changed (109) hide show
  1. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.d.ts +16 -0
  2. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js +139 -0
  3. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator-cli.js.map +1 -0
  4. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.d.ts +111 -0
  5. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js +304 -0
  6. package/dist/plugins/specweave/lib/vendor/core/ac-test-validator.js.map +1 -0
  7. package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.d.ts +115 -0
  8. package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js +359 -0
  9. package/dist/plugins/specweave/lib/vendor/core/increment/ac-status-manager.js.map +1 -0
  10. package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.d.ts +121 -0
  11. package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js +273 -0
  12. package/dist/plugins/specweave/lib/vendor/core/increment/active-increment-manager.js.map +1 -0
  13. package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.d.ts +72 -0
  14. package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js +237 -0
  15. package/dist/plugins/specweave/lib/vendor/core/increment/auto-transition-manager.js.map +1 -0
  16. package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.d.ts +52 -0
  17. package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js +281 -0
  18. package/dist/plugins/specweave/lib/vendor/core/increment/duplicate-detector.js.map +1 -0
  19. package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.d.ts +278 -0
  20. package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js +925 -0
  21. package/dist/plugins/specweave/lib/vendor/core/increment/metadata-manager.js.map +1 -0
  22. package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.d.ts +113 -0
  23. package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js +317 -0
  24. package/dist/plugins/specweave/lib/vendor/core/increment/status-auto-transition.js.map +1 -0
  25. package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.d.ts +442 -0
  26. package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js +246 -0
  27. package/dist/plugins/specweave/lib/vendor/core/types/increment-metadata.js.map +1 -0
  28. package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.d.ts +64 -0
  29. package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js +228 -0
  30. package/dist/plugins/specweave/lib/vendor/core/universal-auto-create.js.map +1 -0
  31. package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.d.ts +95 -0
  32. package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js +300 -0
  33. package/dist/plugins/specweave/lib/vendor/generators/spec/task-parser.js.map +1 -0
  34. package/dist/plugins/specweave/lib/vendor/sync/config.d.ts +73 -0
  35. package/dist/plugins/specweave/lib/vendor/sync/config.js +132 -0
  36. package/dist/plugins/specweave/lib/vendor/sync/config.js.map +1 -0
  37. package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.d.ts +163 -0
  38. package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js +898 -0
  39. package/dist/plugins/specweave/lib/vendor/sync/github-reconciler.js.map +1 -0
  40. package/dist/plugins/specweave/lib/vendor/sync/provider-router.d.ts +86 -0
  41. package/dist/plugins/specweave/lib/vendor/sync/provider-router.js +147 -0
  42. package/dist/plugins/specweave/lib/vendor/sync/provider-router.js.map +1 -0
  43. package/dist/plugins/specweave/lib/vendor/sync/status-mapper.d.ts +120 -0
  44. package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js +164 -0
  45. package/dist/plugins/specweave/lib/vendor/sync/status-mapper.js.map +1 -0
  46. package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
  47. package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
  48. package/dist/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
  49. package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.d.ts +38 -0
  50. package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js +118 -0
  51. package/dist/plugins/specweave/lib/vendor/utils/chalk-fallback.js.map +1 -0
  52. package/dist/plugins/specweave/lib/vendor/utils/clean-env.d.ts +47 -0
  53. package/dist/plugins/specweave/lib/vendor/utils/clean-env.js +63 -0
  54. package/dist/plugins/specweave/lib/vendor/utils/clean-env.js.map +1 -0
  55. package/dist/plugins/specweave/lib/vendor/utils/credential-masker.d.ts +118 -0
  56. package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js +275 -0
  57. package/dist/plugins/specweave/lib/vendor/utils/credential-masker.js.map +1 -0
  58. package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.d.ts +99 -0
  59. package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js +149 -0
  60. package/dist/plugins/specweave/lib/vendor/utils/execFileNoThrow.js.map +1 -0
  61. package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.d.ts +63 -0
  62. package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js +85 -0
  63. package/dist/plugins/specweave/lib/vendor/utils/feature-id-derivation.js.map +1 -0
  64. package/dist/plugins/specweave/lib/vendor/utils/fs-native.d.ts +219 -0
  65. package/dist/plugins/specweave/lib/vendor/utils/fs-native.js +397 -0
  66. package/dist/plugins/specweave/lib/vendor/utils/fs-native.js.map +1 -0
  67. package/dist/plugins/specweave/lib/vendor/utils/logger.d.ts +56 -0
  68. package/dist/plugins/specweave/lib/vendor/utils/logger.js +123 -0
  69. package/dist/plugins/specweave/lib/vendor/utils/logger.js.map +1 -0
  70. package/dist/plugins/specweave/lib/vendor/utils/translation.d.ts +187 -0
  71. package/dist/plugins/specweave/lib/vendor/utils/translation.js +414 -0
  72. package/dist/plugins/specweave/lib/vendor/utils/translation.js.map +1 -0
  73. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
  74. package/dist/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js.map +1 -1
  75. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
  76. package/dist/plugins/specweave-ado/lib/ado-spec-sync.js.map +1 -1
  77. package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js +2 -2
  78. package/dist/plugins/specweave-github/lib/github-ac-checkbox-sync.js.map +1 -1
  79. package/dist/plugins/specweave-github/lib/github-feature-sync.d.ts.map +1 -1
  80. package/dist/plugins/specweave-github/lib/github-feature-sync.js +13 -4
  81. package/dist/plugins/specweave-github/lib/github-feature-sync.js.map +1 -1
  82. package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
  83. package/dist/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js.map +1 -1
  84. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
  85. package/dist/plugins/specweave-jira/lib/jira-spec-sync.js.map +1 -1
  86. package/dist/src/sync/spec-to-living-docs-sync.js +1 -1
  87. package/dist/src/sync/spec-to-living-docs-sync.js.map +1 -1
  88. package/package.json +1 -1
  89. package/plugins/specweave/lib/vendor/utils/auth-helpers.d.ts +151 -0
  90. package/plugins/specweave/lib/vendor/utils/auth-helpers.js +359 -0
  91. package/plugins/specweave/lib/vendor/utils/auth-helpers.js.map +1 -0
  92. package/plugins/specweave/skills/team-lead/SKILL.md +150 -56
  93. package/plugins/specweave/skills/team-lead/agents/backend.md +13 -9
  94. package/plugins/specweave/skills/team-lead/agents/database.md +13 -9
  95. package/plugins/specweave/skills/team-lead/agents/frontend.md +12 -8
  96. package/plugins/specweave/skills/team-lead/agents/security.md +13 -9
  97. package/plugins/specweave/skills/team-lead/agents/testing.md +12 -8
  98. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.js +1 -1
  99. package/plugins/specweave-ado/lib/ado-ac-checkbox-sync.ts +1 -1
  100. package/plugins/specweave-ado/lib/ado-spec-sync.js +1 -1
  101. package/plugins/specweave-ado/lib/ado-spec-sync.ts +1 -1
  102. package/plugins/specweave-github/lib/github-ac-checkbox-sync.js +1 -1
  103. package/plugins/specweave-github/lib/github-ac-checkbox-sync.ts +2 -2
  104. package/plugins/specweave-github/lib/github-feature-sync.js +11 -3
  105. package/plugins/specweave-github/lib/github-feature-sync.ts +13 -4
  106. package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.js +1 -1
  107. package/plugins/specweave-jira/lib/jira-ac-checkbox-sync.ts +1 -1
  108. package/plugins/specweave-jira/lib/jira-spec-sync.js +1 -1
  109. 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