specweave 0.33.2 → 0.33.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 +56 -0
- 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/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/sync/sync-coordinator.d.ts +20 -0
- package/dist/src/sync/sync-coordinator.d.ts.map +1 -1
- package/dist/src/sync/sync-coordinator.js +258 -33
- package/dist/src/sync/sync-coordinator.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/hooks/hooks.json +10 -0
- 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-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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { consoleLogger } from "../../../src/utils/logger.js";
|
|
2
|
+
class PerUSAdoSync {
|
|
3
|
+
constructor(adoClient, projectMappings, options = {}) {
|
|
4
|
+
this.adoClient = adoClient;
|
|
5
|
+
this.projectMappings = projectMappings;
|
|
6
|
+
this.logger = options.logger ?? consoleLogger;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Sync all user stories to their respective ADO projects
|
|
10
|
+
*
|
|
11
|
+
* @param userStories - User stories with explicit project/board fields
|
|
12
|
+
* @param featureId - Feature ID (e.g., "FS-137")
|
|
13
|
+
* @param options - Sync options
|
|
14
|
+
*/
|
|
15
|
+
async syncUserStories(userStories, featureId, options = {}) {
|
|
16
|
+
const synced = [];
|
|
17
|
+
const failed = [];
|
|
18
|
+
const externalRefs = {};
|
|
19
|
+
const groups = this.groupByProject(userStories, options.defaultProject);
|
|
20
|
+
this.logger.log(`\u{1F4E1} Per-US ADO Sync: ${userStories.length} USs across ${groups.size} projects`);
|
|
21
|
+
for (const [projectId, stories] of groups) {
|
|
22
|
+
const mapping = this.projectMappings[projectId]?.ado;
|
|
23
|
+
if (!mapping) {
|
|
24
|
+
this.logger.warn(` \u26A0\uFE0F No ADO mapping for project "${projectId}" - skipping ${stories.length} USs`);
|
|
25
|
+
for (const story of stories) {
|
|
26
|
+
failed.push({
|
|
27
|
+
usId: story.id,
|
|
28
|
+
projectId,
|
|
29
|
+
adoProject: "N/A",
|
|
30
|
+
areaPath: "",
|
|
31
|
+
workItemId: 0,
|
|
32
|
+
url: "",
|
|
33
|
+
action: "skipped",
|
|
34
|
+
error: `No ADO mapping for project "${projectId}"`
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (const story of stories) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await this.syncUserStory(story, mapping, featureId, options);
|
|
42
|
+
synced.push({
|
|
43
|
+
...result,
|
|
44
|
+
projectId
|
|
45
|
+
});
|
|
46
|
+
if (!options.dryRun && result.action !== "skipped") {
|
|
47
|
+
externalRefs[story.id] = {
|
|
48
|
+
ado: {
|
|
49
|
+
provider: "ado",
|
|
50
|
+
issueNumber: result.workItemId,
|
|
51
|
+
url: result.url,
|
|
52
|
+
targetProject: projectId,
|
|
53
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString()
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
failed.push({
|
|
59
|
+
usId: story.id,
|
|
60
|
+
projectId,
|
|
61
|
+
adoProject: mapping.project,
|
|
62
|
+
areaPath: mapping.areaPath || "",
|
|
63
|
+
workItemId: 0,
|
|
64
|
+
url: "",
|
|
65
|
+
action: "skipped",
|
|
66
|
+
error: error instanceof Error ? error.message : String(error)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const created = synced.filter((r) => r.action === "created").length;
|
|
72
|
+
const updated = synced.filter((r) => r.action === "updated").length;
|
|
73
|
+
const skipped = synced.filter((r) => r.action === "skipped").length;
|
|
74
|
+
return {
|
|
75
|
+
success: failed.length === 0,
|
|
76
|
+
synced,
|
|
77
|
+
failed,
|
|
78
|
+
externalRefs,
|
|
79
|
+
summary: {
|
|
80
|
+
total: userStories.length,
|
|
81
|
+
created,
|
|
82
|
+
updated,
|
|
83
|
+
skipped,
|
|
84
|
+
failed: failed.length
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Sync a single user story to ADO
|
|
90
|
+
*
|
|
91
|
+
* For 2-level structures:
|
|
92
|
+
* - **Project**: maps to ADO project
|
|
93
|
+
* - **Board**: maps to area path under the project
|
|
94
|
+
*/
|
|
95
|
+
async syncUserStory(story, mapping, featureId, options) {
|
|
96
|
+
const title = `[${featureId}][${story.id}] ${story.title}`;
|
|
97
|
+
const description = this.buildWorkItemDescription(story, featureId);
|
|
98
|
+
let areaPath = mapping.areaPath || mapping.project;
|
|
99
|
+
if (story.board) {
|
|
100
|
+
areaPath = `${mapping.project}\\${story.board}`;
|
|
101
|
+
}
|
|
102
|
+
if (options.dryRun) {
|
|
103
|
+
this.logger.log(` \u{1F50D} [DRY-RUN] Would sync ${story.id} to ${mapping.project} (area: ${areaPath})`);
|
|
104
|
+
return {
|
|
105
|
+
usId: story.id,
|
|
106
|
+
projectId: story.project || "unknown",
|
|
107
|
+
adoProject: mapping.project,
|
|
108
|
+
areaPath,
|
|
109
|
+
workItemId: 0,
|
|
110
|
+
url: "",
|
|
111
|
+
action: "skipped"
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const existingItem = await this.findExistingWorkItem(mapping.project, story.id);
|
|
115
|
+
if (existingItem) {
|
|
116
|
+
await this.adoClient.updateWorkItem(
|
|
117
|
+
mapping.project,
|
|
118
|
+
existingItem.id,
|
|
119
|
+
title,
|
|
120
|
+
description,
|
|
121
|
+
areaPath
|
|
122
|
+
);
|
|
123
|
+
this.logger.log(` \u{1F504} Updated ${story.id} \u2192 ${mapping.project}/${existingItem.id}`);
|
|
124
|
+
return {
|
|
125
|
+
usId: story.id,
|
|
126
|
+
projectId: story.project || "unknown",
|
|
127
|
+
adoProject: mapping.project,
|
|
128
|
+
areaPath,
|
|
129
|
+
workItemId: existingItem.id,
|
|
130
|
+
url: this.adoClient.getWorkItemUrl(mapping.project, existingItem.id),
|
|
131
|
+
action: "updated"
|
|
132
|
+
};
|
|
133
|
+
} else {
|
|
134
|
+
const newItem = await this.adoClient.createWorkItem(
|
|
135
|
+
mapping.project,
|
|
136
|
+
"User Story",
|
|
137
|
+
title,
|
|
138
|
+
description,
|
|
139
|
+
areaPath
|
|
140
|
+
);
|
|
141
|
+
this.logger.log(` \u2705 Created ${story.id} \u2192 ${mapping.project}/${newItem.id}`);
|
|
142
|
+
return {
|
|
143
|
+
usId: story.id,
|
|
144
|
+
projectId: story.project || "unknown",
|
|
145
|
+
adoProject: mapping.project,
|
|
146
|
+
areaPath,
|
|
147
|
+
workItemId: newItem.id,
|
|
148
|
+
url: newItem.url,
|
|
149
|
+
action: "created"
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Find existing work item by US ID in title
|
|
155
|
+
*/
|
|
156
|
+
async findExistingWorkItem(project, usId) {
|
|
157
|
+
try {
|
|
158
|
+
const query = `[System.Title] Contains '[${usId}]'`;
|
|
159
|
+
const results = await this.adoClient.searchWorkItems(project, query);
|
|
160
|
+
return results.length > 0 ? { id: results[0].id } : null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build work item description from user story
|
|
167
|
+
*/
|
|
168
|
+
buildWorkItemDescription(story, featureId) {
|
|
169
|
+
const lines = [];
|
|
170
|
+
lines.push(`<h1>${story.title}</h1>`);
|
|
171
|
+
lines.push("");
|
|
172
|
+
if (story.description) {
|
|
173
|
+
lines.push(`<p>${story.description}</p>`);
|
|
174
|
+
lines.push("");
|
|
175
|
+
}
|
|
176
|
+
if (story.acceptanceCriteria && story.acceptanceCriteria.length > 0) {
|
|
177
|
+
lines.push("<h2>Acceptance Criteria</h2>");
|
|
178
|
+
lines.push("<ul>");
|
|
179
|
+
for (const ac of story.acceptanceCriteria) {
|
|
180
|
+
lines.push(` <li>${ac}</li>`);
|
|
181
|
+
}
|
|
182
|
+
lines.push("</ul>");
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
lines.push("<hr/>");
|
|
186
|
+
lines.push("");
|
|
187
|
+
lines.push(`<p><strong>Feature</strong>: ${featureId}</p>`);
|
|
188
|
+
lines.push(`<p><strong>User Story</strong>: ${story.id}</p>`);
|
|
189
|
+
if (story.project) {
|
|
190
|
+
lines.push(`<p><strong>Project</strong>: ${story.project}</p>`);
|
|
191
|
+
}
|
|
192
|
+
if (story.board) {
|
|
193
|
+
lines.push(`<p><strong>Board</strong>: ${story.board}</p>`);
|
|
194
|
+
}
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push("<p><em>Auto-generated by SpecWeave</em></p>");
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Group user stories by their explicit project field
|
|
201
|
+
*/
|
|
202
|
+
groupByProject(userStories, defaultProject) {
|
|
203
|
+
const groups = /* @__PURE__ */ new Map();
|
|
204
|
+
for (const story of userStories) {
|
|
205
|
+
const project = story.project || defaultProject || "default";
|
|
206
|
+
if (!groups.has(project)) {
|
|
207
|
+
groups.set(project, []);
|
|
208
|
+
}
|
|
209
|
+
groups.get(project).push(story);
|
|
210
|
+
}
|
|
211
|
+
return groups;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function formatPerUSSyncResults(result) {
|
|
215
|
+
const lines = [];
|
|
216
|
+
lines.push("");
|
|
217
|
+
lines.push("\u{1F4CA} Per-US ADO Sync Results");
|
|
218
|
+
lines.push("");
|
|
219
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
220
|
+
for (const r of [...result.synced, ...result.failed]) {
|
|
221
|
+
const existing = byProject.get(r.projectId) || [];
|
|
222
|
+
existing.push(r);
|
|
223
|
+
byProject.set(r.projectId, existing);
|
|
224
|
+
}
|
|
225
|
+
for (const [projectId, results] of byProject) {
|
|
226
|
+
const adoProject = results[0]?.adoProject || "N/A";
|
|
227
|
+
const areaPath = results[0]?.areaPath || "";
|
|
228
|
+
lines.push(`**${projectId}** (\u2192 ${adoProject}${areaPath ? ` [${areaPath}]` : ""}):`);
|
|
229
|
+
for (const r of results) {
|
|
230
|
+
const icon = r.action === "created" ? "\u2705" : r.action === "updated" ? "\u{1F504}" : r.error ? "\u274C" : "\u23ED\uFE0F";
|
|
231
|
+
if (r.workItemId > 0) {
|
|
232
|
+
lines.push(` ${icon} ${r.usId} \u2192 ${r.adoProject}/${r.workItemId}`);
|
|
233
|
+
} else if (r.error) {
|
|
234
|
+
lines.push(` ${icon} ${r.usId}: ${r.error}`);
|
|
235
|
+
} else {
|
|
236
|
+
lines.push(` ${icon} ${r.usId} (${r.action})`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
lines.push("");
|
|
240
|
+
}
|
|
241
|
+
lines.push(`\u{1F4C8} Summary: ${result.summary.created} created, ${result.summary.updated} updated, ${result.summary.failed} failed`);
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
export {
|
|
245
|
+
PerUSAdoSync,
|
|
246
|
+
formatPerUSSyncResults
|
|
247
|
+
};
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-US Azure DevOps Sync (v0.34.0+)
|
|
3
|
+
*
|
|
4
|
+
* Syncs each User Story to its explicitly declared project's ADO project/area.
|
|
5
|
+
* Uses the **Project**: and **Board**: fields in spec.md (NOT keyword-based classification).
|
|
6
|
+
*
|
|
7
|
+
* Key difference from multi-project-sync:
|
|
8
|
+
* - Multi-project sync uses keyword/heuristic classification
|
|
9
|
+
* - Per-US sync uses EXPLICIT **Project**: and **Board**: fields from spec.md
|
|
10
|
+
*
|
|
11
|
+
* @module per-us-sync
|
|
12
|
+
* @since v0.34.0
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { UserStoryData } from '../../../src/core/living-docs/types.js';
|
|
16
|
+
import type { ProjectMappings, AdoMapping } from '../../../src/core/types/config.js';
|
|
17
|
+
import type { USExternalRefsMap } from '../../../src/core/types/increment-metadata.js';
|
|
18
|
+
import { Logger, consoleLogger } from '../../../src/utils/logger.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Result of syncing a single US to ADO
|
|
22
|
+
*/
|
|
23
|
+
export interface USSyncResult {
|
|
24
|
+
usId: string;
|
|
25
|
+
projectId: string;
|
|
26
|
+
adoProject: string;
|
|
27
|
+
areaPath: string;
|
|
28
|
+
workItemId: number;
|
|
29
|
+
url: string;
|
|
30
|
+
action: 'created' | 'updated' | 'skipped';
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Result of syncing all USs in an increment
|
|
36
|
+
*/
|
|
37
|
+
export interface PerUSSyncResult {
|
|
38
|
+
success: boolean;
|
|
39
|
+
synced: USSyncResult[];
|
|
40
|
+
failed: USSyncResult[];
|
|
41
|
+
externalRefs: USExternalRefsMap;
|
|
42
|
+
summary: {
|
|
43
|
+
total: number;
|
|
44
|
+
created: number;
|
|
45
|
+
updated: number;
|
|
46
|
+
skipped: number;
|
|
47
|
+
failed: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Options for per-US sync
|
|
53
|
+
*/
|
|
54
|
+
export interface PerUSSyncOptions {
|
|
55
|
+
dryRun?: boolean;
|
|
56
|
+
force?: boolean;
|
|
57
|
+
defaultProject?: string;
|
|
58
|
+
defaultBoard?: string;
|
|
59
|
+
logger?: Logger;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* ADO client interface (to be injected)
|
|
64
|
+
*/
|
|
65
|
+
export interface AdoClient {
|
|
66
|
+
createWorkItem(
|
|
67
|
+
project: string,
|
|
68
|
+
workItemType: string,
|
|
69
|
+
title: string,
|
|
70
|
+
description: string,
|
|
71
|
+
areaPath?: string
|
|
72
|
+
): Promise<{ id: number; url: string }>;
|
|
73
|
+
updateWorkItem(
|
|
74
|
+
project: string,
|
|
75
|
+
workItemId: number,
|
|
76
|
+
title: string,
|
|
77
|
+
description: string,
|
|
78
|
+
areaPath?: string
|
|
79
|
+
): Promise<void>;
|
|
80
|
+
searchWorkItems(project: string, query: string): Promise<Array<{ id: number; fields: { 'System.Title': string } }>>;
|
|
81
|
+
getWorkItemUrl(project: string, workItemId: number): string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Per-US ADO Sync
|
|
86
|
+
*
|
|
87
|
+
* Syncs each US to its declared project's ADO project/area path.
|
|
88
|
+
* For 2-level structures, uses **Board**: to determine area path.
|
|
89
|
+
*/
|
|
90
|
+
export class PerUSAdoSync {
|
|
91
|
+
private projectMappings: ProjectMappings;
|
|
92
|
+
private adoClient: AdoClient;
|
|
93
|
+
private logger: Logger;
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
adoClient: AdoClient,
|
|
97
|
+
projectMappings: ProjectMappings,
|
|
98
|
+
options: { logger?: Logger } = {}
|
|
99
|
+
) {
|
|
100
|
+
this.adoClient = adoClient;
|
|
101
|
+
this.projectMappings = projectMappings;
|
|
102
|
+
this.logger = options.logger ?? consoleLogger;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sync all user stories to their respective ADO projects
|
|
107
|
+
*
|
|
108
|
+
* @param userStories - User stories with explicit project/board fields
|
|
109
|
+
* @param featureId - Feature ID (e.g., "FS-137")
|
|
110
|
+
* @param options - Sync options
|
|
111
|
+
*/
|
|
112
|
+
async syncUserStories(
|
|
113
|
+
userStories: UserStoryData[],
|
|
114
|
+
featureId: string,
|
|
115
|
+
options: PerUSSyncOptions = {}
|
|
116
|
+
): Promise<PerUSSyncResult> {
|
|
117
|
+
const synced: USSyncResult[] = [];
|
|
118
|
+
const failed: USSyncResult[] = [];
|
|
119
|
+
const externalRefs: USExternalRefsMap = {};
|
|
120
|
+
|
|
121
|
+
// Group USs by their declared project
|
|
122
|
+
const groups = this.groupByProject(userStories, options.defaultProject);
|
|
123
|
+
|
|
124
|
+
this.logger.log(`📡 Per-US ADO Sync: ${userStories.length} USs across ${groups.size} projects`);
|
|
125
|
+
|
|
126
|
+
for (const [projectId, stories] of groups) {
|
|
127
|
+
// Get ADO mapping for this project
|
|
128
|
+
const mapping = this.projectMappings[projectId]?.ado;
|
|
129
|
+
|
|
130
|
+
if (!mapping) {
|
|
131
|
+
// No ADO mapping for this project
|
|
132
|
+
this.logger.warn(` ⚠️ No ADO mapping for project "${projectId}" - skipping ${stories.length} USs`);
|
|
133
|
+
for (const story of stories) {
|
|
134
|
+
failed.push({
|
|
135
|
+
usId: story.id,
|
|
136
|
+
projectId,
|
|
137
|
+
adoProject: 'N/A',
|
|
138
|
+
areaPath: '',
|
|
139
|
+
workItemId: 0,
|
|
140
|
+
url: '',
|
|
141
|
+
action: 'skipped',
|
|
142
|
+
error: `No ADO mapping for project "${projectId}"`
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Sync each US to this project's ADO project
|
|
149
|
+
for (const story of stories) {
|
|
150
|
+
try {
|
|
151
|
+
const result = await this.syncUserStory(story, mapping, featureId, options);
|
|
152
|
+
synced.push({
|
|
153
|
+
...result,
|
|
154
|
+
projectId
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Build external ref
|
|
158
|
+
if (!options.dryRun && result.action !== 'skipped') {
|
|
159
|
+
externalRefs[story.id] = {
|
|
160
|
+
ado: {
|
|
161
|
+
provider: 'ado',
|
|
162
|
+
issueNumber: result.workItemId,
|
|
163
|
+
url: result.url,
|
|
164
|
+
targetProject: projectId,
|
|
165
|
+
lastSynced: new Date().toISOString()
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
failed.push({
|
|
171
|
+
usId: story.id,
|
|
172
|
+
projectId,
|
|
173
|
+
adoProject: mapping.project,
|
|
174
|
+
areaPath: mapping.areaPath || '',
|
|
175
|
+
workItemId: 0,
|
|
176
|
+
url: '',
|
|
177
|
+
action: 'skipped',
|
|
178
|
+
error: error instanceof Error ? error.message : String(error)
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Calculate summary
|
|
185
|
+
const created = synced.filter(r => r.action === 'created').length;
|
|
186
|
+
const updated = synced.filter(r => r.action === 'updated').length;
|
|
187
|
+
const skipped = synced.filter(r => r.action === 'skipped').length;
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
success: failed.length === 0,
|
|
191
|
+
synced,
|
|
192
|
+
failed,
|
|
193
|
+
externalRefs,
|
|
194
|
+
summary: {
|
|
195
|
+
total: userStories.length,
|
|
196
|
+
created,
|
|
197
|
+
updated,
|
|
198
|
+
skipped,
|
|
199
|
+
failed: failed.length
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Sync a single user story to ADO
|
|
206
|
+
*
|
|
207
|
+
* For 2-level structures:
|
|
208
|
+
* - **Project**: maps to ADO project
|
|
209
|
+
* - **Board**: maps to area path under the project
|
|
210
|
+
*/
|
|
211
|
+
private async syncUserStory(
|
|
212
|
+
story: UserStoryData,
|
|
213
|
+
mapping: AdoMapping,
|
|
214
|
+
featureId: string,
|
|
215
|
+
options: PerUSSyncOptions
|
|
216
|
+
): Promise<USSyncResult> {
|
|
217
|
+
const title = `[${featureId}][${story.id}] ${story.title}`;
|
|
218
|
+
const description = this.buildWorkItemDescription(story, featureId);
|
|
219
|
+
|
|
220
|
+
// Determine area path:
|
|
221
|
+
// - Use story.board if available (2-level structure)
|
|
222
|
+
// - Fall back to mapping.areaPath
|
|
223
|
+
// - Fall back to project root
|
|
224
|
+
let areaPath = mapping.areaPath || mapping.project;
|
|
225
|
+
if (story.board) {
|
|
226
|
+
// 2-level structure: append board as area path segment
|
|
227
|
+
areaPath = `${mapping.project}\\${story.board}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (options.dryRun) {
|
|
231
|
+
this.logger.log(` 🔍 [DRY-RUN] Would sync ${story.id} to ${mapping.project} (area: ${areaPath})`);
|
|
232
|
+
return {
|
|
233
|
+
usId: story.id,
|
|
234
|
+
projectId: story.project || 'unknown',
|
|
235
|
+
adoProject: mapping.project,
|
|
236
|
+
areaPath,
|
|
237
|
+
workItemId: 0,
|
|
238
|
+
url: '',
|
|
239
|
+
action: 'skipped'
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check for existing work item
|
|
244
|
+
const existingItem = await this.findExistingWorkItem(mapping.project, story.id);
|
|
245
|
+
|
|
246
|
+
if (existingItem) {
|
|
247
|
+
// Update existing work item
|
|
248
|
+
await this.adoClient.updateWorkItem(
|
|
249
|
+
mapping.project,
|
|
250
|
+
existingItem.id,
|
|
251
|
+
title,
|
|
252
|
+
description,
|
|
253
|
+
areaPath
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
this.logger.log(` 🔄 Updated ${story.id} → ${mapping.project}/${existingItem.id}`);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
usId: story.id,
|
|
260
|
+
projectId: story.project || 'unknown',
|
|
261
|
+
adoProject: mapping.project,
|
|
262
|
+
areaPath,
|
|
263
|
+
workItemId: existingItem.id,
|
|
264
|
+
url: this.adoClient.getWorkItemUrl(mapping.project, existingItem.id),
|
|
265
|
+
action: 'updated'
|
|
266
|
+
};
|
|
267
|
+
} else {
|
|
268
|
+
// Create new work item
|
|
269
|
+
const newItem = await this.adoClient.createWorkItem(
|
|
270
|
+
mapping.project,
|
|
271
|
+
'User Story',
|
|
272
|
+
title,
|
|
273
|
+
description,
|
|
274
|
+
areaPath
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
this.logger.log(` ✅ Created ${story.id} → ${mapping.project}/${newItem.id}`);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
usId: story.id,
|
|
281
|
+
projectId: story.project || 'unknown',
|
|
282
|
+
adoProject: mapping.project,
|
|
283
|
+
areaPath,
|
|
284
|
+
workItemId: newItem.id,
|
|
285
|
+
url: newItem.url,
|
|
286
|
+
action: 'created'
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Find existing work item by US ID in title
|
|
293
|
+
*/
|
|
294
|
+
private async findExistingWorkItem(
|
|
295
|
+
project: string,
|
|
296
|
+
usId: string
|
|
297
|
+
): Promise<{ id: number } | null> {
|
|
298
|
+
try {
|
|
299
|
+
const query = `[System.Title] Contains '[${usId}]'`;
|
|
300
|
+
const results = await this.adoClient.searchWorkItems(project, query);
|
|
301
|
+
|
|
302
|
+
return results.length > 0 ? { id: results[0].id } : null;
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Build work item description from user story
|
|
310
|
+
*/
|
|
311
|
+
private buildWorkItemDescription(story: UserStoryData, featureId: string): string {
|
|
312
|
+
const lines: string[] = [];
|
|
313
|
+
|
|
314
|
+
lines.push(`<h1>${story.title}</h1>`);
|
|
315
|
+
lines.push('');
|
|
316
|
+
|
|
317
|
+
if (story.description) {
|
|
318
|
+
lines.push(`<p>${story.description}</p>`);
|
|
319
|
+
lines.push('');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (story.acceptanceCriteria && story.acceptanceCriteria.length > 0) {
|
|
323
|
+
lines.push('<h2>Acceptance Criteria</h2>');
|
|
324
|
+
lines.push('<ul>');
|
|
325
|
+
for (const ac of story.acceptanceCriteria) {
|
|
326
|
+
lines.push(` <li>${ac}</li>`);
|
|
327
|
+
}
|
|
328
|
+
lines.push('</ul>');
|
|
329
|
+
lines.push('');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
lines.push('<hr/>');
|
|
333
|
+
lines.push('');
|
|
334
|
+
lines.push(`<p><strong>Feature</strong>: ${featureId}</p>`);
|
|
335
|
+
lines.push(`<p><strong>User Story</strong>: ${story.id}</p>`);
|
|
336
|
+
if (story.project) {
|
|
337
|
+
lines.push(`<p><strong>Project</strong>: ${story.project}</p>`);
|
|
338
|
+
}
|
|
339
|
+
if (story.board) {
|
|
340
|
+
lines.push(`<p><strong>Board</strong>: ${story.board}</p>`);
|
|
341
|
+
}
|
|
342
|
+
lines.push('');
|
|
343
|
+
lines.push('<p><em>Auto-generated by SpecWeave</em></p>');
|
|
344
|
+
|
|
345
|
+
return lines.join('\n');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Group user stories by their explicit project field
|
|
350
|
+
*/
|
|
351
|
+
private groupByProject(
|
|
352
|
+
userStories: UserStoryData[],
|
|
353
|
+
defaultProject?: string
|
|
354
|
+
): Map<string, UserStoryData[]> {
|
|
355
|
+
const groups = new Map<string, UserStoryData[]>();
|
|
356
|
+
|
|
357
|
+
for (const story of userStories) {
|
|
358
|
+
const project = story.project || defaultProject || 'default';
|
|
359
|
+
|
|
360
|
+
if (!groups.has(project)) {
|
|
361
|
+
groups.set(project, []);
|
|
362
|
+
}
|
|
363
|
+
groups.get(project)!.push(story);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return groups;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Format per-US sync results for display
|
|
372
|
+
*/
|
|
373
|
+
export function formatPerUSSyncResults(result: PerUSSyncResult): string {
|
|
374
|
+
const lines: string[] = [];
|
|
375
|
+
|
|
376
|
+
lines.push('');
|
|
377
|
+
lines.push('📊 Per-US ADO Sync Results');
|
|
378
|
+
lines.push('');
|
|
379
|
+
|
|
380
|
+
// Group by project
|
|
381
|
+
const byProject = new Map<string, USSyncResult[]>();
|
|
382
|
+
for (const r of [...result.synced, ...result.failed]) {
|
|
383
|
+
const existing = byProject.get(r.projectId) || [];
|
|
384
|
+
existing.push(r);
|
|
385
|
+
byProject.set(r.projectId, existing);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (const [projectId, results] of byProject) {
|
|
389
|
+
const adoProject = results[0]?.adoProject || 'N/A';
|
|
390
|
+
const areaPath = results[0]?.areaPath || '';
|
|
391
|
+
lines.push(`**${projectId}** (→ ${adoProject}${areaPath ? ` [${areaPath}]` : ''}):`);
|
|
392
|
+
for (const r of results) {
|
|
393
|
+
const icon = r.action === 'created' ? '✅' :
|
|
394
|
+
r.action === 'updated' ? '🔄' :
|
|
395
|
+
r.error ? '❌' : '⏭️';
|
|
396
|
+
if (r.workItemId > 0) {
|
|
397
|
+
lines.push(` ${icon} ${r.usId} → ${r.adoProject}/${r.workItemId}`);
|
|
398
|
+
} else if (r.error) {
|
|
399
|
+
lines.push(` ${icon} ${r.usId}: ${r.error}`);
|
|
400
|
+
} else {
|
|
401
|
+
lines.push(` ${icon} ${r.usId} (${r.action})`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
lines.push('');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
lines.push(`📈 Summary: ${result.summary.created} created, ${result.summary.updated} updated, ${result.summary.failed} failed`);
|
|
408
|
+
|
|
409
|
+
return lines.join('\n');
|
|
410
|
+
}
|
|
@@ -291,10 +291,13 @@ ${body}`;
|
|
|
291
291
|
* Search for issue by exact title match
|
|
292
292
|
*
|
|
293
293
|
* IDEMPOTENCY: Use this before creating issues to prevent duplicates
|
|
294
|
+
*
|
|
295
|
+
* @param title - Title pattern to search for (e.g., "[FS-136][US-001]")
|
|
296
|
+
* @param includeClosedIssues - If true, searches all issues (open+closed). Default: false (open only)
|
|
294
297
|
*/
|
|
295
|
-
async searchIssueByTitle(title) {
|
|
298
|
+
async searchIssueByTitle(title, includeClosedIssues = false) {
|
|
296
299
|
const escapedTitle = title.replace(/"/g, '\\"');
|
|
297
|
-
const
|
|
300
|
+
const args = [
|
|
298
301
|
"issue",
|
|
299
302
|
"list",
|
|
300
303
|
"--repo",
|
|
@@ -306,7 +309,11 @@ ${body}`;
|
|
|
306
309
|
"--limit",
|
|
307
310
|
"50"
|
|
308
311
|
// ✅ FIX: Increased from 1 to 50 to catch duplicates (Issue #0047)
|
|
309
|
-
]
|
|
312
|
+
];
|
|
313
|
+
if (includeClosedIssues) {
|
|
314
|
+
args.push("--state", "all");
|
|
315
|
+
}
|
|
316
|
+
const result = await execFileNoThrow("gh", args);
|
|
310
317
|
if (result.exitCode !== 0) {
|
|
311
318
|
return null;
|
|
312
319
|
}
|