specweave 0.32.2 → 0.32.3
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 +39 -0
- package/bin/specweave.js +34 -0
- package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.d.ts +100 -0
- package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.d.ts.map +1 -0
- package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.js +291 -0
- package/dist/plugins/specweave-ado/lib/ado-duplicate-detector.js.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.d.ts +103 -0
- package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.js +310 -0
- package/dist/plugins/specweave-jira/lib/jira-duplicate-detector.js.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.d.ts +126 -0
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.d.ts.map +1 -0
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.js +207 -0
- package/dist/plugins/specweave-jira/lib/jira-permission-gate.js.map +1 -0
- package/dist/src/adapters/codex/README.md +1 -1
- package/dist/src/adapters/codex/adapter.js +1 -1
- package/dist/src/cli/commands/archive.d.ts +2 -0
- package/dist/src/cli/commands/archive.d.ts.map +1 -1
- package/dist/src/cli/commands/archive.js +33 -0
- package/dist/src/cli/commands/archive.js.map +1 -1
- package/dist/src/cli/commands/context.d.ts +92 -0
- package/dist/src/cli/commands/context.d.ts.map +1 -0
- package/dist/src/cli/commands/context.js +205 -0
- package/dist/src/cli/commands/context.js.map +1 -0
- package/dist/src/cli/commands/init.d.ts.map +1 -1
- package/dist/src/cli/commands/init.js +111 -69
- package/dist/src/cli/commands/init.js.map +1 -1
- package/dist/src/cli/helpers/init/external-import.d.ts +3 -0
- package/dist/src/cli/helpers/init/external-import.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/external-import.js +17 -4
- package/dist/src/cli/helpers/init/external-import.js.map +1 -1
- package/dist/src/cli/helpers/init/index.d.ts +1 -0
- package/dist/src/cli/helpers/init/index.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/index.js +2 -0
- package/dist/src/cli/helpers/init/index.js.map +1 -1
- package/dist/src/cli/helpers/init/jira-ado-auto-detect.d.ts +70 -0
- package/dist/src/cli/helpers/init/jira-ado-auto-detect.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/jira-ado-auto-detect.js +214 -4
- package/dist/src/cli/helpers/init/jira-ado-auto-detect.js.map +1 -1
- package/dist/src/cli/helpers/init/living-docs-preflight.d.ts +4 -0
- package/dist/src/cli/helpers/init/living-docs-preflight.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/living-docs-preflight.js +34 -3
- package/dist/src/cli/helpers/init/living-docs-preflight.js.map +1 -1
- package/dist/src/cli/helpers/init/testing-config.d.ts +3 -0
- package/dist/src/cli/helpers/init/testing-config.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/testing-config.js +9 -2
- package/dist/src/cli/helpers/init/testing-config.js.map +1 -1
- package/dist/src/cli/helpers/init/translation-config.d.ts +3 -0
- package/dist/src/cli/helpers/init/translation-config.d.ts.map +1 -1
- package/dist/src/cli/helpers/init/translation-config.js +21 -4
- package/dist/src/cli/helpers/init/translation-config.js.map +1 -1
- package/dist/src/cli/helpers/init/wizard-navigation.d.ts +45 -0
- package/dist/src/cli/helpers/init/wizard-navigation.d.ts.map +1 -0
- package/dist/src/cli/helpers/init/wizard-navigation.js +97 -0
- package/dist/src/cli/helpers/init/wizard-navigation.js.map +1 -0
- package/dist/src/core/increment/increment-archiver.d.ts +25 -4
- package/dist/src/core/increment/increment-archiver.d.ts.map +1 -1
- package/dist/src/core/increment/increment-archiver.js +64 -20
- package/dist/src/core/increment/increment-archiver.js.map +1 -1
- package/dist/src/core/increment/increment-utils.d.ts +65 -0
- package/dist/src/core/increment/increment-utils.d.ts.map +1 -1
- package/dist/src/core/increment/increment-utils.js +114 -0
- package/dist/src/core/increment/increment-utils.js.map +1 -1
- package/dist/src/core/living-docs/feature-archiver.d.ts +4 -0
- package/dist/src/core/living-docs/feature-archiver.d.ts.map +1 -1
- package/dist/src/core/living-docs/feature-archiver.js +32 -10
- package/dist/src/core/living-docs/feature-archiver.js.map +1 -1
- package/dist/src/core/living-docs/feature-id-manager.d.ts.map +1 -1
- package/dist/src/core/living-docs/feature-id-manager.js +7 -3
- package/dist/src/core/living-docs/feature-id-manager.js.map +1 -1
- package/dist/src/core/living-docs/governance/ecosystem-detector.d.ts +38 -0
- package/dist/src/core/living-docs/governance/ecosystem-detector.d.ts.map +1 -0
- package/dist/src/core/living-docs/governance/ecosystem-detector.js +325 -0
- package/dist/src/core/living-docs/governance/ecosystem-detector.js.map +1 -0
- package/dist/src/core/living-docs/governance/frontend-standards-parser.d.ts +74 -0
- package/dist/src/core/living-docs/governance/frontend-standards-parser.d.ts.map +1 -0
- package/dist/src/core/living-docs/governance/frontend-standards-parser.js +366 -0
- package/dist/src/core/living-docs/governance/frontend-standards-parser.js.map +1 -0
- package/dist/src/core/living-docs/governance/go-standards-parser.d.ts +64 -0
- package/dist/src/core/living-docs/governance/go-standards-parser.d.ts.map +1 -0
- package/dist/src/core/living-docs/governance/go-standards-parser.js +229 -0
- package/dist/src/core/living-docs/governance/go-standards-parser.js.map +1 -0
- package/dist/src/core/living-docs/governance/index.d.ts +50 -0
- package/dist/src/core/living-docs/governance/index.d.ts.map +1 -0
- package/dist/src/core/living-docs/governance/index.js +56 -0
- package/dist/src/core/living-docs/governance/index.js.map +1 -0
- package/dist/src/core/living-docs/governance/java-standards-parser.d.ts +89 -0
- package/dist/src/core/living-docs/governance/java-standards-parser.d.ts.map +1 -0
- package/dist/src/core/living-docs/governance/java-standards-parser.js +356 -0
- package/dist/src/core/living-docs/governance/java-standards-parser.js.map +1 -0
- package/dist/src/core/living-docs/governance/python-standards-parser.d.ts +83 -0
- package/dist/src/core/living-docs/governance/python-standards-parser.d.ts.map +1 -0
- package/dist/src/core/living-docs/governance/python-standards-parser.js +347 -0
- package/dist/src/core/living-docs/governance/python-standards-parser.js.map +1 -0
- package/dist/src/core/living-docs/governance/standards-generator.d.ts +38 -0
- package/dist/src/core/living-docs/governance/standards-generator.d.ts.map +1 -0
- package/dist/src/core/living-docs/governance/standards-generator.js +476 -0
- package/dist/src/core/living-docs/governance/standards-generator.js.map +1 -0
- package/dist/src/core/living-docs/intelligent-analyzer/architecture-generator.d.ts.map +1 -1
- package/dist/src/core/living-docs/intelligent-analyzer/architecture-generator.js +54 -2
- package/dist/src/core/living-docs/intelligent-analyzer/architecture-generator.js.map +1 -1
- package/dist/src/core/living-docs/intelligent-analyzer/organization-synthesizer.d.ts +5 -1
- package/dist/src/core/living-docs/intelligent-analyzer/organization-synthesizer.d.ts.map +1 -1
- package/dist/src/core/living-docs/intelligent-analyzer/organization-synthesizer.js +358 -30
- package/dist/src/core/living-docs/intelligent-analyzer/organization-synthesizer.js.map +1 -1
- package/dist/src/core/living-docs/intelligent-analyzer/types.d.ts +44 -0
- package/dist/src/core/living-docs/intelligent-analyzer/types.d.ts.map +1 -1
- package/dist/src/core/living-docs/living-docs-sync.d.ts +6 -3
- package/dist/src/core/living-docs/living-docs-sync.d.ts.map +1 -1
- package/dist/src/core/living-docs/living-docs-sync.js +17 -8
- package/dist/src/core/living-docs/living-docs-sync.js.map +1 -1
- package/dist/src/core/living-docs/module-analyzer.d.ts +22 -0
- package/dist/src/core/living-docs/module-analyzer.d.ts.map +1 -1
- package/dist/src/core/living-docs/module-analyzer.js +123 -19
- package/dist/src/core/living-docs/module-analyzer.js.map +1 -1
- package/dist/src/core/llm/provider-factory.js +2 -2
- package/dist/src/core/llm/provider-factory.js.map +1 -1
- package/dist/src/core/llm/providers/anthropic-provider.js +1 -1
- package/dist/src/core/llm/providers/bedrock-provider.d.ts.map +1 -1
- package/dist/src/core/llm/providers/bedrock-provider.js +8 -4
- package/dist/src/core/llm/providers/bedrock-provider.js.map +1 -1
- package/dist/src/importers/jira-importer.d.ts +14 -0
- package/dist/src/importers/jira-importer.d.ts.map +1 -1
- package/dist/src/importers/jira-importer.js +75 -0
- package/dist/src/importers/jira-importer.js.map +1 -1
- package/dist/src/integrations/jira/jira-token-provider.d.ts +93 -0
- package/dist/src/integrations/jira/jira-token-provider.d.ts.map +1 -0
- package/dist/src/integrations/jira/jira-token-provider.js +160 -0
- package/dist/src/integrations/jira/jira-token-provider.js.map +1 -0
- package/dist/src/sync/ado-reconciler.d.ts +92 -0
- package/dist/src/sync/ado-reconciler.d.ts.map +1 -0
- package/dist/src/sync/ado-reconciler.js +335 -0
- package/dist/src/sync/ado-reconciler.js.map +1 -0
- package/dist/src/sync/jira-reconciler.d.ts +106 -0
- package/dist/src/sync/jira-reconciler.d.ts.map +1 -0
- package/dist/src/sync/jira-reconciler.js +405 -0
- package/dist/src/sync/jira-reconciler.js.map +1 -0
- package/dist/src/types/model-selection.d.ts +6 -4
- package/dist/src/types/model-selection.d.ts.map +1 -1
- package/dist/src/types/model-selection.js +3 -1
- package/dist/src/types/model-selection.js.map +1 -1
- package/dist/src/utils/external-tool-drift-detector.d.ts +1 -1
- package/dist/src/utils/external-tool-drift-detector.d.ts.map +1 -1
- package/dist/src/utils/external-tool-drift-detector.js +5 -4
- package/dist/src/utils/external-tool-drift-detector.js.map +1 -1
- package/dist/src/utils/feature-id-derivation.d.ts +8 -3
- package/dist/src/utils/feature-id-derivation.d.ts.map +1 -1
- package/dist/src/utils/feature-id-derivation.js +14 -6
- package/dist/src/utils/feature-id-derivation.js.map +1 -1
- package/dist/src/utils/model-selection.d.ts +3 -4
- package/dist/src/utils/model-selection.d.ts.map +1 -1
- package/dist/src/utils/model-selection.js +3 -4
- package/dist/src/utils/model-selection.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/agents/code-standards-detective/AGENT.md +48 -0
- package/plugins/specweave/commands/specweave-costs.md +4 -4
- package/plugins/specweave/commands/specweave-do.md +9 -9
- package/plugins/specweave/commands/specweave-done.md +13 -0
- package/plugins/specweave/commands/specweave-validate.md +27 -1
- package/plugins/specweave/hooks/hooks.json +10 -0
- package/plugins/specweave/hooks/spec-project-validator.sh +80 -25
- package/plugins/specweave/hooks/v2/guards/increment-duplicate-guard.sh +135 -0
- package/plugins/specweave/scripts/read-costs.sh +3 -3
- package/plugins/specweave/skills/code-standards-analyzer/SKILL.md +58 -6
- package/plugins/specweave/skills/increment-planner/SKILL.md +56 -25
- package/plugins/specweave/skills/increment-planner/templates/spec-multi-project.md +4 -2
- package/plugins/specweave/skills/increment-planner/templates/spec-single-project.md +2 -1
- package/plugins/specweave/skills/increment-planner/templates/tasks-multi-project.md +1 -1
- package/plugins/specweave/skills/increment-planner/templates/tasks-single-project.md +1 -1
- package/plugins/specweave-ado/commands/cleanup-duplicates.md +212 -0
- package/plugins/specweave-ado/commands/reconcile.md +120 -0
- package/plugins/specweave-ado/lib/ado-duplicate-detector.js +279 -0
- package/plugins/specweave-ado/lib/ado-duplicate-detector.ts +407 -0
- package/plugins/specweave-github/agents/github-manager/AGENT.md +2 -2
- package/plugins/specweave-infrastructure/skills/hetzner-provisioner/README.md +1 -1
- package/plugins/specweave-jira/agents/jira-manager/AGENT.md +1 -1
- package/plugins/specweave-jira/agents/jira-multi-project-mapper/AGENT.md +530 -0
- package/plugins/specweave-jira/agents/jira-sync-judge/AGENT.md +438 -0
- package/plugins/specweave-jira/commands/cleanup-duplicates.md +219 -0
- package/plugins/specweave-jira/commands/close.md +297 -0
- package/plugins/specweave-jira/commands/create.md +198 -0
- package/plugins/specweave-jira/commands/reconcile.md +123 -0
- package/plugins/specweave-jira/commands/status.md +215 -0
- package/plugins/specweave-jira/lib/jira-duplicate-detector.js +296 -0
- package/plugins/specweave-jira/lib/jira-duplicate-detector.ts +434 -0
- package/plugins/specweave-jira/lib/jira-permission-gate.js +160 -0
- package/plugins/specweave-jira/lib/jira-permission-gate.ts +276 -0
- package/plugins/specweave-jira/lib/jira-profile-resolver.js +222 -0
- package/plugins/specweave-jira/lib/jira-profile-resolver.ts +427 -0
- package/plugins/specweave-jira/reference/jira-specweave-mapping.md +16 -11
- package/plugins/specweave-release/commands/specweave-release-npm.md +140 -14
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { consoleLogger } from "../../../src/utils/logger.js";
|
|
2
|
+
class AdoDuplicateDetector {
|
|
3
|
+
constructor(options = {}) {
|
|
4
|
+
this.org = options.org || process.env.AZURE_DEVOPS_ORG || "";
|
|
5
|
+
this.project = options.project || process.env.AZURE_DEVOPS_PROJECT || "";
|
|
6
|
+
this.pat = options.pat || process.env.AZURE_DEVOPS_PAT || process.env.AZURE_DEVOPS_TOKEN || "";
|
|
7
|
+
this.logger = options.logger || consoleLogger;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Phase 1: Check if work item exists before creating
|
|
11
|
+
*/
|
|
12
|
+
async checkBeforeCreate(titlePattern, incrementId) {
|
|
13
|
+
try {
|
|
14
|
+
const items = await this.searchWorkItems(titlePattern);
|
|
15
|
+
if (items.length > 0) {
|
|
16
|
+
return {
|
|
17
|
+
found: true,
|
|
18
|
+
existingItem: items[0],
|
|
19
|
+
count: items.length
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { found: false, count: 0 };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
this.logger.log(`\u26A0\uFE0F Detection check failed: ${error.message}`);
|
|
25
|
+
return { found: false, count: 0 };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Phase 2: Verify count after creation
|
|
30
|
+
*/
|
|
31
|
+
async verifyAfterCreate(titlePattern, expectedCount = 1) {
|
|
32
|
+
try {
|
|
33
|
+
const items = await this.searchWorkItems(titlePattern);
|
|
34
|
+
if (items.length > expectedCount) {
|
|
35
|
+
const sorted = items.sort(
|
|
36
|
+
(a, b) => a.id - b.id
|
|
37
|
+
// Sort by ID (lowest = oldest)
|
|
38
|
+
);
|
|
39
|
+
return {
|
|
40
|
+
success: false,
|
|
41
|
+
expectedCount,
|
|
42
|
+
actualCount: items.length,
|
|
43
|
+
duplicates: sorted.slice(expectedCount)
|
|
44
|
+
// All items after expected count
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
success: true,
|
|
49
|
+
expectedCount,
|
|
50
|
+
actualCount: items.length,
|
|
51
|
+
duplicates: []
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
this.logger.log(`\u26A0\uFE0F Verification check failed: ${error.message}`);
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
// Assume success on error
|
|
58
|
+
expectedCount,
|
|
59
|
+
actualCount: expectedCount,
|
|
60
|
+
duplicates: []
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Phase 3: Auto-close duplicates
|
|
66
|
+
*/
|
|
67
|
+
async closeDuplicates(duplicates, keepItemId) {
|
|
68
|
+
const result = {
|
|
69
|
+
closedCount: 0,
|
|
70
|
+
keptCount: 1,
|
|
71
|
+
errors: []
|
|
72
|
+
};
|
|
73
|
+
for (const item of duplicates) {
|
|
74
|
+
try {
|
|
75
|
+
await this.closeWorkItem(item.id, keepItemId);
|
|
76
|
+
result.closedCount++;
|
|
77
|
+
this.logger.log(` \u2705 Closed #${item.id} (duplicate of #${keepItemId})`);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
result.errors.push(`#${item.id}: ${error.message}`);
|
|
80
|
+
this.logger.log(` \u274C Failed to close #${item.id}: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Full cleanup: Find and close all duplicates for a feature
|
|
87
|
+
*/
|
|
88
|
+
async cleanupFeatureDuplicates(featureId, dryRun = false) {
|
|
89
|
+
const searchPattern = `[${featureId}]`;
|
|
90
|
+
const items = await this.searchWorkItems(searchPattern);
|
|
91
|
+
this.logger.log(`
|
|
92
|
+
\u{1F50D} Scanning for duplicates in Feature ${featureId}...`);
|
|
93
|
+
this.logger.log(` Found ${items.length} total work items`);
|
|
94
|
+
const groups = this.groupByTitle(items);
|
|
95
|
+
const duplicateGroups = groups.filter((g) => g.duplicates.length > 0);
|
|
96
|
+
if (duplicateGroups.length === 0) {
|
|
97
|
+
this.logger.log(` \u2705 No duplicates found!`);
|
|
98
|
+
return {
|
|
99
|
+
groups: [],
|
|
100
|
+
totalItems: items.length,
|
|
101
|
+
duplicateCount: 0,
|
|
102
|
+
closedCount: 0
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
this.logger.log(` Detected ${duplicateGroups.length} duplicate groups:
|
|
106
|
+
`);
|
|
107
|
+
for (let i = 0; i < duplicateGroups.length; i++) {
|
|
108
|
+
const group = duplicateGroups[i];
|
|
109
|
+
this.logger.log(` \u{1F4CB} Group ${i + 1}: "${group.title.substring(0, 50)}..."`);
|
|
110
|
+
this.logger.log(` - #${group.keepItem.id} (KEEP) - Created ${group.keepItem.createdDate.split("T")[0]}`);
|
|
111
|
+
for (const dup of group.duplicates) {
|
|
112
|
+
this.logger.log(` - #${dup.id} (CLOSE) - Created ${dup.createdDate.split("T")[0]} - DUPLICATE`);
|
|
113
|
+
}
|
|
114
|
+
this.logger.log("");
|
|
115
|
+
}
|
|
116
|
+
const totalDuplicates = duplicateGroups.reduce((sum, g) => sum + g.duplicates.length, 0);
|
|
117
|
+
if (dryRun) {
|
|
118
|
+
this.logger.log(`
|
|
119
|
+
\u2705 Dry run complete!`);
|
|
120
|
+
this.logger.log(` Total work items: ${items.length}`);
|
|
121
|
+
this.logger.log(` Duplicate groups: ${duplicateGroups.length}`);
|
|
122
|
+
this.logger.log(` Work items to close: ${totalDuplicates}`);
|
|
123
|
+
this.logger.log(`
|
|
124
|
+
\u26A0\uFE0F This was a DRY RUN - no changes made.`);
|
|
125
|
+
return {
|
|
126
|
+
groups: duplicateGroups,
|
|
127
|
+
totalItems: items.length,
|
|
128
|
+
duplicateCount: totalDuplicates,
|
|
129
|
+
closedCount: 0
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
let closedCount = 0;
|
|
133
|
+
this.logger.log(`\u{1F5D1}\uFE0F Closing duplicates...`);
|
|
134
|
+
for (const group of duplicateGroups) {
|
|
135
|
+
const result = await this.closeDuplicates(group.duplicates, group.keepItem.id);
|
|
136
|
+
closedCount += result.closedCount;
|
|
137
|
+
}
|
|
138
|
+
this.logger.log(`
|
|
139
|
+
\u2705 Cleanup complete!`);
|
|
140
|
+
this.logger.log(` Closed: ${closedCount} duplicates`);
|
|
141
|
+
this.logger.log(` Kept: ${duplicateGroups.length} original work items`);
|
|
142
|
+
return {
|
|
143
|
+
groups: duplicateGroups,
|
|
144
|
+
totalItems: items.length,
|
|
145
|
+
duplicateCount: totalDuplicates,
|
|
146
|
+
closedCount
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Group work items by title
|
|
151
|
+
*/
|
|
152
|
+
groupByTitle(items) {
|
|
153
|
+
const titleMap = /* @__PURE__ */ new Map();
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
const existing = titleMap.get(item.title) || [];
|
|
156
|
+
existing.push(item);
|
|
157
|
+
titleMap.set(item.title, existing);
|
|
158
|
+
}
|
|
159
|
+
const groups = [];
|
|
160
|
+
for (const [title, groupItems] of titleMap) {
|
|
161
|
+
const sorted = groupItems.sort((a, b) => a.id - b.id);
|
|
162
|
+
groups.push({
|
|
163
|
+
title,
|
|
164
|
+
items: sorted,
|
|
165
|
+
keepItem: sorted[0],
|
|
166
|
+
duplicates: sorted.slice(1)
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return groups;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Search for work items using WIQL
|
|
173
|
+
*/
|
|
174
|
+
async searchWorkItems(titlePattern) {
|
|
175
|
+
if (!this.org || !this.pat) {
|
|
176
|
+
throw new Error("ADO credentials not configured");
|
|
177
|
+
}
|
|
178
|
+
const wiql = {
|
|
179
|
+
query: `SELECT [System.Id], [System.Title], [System.State], [System.CreatedDate]
|
|
180
|
+
FROM WorkItems
|
|
181
|
+
WHERE [System.TeamProject] = '${this.project}'
|
|
182
|
+
AND [System.Title] CONTAINS '${titlePattern}'
|
|
183
|
+
ORDER BY [System.Id] ASC`
|
|
184
|
+
};
|
|
185
|
+
const auth = Buffer.from(`:${this.pat}`).toString("base64");
|
|
186
|
+
const url = `https://dev.azure.com/${this.org}/${this.project}/_apis/wit/wiql?api-version=7.1`;
|
|
187
|
+
const response = await fetch(url, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: {
|
|
190
|
+
Authorization: `Basic ${auth}`,
|
|
191
|
+
"Content-Type": "application/json"
|
|
192
|
+
},
|
|
193
|
+
body: JSON.stringify(wiql)
|
|
194
|
+
});
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
throw new Error(`WIQL query failed: ${response.status}`);
|
|
197
|
+
}
|
|
198
|
+
const data = await response.json();
|
|
199
|
+
const workItemIds = data.workItems?.map((wi) => wi.id) || [];
|
|
200
|
+
if (workItemIds.length === 0) {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
return this.getWorkItemDetails(workItemIds);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Get work item details by IDs
|
|
207
|
+
*/
|
|
208
|
+
async getWorkItemDetails(ids) {
|
|
209
|
+
if (ids.length === 0) return [];
|
|
210
|
+
const auth = Buffer.from(`:${this.pat}`).toString("base64");
|
|
211
|
+
const url = `https://dev.azure.com/${this.org}/_apis/wit/workitems?ids=${ids.join(",")}&api-version=7.1`;
|
|
212
|
+
const response = await fetch(url, {
|
|
213
|
+
headers: {
|
|
214
|
+
Authorization: `Basic ${auth}`,
|
|
215
|
+
"Content-Type": "application/json"
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
if (!response.ok) {
|
|
219
|
+
throw new Error(`Failed to get work items: ${response.status}`);
|
|
220
|
+
}
|
|
221
|
+
const data = await response.json();
|
|
222
|
+
return (data.value || []).map((wi) => ({
|
|
223
|
+
id: wi.id,
|
|
224
|
+
title: wi.fields["System.Title"],
|
|
225
|
+
state: wi.fields["System.State"],
|
|
226
|
+
createdDate: wi.fields["System.CreatedDate"],
|
|
227
|
+
url: wi._links?.html?.href
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Close a work item with duplicate comment
|
|
232
|
+
*/
|
|
233
|
+
async closeWorkItem(workItemId, originalId) {
|
|
234
|
+
const auth = Buffer.from(`:${this.pat}`).toString("base64");
|
|
235
|
+
const url = `https://dev.azure.com/${this.org}/_apis/wit/workitems/${workItemId}?api-version=7.1`;
|
|
236
|
+
const comment = `## Duplicate of #${originalId}
|
|
237
|
+
|
|
238
|
+
This work item was automatically closed by SpecWeave cleanup because it is a duplicate.
|
|
239
|
+
|
|
240
|
+
The original work item (#${originalId}) contains the same content and should be used for tracking instead.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
\u{1F916} Auto-closed by SpecWeave Duplicate Cleanup`;
|
|
244
|
+
const operations = [
|
|
245
|
+
{
|
|
246
|
+
op: "add",
|
|
247
|
+
path: "/fields/System.State",
|
|
248
|
+
value: "Closed"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
op: "add",
|
|
252
|
+
path: "/fields/System.History",
|
|
253
|
+
value: comment
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
const response = await fetch(url, {
|
|
257
|
+
method: "PATCH",
|
|
258
|
+
headers: {
|
|
259
|
+
Authorization: `Basic ${auth}`,
|
|
260
|
+
"Content-Type": "application/json-patch+json"
|
|
261
|
+
},
|
|
262
|
+
body: JSON.stringify(operations)
|
|
263
|
+
});
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
const error = await response.text();
|
|
266
|
+
throw new Error(`Failed to close work item: ${response.status} - ${error}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function cleanupAdoDuplicates(featureId, dryRun = false) {
|
|
271
|
+
const detector = new AdoDuplicateDetector();
|
|
272
|
+
return detector.cleanupFeatureDuplicates(featureId, dryRun);
|
|
273
|
+
}
|
|
274
|
+
var ado_duplicate_detector_default = AdoDuplicateDetector;
|
|
275
|
+
export {
|
|
276
|
+
AdoDuplicateDetector,
|
|
277
|
+
cleanupAdoDuplicates,
|
|
278
|
+
ado_duplicate_detector_default as default
|
|
279
|
+
};
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure DevOps Duplicate Detector (v0.33.0)
|
|
3
|
+
*
|
|
4
|
+
* Implements 3-phase duplicate protection for ADO work items:
|
|
5
|
+
* 1. Detection: Check before create
|
|
6
|
+
* 2. Verification: Count check after create
|
|
7
|
+
* 3. Reflection: Auto-close duplicates
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the GitHub DuplicateDetector pattern for consistency.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Logger, consoleLogger } from '../../../src/utils/logger.js';
|
|
13
|
+
|
|
14
|
+
export interface AdoWorkItem {
|
|
15
|
+
id: number;
|
|
16
|
+
title: string;
|
|
17
|
+
state: string;
|
|
18
|
+
createdDate: string;
|
|
19
|
+
url?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DuplicateGroup {
|
|
23
|
+
title: string;
|
|
24
|
+
items: AdoWorkItem[];
|
|
25
|
+
keepItem: AdoWorkItem;
|
|
26
|
+
duplicates: AdoWorkItem[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DetectionResult {
|
|
30
|
+
found: boolean;
|
|
31
|
+
existingItem?: AdoWorkItem;
|
|
32
|
+
count: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface VerificationResult {
|
|
36
|
+
success: boolean;
|
|
37
|
+
expectedCount: number;
|
|
38
|
+
actualCount: number;
|
|
39
|
+
duplicates: AdoWorkItem[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CleanupResult {
|
|
43
|
+
closedCount: number;
|
|
44
|
+
keptCount: number;
|
|
45
|
+
errors: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AdoDuplicateDetector {
|
|
49
|
+
private org: string;
|
|
50
|
+
private project: string;
|
|
51
|
+
private pat: string;
|
|
52
|
+
private logger: Logger;
|
|
53
|
+
|
|
54
|
+
constructor(options: {
|
|
55
|
+
org?: string;
|
|
56
|
+
project?: string;
|
|
57
|
+
pat?: string;
|
|
58
|
+
logger?: Logger;
|
|
59
|
+
} = {}) {
|
|
60
|
+
this.org = options.org || process.env.AZURE_DEVOPS_ORG || '';
|
|
61
|
+
this.project = options.project || process.env.AZURE_DEVOPS_PROJECT || '';
|
|
62
|
+
this.pat = options.pat || process.env.AZURE_DEVOPS_PAT || process.env.AZURE_DEVOPS_TOKEN || '';
|
|
63
|
+
this.logger = options.logger || consoleLogger;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Phase 1: Check if work item exists before creating
|
|
68
|
+
*/
|
|
69
|
+
async checkBeforeCreate(
|
|
70
|
+
titlePattern: string,
|
|
71
|
+
incrementId?: string
|
|
72
|
+
): Promise<DetectionResult> {
|
|
73
|
+
try {
|
|
74
|
+
const items = await this.searchWorkItems(titlePattern);
|
|
75
|
+
|
|
76
|
+
if (items.length > 0) {
|
|
77
|
+
return {
|
|
78
|
+
found: true,
|
|
79
|
+
existingItem: items[0],
|
|
80
|
+
count: items.length,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { found: false, count: 0 };
|
|
85
|
+
} catch (error: any) {
|
|
86
|
+
this.logger.log(`⚠️ Detection check failed: ${error.message}`);
|
|
87
|
+
// Graceful degradation - continue anyway
|
|
88
|
+
return { found: false, count: 0 };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Phase 2: Verify count after creation
|
|
94
|
+
*/
|
|
95
|
+
async verifyAfterCreate(
|
|
96
|
+
titlePattern: string,
|
|
97
|
+
expectedCount: number = 1
|
|
98
|
+
): Promise<VerificationResult> {
|
|
99
|
+
try {
|
|
100
|
+
const items = await this.searchWorkItems(titlePattern);
|
|
101
|
+
|
|
102
|
+
if (items.length > expectedCount) {
|
|
103
|
+
// Duplicates detected!
|
|
104
|
+
const sorted = items.sort(
|
|
105
|
+
(a, b) => a.id - b.id // Sort by ID (lowest = oldest)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
expectedCount,
|
|
111
|
+
actualCount: items.length,
|
|
112
|
+
duplicates: sorted.slice(expectedCount), // All items after expected count
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
expectedCount,
|
|
119
|
+
actualCount: items.length,
|
|
120
|
+
duplicates: [],
|
|
121
|
+
};
|
|
122
|
+
} catch (error: any) {
|
|
123
|
+
this.logger.log(`⚠️ Verification check failed: ${error.message}`);
|
|
124
|
+
return {
|
|
125
|
+
success: true, // Assume success on error
|
|
126
|
+
expectedCount,
|
|
127
|
+
actualCount: expectedCount,
|
|
128
|
+
duplicates: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Phase 3: Auto-close duplicates
|
|
135
|
+
*/
|
|
136
|
+
async closeDuplicates(
|
|
137
|
+
duplicates: AdoWorkItem[],
|
|
138
|
+
keepItemId: number
|
|
139
|
+
): Promise<CleanupResult> {
|
|
140
|
+
const result: CleanupResult = {
|
|
141
|
+
closedCount: 0,
|
|
142
|
+
keptCount: 1,
|
|
143
|
+
errors: [],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
for (const item of duplicates) {
|
|
147
|
+
try {
|
|
148
|
+
await this.closeWorkItem(item.id, keepItemId);
|
|
149
|
+
result.closedCount++;
|
|
150
|
+
this.logger.log(` ✅ Closed #${item.id} (duplicate of #${keepItemId})`);
|
|
151
|
+
} catch (error: any) {
|
|
152
|
+
result.errors.push(`#${item.id}: ${error.message}`);
|
|
153
|
+
this.logger.log(` ❌ Failed to close #${item.id}: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Full cleanup: Find and close all duplicates for a feature
|
|
162
|
+
*/
|
|
163
|
+
async cleanupFeatureDuplicates(
|
|
164
|
+
featureId: string,
|
|
165
|
+
dryRun: boolean = false
|
|
166
|
+
): Promise<{
|
|
167
|
+
groups: DuplicateGroup[];
|
|
168
|
+
totalItems: number;
|
|
169
|
+
duplicateCount: number;
|
|
170
|
+
closedCount: number;
|
|
171
|
+
}> {
|
|
172
|
+
// 1. Search for all work items with feature ID
|
|
173
|
+
const searchPattern = `[${featureId}]`;
|
|
174
|
+
const items = await this.searchWorkItems(searchPattern);
|
|
175
|
+
|
|
176
|
+
this.logger.log(`\n🔍 Scanning for duplicates in Feature ${featureId}...`);
|
|
177
|
+
this.logger.log(` Found ${items.length} total work items`);
|
|
178
|
+
|
|
179
|
+
// 2. Group by title
|
|
180
|
+
const groups = this.groupByTitle(items);
|
|
181
|
+
const duplicateGroups = groups.filter(g => g.duplicates.length > 0);
|
|
182
|
+
|
|
183
|
+
if (duplicateGroups.length === 0) {
|
|
184
|
+
this.logger.log(` ✅ No duplicates found!`);
|
|
185
|
+
return {
|
|
186
|
+
groups: [],
|
|
187
|
+
totalItems: items.length,
|
|
188
|
+
duplicateCount: 0,
|
|
189
|
+
closedCount: 0,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.logger.log(` Detected ${duplicateGroups.length} duplicate groups:\n`);
|
|
194
|
+
|
|
195
|
+
// 3. Display groups
|
|
196
|
+
for (let i = 0; i < duplicateGroups.length; i++) {
|
|
197
|
+
const group = duplicateGroups[i];
|
|
198
|
+
this.logger.log(` 📋 Group ${i + 1}: "${group.title.substring(0, 50)}..."`);
|
|
199
|
+
this.logger.log(` - #${group.keepItem.id} (KEEP) - Created ${group.keepItem.createdDate.split('T')[0]}`);
|
|
200
|
+
for (const dup of group.duplicates) {
|
|
201
|
+
this.logger.log(` - #${dup.id} (CLOSE) - Created ${dup.createdDate.split('T')[0]} - DUPLICATE`);
|
|
202
|
+
}
|
|
203
|
+
this.logger.log('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const totalDuplicates = duplicateGroups.reduce((sum, g) => sum + g.duplicates.length, 0);
|
|
207
|
+
|
|
208
|
+
if (dryRun) {
|
|
209
|
+
this.logger.log(`\n✅ Dry run complete!`);
|
|
210
|
+
this.logger.log(` Total work items: ${items.length}`);
|
|
211
|
+
this.logger.log(` Duplicate groups: ${duplicateGroups.length}`);
|
|
212
|
+
this.logger.log(` Work items to close: ${totalDuplicates}`);
|
|
213
|
+
this.logger.log(`\n⚠️ This was a DRY RUN - no changes made.`);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
groups: duplicateGroups,
|
|
217
|
+
totalItems: items.length,
|
|
218
|
+
duplicateCount: totalDuplicates,
|
|
219
|
+
closedCount: 0,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 4. Close duplicates
|
|
224
|
+
let closedCount = 0;
|
|
225
|
+
this.logger.log(`🗑️ Closing duplicates...`);
|
|
226
|
+
|
|
227
|
+
for (const group of duplicateGroups) {
|
|
228
|
+
const result = await this.closeDuplicates(group.duplicates, group.keepItem.id);
|
|
229
|
+
closedCount += result.closedCount;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.logger.log(`\n✅ Cleanup complete!`);
|
|
233
|
+
this.logger.log(` Closed: ${closedCount} duplicates`);
|
|
234
|
+
this.logger.log(` Kept: ${duplicateGroups.length} original work items`);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
groups: duplicateGroups,
|
|
238
|
+
totalItems: items.length,
|
|
239
|
+
duplicateCount: totalDuplicates,
|
|
240
|
+
closedCount,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Group work items by title
|
|
246
|
+
*/
|
|
247
|
+
private groupByTitle(items: AdoWorkItem[]): DuplicateGroup[] {
|
|
248
|
+
const titleMap = new Map<string, AdoWorkItem[]>();
|
|
249
|
+
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
const existing = titleMap.get(item.title) || [];
|
|
252
|
+
existing.push(item);
|
|
253
|
+
titleMap.set(item.title, existing);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const groups: DuplicateGroup[] = [];
|
|
257
|
+
|
|
258
|
+
for (const [title, groupItems] of titleMap) {
|
|
259
|
+
// Sort by ID (lowest = oldest)
|
|
260
|
+
const sorted = groupItems.sort((a, b) => a.id - b.id);
|
|
261
|
+
|
|
262
|
+
groups.push({
|
|
263
|
+
title,
|
|
264
|
+
items: sorted,
|
|
265
|
+
keepItem: sorted[0],
|
|
266
|
+
duplicates: sorted.slice(1),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return groups;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Search for work items using WIQL
|
|
275
|
+
*/
|
|
276
|
+
private async searchWorkItems(titlePattern: string): Promise<AdoWorkItem[]> {
|
|
277
|
+
if (!this.org || !this.pat) {
|
|
278
|
+
throw new Error('ADO credentials not configured');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const wiql = {
|
|
282
|
+
query: `SELECT [System.Id], [System.Title], [System.State], [System.CreatedDate]
|
|
283
|
+
FROM WorkItems
|
|
284
|
+
WHERE [System.TeamProject] = '${this.project}'
|
|
285
|
+
AND [System.Title] CONTAINS '${titlePattern}'
|
|
286
|
+
ORDER BY [System.Id] ASC`,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const auth = Buffer.from(`:${this.pat}`).toString('base64');
|
|
290
|
+
const url = `https://dev.azure.com/${this.org}/${this.project}/_apis/wit/wiql?api-version=7.1`;
|
|
291
|
+
|
|
292
|
+
const response = await fetch(url, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: {
|
|
295
|
+
Authorization: `Basic ${auth}`,
|
|
296
|
+
'Content-Type': 'application/json',
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify(wiql),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
throw new Error(`WIQL query failed: ${response.status}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const data = await response.json();
|
|
306
|
+
const workItemIds = data.workItems?.map((wi: any) => wi.id) || [];
|
|
307
|
+
|
|
308
|
+
if (workItemIds.length === 0) {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Fetch full work item details
|
|
313
|
+
return this.getWorkItemDetails(workItemIds);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get work item details by IDs
|
|
318
|
+
*/
|
|
319
|
+
private async getWorkItemDetails(ids: number[]): Promise<AdoWorkItem[]> {
|
|
320
|
+
if (ids.length === 0) return [];
|
|
321
|
+
|
|
322
|
+
const auth = Buffer.from(`:${this.pat}`).toString('base64');
|
|
323
|
+
const url = `https://dev.azure.com/${this.org}/_apis/wit/workitems?ids=${ids.join(',')}&api-version=7.1`;
|
|
324
|
+
|
|
325
|
+
const response = await fetch(url, {
|
|
326
|
+
headers: {
|
|
327
|
+
Authorization: `Basic ${auth}`,
|
|
328
|
+
'Content-Type': 'application/json',
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
throw new Error(`Failed to get work items: ${response.status}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const data = await response.json();
|
|
337
|
+
return (data.value || []).map((wi: any) => ({
|
|
338
|
+
id: wi.id,
|
|
339
|
+
title: wi.fields['System.Title'],
|
|
340
|
+
state: wi.fields['System.State'],
|
|
341
|
+
createdDate: wi.fields['System.CreatedDate'],
|
|
342
|
+
url: wi._links?.html?.href,
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Close a work item with duplicate comment
|
|
348
|
+
*/
|
|
349
|
+
private async closeWorkItem(workItemId: number, originalId: number): Promise<void> {
|
|
350
|
+
const auth = Buffer.from(`:${this.pat}`).toString('base64');
|
|
351
|
+
const url = `https://dev.azure.com/${this.org}/_apis/wit/workitems/${workItemId}?api-version=7.1`;
|
|
352
|
+
|
|
353
|
+
const comment = `## Duplicate of #${originalId}
|
|
354
|
+
|
|
355
|
+
This work item was automatically closed by SpecWeave cleanup because it is a duplicate.
|
|
356
|
+
|
|
357
|
+
The original work item (#${originalId}) contains the same content and should be used for tracking instead.
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
🤖 Auto-closed by SpecWeave Duplicate Cleanup`;
|
|
361
|
+
|
|
362
|
+
const operations = [
|
|
363
|
+
{
|
|
364
|
+
op: 'add',
|
|
365
|
+
path: '/fields/System.State',
|
|
366
|
+
value: 'Closed',
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
op: 'add',
|
|
370
|
+
path: '/fields/System.History',
|
|
371
|
+
value: comment,
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
const response = await fetch(url, {
|
|
376
|
+
method: 'PATCH',
|
|
377
|
+
headers: {
|
|
378
|
+
Authorization: `Basic ${auth}`,
|
|
379
|
+
'Content-Type': 'application/json-patch+json',
|
|
380
|
+
},
|
|
381
|
+
body: JSON.stringify(operations),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (!response.ok) {
|
|
385
|
+
const error = await response.text();
|
|
386
|
+
throw new Error(`Failed to close work item: ${response.status} - ${error}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Convenience function for quick duplicate cleanup
|
|
393
|
+
*/
|
|
394
|
+
export async function cleanupAdoDuplicates(
|
|
395
|
+
featureId: string,
|
|
396
|
+
dryRun: boolean = false
|
|
397
|
+
): Promise<{
|
|
398
|
+
groups: DuplicateGroup[];
|
|
399
|
+
totalItems: number;
|
|
400
|
+
duplicateCount: number;
|
|
401
|
+
closedCount: number;
|
|
402
|
+
}> {
|
|
403
|
+
const detector = new AdoDuplicateDetector();
|
|
404
|
+
return detector.cleanupFeatureDuplicates(featureId, dryRun);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export default AdoDuplicateDetector;
|
|
@@ -780,8 +780,8 @@ You can now:
|
|
|
780
780
|
---
|
|
781
781
|
|
|
782
782
|
**Agent Type**: Specialized
|
|
783
|
-
**Model**:
|
|
783
|
+
**Model**: Opus (Claude Opus 4.5) - Best for API operations and structured tasks
|
|
784
784
|
**Context**: Separate context window (doesn't pollute main conversation)
|
|
785
785
|
**Version**: 1.0.0
|
|
786
786
|
**Plugin**: specweave-github
|
|
787
|
-
**Last Updated**: 2025-
|
|
787
|
+
**Last Updated**: 2025-12-07
|
|
@@ -415,7 +415,7 @@ done
|
|
|
415
415
|
3. Format Jira epic payload (project key, summary, description, labels)
|
|
416
416
|
4. POST to Jira API → Create epic
|
|
417
417
|
5. Parse response → Extract epic key (PROJ-123)
|
|
418
|
-
6. Save to metadata.json: `
|
|
418
|
+
6. Save to metadata.json: `external_sync.jira.issueKey = PROJ-123`
|
|
419
419
|
7. Display: "Created Jira Epic: PROJ-123"
|
|
420
420
|
|
|
421
421
|
### Example 2: Sync Progress
|