specweave 0.13.6 → 0.14.0
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 +189 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/status-line.d.ts +14 -0
- package/dist/cli/commands/status-line.d.ts.map +1 -0
- package/dist/cli/commands/status-line.js +75 -0
- package/dist/cli/commands/status-line.js.map +1 -0
- package/dist/core/status-line/status-line-manager.d.ts +62 -0
- package/dist/core/status-line/status-line-manager.d.ts.map +1 -0
- package/dist/core/status-line/status-line-manager.js +169 -0
- package/dist/core/status-line/status-line-manager.js.map +1 -0
- package/dist/core/status-line/types.d.ts +50 -0
- package/dist/core/status-line/types.d.ts.map +1 -0
- package/dist/core/status-line/types.js +17 -0
- package/dist/core/status-line/types.js.map +1 -0
- package/dist/utils/project-mapper.d.ts +74 -0
- package/dist/utils/project-mapper.d.ts.map +1 -0
- package/dist/utils/project-mapper.js +273 -0
- package/dist/utils/project-mapper.js.map +1 -0
- package/dist/utils/spec-splitter.d.ts +68 -0
- package/dist/utils/spec-splitter.d.ts.map +1 -0
- package/dist/utils/spec-splitter.js +314 -0
- package/dist/utils/spec-splitter.js.map +1 -0
- package/package.json +1 -1
- package/plugins/specweave/hooks/lib/update-status-line.sh +138 -0
- package/plugins/specweave/hooks/post-task-completion.sh +10 -0
- package/plugins/specweave/skills/multi-project-spec-mapper/SKILL.md +399 -0
- package/plugins/specweave-ado/lib/ado-multi-project-sync.js +453 -0
- package/plugins/specweave-ado/lib/ado-multi-project-sync.ts +633 -0
- package/plugins/specweave-docs/skills/docusaurus/SKILL.md +17 -3
- package/plugins/specweave-docs-preview/commands/preview.md +29 -4
- package/plugins/specweave-github/lib/github-multi-project-sync.js +340 -0
- package/plugins/specweave-github/lib/github-multi-project-sync.ts +461 -0
- package/plugins/specweave-jira/lib/jira-multi-project-sync.js +244 -0
- package/plugins/specweave-jira/lib/jira-multi-project-sync.ts +358 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Multi-Project Sync
|
|
3
|
+
*
|
|
4
|
+
* Supports two patterns:
|
|
5
|
+
* 1. **Multiple Repos**: Separate repos for each project (FE, BE, MOBILE)
|
|
6
|
+
* 2. **Master + Nested**: Master repo (epics) + nested repos (detailed tasks)
|
|
7
|
+
*
|
|
8
|
+
* @module github-multi-project-sync
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Octokit } from '@octokit/rest';
|
|
12
|
+
import { UserStory, getPrimaryProject, ProjectMapping } from '../../../src/utils/project-mapper.js';
|
|
13
|
+
import { parseSpecFile } from '../../../src/utils/spec-splitter.js';
|
|
14
|
+
|
|
15
|
+
export interface GitHubMultiProjectConfig {
|
|
16
|
+
owner: string;
|
|
17
|
+
token: string;
|
|
18
|
+
|
|
19
|
+
// Pattern 1: Multiple repos (simple)
|
|
20
|
+
repos?: string[]; // ['frontend-web', 'backend-api', 'mobile-app']
|
|
21
|
+
|
|
22
|
+
// Pattern 2: Master + nested repos (advanced)
|
|
23
|
+
masterRepo?: string; // 'master-project' (high-level epics)
|
|
24
|
+
nestedRepos?: string[]; // ['frontend-web', 'backend-api', 'mobile-app'] (detailed tasks)
|
|
25
|
+
|
|
26
|
+
// Settings
|
|
27
|
+
masterRepoLevel?: 'epic'; // Master repo contains epics
|
|
28
|
+
nestedRepoLevel?: 'story-task'; // Nested repos contain stories/tasks
|
|
29
|
+
crossLinking?: boolean; // Enable epic → issue links
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GitHubIssue {
|
|
33
|
+
number: number;
|
|
34
|
+
title: string;
|
|
35
|
+
body: string;
|
|
36
|
+
state: 'open' | 'closed';
|
|
37
|
+
html_url: string;
|
|
38
|
+
labels: Array<{ name: string }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SyncResult {
|
|
42
|
+
project: string;
|
|
43
|
+
repo: string;
|
|
44
|
+
issueNumber: number;
|
|
45
|
+
url: string;
|
|
46
|
+
action: 'created' | 'updated' | 'skipped';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* GitHub Multi-Project Sync Client
|
|
51
|
+
*/
|
|
52
|
+
export class GitHubMultiProjectSync {
|
|
53
|
+
private octokit: Octokit;
|
|
54
|
+
private config: GitHubMultiProjectConfig;
|
|
55
|
+
|
|
56
|
+
constructor(config: GitHubMultiProjectConfig) {
|
|
57
|
+
this.config = config;
|
|
58
|
+
this.octokit = new Octokit({ auth: config.token });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sync spec to appropriate GitHub repos based on project mapping
|
|
63
|
+
*
|
|
64
|
+
* @param specPath Path to spec file
|
|
65
|
+
* @returns Array of sync results
|
|
66
|
+
*/
|
|
67
|
+
async syncSpec(specPath: string): Promise<SyncResult[]> {
|
|
68
|
+
const results: SyncResult[] = [];
|
|
69
|
+
|
|
70
|
+
// Parse spec
|
|
71
|
+
const parsedSpec = await parseSpecFile(specPath);
|
|
72
|
+
|
|
73
|
+
// Determine sync pattern
|
|
74
|
+
const isMasterNested = !!this.config.masterRepo && !!this.config.nestedRepos;
|
|
75
|
+
|
|
76
|
+
if (isMasterNested) {
|
|
77
|
+
// Pattern 2: Master + Nested
|
|
78
|
+
results.push(...await this.syncMasterNested(parsedSpec));
|
|
79
|
+
} else if (this.config.repos) {
|
|
80
|
+
// Pattern 1: Multiple Repos
|
|
81
|
+
results.push(...await this.syncMultipleRepos(parsedSpec));
|
|
82
|
+
} else {
|
|
83
|
+
throw new Error('Invalid config: Must specify repos[] or masterRepo+nestedRepos[]');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pattern 1: Sync to multiple repos (simple)
|
|
91
|
+
*
|
|
92
|
+
* Each project → separate repo
|
|
93
|
+
* - FE user stories → company/frontend-web
|
|
94
|
+
* - BE user stories → company/backend-api
|
|
95
|
+
* - MOBILE user stories → company/mobile-app
|
|
96
|
+
*/
|
|
97
|
+
private async syncMultipleRepos(parsedSpec: any): Promise<SyncResult[]> {
|
|
98
|
+
const results: SyncResult[] = [];
|
|
99
|
+
|
|
100
|
+
// Classify user stories by project
|
|
101
|
+
const projectStories = new Map<string, UserStory[]>();
|
|
102
|
+
|
|
103
|
+
for (const userStory of parsedSpec.userStories) {
|
|
104
|
+
const primaryProject = getPrimaryProject(userStory);
|
|
105
|
+
|
|
106
|
+
if (primaryProject) {
|
|
107
|
+
const existing = projectStories.get(primaryProject.projectId) || [];
|
|
108
|
+
existing.push(userStory);
|
|
109
|
+
projectStories.set(primaryProject.projectId, existing);
|
|
110
|
+
} else {
|
|
111
|
+
// No confident match - skip or assign to default repo
|
|
112
|
+
console.warn(`⚠️ No confident project match for ${userStory.id} - skipping`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sync each project to its repo
|
|
117
|
+
for (const [projectId, stories] of projectStories.entries()) {
|
|
118
|
+
const repo = this.findRepoForProject(projectId);
|
|
119
|
+
|
|
120
|
+
if (!repo) {
|
|
121
|
+
console.warn(`⚠️ No GitHub repo configured for project ${projectId} - skipping`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create or update issue for each story
|
|
126
|
+
for (const story of stories) {
|
|
127
|
+
const result = await this.createOrUpdateIssue(repo, story, projectId);
|
|
128
|
+
results.push(result);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pattern 2: Sync to master + nested repos (advanced)
|
|
137
|
+
*
|
|
138
|
+
* - Master repo (epic-level): High-level overview
|
|
139
|
+
* - Nested repos (task-level): Detailed implementation
|
|
140
|
+
*
|
|
141
|
+
* Example:
|
|
142
|
+
* Master (company/master-project):
|
|
143
|
+
* Epic #10: User Authentication
|
|
144
|
+
* → Links to: frontend-web#42, backend-api#15, mobile-app#8
|
|
145
|
+
*
|
|
146
|
+
* Nested (company/frontend-web):
|
|
147
|
+
* Issue #42: Implement Login UI
|
|
148
|
+
* Task 1: Create login component
|
|
149
|
+
* Task 2: Add form validation
|
|
150
|
+
*/
|
|
151
|
+
private async syncMasterNested(parsedSpec: any): Promise<SyncResult[]> {
|
|
152
|
+
const results: SyncResult[] = [];
|
|
153
|
+
|
|
154
|
+
if (!this.config.masterRepo || !this.config.nestedRepos) {
|
|
155
|
+
throw new Error('Master+nested mode requires masterRepo and nestedRepos');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Step 1: Create epic in master repo
|
|
159
|
+
const epicResult = await this.createEpicInMasterRepo(parsedSpec);
|
|
160
|
+
results.push(epicResult);
|
|
161
|
+
|
|
162
|
+
// Step 2: Classify user stories by project
|
|
163
|
+
const projectStories = new Map<string, UserStory[]>();
|
|
164
|
+
|
|
165
|
+
for (const userStory of parsedSpec.userStories) {
|
|
166
|
+
const primaryProject = getPrimaryProject(userStory);
|
|
167
|
+
|
|
168
|
+
if (primaryProject) {
|
|
169
|
+
const existing = projectStories.get(primaryProject.projectId) || [];
|
|
170
|
+
existing.push(userStory);
|
|
171
|
+
projectStories.set(primaryProject.projectId, existing);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Step 3: Create issues in nested repos + link to epic
|
|
176
|
+
for (const [projectId, stories] of projectStories.entries()) {
|
|
177
|
+
const repo = this.findNestedRepoForProject(projectId);
|
|
178
|
+
|
|
179
|
+
if (!repo) {
|
|
180
|
+
console.warn(`⚠️ No nested repo for project ${projectId} - skipping`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const story of stories) {
|
|
185
|
+
const result = await this.createIssueInNestedRepo(repo, story, projectId, epicResult.issueNumber);
|
|
186
|
+
results.push(result);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Step 4: Update epic with links to nested issues
|
|
191
|
+
if (this.config.crossLinking) {
|
|
192
|
+
await this.updateEpicWithLinks(epicResult.issueNumber, results.filter(r => r.repo !== this.config.masterRepo));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Create epic issue in master repo
|
|
200
|
+
*/
|
|
201
|
+
private async createEpicInMasterRepo(parsedSpec: any): Promise<SyncResult> {
|
|
202
|
+
const title = `Epic: ${parsedSpec.metadata.title}`;
|
|
203
|
+
|
|
204
|
+
const body = `# ${parsedSpec.metadata.title}
|
|
205
|
+
|
|
206
|
+
**Status**: ${parsedSpec.metadata.status}
|
|
207
|
+
**Priority**: ${parsedSpec.metadata.priority}
|
|
208
|
+
**Estimated Effort**: ${parsedSpec.metadata.estimatedEffort || parsedSpec.metadata.estimated_effort}
|
|
209
|
+
|
|
210
|
+
## Executive Summary
|
|
211
|
+
|
|
212
|
+
${parsedSpec.executiveSummary}
|
|
213
|
+
|
|
214
|
+
## User Stories (${parsedSpec.userStories.length} total)
|
|
215
|
+
|
|
216
|
+
${parsedSpec.userStories.map((s: UserStory, i: number) => `${i + 1}. ${s.id}: ${s.title}`).join('\n')}
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
📊 **This is a high-level epic** - detailed implementation tracked in nested repos
|
|
221
|
+
🔗 **Links to implementation issues** (will be added automatically)
|
|
222
|
+
|
|
223
|
+
🤖 Auto-generated by SpecWeave
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
const response = await this.octokit.issues.create({
|
|
227
|
+
owner: this.config.owner,
|
|
228
|
+
repo: this.config.masterRepo!,
|
|
229
|
+
title,
|
|
230
|
+
body,
|
|
231
|
+
labels: ['epic', 'specweave']
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
project: 'MASTER',
|
|
236
|
+
repo: this.config.masterRepo!,
|
|
237
|
+
issueNumber: response.data.number,
|
|
238
|
+
url: response.data.html_url,
|
|
239
|
+
action: 'created'
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Create issue in nested repo + link to epic
|
|
245
|
+
*/
|
|
246
|
+
private async createIssueInNestedRepo(
|
|
247
|
+
repo: string,
|
|
248
|
+
userStory: UserStory,
|
|
249
|
+
projectId: string,
|
|
250
|
+
epicNumber?: number
|
|
251
|
+
): Promise<SyncResult> {
|
|
252
|
+
const title = `${userStory.id}: ${userStory.title}`;
|
|
253
|
+
|
|
254
|
+
let body = `# ${userStory.title}
|
|
255
|
+
|
|
256
|
+
${userStory.description}
|
|
257
|
+
|
|
258
|
+
## Acceptance Criteria
|
|
259
|
+
|
|
260
|
+
${userStory.acceptanceCriteria.map((ac, i) => `- [ ] ${ac}`).join('\n')}
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
// Add technical context if present
|
|
264
|
+
if (userStory.technicalContext) {
|
|
265
|
+
body += `\n## Technical Context\n\n${userStory.technicalContext}\n`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Add link to epic if cross-linking enabled
|
|
269
|
+
if (this.config.crossLinking && epicNumber) {
|
|
270
|
+
body += `\n---\n\n📊 Part of Epic: ${this.config.owner}/${this.config.masterRepo}#${epicNumber}\n`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
body += `\n🤖 Auto-generated by SpecWeave\n`;
|
|
274
|
+
|
|
275
|
+
const response = await this.octokit.issues.create({
|
|
276
|
+
owner: this.config.owner,
|
|
277
|
+
repo,
|
|
278
|
+
title,
|
|
279
|
+
body,
|
|
280
|
+
labels: ['story', 'specweave', projectId.toLowerCase()]
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
project: projectId,
|
|
285
|
+
repo,
|
|
286
|
+
issueNumber: response.data.number,
|
|
287
|
+
url: response.data.html_url,
|
|
288
|
+
action: 'created'
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Create or update issue (for simple multi-repo pattern)
|
|
294
|
+
*/
|
|
295
|
+
private async createOrUpdateIssue(repo: string, userStory: UserStory, projectId: string): Promise<SyncResult> {
|
|
296
|
+
const title = `${userStory.id}: ${userStory.title}`;
|
|
297
|
+
|
|
298
|
+
const body = `# ${userStory.title}
|
|
299
|
+
|
|
300
|
+
${userStory.description}
|
|
301
|
+
|
|
302
|
+
## Acceptance Criteria
|
|
303
|
+
|
|
304
|
+
${userStory.acceptanceCriteria.map((ac, i) => `- [ ] ${ac}`).join('\n')}
|
|
305
|
+
|
|
306
|
+
${userStory.technicalContext ? `\n## Technical Context\n\n${userStory.technicalContext}\n` : ''}
|
|
307
|
+
|
|
308
|
+
🤖 Auto-generated by SpecWeave
|
|
309
|
+
`;
|
|
310
|
+
|
|
311
|
+
// Check if issue already exists (search by title)
|
|
312
|
+
const existingIssues = await this.octokit.issues.listForRepo({
|
|
313
|
+
owner: this.config.owner,
|
|
314
|
+
repo,
|
|
315
|
+
labels: 'specweave',
|
|
316
|
+
state: 'all'
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const existing = existingIssues.data.find(issue => issue.title === title);
|
|
320
|
+
|
|
321
|
+
if (existing) {
|
|
322
|
+
// Update existing issue
|
|
323
|
+
const response = await this.octokit.issues.update({
|
|
324
|
+
owner: this.config.owner,
|
|
325
|
+
repo,
|
|
326
|
+
issue_number: existing.number,
|
|
327
|
+
body
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
project: projectId,
|
|
332
|
+
repo,
|
|
333
|
+
issueNumber: response.data.number,
|
|
334
|
+
url: response.data.html_url,
|
|
335
|
+
action: 'updated'
|
|
336
|
+
};
|
|
337
|
+
} else {
|
|
338
|
+
// Create new issue
|
|
339
|
+
const response = await this.octokit.issues.create({
|
|
340
|
+
owner: this.config.owner,
|
|
341
|
+
repo,
|
|
342
|
+
title,
|
|
343
|
+
body,
|
|
344
|
+
labels: ['specweave', projectId.toLowerCase()]
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
project: projectId,
|
|
349
|
+
repo,
|
|
350
|
+
issueNumber: response.data.number,
|
|
351
|
+
url: response.data.html_url,
|
|
352
|
+
action: 'created'
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Update epic with links to nested issues
|
|
359
|
+
*/
|
|
360
|
+
private async updateEpicWithLinks(epicNumber: number, nestedResults: SyncResult[]): Promise<void> {
|
|
361
|
+
if (!this.config.masterRepo) return;
|
|
362
|
+
|
|
363
|
+
const linksSection = `\n\n## Implementation Issues\n\n${nestedResults
|
|
364
|
+
.map(r => `- ${r.project}: ${this.config.owner}/${r.repo}#${r.issueNumber} - ${r.url}`)
|
|
365
|
+
.join('\n')}\n`;
|
|
366
|
+
|
|
367
|
+
// Get current epic body
|
|
368
|
+
const epic = await this.octokit.issues.get({
|
|
369
|
+
owner: this.config.owner,
|
|
370
|
+
repo: this.config.masterRepo,
|
|
371
|
+
issue_number: epicNumber
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Append links section
|
|
375
|
+
const updatedBody = epic.data.body + linksSection;
|
|
376
|
+
|
|
377
|
+
// Update epic
|
|
378
|
+
await this.octokit.issues.update({
|
|
379
|
+
owner: this.config.owner,
|
|
380
|
+
repo: this.config.masterRepo,
|
|
381
|
+
issue_number: epicNumber,
|
|
382
|
+
body: updatedBody
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Find GitHub repo for project ID
|
|
388
|
+
*
|
|
389
|
+
* Maps project IDs to repo names:
|
|
390
|
+
* - FE → frontend-web
|
|
391
|
+
* - BE → backend-api
|
|
392
|
+
* - MOBILE → mobile-app
|
|
393
|
+
*/
|
|
394
|
+
private findRepoForProject(projectId: string): string | undefined {
|
|
395
|
+
if (!this.config.repos) return undefined;
|
|
396
|
+
|
|
397
|
+
// Try exact match first
|
|
398
|
+
let match = this.config.repos.find(repo => repo.toLowerCase().includes(projectId.toLowerCase()));
|
|
399
|
+
|
|
400
|
+
if (!match) {
|
|
401
|
+
// Try fuzzy match (FE → frontend, BE → backend, MOBILE → mobile)
|
|
402
|
+
const fuzzyMap: Record<string, string[]> = {
|
|
403
|
+
FE: ['frontend', 'web', 'ui', 'client'],
|
|
404
|
+
BE: ['backend', 'api', 'server'],
|
|
405
|
+
MOBILE: ['mobile', 'app', 'ios', 'android'],
|
|
406
|
+
INFRA: ['infra', 'infrastructure', 'devops', 'platform']
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const keywords = fuzzyMap[projectId] || [];
|
|
410
|
+
|
|
411
|
+
match = this.config.repos.find(repo =>
|
|
412
|
+
keywords.some(keyword => repo.toLowerCase().includes(keyword))
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return match;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Find nested repo for project ID (same logic as findRepoForProject)
|
|
421
|
+
*/
|
|
422
|
+
private findNestedRepoForProject(projectId: string): string | undefined {
|
|
423
|
+
if (!this.config.nestedRepos) return undefined;
|
|
424
|
+
|
|
425
|
+
// Reuse same logic
|
|
426
|
+
const tempConfig = { ...this.config, repos: this.config.nestedRepos };
|
|
427
|
+
const tempSync = new GitHubMultiProjectSync(tempConfig);
|
|
428
|
+
return tempSync.findRepoForProject(projectId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Format sync results for display
|
|
434
|
+
*/
|
|
435
|
+
export function formatSyncResults(results: SyncResult[]): string {
|
|
436
|
+
const lines: string[] = [];
|
|
437
|
+
|
|
438
|
+
lines.push('📊 GitHub Multi-Project Sync Results:\n');
|
|
439
|
+
|
|
440
|
+
const byProject = new Map<string, SyncResult[]>();
|
|
441
|
+
|
|
442
|
+
for (const result of results) {
|
|
443
|
+
const existing = byProject.get(result.project) || [];
|
|
444
|
+
existing.push(result);
|
|
445
|
+
byProject.set(result.project, existing);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
for (const [project, projectResults] of byProject.entries()) {
|
|
449
|
+
lines.push(`\n**${project}**:`);
|
|
450
|
+
|
|
451
|
+
for (const result of projectResults) {
|
|
452
|
+
const icon = result.action === 'created' ? '✅' : result.action === 'updated' ? '🔄' : '⏭️';
|
|
453
|
+
lines.push(` ${icon} ${result.repo}#${result.issueNumber} (${result.action})`);
|
|
454
|
+
lines.push(` ${result.url}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
lines.push(`\n✅ Total: ${results.length} issues synced\n`);
|
|
459
|
+
|
|
460
|
+
return lines.join('\n');
|
|
461
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { JiraClient } from "../../../src/integrations/jira/jira-client.js";
|
|
2
|
+
import {
|
|
3
|
+
suggestJiraItemType,
|
|
4
|
+
mapUserStoryToProjects
|
|
5
|
+
} from "../../../src/utils/project-mapper.js";
|
|
6
|
+
import { parseSpecFile } from "../../../src/utils/spec-splitter.js";
|
|
7
|
+
class JiraMultiProjectSync {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.client = new JiraClient({
|
|
11
|
+
domain: config.domain,
|
|
12
|
+
email: config.email,
|
|
13
|
+
apiToken: config.apiToken
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Sync spec to JIRA projects with intelligent mapping
|
|
18
|
+
*
|
|
19
|
+
* @param specPath Path to spec file
|
|
20
|
+
* @returns Array of sync results
|
|
21
|
+
*/
|
|
22
|
+
async syncSpec(specPath) {
|
|
23
|
+
const results = [];
|
|
24
|
+
const parsedSpec = await parseSpecFile(specPath);
|
|
25
|
+
const epicsByProject = /* @__PURE__ */ new Map();
|
|
26
|
+
if (this.config.autoCreateEpics !== false) {
|
|
27
|
+
for (const project of this.config.projects) {
|
|
28
|
+
const epicResult = await this.createEpicForProject(parsedSpec, project);
|
|
29
|
+
epicsByProject.set(project, epicResult.issueKey);
|
|
30
|
+
results.push(epicResult);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const projectStories = /* @__PURE__ */ new Map();
|
|
34
|
+
for (const userStory of parsedSpec.userStories) {
|
|
35
|
+
if (this.config.intelligentMapping !== false) {
|
|
36
|
+
const mappings = mapUserStoryToProjects(userStory);
|
|
37
|
+
if (mappings.length > 0 && mappings[0].confidence >= 0.3) {
|
|
38
|
+
const primary = mappings[0];
|
|
39
|
+
const existing = projectStories.get(primary.projectId) || [];
|
|
40
|
+
existing.push({ story: userStory, confidence: primary.confidence });
|
|
41
|
+
projectStories.set(primary.projectId, existing);
|
|
42
|
+
} else {
|
|
43
|
+
console.warn(`\u26A0\uFE0F Low confidence for ${userStory.id} (${(mappings[0]?.confidence || 0) * 100}%) - assigning to ${this.config.projects[0]}`);
|
|
44
|
+
const fallback = this.config.projects[0];
|
|
45
|
+
const existing = projectStories.get(fallback) || [];
|
|
46
|
+
existing.push({ story: userStory, confidence: mappings[0]?.confidence || 0 });
|
|
47
|
+
projectStories.set(fallback, existing);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
const projectHint = this.extractProjectHint(userStory);
|
|
51
|
+
if (projectHint && this.config.projects.includes(projectHint)) {
|
|
52
|
+
const existing = projectStories.get(projectHint) || [];
|
|
53
|
+
existing.push({ story: userStory, confidence: 1 });
|
|
54
|
+
projectStories.set(projectHint, existing);
|
|
55
|
+
} else {
|
|
56
|
+
const fallback = this.config.projects[0];
|
|
57
|
+
const existing = projectStories.get(fallback) || [];
|
|
58
|
+
existing.push({ story: userStory, confidence: 0 });
|
|
59
|
+
projectStories.set(fallback, existing);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [projectId, stories] of projectStories.entries()) {
|
|
64
|
+
const epicKey = epicsByProject.get(projectId);
|
|
65
|
+
for (const { story, confidence } of stories) {
|
|
66
|
+
const result = await this.createIssueForUserStory(projectId, story, epicKey, confidence);
|
|
67
|
+
results.push(result);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create epic for project
|
|
74
|
+
*/
|
|
75
|
+
async createEpicForProject(parsedSpec, projectId) {
|
|
76
|
+
const summary = `${parsedSpec.metadata.title} - ${projectId}`;
|
|
77
|
+
const description = `h2. ${projectId} Implementation
|
|
78
|
+
|
|
79
|
+
*Status*: ${parsedSpec.metadata.status}
|
|
80
|
+
*Priority*: ${parsedSpec.metadata.priority}
|
|
81
|
+
*Estimated Effort*: ${parsedSpec.metadata.estimatedEffort || parsedSpec.metadata.estimated_effort}
|
|
82
|
+
|
|
83
|
+
h3. Executive Summary
|
|
84
|
+
|
|
85
|
+
${parsedSpec.executiveSummary}
|
|
86
|
+
|
|
87
|
+
h3. Scope (${projectId})
|
|
88
|
+
|
|
89
|
+
This epic covers all ${projectId}-related user stories for "${parsedSpec.metadata.title}".
|
|
90
|
+
|
|
91
|
+
User stories will be added as child issues.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
\u{1F916} Auto-generated by SpecWeave
|
|
96
|
+
`;
|
|
97
|
+
const issue = await this.client.createIssue({
|
|
98
|
+
project: { key: projectId },
|
|
99
|
+
summary,
|
|
100
|
+
description,
|
|
101
|
+
issuetype: { name: this.config.itemTypeMapping?.epic || "Epic" }
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
project: projectId,
|
|
105
|
+
issueKey: issue.key,
|
|
106
|
+
issueType: "Epic",
|
|
107
|
+
summary,
|
|
108
|
+
url: `https://${this.config.domain}/browse/${issue.key}`,
|
|
109
|
+
action: "created"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Create issue for user story
|
|
114
|
+
*/
|
|
115
|
+
async createIssueForUserStory(projectId, userStory, epicKey, confidence) {
|
|
116
|
+
const summary = `${userStory.id}: ${userStory.title}`;
|
|
117
|
+
const itemType = suggestJiraItemType(userStory);
|
|
118
|
+
const description = `h3. ${userStory.title}
|
|
119
|
+
|
|
120
|
+
${userStory.description}
|
|
121
|
+
|
|
122
|
+
h4. Acceptance Criteria
|
|
123
|
+
|
|
124
|
+
${userStory.acceptanceCriteria.map((ac, i) => `* ${ac}`).join("\n")}
|
|
125
|
+
|
|
126
|
+
${userStory.technicalContext ? `
|
|
127
|
+
h4. Technical Context
|
|
128
|
+
|
|
129
|
+
${userStory.technicalContext}
|
|
130
|
+
` : ""}
|
|
131
|
+
|
|
132
|
+
${confidence !== void 0 ? `
|
|
133
|
+
_Classification confidence: ${(confidence * 100).toFixed(0)}%_
|
|
134
|
+
` : ""}
|
|
135
|
+
|
|
136
|
+
\u{1F916} Auto-generated by SpecWeave
|
|
137
|
+
`;
|
|
138
|
+
const issueData = {
|
|
139
|
+
project: { key: projectId },
|
|
140
|
+
summary,
|
|
141
|
+
description,
|
|
142
|
+
issuetype: { name: this.getIssueTypeName(itemType) }
|
|
143
|
+
};
|
|
144
|
+
if (epicKey) {
|
|
145
|
+
issueData.parent = { key: epicKey };
|
|
146
|
+
}
|
|
147
|
+
const issue = await this.client.createIssue(issueData);
|
|
148
|
+
return {
|
|
149
|
+
project: projectId,
|
|
150
|
+
issueKey: issue.key,
|
|
151
|
+
issueType: itemType,
|
|
152
|
+
summary,
|
|
153
|
+
url: `https://${this.config.domain}/browse/${issue.key}`,
|
|
154
|
+
action: "created",
|
|
155
|
+
confidence
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Get JIRA issue type name from suggested type
|
|
160
|
+
*/
|
|
161
|
+
getIssueTypeName(itemType) {
|
|
162
|
+
const mapping = this.config.itemTypeMapping || {};
|
|
163
|
+
switch (itemType) {
|
|
164
|
+
case "Epic":
|
|
165
|
+
return mapping.epic || "Epic";
|
|
166
|
+
case "Story":
|
|
167
|
+
return mapping.story || "Story";
|
|
168
|
+
case "Task":
|
|
169
|
+
return mapping.task || "Task";
|
|
170
|
+
case "Subtask":
|
|
171
|
+
return mapping.subtask || "Sub-task";
|
|
172
|
+
default:
|
|
173
|
+
return "Story";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Extract project hint from user story (manual override)
|
|
178
|
+
*
|
|
179
|
+
* Looks for hints like:
|
|
180
|
+
* - Title: "[FE] Login UI"
|
|
181
|
+
* - Description: "Project: FE"
|
|
182
|
+
* - Technical context: "Frontend: React"
|
|
183
|
+
*/
|
|
184
|
+
extractProjectHint(userStory) {
|
|
185
|
+
const titleMatch = userStory.title.match(/^\[([A-Z]+)\]/);
|
|
186
|
+
if (titleMatch) {
|
|
187
|
+
return titleMatch[1];
|
|
188
|
+
}
|
|
189
|
+
const descMatch = userStory.description.match(/Project:\s*([A-Z]+)/i);
|
|
190
|
+
if (descMatch) {
|
|
191
|
+
return descMatch[1].toUpperCase();
|
|
192
|
+
}
|
|
193
|
+
return void 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function formatJiraSyncResults(results) {
|
|
197
|
+
const lines = [];
|
|
198
|
+
lines.push("\u{1F4CA} JIRA Multi-Project Sync Results:\n");
|
|
199
|
+
const byProject = /* @__PURE__ */ new Map();
|
|
200
|
+
for (const result of results) {
|
|
201
|
+
const existing = byProject.get(result.project) || [];
|
|
202
|
+
existing.push(result);
|
|
203
|
+
byProject.set(result.project, existing);
|
|
204
|
+
}
|
|
205
|
+
for (const [project, projectResults] of byProject.entries()) {
|
|
206
|
+
lines.push(`
|
|
207
|
+
**JIRA Project ${project}**:`);
|
|
208
|
+
for (const result of projectResults) {
|
|
209
|
+
const icon = result.action === "created" ? "\u2705" : result.action === "updated" ? "\u{1F504}" : "\u23ED\uFE0F";
|
|
210
|
+
const confidence = result.confidence !== void 0 ? ` (${(result.confidence * 100).toFixed(0)}% confidence)` : "";
|
|
211
|
+
lines.push(` ${icon} ${result.issueKey} [${result.issueType}]: ${result.summary}${confidence}`);
|
|
212
|
+
lines.push(` ${result.url}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
lines.push(`
|
|
216
|
+
\u2705 Total: ${results.length} issues synced
|
|
217
|
+
`);
|
|
218
|
+
const epicCount = results.filter((r) => r.issueType === "Epic").length;
|
|
219
|
+
const storyCount = results.filter((r) => r.issueType === "Story").length;
|
|
220
|
+
const taskCount = results.filter((r) => r.issueType === "Task").length;
|
|
221
|
+
const subtaskCount = results.filter((r) => r.issueType === "Subtask").length;
|
|
222
|
+
lines.push("\u{1F4C8} Item Type Distribution:");
|
|
223
|
+
if (epicCount > 0) lines.push(` - Epics: ${epicCount}`);
|
|
224
|
+
if (storyCount > 0) lines.push(` - Stories: ${storyCount}`);
|
|
225
|
+
if (taskCount > 0) lines.push(` - Tasks: ${taskCount}`);
|
|
226
|
+
if (subtaskCount > 0) lines.push(` - Subtasks: ${subtaskCount}`);
|
|
227
|
+
return lines.join("\n");
|
|
228
|
+
}
|
|
229
|
+
async function validateJiraProjects(client, projectKeys) {
|
|
230
|
+
const missing = [];
|
|
231
|
+
for (const key of projectKeys) {
|
|
232
|
+
try {
|
|
233
|
+
await client.getProject(key);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
missing.push(key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return missing;
|
|
239
|
+
}
|
|
240
|
+
export {
|
|
241
|
+
JiraMultiProjectSync,
|
|
242
|
+
formatJiraSyncResults,
|
|
243
|
+
validateJiraProjects
|
|
244
|
+
};
|