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,224 @@
|
|
|
1
|
+
import { consoleLogger } from "../../../src/utils/logger.js";
|
|
2
|
+
class PerUSJiraSync {
|
|
3
|
+
constructor(jiraClient, projectMappings, options = {}) {
|
|
4
|
+
this.jiraClient = jiraClient;
|
|
5
|
+
this.projectMappings = projectMappings;
|
|
6
|
+
this.logger = options.logger ?? consoleLogger;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Sync all user stories to their respective JIRA projects
|
|
10
|
+
*
|
|
11
|
+
* @param userStories - User stories with explicit project 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 JIRA Sync: ${userStories.length} USs across ${groups.size} projects`);
|
|
21
|
+
for (const [projectId, stories] of groups) {
|
|
22
|
+
const mapping = this.projectMappings[projectId]?.jira;
|
|
23
|
+
if (!mapping) {
|
|
24
|
+
this.logger.warn(` \u26A0\uFE0F No JIRA mapping for project "${projectId}" - skipping ${stories.length} USs`);
|
|
25
|
+
for (const story of stories) {
|
|
26
|
+
failed.push({
|
|
27
|
+
usId: story.id,
|
|
28
|
+
projectId,
|
|
29
|
+
jiraProject: "N/A",
|
|
30
|
+
issueKey: "",
|
|
31
|
+
url: "",
|
|
32
|
+
action: "skipped",
|
|
33
|
+
error: `No JIRA mapping for project "${projectId}"`
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
for (const story of stories) {
|
|
39
|
+
try {
|
|
40
|
+
const result = await this.syncUserStory(story, mapping, featureId, options);
|
|
41
|
+
synced.push({
|
|
42
|
+
...result,
|
|
43
|
+
projectId
|
|
44
|
+
});
|
|
45
|
+
if (!options.dryRun && result.action !== "skipped") {
|
|
46
|
+
externalRefs[story.id] = {
|
|
47
|
+
jira: {
|
|
48
|
+
provider: "jira",
|
|
49
|
+
issueNumber: result.issueKey,
|
|
50
|
+
url: result.url,
|
|
51
|
+
targetProject: projectId,
|
|
52
|
+
lastSynced: (/* @__PURE__ */ new Date()).toISOString()
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
failed.push({
|
|
58
|
+
usId: story.id,
|
|
59
|
+
projectId,
|
|
60
|
+
jiraProject: mapping.project,
|
|
61
|
+
issueKey: "",
|
|
62
|
+
url: "",
|
|
63
|
+
action: "skipped",
|
|
64
|
+
error: error instanceof Error ? error.message : String(error)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const created = synced.filter((r) => r.action === "created").length;
|
|
70
|
+
const updated = synced.filter((r) => r.action === "updated").length;
|
|
71
|
+
const skipped = synced.filter((r) => r.action === "skipped").length;
|
|
72
|
+
return {
|
|
73
|
+
success: failed.length === 0,
|
|
74
|
+
synced,
|
|
75
|
+
failed,
|
|
76
|
+
externalRefs,
|
|
77
|
+
summary: {
|
|
78
|
+
total: userStories.length,
|
|
79
|
+
created,
|
|
80
|
+
updated,
|
|
81
|
+
skipped,
|
|
82
|
+
failed: failed.length
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Sync a single user story to JIRA
|
|
88
|
+
*/
|
|
89
|
+
async syncUserStory(story, mapping, featureId, options) {
|
|
90
|
+
const summary = `[${featureId}][${story.id}] ${story.title}`;
|
|
91
|
+
const description = this.buildIssueDescription(story, featureId);
|
|
92
|
+
if (options.dryRun) {
|
|
93
|
+
this.logger.log(` \u{1F50D} [DRY-RUN] Would sync ${story.id} to JIRA project ${mapping.project}`);
|
|
94
|
+
return {
|
|
95
|
+
usId: story.id,
|
|
96
|
+
projectId: story.project || "unknown",
|
|
97
|
+
jiraProject: mapping.project,
|
|
98
|
+
issueKey: "",
|
|
99
|
+
url: "",
|
|
100
|
+
action: "skipped"
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const existingIssue = await this.findExistingIssue(mapping.project, story.id);
|
|
104
|
+
if (existingIssue) {
|
|
105
|
+
await this.jiraClient.updateIssue(existingIssue.key, summary, description);
|
|
106
|
+
this.logger.log(` \u{1F504} Updated ${story.id} \u2192 ${existingIssue.key}`);
|
|
107
|
+
return {
|
|
108
|
+
usId: story.id,
|
|
109
|
+
projectId: story.project || "unknown",
|
|
110
|
+
jiraProject: mapping.project,
|
|
111
|
+
issueKey: existingIssue.key,
|
|
112
|
+
url: this.jiraClient.getIssueUrl(existingIssue.key),
|
|
113
|
+
action: "updated"
|
|
114
|
+
};
|
|
115
|
+
} else {
|
|
116
|
+
const newIssue = await this.jiraClient.createIssue(
|
|
117
|
+
mapping.project,
|
|
118
|
+
"Story",
|
|
119
|
+
summary,
|
|
120
|
+
description
|
|
121
|
+
);
|
|
122
|
+
this.logger.log(` \u2705 Created ${story.id} \u2192 ${newIssue.key}`);
|
|
123
|
+
return {
|
|
124
|
+
usId: story.id,
|
|
125
|
+
projectId: story.project || "unknown",
|
|
126
|
+
jiraProject: mapping.project,
|
|
127
|
+
issueKey: newIssue.key,
|
|
128
|
+
url: this.jiraClient.getIssueUrl(newIssue.key),
|
|
129
|
+
action: "created"
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Find existing issue by US ID in summary
|
|
135
|
+
*/
|
|
136
|
+
async findExistingIssue(project, usId) {
|
|
137
|
+
try {
|
|
138
|
+
const jql = `project = "${project}" AND summary ~ "[${usId}]"`;
|
|
139
|
+
const results = await this.jiraClient.searchIssues(jql);
|
|
140
|
+
return results.length > 0 ? { key: results[0].key } : null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Build issue description from user story
|
|
147
|
+
*/
|
|
148
|
+
buildIssueDescription(story, featureId) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
lines.push(`h1. ${story.title}`);
|
|
151
|
+
lines.push("");
|
|
152
|
+
if (story.description) {
|
|
153
|
+
lines.push(story.description);
|
|
154
|
+
lines.push("");
|
|
155
|
+
}
|
|
156
|
+
if (story.acceptanceCriteria && story.acceptanceCriteria.length > 0) {
|
|
157
|
+
lines.push("h2. Acceptance Criteria");
|
|
158
|
+
lines.push("");
|
|
159
|
+
for (const ac of story.acceptanceCriteria) {
|
|
160
|
+
lines.push(`* ${ac}`);
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
lines.push("----");
|
|
165
|
+
lines.push("");
|
|
166
|
+
lines.push(`*Feature*: ${featureId}`);
|
|
167
|
+
lines.push(`*User Story*: ${story.id}`);
|
|
168
|
+
if (story.project) {
|
|
169
|
+
lines.push(`*Project*: ${story.project}`);
|
|
170
|
+
}
|
|
171
|
+
if (story.board) {
|
|
172
|
+
lines.push(`*Board*: ${story.board}`);
|
|
173
|
+
}
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push("_Auto-generated by SpecWeave_");
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Group user stories by their explicit project field
|
|
180
|
+
*/
|
|
181
|
+
groupByProject(userStories, defaultProject) {
|
|
182
|
+
const groups = /* @__PURE__ */ new Map();
|
|
183
|
+
for (const story of userStories) {
|
|
184
|
+
const project = story.project || defaultProject || "default";
|
|
185
|
+
if (!groups.has(project)) {
|
|
186
|
+
groups.set(project, []);
|
|
187
|
+
}
|
|
188
|
+
groups.get(project).push(story);
|
|
189
|
+
}
|
|
190
|
+
return groups;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function formatPerUSSyncResults(result) {
|
|
194
|
+
const lines = [];
|
|
195
|
+
lines.push("");
|
|
196
|
+
lines.push("\u{1F4CA} Per-US JIRA Sync Results");
|
|
197
|
+
lines.push("");
|
|
198
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
199
|
+
for (const r of [...result.synced, ...result.failed]) {
|
|
200
|
+
const existing = byProject.get(r.projectId) || [];
|
|
201
|
+
existing.push(r);
|
|
202
|
+
byProject.set(r.projectId, existing);
|
|
203
|
+
}
|
|
204
|
+
for (const [projectId, results] of byProject) {
|
|
205
|
+
lines.push(`**${projectId}** (\u2192 ${results[0]?.jiraProject || "N/A"}):`);
|
|
206
|
+
for (const r of results) {
|
|
207
|
+
const icon = r.action === "created" ? "\u2705" : r.action === "updated" ? "\u{1F504}" : r.error ? "\u274C" : "\u23ED\uFE0F";
|
|
208
|
+
if (r.issueKey) {
|
|
209
|
+
lines.push(` ${icon} ${r.usId} \u2192 ${r.issueKey}`);
|
|
210
|
+
} else if (r.error) {
|
|
211
|
+
lines.push(` ${icon} ${r.usId}: ${r.error}`);
|
|
212
|
+
} else {
|
|
213
|
+
lines.push(` ${icon} ${r.usId} (${r.action})`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
lines.push("");
|
|
217
|
+
}
|
|
218
|
+
lines.push(`\u{1F4C8} Summary: ${result.summary.created} created, ${result.summary.updated} updated, ${result.summary.failed} failed`);
|
|
219
|
+
return lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
export {
|
|
222
|
+
PerUSJiraSync,
|
|
223
|
+
formatPerUSSyncResults
|
|
224
|
+
};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-US JIRA Sync (v0.34.0+)
|
|
3
|
+
*
|
|
4
|
+
* Syncs each User Story to its explicitly declared project's JIRA project.
|
|
5
|
+
* Uses the **Project**: field 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**: field 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, JiraMapping } 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 JIRA
|
|
22
|
+
*/
|
|
23
|
+
export interface USSyncResult {
|
|
24
|
+
usId: string;
|
|
25
|
+
projectId: string;
|
|
26
|
+
jiraProject: string;
|
|
27
|
+
issueKey: string;
|
|
28
|
+
url: string;
|
|
29
|
+
action: 'created' | 'updated' | 'skipped';
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Result of syncing all USs in an increment
|
|
35
|
+
*/
|
|
36
|
+
export interface PerUSSyncResult {
|
|
37
|
+
success: boolean;
|
|
38
|
+
synced: USSyncResult[];
|
|
39
|
+
failed: USSyncResult[];
|
|
40
|
+
externalRefs: USExternalRefsMap;
|
|
41
|
+
summary: {
|
|
42
|
+
total: number;
|
|
43
|
+
created: number;
|
|
44
|
+
updated: number;
|
|
45
|
+
skipped: number;
|
|
46
|
+
failed: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options for per-US sync
|
|
52
|
+
*/
|
|
53
|
+
export interface PerUSSyncOptions {
|
|
54
|
+
dryRun?: boolean;
|
|
55
|
+
force?: boolean;
|
|
56
|
+
defaultProject?: string;
|
|
57
|
+
logger?: Logger;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* JIRA client interface (to be injected)
|
|
62
|
+
*/
|
|
63
|
+
export interface JiraClient {
|
|
64
|
+
createIssue(project: string, issueType: string, summary: string, description: string): Promise<{ key: string; self: string }>;
|
|
65
|
+
updateIssue(issueKey: string, summary: string, description: string): Promise<void>;
|
|
66
|
+
searchIssues(jql: string): Promise<Array<{ key: string; fields: { summary: string } }>>;
|
|
67
|
+
getIssueUrl(issueKey: string): string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-US JIRA Sync
|
|
72
|
+
*
|
|
73
|
+
* Syncs each US to its declared project's JIRA project.
|
|
74
|
+
*/
|
|
75
|
+
export class PerUSJiraSync {
|
|
76
|
+
private projectMappings: ProjectMappings;
|
|
77
|
+
private jiraClient: JiraClient;
|
|
78
|
+
private logger: Logger;
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
jiraClient: JiraClient,
|
|
82
|
+
projectMappings: ProjectMappings,
|
|
83
|
+
options: { logger?: Logger } = {}
|
|
84
|
+
) {
|
|
85
|
+
this.jiraClient = jiraClient;
|
|
86
|
+
this.projectMappings = projectMappings;
|
|
87
|
+
this.logger = options.logger ?? consoleLogger;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Sync all user stories to their respective JIRA projects
|
|
92
|
+
*
|
|
93
|
+
* @param userStories - User stories with explicit project fields
|
|
94
|
+
* @param featureId - Feature ID (e.g., "FS-137")
|
|
95
|
+
* @param options - Sync options
|
|
96
|
+
*/
|
|
97
|
+
async syncUserStories(
|
|
98
|
+
userStories: UserStoryData[],
|
|
99
|
+
featureId: string,
|
|
100
|
+
options: PerUSSyncOptions = {}
|
|
101
|
+
): Promise<PerUSSyncResult> {
|
|
102
|
+
const synced: USSyncResult[] = [];
|
|
103
|
+
const failed: USSyncResult[] = [];
|
|
104
|
+
const externalRefs: USExternalRefsMap = {};
|
|
105
|
+
|
|
106
|
+
// Group USs by their declared project
|
|
107
|
+
const groups = this.groupByProject(userStories, options.defaultProject);
|
|
108
|
+
|
|
109
|
+
this.logger.log(`📡 Per-US JIRA Sync: ${userStories.length} USs across ${groups.size} projects`);
|
|
110
|
+
|
|
111
|
+
for (const [projectId, stories] of groups) {
|
|
112
|
+
// Get JIRA mapping for this project
|
|
113
|
+
const mapping = this.projectMappings[projectId]?.jira;
|
|
114
|
+
|
|
115
|
+
if (!mapping) {
|
|
116
|
+
// No JIRA mapping for this project
|
|
117
|
+
this.logger.warn(` ⚠️ No JIRA mapping for project "${projectId}" - skipping ${stories.length} USs`);
|
|
118
|
+
for (const story of stories) {
|
|
119
|
+
failed.push({
|
|
120
|
+
usId: story.id,
|
|
121
|
+
projectId,
|
|
122
|
+
jiraProject: 'N/A',
|
|
123
|
+
issueKey: '',
|
|
124
|
+
url: '',
|
|
125
|
+
action: 'skipped',
|
|
126
|
+
error: `No JIRA mapping for project "${projectId}"`
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Sync each US to this project's JIRA project
|
|
133
|
+
for (const story of stories) {
|
|
134
|
+
try {
|
|
135
|
+
const result = await this.syncUserStory(story, mapping, featureId, options);
|
|
136
|
+
synced.push({
|
|
137
|
+
...result,
|
|
138
|
+
projectId
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Build external ref
|
|
142
|
+
if (!options.dryRun && result.action !== 'skipped') {
|
|
143
|
+
externalRefs[story.id] = {
|
|
144
|
+
jira: {
|
|
145
|
+
provider: 'jira',
|
|
146
|
+
issueNumber: result.issueKey,
|
|
147
|
+
url: result.url,
|
|
148
|
+
targetProject: projectId,
|
|
149
|
+
lastSynced: new Date().toISOString()
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
failed.push({
|
|
155
|
+
usId: story.id,
|
|
156
|
+
projectId,
|
|
157
|
+
jiraProject: mapping.project,
|
|
158
|
+
issueKey: '',
|
|
159
|
+
url: '',
|
|
160
|
+
action: 'skipped',
|
|
161
|
+
error: error instanceof Error ? error.message : String(error)
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Calculate summary
|
|
168
|
+
const created = synced.filter(r => r.action === 'created').length;
|
|
169
|
+
const updated = synced.filter(r => r.action === 'updated').length;
|
|
170
|
+
const skipped = synced.filter(r => r.action === 'skipped').length;
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
success: failed.length === 0,
|
|
174
|
+
synced,
|
|
175
|
+
failed,
|
|
176
|
+
externalRefs,
|
|
177
|
+
summary: {
|
|
178
|
+
total: userStories.length,
|
|
179
|
+
created,
|
|
180
|
+
updated,
|
|
181
|
+
skipped,
|
|
182
|
+
failed: failed.length
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sync a single user story to JIRA
|
|
189
|
+
*/
|
|
190
|
+
private async syncUserStory(
|
|
191
|
+
story: UserStoryData,
|
|
192
|
+
mapping: JiraMapping,
|
|
193
|
+
featureId: string,
|
|
194
|
+
options: PerUSSyncOptions
|
|
195
|
+
): Promise<USSyncResult> {
|
|
196
|
+
const summary = `[${featureId}][${story.id}] ${story.title}`;
|
|
197
|
+
const description = this.buildIssueDescription(story, featureId);
|
|
198
|
+
|
|
199
|
+
if (options.dryRun) {
|
|
200
|
+
this.logger.log(` 🔍 [DRY-RUN] Would sync ${story.id} to JIRA project ${mapping.project}`);
|
|
201
|
+
return {
|
|
202
|
+
usId: story.id,
|
|
203
|
+
projectId: story.project || 'unknown',
|
|
204
|
+
jiraProject: mapping.project,
|
|
205
|
+
issueKey: '',
|
|
206
|
+
url: '',
|
|
207
|
+
action: 'skipped'
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check for existing issue
|
|
212
|
+
const existingIssue = await this.findExistingIssue(mapping.project, story.id);
|
|
213
|
+
|
|
214
|
+
if (existingIssue) {
|
|
215
|
+
// Update existing issue
|
|
216
|
+
await this.jiraClient.updateIssue(existingIssue.key, summary, description);
|
|
217
|
+
|
|
218
|
+
this.logger.log(` 🔄 Updated ${story.id} → ${existingIssue.key}`);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
usId: story.id,
|
|
222
|
+
projectId: story.project || 'unknown',
|
|
223
|
+
jiraProject: mapping.project,
|
|
224
|
+
issueKey: existingIssue.key,
|
|
225
|
+
url: this.jiraClient.getIssueUrl(existingIssue.key),
|
|
226
|
+
action: 'updated'
|
|
227
|
+
};
|
|
228
|
+
} else {
|
|
229
|
+
// Create new issue
|
|
230
|
+
const newIssue = await this.jiraClient.createIssue(
|
|
231
|
+
mapping.project,
|
|
232
|
+
'Story',
|
|
233
|
+
summary,
|
|
234
|
+
description
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
this.logger.log(` ✅ Created ${story.id} → ${newIssue.key}`);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
usId: story.id,
|
|
241
|
+
projectId: story.project || 'unknown',
|
|
242
|
+
jiraProject: mapping.project,
|
|
243
|
+
issueKey: newIssue.key,
|
|
244
|
+
url: this.jiraClient.getIssueUrl(newIssue.key),
|
|
245
|
+
action: 'created'
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Find existing issue by US ID in summary
|
|
252
|
+
*/
|
|
253
|
+
private async findExistingIssue(
|
|
254
|
+
project: string,
|
|
255
|
+
usId: string
|
|
256
|
+
): Promise<{ key: string } | null> {
|
|
257
|
+
try {
|
|
258
|
+
const jql = `project = "${project}" AND summary ~ "[${usId}]"`;
|
|
259
|
+
const results = await this.jiraClient.searchIssues(jql);
|
|
260
|
+
|
|
261
|
+
return results.length > 0 ? { key: results[0].key } : null;
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Build issue description from user story
|
|
269
|
+
*/
|
|
270
|
+
private buildIssueDescription(story: UserStoryData, featureId: string): string {
|
|
271
|
+
const lines: string[] = [];
|
|
272
|
+
|
|
273
|
+
lines.push(`h1. ${story.title}`);
|
|
274
|
+
lines.push('');
|
|
275
|
+
|
|
276
|
+
if (story.description) {
|
|
277
|
+
lines.push(story.description);
|
|
278
|
+
lines.push('');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (story.acceptanceCriteria && story.acceptanceCriteria.length > 0) {
|
|
282
|
+
lines.push('h2. Acceptance Criteria');
|
|
283
|
+
lines.push('');
|
|
284
|
+
for (const ac of story.acceptanceCriteria) {
|
|
285
|
+
lines.push(`* ${ac}`);
|
|
286
|
+
}
|
|
287
|
+
lines.push('');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
lines.push('----');
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push(`*Feature*: ${featureId}`);
|
|
293
|
+
lines.push(`*User Story*: ${story.id}`);
|
|
294
|
+
if (story.project) {
|
|
295
|
+
lines.push(`*Project*: ${story.project}`);
|
|
296
|
+
}
|
|
297
|
+
if (story.board) {
|
|
298
|
+
lines.push(`*Board*: ${story.board}`);
|
|
299
|
+
}
|
|
300
|
+
lines.push('');
|
|
301
|
+
lines.push('_Auto-generated by SpecWeave_');
|
|
302
|
+
|
|
303
|
+
return lines.join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Group user stories by their explicit project field
|
|
308
|
+
*/
|
|
309
|
+
private groupByProject(
|
|
310
|
+
userStories: UserStoryData[],
|
|
311
|
+
defaultProject?: string
|
|
312
|
+
): Map<string, UserStoryData[]> {
|
|
313
|
+
const groups = new Map<string, UserStoryData[]>();
|
|
314
|
+
|
|
315
|
+
for (const story of userStories) {
|
|
316
|
+
const project = story.project || defaultProject || 'default';
|
|
317
|
+
|
|
318
|
+
if (!groups.has(project)) {
|
|
319
|
+
groups.set(project, []);
|
|
320
|
+
}
|
|
321
|
+
groups.get(project)!.push(story);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return groups;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Format per-US sync results for display
|
|
330
|
+
*/
|
|
331
|
+
export function formatPerUSSyncResults(result: PerUSSyncResult): string {
|
|
332
|
+
const lines: string[] = [];
|
|
333
|
+
|
|
334
|
+
lines.push('');
|
|
335
|
+
lines.push('📊 Per-US JIRA Sync Results');
|
|
336
|
+
lines.push('');
|
|
337
|
+
|
|
338
|
+
// Group by project
|
|
339
|
+
const byProject = new Map<string, USSyncResult[]>();
|
|
340
|
+
for (const r of [...result.synced, ...result.failed]) {
|
|
341
|
+
const existing = byProject.get(r.projectId) || [];
|
|
342
|
+
existing.push(r);
|
|
343
|
+
byProject.set(r.projectId, existing);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const [projectId, results] of byProject) {
|
|
347
|
+
lines.push(`**${projectId}** (→ ${results[0]?.jiraProject || 'N/A'}):`);
|
|
348
|
+
for (const r of results) {
|
|
349
|
+
const icon = r.action === 'created' ? '✅' :
|
|
350
|
+
r.action === 'updated' ? '🔄' :
|
|
351
|
+
r.error ? '❌' : '⏭️';
|
|
352
|
+
if (r.issueKey) {
|
|
353
|
+
lines.push(` ${icon} ${r.usId} → ${r.issueKey}`);
|
|
354
|
+
} else if (r.error) {
|
|
355
|
+
lines.push(` ${icon} ${r.usId}: ${r.error}`);
|
|
356
|
+
} else {
|
|
357
|
+
lines.push(` ${icon} ${r.usId} (${r.action})`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
lines.push('');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
lines.push(`📈 Summary: ${result.summary.created} created, ${result.summary.updated} updated, ${result.summary.failed} failed`);
|
|
364
|
+
|
|
365
|
+
return lines.join('\n');
|
|
366
|
+
}
|