specweave 0.33.2 â 0.33.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +133 -19
- package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts +120 -0
- package/dist/plugins/specweave-ado/lib/per-us-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-ado/lib/per-us-sync.js +276 -0
- package/dist/plugins/specweave-ado/lib/per-us-sync.js.map +1 -0
- package/dist/plugins/specweave-github/lib/github-client-v2.d.ts +4 -1
- package/dist/plugins/specweave-github/lib/github-client-v2.d.ts.map +1 -1
- package/dist/plugins/specweave-github/lib/github-client-v2.js +13 -3
- package/dist/plugins/specweave-github/lib/github-client-v2.js.map +1 -1
- package/dist/plugins/specweave-github/lib/per-us-sync.d.ts +97 -0
- package/dist/plugins/specweave-github/lib/per-us-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-github/lib/per-us-sync.js +274 -0
- package/dist/plugins/specweave-github/lib/per-us-sync.js.map +1 -0
- package/dist/plugins/specweave-jira/lib/per-us-sync.d.ts +113 -0
- package/dist/plugins/specweave-jira/lib/per-us-sync.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/per-us-sync.js +254 -0
- package/dist/plugins/specweave-jira/lib/per-us-sync.js.map +1 -0
- package/dist/src/cli/cleanup-zombies.js +8 -5
- package/dist/src/cli/cleanup-zombies.js.map +1 -1
- package/dist/src/config/types.d.ts +203 -1208
- package/dist/src/config/types.d.ts.map +1 -1
- package/dist/src/core/config/config-manager.d.ts.map +1 -1
- package/dist/src/core/config/config-manager.js +58 -0
- package/dist/src/core/config/config-manager.js.map +1 -1
- package/dist/src/core/config/types.d.ts +80 -0
- package/dist/src/core/config/types.d.ts.map +1 -1
- package/dist/src/core/config/types.js.map +1 -1
- package/dist/src/core/living-docs/cross-project-sync.d.ts +87 -15
- package/dist/src/core/living-docs/cross-project-sync.d.ts.map +1 -1
- package/dist/src/core/living-docs/cross-project-sync.js +147 -28
- package/dist/src/core/living-docs/cross-project-sync.js.map +1 -1
- package/dist/src/core/living-docs/living-docs-sync.d.ts.map +1 -1
- package/dist/src/core/living-docs/living-docs-sync.js +26 -22
- package/dist/src/core/living-docs/living-docs-sync.js.map +1 -1
- package/dist/src/core/living-docs/types.d.ts +24 -3
- package/dist/src/core/living-docs/types.d.ts.map +1 -1
- package/dist/src/core/types/config.d.ts +79 -0
- package/dist/src/core/types/config.d.ts.map +1 -1
- package/dist/src/core/types/config.js.map +1 -1
- package/dist/src/importers/jira-importer.d.ts +10 -0
- package/dist/src/importers/jira-importer.d.ts.map +1 -1
- package/dist/src/importers/jira-importer.js +55 -5
- package/dist/src/importers/jira-importer.js.map +1 -1
- package/dist/src/init/architecture/types.d.ts +33 -140
- package/dist/src/init/architecture/types.d.ts.map +1 -1
- package/dist/src/init/compliance/types.d.ts +30 -27
- package/dist/src/init/compliance/types.d.ts.map +1 -1
- package/dist/src/init/repo/types.d.ts +11 -34
- package/dist/src/init/repo/types.d.ts.map +1 -1
- package/dist/src/init/research/src/config/types.d.ts +15 -82
- package/dist/src/init/research/src/config/types.d.ts.map +1 -1
- package/dist/src/init/research/types.d.ts +38 -93
- package/dist/src/init/research/types.d.ts.map +1 -1
- package/dist/src/init/team/types.d.ts +4 -42
- package/dist/src/init/team/types.d.ts.map +1 -1
- package/dist/src/sync/closure-metrics.d.ts +102 -0
- package/dist/src/sync/closure-metrics.d.ts.map +1 -0
- package/dist/src/sync/closure-metrics.js +267 -0
- package/dist/src/sync/closure-metrics.js.map +1 -0
- package/dist/src/sync/sync-coordinator.d.ts +49 -0
- package/dist/src/sync/sync-coordinator.d.ts.map +1 -1
- package/dist/src/sync/sync-coordinator.js +399 -37
- package/dist/src/sync/sync-coordinator.js.map +1 -1
- package/dist/src/utils/notification-constants.d.ts +85 -0
- package/dist/src/utils/notification-constants.d.ts.map +1 -0
- package/dist/src/utils/notification-constants.js +129 -0
- package/dist/src/utils/notification-constants.js.map +1 -0
- package/dist/src/utils/platform-utils.d.ts +13 -3
- package/dist/src/utils/platform-utils.d.ts.map +1 -1
- package/dist/src/utils/platform-utils.js +17 -6
- package/dist/src/utils/platform-utils.js.map +1 -1
- package/dist/src/utils/project-resolver.d.ts +156 -0
- package/dist/src/utils/project-resolver.d.ts.map +1 -0
- package/dist/src/utils/project-resolver.js +587 -0
- package/dist/src/utils/project-resolver.js.map +1 -0
- package/package.json +1 -1
- package/plugins/specweave/commands/specweave-increment.md +46 -0
- package/plugins/specweave/commands/specweave-jobs.md +153 -8
- package/plugins/specweave/hooks/hooks.json +10 -0
- package/plugins/specweave/hooks/spec-project-validator.sh +24 -2
- package/plugins/specweave/hooks/universal/hook-wrapper.cmd +26 -26
- package/plugins/specweave/hooks/universal/session-start.cmd +16 -16
- package/plugins/specweave/hooks/universal/session-start.ps1 +16 -16
- package/plugins/specweave/hooks/user-prompt-submit.sh +105 -3
- package/plugins/specweave/hooks/v2/guards/per-us-project-validator.sh +281 -0
- package/plugins/specweave/hooks/v2/handlers/living-specs-handler.sh +29 -0
- package/plugins/specweave/scripts/session-watchdog.sh +278 -130
- package/plugins/specweave/skills/increment-planner/SKILL.md +48 -18
- package/plugins/specweave/skills/increment-planner/templates/spec-multi-project.md +27 -14
- package/plugins/specweave/skills/increment-planner/templates/spec-single-project.md +16 -5
- package/plugins/specweave/skills/spec-generator/SKILL.md +74 -15
- package/plugins/specweave-ado/lib/per-us-sync.js +247 -0
- package/plugins/specweave-ado/lib/per-us-sync.ts +410 -0
- package/plugins/specweave-github/lib/github-client-v2.js +10 -3
- package/plugins/specweave-github/lib/github-client-v2.ts +15 -3
- package/plugins/specweave-github/lib/per-us-sync.js +241 -0
- package/plugins/specweave-github/lib/per-us-sync.ts +375 -0
- package/plugins/specweave-jira/lib/per-us-sync.js +224 -0
- package/plugins/specweave-jira/lib/per-us-sync.ts +366 -0
- package/plugins/specweave-github/hooks/.specweave/logs/hooks-debug.log +0 -738
- package/plugins/specweave-release/hooks/.specweave/logs/dora-tracking.log +0 -1107
|
@@ -16,6 +16,8 @@ import { autoDetectProjectIdSync } from '../utils/project-detection.js';
|
|
|
16
16
|
import { UserStoryContentBuilder } from '../../plugins/specweave-github/lib/user-story-content-builder.js';
|
|
17
17
|
import { AdoClient } from '../integrations/ado/ado-client.js';
|
|
18
18
|
import { getAdoPat } from '../integrations/ado/ado-pat-provider.js';
|
|
19
|
+
import { deriveFeatureId } from '../utils/feature-id-derivation.js';
|
|
20
|
+
import { createClosureMetrics } from './closure-metrics.js';
|
|
19
21
|
export class SyncCoordinator {
|
|
20
22
|
constructor(options) {
|
|
21
23
|
this.projectRoot = options.projectRoot;
|
|
@@ -26,6 +28,23 @@ export class SyncCoordinator {
|
|
|
26
28
|
this.projectId = autoDetectProjectIdSync(this.projectRoot, { silent: true });
|
|
27
29
|
// Store resolved ADO profile for multi-project sync
|
|
28
30
|
this.adoProfile = options.adoProfile;
|
|
31
|
+
// Initialize closure metrics (v0.34.0)
|
|
32
|
+
this.metrics = createClosureMetrics(this.projectRoot, this.incrementId, this.logger);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get closure sync metrics summary (v0.34.0)
|
|
36
|
+
*
|
|
37
|
+
* Returns aggregated metrics for all external tool closure operations.
|
|
38
|
+
* Useful for monitoring and alerting on sync health.
|
|
39
|
+
*/
|
|
40
|
+
getClosureMetrics() {
|
|
41
|
+
return this.metrics.getSummary();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get formatted closure metrics for display (v0.34.0)
|
|
45
|
+
*/
|
|
46
|
+
getFormattedClosureMetrics() {
|
|
47
|
+
return this.metrics.formatSummary();
|
|
29
48
|
}
|
|
30
49
|
/**
|
|
31
50
|
* Create GitHub issues for all User Stories in the feature (NEW in v0.25.0)
|
|
@@ -70,13 +89,14 @@ export class SyncCoordinator {
|
|
|
70
89
|
}
|
|
71
90
|
}
|
|
72
91
|
if (!featureId) {
|
|
73
|
-
// AUTO-GENERATE feature ID
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
92
|
+
// AUTO-GENERATE feature ID using deriveFeatureId() (ADR-0139)
|
|
93
|
+
// CRITICAL FIX (v0.34.0): Must use deriveFeatureId() to get correct format (FS-128, not FS-0128)
|
|
94
|
+
// The old code used raw regex match which preserved leading zeros, causing duplicates
|
|
95
|
+
try {
|
|
96
|
+
featureId = deriveFeatureId(this.incrementId);
|
|
77
97
|
this.logger.log(`đ Auto-generated feature ID: ${featureId} (no epic/feature_id in spec.md)`);
|
|
78
98
|
}
|
|
79
|
-
|
|
99
|
+
catch {
|
|
80
100
|
this.logger.log('â ď¸ No feature ID found and could not auto-generate - skipping GitHub sync');
|
|
81
101
|
this.logger.log(' đĄ Add epic: FS-XXX or feature_id: FS-XXX to spec.md frontmatter');
|
|
82
102
|
return createdIssues;
|
|
@@ -189,7 +209,32 @@ export class SyncCoordinator {
|
|
|
189
209
|
}
|
|
190
210
|
continue;
|
|
191
211
|
}
|
|
192
|
-
// All 3 layers miss -
|
|
212
|
+
// All 3 layers miss - but check for DUPLICATES with wrong format first!
|
|
213
|
+
// ========================================================================
|
|
214
|
+
// DUPLICATE DETECTION (v0.34.0): Prevent FS-0128 vs FS-128 duplicates
|
|
215
|
+
// ========================================================================
|
|
216
|
+
// Before creating, search for issues with similar titles but different feature ID formats.
|
|
217
|
+
// This catches cases where an old bug created issues with leading zeros (FS-0128)
|
|
218
|
+
// but the correct format is without (FS-128).
|
|
219
|
+
const duplicateCheck = await this.detectDuplicateIssue(client, featureId, usFile.id);
|
|
220
|
+
if (duplicateCheck) {
|
|
221
|
+
this.logger.log(` â ď¸ DUPLICATE DETECTED: Issue #${duplicateCheck.number} exists with format "${duplicateCheck.format}"`);
|
|
222
|
+
this.logger.log(` Current format: [${featureId}][${usFile.id}]`);
|
|
223
|
+
this.logger.log(` Existing issue: ${duplicateCheck.title}`);
|
|
224
|
+
this.logger.log(` âď¸ Skipping creation to avoid duplicate. Use existing issue #${duplicateCheck.number}`);
|
|
225
|
+
// Backfill metadata with the existing issue (even if wrong format)
|
|
226
|
+
await this.frontmatterUpdater.updateUserStoryFrontmatter({
|
|
227
|
+
projectRoot: this.projectRoot,
|
|
228
|
+
featureId,
|
|
229
|
+
userStoryId: usFile.id,
|
|
230
|
+
githubIssue: {
|
|
231
|
+
number: duplicateCheck.number,
|
|
232
|
+
url: duplicateCheck.url,
|
|
233
|
+
createdAt: new Date().toISOString(),
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
193
238
|
this.logger.log(` đ Creating GitHub issue for ${usFile.id}...`);
|
|
194
239
|
// Format issue body - use UserStoryContentBuilder for rich content with ACs
|
|
195
240
|
let issueBody;
|
|
@@ -323,13 +368,13 @@ export class SyncCoordinator {
|
|
|
323
368
|
}
|
|
324
369
|
}
|
|
325
370
|
if (!featureId) {
|
|
326
|
-
// AUTO-GENERATE feature ID
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
featureId =
|
|
371
|
+
// AUTO-GENERATE feature ID using deriveFeatureId() (same logic as create)
|
|
372
|
+
// CRITICAL FIX (v0.34.0): Must use deriveFeatureId() to get correct format (FS-128, not FS-0128)
|
|
373
|
+
try {
|
|
374
|
+
featureId = deriveFeatureId(this.incrementId);
|
|
330
375
|
this.logger.log(`đ Auto-generated feature ID: ${featureId}`);
|
|
331
376
|
}
|
|
332
|
-
|
|
377
|
+
catch {
|
|
333
378
|
this.logger.log('â ď¸ No feature ID found - skipping GitHub issue closure');
|
|
334
379
|
return closedIssues;
|
|
335
380
|
}
|
|
@@ -338,14 +383,16 @@ export class SyncCoordinator {
|
|
|
338
383
|
for (const usFile of userStories) {
|
|
339
384
|
try {
|
|
340
385
|
// Search for the GitHub issue by title pattern
|
|
386
|
+
// v0.34.0: includeClosedIssues=true to correctly detect already-closed issues
|
|
387
|
+
// Without this, we'd report "No issue found" instead of "already closed"
|
|
341
388
|
const searchTitle = `[${featureId}][${usFile.id}]`;
|
|
342
|
-
const existingIssue = await client.searchIssueByTitle(searchTitle);
|
|
389
|
+
const existingIssue = await client.searchIssueByTitle(searchTitle, true);
|
|
343
390
|
if (!existingIssue) {
|
|
344
391
|
this.logger.log(` âď¸ ${usFile.id} - No GitHub issue found (skipping)`);
|
|
345
392
|
continue;
|
|
346
393
|
}
|
|
347
|
-
// Check if already closed (GitHub returns
|
|
348
|
-
if (existingIssue.state === 'closed') {
|
|
394
|
+
// Check if already closed (GitHub API returns UPPERCASE: "OPEN" or "CLOSED")
|
|
395
|
+
if (existingIssue.state.toLowerCase() === 'closed') {
|
|
349
396
|
this.logger.log(` âď¸ ${usFile.id} - Issue #${existingIssue.number} already closed`);
|
|
350
397
|
continue;
|
|
351
398
|
}
|
|
@@ -365,9 +412,18 @@ Increment \`${this.incrementId}\` has been marked as **completed**.
|
|
|
365
412
|
|
|
366
413
|
---
|
|
367
414
|
đ¤ Auto-closed by SpecWeave on increment completion`;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
415
|
+
// Track metrics (v0.34.0)
|
|
416
|
+
this.metrics.startOperation();
|
|
417
|
+
try {
|
|
418
|
+
await client.closeIssue(existingIssue.number, completionComment);
|
|
419
|
+
closedIssues.push(existingIssue.number);
|
|
420
|
+
this.metrics.recordClosure('github', existingIssue.number, true);
|
|
421
|
+
this.logger.log(` â
Closed issue #${existingIssue.number}`);
|
|
422
|
+
}
|
|
423
|
+
catch (closeError) {
|
|
424
|
+
this.metrics.recordClosure('github', existingIssue.number, false, String(closeError));
|
|
425
|
+
throw closeError;
|
|
426
|
+
}
|
|
371
427
|
}
|
|
372
428
|
catch (error) {
|
|
373
429
|
this.logger.error(` â Failed to close issue for ${usFile.id}:`, error);
|
|
@@ -386,6 +442,186 @@ Increment \`${this.incrementId}\` has been marked as **completed**.
|
|
|
386
442
|
throw error;
|
|
387
443
|
}
|
|
388
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Close JIRA issues for completed user stories (v0.34.0)
|
|
447
|
+
*
|
|
448
|
+
* Transitions JIRA issues to "Done" status when increment is completed.
|
|
449
|
+
* Reads issue references from user story frontmatter (external.jira.issue_key).
|
|
450
|
+
*
|
|
451
|
+
* @param config - Project config with JIRA settings
|
|
452
|
+
* @returns Number of closed JIRA issues
|
|
453
|
+
*/
|
|
454
|
+
async closeJiraIssuesForUserStories(config) {
|
|
455
|
+
let closedCount = 0;
|
|
456
|
+
try {
|
|
457
|
+
const userStories = await this.loadUserStoriesForIncrement();
|
|
458
|
+
this.logger.log(`đ Found ${userStories.length} user stor${userStories.length === 1 ? 'y' : 'ies'} for JIRA closure`);
|
|
459
|
+
if (userStories.length === 0) {
|
|
460
|
+
return 0;
|
|
461
|
+
}
|
|
462
|
+
// Get JIRA config
|
|
463
|
+
const jiraConfig = config.sync?.jira;
|
|
464
|
+
if (!jiraConfig?.domain || !jiraConfig?.email) {
|
|
465
|
+
this.logger.log('â ď¸ JIRA config incomplete (missing domain or email)');
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
// Import JIRA client dynamically to avoid circular deps
|
|
469
|
+
// JiraClient uses credentialsManager internally to get credentials from env
|
|
470
|
+
const { JiraClient } = await import('../integrations/jira/jira-client.js');
|
|
471
|
+
const jiraClient = new JiraClient();
|
|
472
|
+
// Target status for completion (configurable via statusSync.mappings.jira.completed)
|
|
473
|
+
const targetStatus = config.sync?.statusSync?.mappings?.jira?.completed || 'Done';
|
|
474
|
+
for (const usFile of userStories) {
|
|
475
|
+
try {
|
|
476
|
+
// Check if US has JIRA reference in frontmatter (key not issue_key per type def)
|
|
477
|
+
const jiraKey = usFile.external_tools?.jira?.key || usFile.external_id;
|
|
478
|
+
if (!jiraKey || !String(jiraKey).includes('-')) {
|
|
479
|
+
this.logger.log(` âď¸ ${usFile.id} - No JIRA issue reference`);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
// Get current issue status
|
|
483
|
+
const issue = await jiraClient.getIssue(jiraKey);
|
|
484
|
+
if (!issue) {
|
|
485
|
+
this.logger.log(` â ď¸ ${usFile.id} - JIRA issue ${jiraKey} not found`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
// Check if already in target status (status is nested in fields)
|
|
489
|
+
const currentStatus = issue.fields?.status?.name || '';
|
|
490
|
+
if (currentStatus.toLowerCase() === targetStatus.toLowerCase()) {
|
|
491
|
+
this.logger.log(` âď¸ ${usFile.id} - ${jiraKey} already ${targetStatus}`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Transition to Done
|
|
495
|
+
this.logger.log(` đ Transitioning JIRA ${jiraKey} to ${targetStatus}...`);
|
|
496
|
+
// Track metrics (v0.34.0)
|
|
497
|
+
this.metrics.startOperation();
|
|
498
|
+
try {
|
|
499
|
+
await jiraClient.updateIssue({
|
|
500
|
+
key: jiraKey,
|
|
501
|
+
status: targetStatus
|
|
502
|
+
});
|
|
503
|
+
this.metrics.recordClosure('jira', jiraKey, true);
|
|
504
|
+
this.logger.log(` â
Transitioned ${jiraKey}`);
|
|
505
|
+
closedCount++;
|
|
506
|
+
}
|
|
507
|
+
catch (updateError) {
|
|
508
|
+
this.metrics.recordClosure('jira', jiraKey, false, String(updateError));
|
|
509
|
+
throw updateError;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
this.logger.error(` â Failed to close JIRA issue for ${usFile.id}:`, error);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return closedCount;
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
this.logger.error('â Failed to close JIRA issues:', error);
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Close ADO work items for completed user stories (v0.34.0)
|
|
525
|
+
*
|
|
526
|
+
* Transitions ADO work items to "Closed" state when increment is completed.
|
|
527
|
+
* Reads work item references from user story frontmatter (external.ado.id).
|
|
528
|
+
*
|
|
529
|
+
* @param config - Project config with ADO settings
|
|
530
|
+
* @returns Number of closed ADO work items
|
|
531
|
+
*/
|
|
532
|
+
async closeAdoWorkItemsForUserStories(config) {
|
|
533
|
+
let closedCount = 0;
|
|
534
|
+
try {
|
|
535
|
+
const userStories = await this.loadUserStoriesForIncrement();
|
|
536
|
+
this.logger.log(`đ Found ${userStories.length} user stor${userStories.length === 1 ? 'y' : 'ies'} for ADO closure`);
|
|
537
|
+
if (userStories.length === 0) {
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
// Get ADO config
|
|
541
|
+
const adoConfig = config.sync?.ado;
|
|
542
|
+
if (!adoConfig?.organization || !adoConfig?.project) {
|
|
543
|
+
this.logger.log('â ď¸ ADO config incomplete (missing organization or project)');
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
// Get PAT from environment
|
|
547
|
+
const adoPat = await getAdoPat(adoConfig.organization);
|
|
548
|
+
if (!adoPat) {
|
|
549
|
+
this.logger.log('â ď¸ ADO PAT not available');
|
|
550
|
+
return 0;
|
|
551
|
+
}
|
|
552
|
+
// Create ADO client
|
|
553
|
+
const adoClient = new AdoClient({
|
|
554
|
+
organization: adoConfig.organization,
|
|
555
|
+
project: adoConfig.project,
|
|
556
|
+
pat: adoPat
|
|
557
|
+
});
|
|
558
|
+
// Target state for completion (configurable via statusSync.mappings.ado.completed)
|
|
559
|
+
const targetStateConfig = config.sync?.statusSync?.mappings?.ado?.completed || { state: 'Closed' };
|
|
560
|
+
const targetState = typeof targetStateConfig === 'string' ? targetStateConfig : targetStateConfig.state;
|
|
561
|
+
// Collect work item IDs to fetch
|
|
562
|
+
const workItemIds = [];
|
|
563
|
+
for (const usFile of userStories) {
|
|
564
|
+
// Check if US has ADO reference in frontmatter (id not work_item_id per type def)
|
|
565
|
+
const adoId = usFile.external_tools?.ado?.id || usFile.external_id;
|
|
566
|
+
if (adoId && !isNaN(Number(adoId))) {
|
|
567
|
+
workItemIds.push({ id: Number(adoId), usId: usFile.id });
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
this.logger.log(` âď¸ ${usFile.id} - No ADO work item reference`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (workItemIds.length === 0) {
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
// Fetch work items in batch (ADO supports batch retrieval)
|
|
577
|
+
const workItems = await adoClient.listWorkItems({
|
|
578
|
+
workItemIds: workItemIds.map(w => w.id)
|
|
579
|
+
});
|
|
580
|
+
// Create lookup map
|
|
581
|
+
const workItemMap = new Map(workItems.map(w => [w.id, w]));
|
|
582
|
+
// Close each work item
|
|
583
|
+
for (const { id: workItemId, usId } of workItemIds) {
|
|
584
|
+
try {
|
|
585
|
+
const workItem = workItemMap.get(workItemId);
|
|
586
|
+
if (!workItem) {
|
|
587
|
+
this.logger.log(` â ď¸ ${usId} - ADO work item #${workItemId} not found`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
// Check if already in target state (state is in fields['System.State'])
|
|
591
|
+
const currentState = workItem.fields?.['System.State'] || '';
|
|
592
|
+
if (currentState.toLowerCase() === targetState.toLowerCase()) {
|
|
593
|
+
this.logger.log(` âď¸ ${usId} - #${workItemId} already ${targetState}`);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
// Update to Closed state
|
|
597
|
+
this.logger.log(` đ Closing ADO work item #${workItemId}...`);
|
|
598
|
+
// Track metrics (v0.34.0)
|
|
599
|
+
this.metrics.startOperation();
|
|
600
|
+
try {
|
|
601
|
+
await adoClient.updateWorkItem({
|
|
602
|
+
id: workItemId,
|
|
603
|
+
state: targetState
|
|
604
|
+
});
|
|
605
|
+
this.metrics.recordClosure('ado', workItemId, true);
|
|
606
|
+
this.logger.log(` â
Closed #${workItemId}`);
|
|
607
|
+
closedCount++;
|
|
608
|
+
}
|
|
609
|
+
catch (updateError) {
|
|
610
|
+
this.metrics.recordClosure('ado', workItemId, false, String(updateError));
|
|
611
|
+
throw updateError;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
this.logger.error(` â Failed to close ADO work item for ${usId}:`, error);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return closedCount;
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
this.logger.error('â Failed to close ADO work items:', error);
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
389
625
|
/**
|
|
390
626
|
* Sync increment closure to external tools (NEW in v0.28.1)
|
|
391
627
|
*
|
|
@@ -430,38 +666,87 @@ Increment \`${this.incrementId}\` has been marked as **completed**.
|
|
|
430
666
|
result.success = true;
|
|
431
667
|
return result;
|
|
432
668
|
}
|
|
433
|
-
|
|
434
|
-
|
|
669
|
+
// Check which external tools are enabled
|
|
670
|
+
const jiraEnabled = config.sync?.jira?.enabled ?? false;
|
|
671
|
+
const adoEnabled = config.sync?.ado?.enabled ?? false;
|
|
672
|
+
if (!githubEnabled && !jiraEnabled && !adoEnabled) {
|
|
673
|
+
this.logger.log('âšď¸ No external tools enabled (GitHub/JIRA/ADO all disabled)');
|
|
435
674
|
result.syncMode = 'external-disabled';
|
|
436
675
|
result.success = true;
|
|
437
676
|
return result;
|
|
438
677
|
}
|
|
439
|
-
this.logger.log('â
All gates passed - closing
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
this.logger.
|
|
447
|
-
|
|
678
|
+
this.logger.log('â
All gates passed - closing external issues for user stories');
|
|
679
|
+
// Track closed items across all tools
|
|
680
|
+
let totalClosed = 0;
|
|
681
|
+
// ========================================================================
|
|
682
|
+
// GitHub Closure
|
|
683
|
+
// ========================================================================
|
|
684
|
+
if (githubEnabled) {
|
|
685
|
+
this.logger.log('\nđš GitHub: Ensuring all issues exist...');
|
|
686
|
+
try {
|
|
687
|
+
await this.createGitHubIssuesForUserStories(config);
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
this.logger.error('â ď¸ GitHub issue creation failed (non-blocking):', error);
|
|
691
|
+
result.errors.push(`GitHub issue creation error: ${error}`);
|
|
692
|
+
}
|
|
693
|
+
this.logger.log('\nđš GitHub: Closing issues for completed user stories...');
|
|
694
|
+
try {
|
|
695
|
+
result.closedIssues = await this.closeGitHubIssuesForUserStories(config);
|
|
696
|
+
totalClosed += result.closedIssues.length;
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
this.logger.error('â ď¸ GitHub issue closure failed:', error);
|
|
700
|
+
result.errors.push(`GitHub issue closure error: ${error}`);
|
|
701
|
+
}
|
|
448
702
|
}
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
703
|
+
// ========================================================================
|
|
704
|
+
// JIRA Closure (v0.34.0)
|
|
705
|
+
// ========================================================================
|
|
706
|
+
if (jiraEnabled) {
|
|
707
|
+
this.logger.log('\nđš JIRA: Closing issues for completed user stories...');
|
|
708
|
+
try {
|
|
709
|
+
const jiraClosed = await this.closeJiraIssuesForUserStories(config);
|
|
710
|
+
totalClosed += jiraClosed;
|
|
711
|
+
this.logger.log(` â
Closed ${jiraClosed} JIRA issue(s)`);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
this.logger.error('â ď¸ JIRA issue closure failed:', error);
|
|
715
|
+
result.errors.push(`JIRA issue closure error: ${error}`);
|
|
716
|
+
}
|
|
453
717
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
718
|
+
// ========================================================================
|
|
719
|
+
// ADO Closure (v0.34.0)
|
|
720
|
+
// ========================================================================
|
|
721
|
+
if (adoEnabled) {
|
|
722
|
+
this.logger.log('\nđš ADO: Closing work items for completed user stories...');
|
|
723
|
+
try {
|
|
724
|
+
const adoClosed = await this.closeAdoWorkItemsForUserStories(config);
|
|
725
|
+
totalClosed += adoClosed;
|
|
726
|
+
this.logger.log(` â
Closed ${adoClosed} ADO work item(s)`);
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
this.logger.error('â ď¸ ADO work item closure failed:', error);
|
|
730
|
+
result.errors.push(`ADO work item closure error: ${error}`);
|
|
731
|
+
}
|
|
457
732
|
}
|
|
458
733
|
result.success = result.errors.length === 0;
|
|
459
734
|
result.syncMode = 'full-sync';
|
|
460
735
|
this.logger.log(`\nâ
Increment closure sync complete`);
|
|
461
|
-
this.logger.log(`
|
|
736
|
+
this.logger.log(` Total items closed: ${totalClosed}`);
|
|
737
|
+
if (result.closedIssues.length > 0) {
|
|
738
|
+
this.logger.log(` GitHub: ${result.closedIssues.length}`);
|
|
739
|
+
}
|
|
462
740
|
if (result.errors.length > 0) {
|
|
463
741
|
this.logger.log(` â ď¸ ${result.errors.length} error(s) occurred`);
|
|
464
742
|
}
|
|
743
|
+
// Check for high failure rate and warn (v0.34.0 metrics)
|
|
744
|
+
const tools = ['github', 'jira', 'ado'];
|
|
745
|
+
for (const tool of tools) {
|
|
746
|
+
if (this.metrics.isFailureRateHigh(tool)) {
|
|
747
|
+
this.logger.log(` â ď¸ ${tool.toUpperCase()} has high failure rate - check credentials/permissions`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
465
750
|
return result;
|
|
466
751
|
}
|
|
467
752
|
catch (error) {
|
|
@@ -470,6 +755,66 @@ Increment \`${this.incrementId}\` has been marked as **completed**.
|
|
|
470
755
|
return result;
|
|
471
756
|
}
|
|
472
757
|
}
|
|
758
|
+
/**
|
|
759
|
+
* Detect duplicate issues with different feature ID formats (v0.34.0)
|
|
760
|
+
*
|
|
761
|
+
* Searches for issues that match the user story but have a different feature ID format.
|
|
762
|
+
* This prevents creating duplicates like:
|
|
763
|
+
* - [FS-0128][US-001] (old bug format with leading zeros)
|
|
764
|
+
* - [FS-128][US-001] (correct format)
|
|
765
|
+
*
|
|
766
|
+
* The search uses a regex pattern that matches any FS-XXX format for the same US-XXX.
|
|
767
|
+
*
|
|
768
|
+
* @param client - GitHub client
|
|
769
|
+
* @param featureId - Current feature ID (e.g., "FS-128")
|
|
770
|
+
* @param userStoryId - User story ID (e.g., "US-001")
|
|
771
|
+
* @returns Duplicate info if found, null otherwise
|
|
772
|
+
*/
|
|
773
|
+
async detectDuplicateIssue(client, featureId, userStoryId) {
|
|
774
|
+
try {
|
|
775
|
+
// Extract the numeric part of the feature ID (e.g., "128" from "FS-128" or "FS-0128")
|
|
776
|
+
const featureNumMatch = featureId.match(/FS-0*(\d+)E?/i);
|
|
777
|
+
if (!featureNumMatch) {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
const featureNum = featureNumMatch[1];
|
|
781
|
+
// Search for issues with ANY format of this feature ID + user story
|
|
782
|
+
// Patterns to check:
|
|
783
|
+
// - [FS-128][US-001] (correct, no leading zeros)
|
|
784
|
+
// - [FS-0128][US-001] (bug format, with leading zeros)
|
|
785
|
+
// - [FS-00128][US-001] (edge case, multiple leading zeros)
|
|
786
|
+
const searchPatterns = [
|
|
787
|
+
`[FS-${featureNum}][${userStoryId}]`, // FS-128
|
|
788
|
+
`[FS-0${featureNum}][${userStoryId}]`, // FS-0128
|
|
789
|
+
`[FS-00${featureNum}][${userStoryId}]`, // FS-00128
|
|
790
|
+
`[FS-${featureNum}E][${userStoryId}]`, // FS-128E (external)
|
|
791
|
+
`[FS-0${featureNum}E][${userStoryId}]`, // FS-0128E
|
|
792
|
+
];
|
|
793
|
+
// Filter out the exact current format (we already checked that)
|
|
794
|
+
const currentFormat = `[${featureId}][${userStoryId}]`;
|
|
795
|
+
const alternatePatterns = searchPatterns.filter(p => p !== currentFormat);
|
|
796
|
+
for (const pattern of alternatePatterns) {
|
|
797
|
+
const existingIssue = await client.searchIssueByTitle(pattern, true); // Include closed
|
|
798
|
+
if (existingIssue) {
|
|
799
|
+
// Extract the format from the issue title
|
|
800
|
+
const formatMatch = existingIssue.title.match(/\[(FS-\d+E?)\]/i);
|
|
801
|
+
const detectedFormat = formatMatch ? formatMatch[1] : 'unknown';
|
|
802
|
+
return {
|
|
803
|
+
number: existingIssue.number,
|
|
804
|
+
url: existingIssue.html_url,
|
|
805
|
+
title: existingIssue.title,
|
|
806
|
+
format: detectedFormat,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
// Don't block issue creation on duplicate detection failure
|
|
814
|
+
this.logger.log(` â ď¸ Duplicate detection failed (non-blocking): ${error}`);
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
473
818
|
/**
|
|
474
819
|
* Update issue if it has placeholder content
|
|
475
820
|
* Fetches issue from GitHub, checks for placeholder, and updates with rich content
|
|
@@ -799,7 +1144,24 @@ Increment \`${this.incrementId}\` has been marked as **completed**.
|
|
|
799
1144
|
return [];
|
|
800
1145
|
}
|
|
801
1146
|
const frontmatter = yaml.parse(frontmatterMatch[1]);
|
|
802
|
-
|
|
1147
|
+
let featureId = frontmatter.feature_id || frontmatter.epic || frontmatter.feature;
|
|
1148
|
+
// v0.34.0: Fallback to metadata.json if spec.md doesn't have feature_id
|
|
1149
|
+
// This handles legacy increments where feature_id was only in metadata.json
|
|
1150
|
+
if (!featureId) {
|
|
1151
|
+
const metadataFile = path.join(this.projectRoot, '.specweave/increments', this.incrementId, 'metadata.json');
|
|
1152
|
+
if (existsSync(metadataFile)) {
|
|
1153
|
+
try {
|
|
1154
|
+
const metadata = JSON.parse(await fs.readFile(metadataFile, 'utf-8'));
|
|
1155
|
+
featureId = metadata.feature_id || metadata.epic_id;
|
|
1156
|
+
if (featureId) {
|
|
1157
|
+
this.logger.log(` đ Using feature_id from metadata.json: ${featureId}`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
// Ignore parse errors
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
803
1165
|
if (!featureId) {
|
|
804
1166
|
return [];
|
|
805
1167
|
}
|