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,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JIRA Multi-Project Sync with Intelligent Mapping
|
|
3
|
+
*
|
|
4
|
+
* Automatically maps user stories to JIRA projects based on content analysis:
|
|
5
|
+
* - FE user stories → JIRA Project FE
|
|
6
|
+
* - BE user stories → JIRA Project BE
|
|
7
|
+
* - MOBILE user stories → JIRA Project MOBILE
|
|
8
|
+
*
|
|
9
|
+
* Supports hierarchical issue types:
|
|
10
|
+
* - Epic (> 13 story points): Large feature area
|
|
11
|
+
* - Story (3-13 story points): Standard user story
|
|
12
|
+
* - Task (1-2 story points): Small implementation task
|
|
13
|
+
* - Subtask (< 1 story point): Granular work item
|
|
14
|
+
*
|
|
15
|
+
* @module jira-multi-project-sync
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { JiraClient, JiraIssue } from '../../../src/integrations/jira/jira-client.js';
|
|
19
|
+
import {
|
|
20
|
+
UserStory,
|
|
21
|
+
getPrimaryProject,
|
|
22
|
+
suggestJiraItemType,
|
|
23
|
+
mapUserStoryToProjects
|
|
24
|
+
} from '../../../src/utils/project-mapper.js';
|
|
25
|
+
import { parseSpecFile } from '../../../src/utils/spec-splitter.js';
|
|
26
|
+
|
|
27
|
+
export interface JiraMultiProjectConfig {
|
|
28
|
+
domain: string;
|
|
29
|
+
email: string;
|
|
30
|
+
apiToken: string;
|
|
31
|
+
|
|
32
|
+
// Simple: List of JIRA projects
|
|
33
|
+
projects: string[]; // ['FE', 'BE', 'MOBILE']
|
|
34
|
+
|
|
35
|
+
// Item type mapping (optional)
|
|
36
|
+
itemTypeMapping?: {
|
|
37
|
+
epic: string; // Default: 'Epic'
|
|
38
|
+
story: string; // Default: 'Story'
|
|
39
|
+
task: string; // Default: 'Task'
|
|
40
|
+
subtask: string; // Default: 'Sub-task'
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Settings
|
|
44
|
+
intelligentMapping?: boolean; // Default: true (auto-classify user stories)
|
|
45
|
+
autoCreateEpics?: boolean; // Default: true (create epic per project)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface JiraSyncResult {
|
|
49
|
+
project: string;
|
|
50
|
+
issueKey: string;
|
|
51
|
+
issueType: string;
|
|
52
|
+
summary: string;
|
|
53
|
+
url: string;
|
|
54
|
+
action: 'created' | 'updated' | 'skipped';
|
|
55
|
+
confidence?: number; // Classification confidence (0.0-1.0)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* JIRA Multi-Project Sync Client
|
|
60
|
+
*/
|
|
61
|
+
export class JiraMultiProjectSync {
|
|
62
|
+
private client: JiraClient;
|
|
63
|
+
private config: JiraMultiProjectConfig;
|
|
64
|
+
|
|
65
|
+
constructor(config: JiraMultiProjectConfig) {
|
|
66
|
+
this.config = config;
|
|
67
|
+
this.client = new JiraClient({
|
|
68
|
+
domain: config.domain,
|
|
69
|
+
email: config.email,
|
|
70
|
+
apiToken: config.apiToken
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sync spec to JIRA projects with intelligent mapping
|
|
76
|
+
*
|
|
77
|
+
* @param specPath Path to spec file
|
|
78
|
+
* @returns Array of sync results
|
|
79
|
+
*/
|
|
80
|
+
async syncSpec(specPath: string): Promise<JiraSyncResult[]> {
|
|
81
|
+
const results: JiraSyncResult[] = [];
|
|
82
|
+
|
|
83
|
+
// Parse spec
|
|
84
|
+
const parsedSpec = await parseSpecFile(specPath);
|
|
85
|
+
|
|
86
|
+
// Step 1: Create epic per project (if enabled)
|
|
87
|
+
const epicsByProject = new Map<string, string>(); // projectId → epicKey
|
|
88
|
+
|
|
89
|
+
if (this.config.autoCreateEpics !== false) {
|
|
90
|
+
for (const project of this.config.projects) {
|
|
91
|
+
const epicResult = await this.createEpicForProject(parsedSpec, project);
|
|
92
|
+
epicsByProject.set(project, epicResult.issueKey);
|
|
93
|
+
results.push(epicResult);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Step 2: Classify user stories by project
|
|
98
|
+
const projectStories = new Map<string, Array<{ story: UserStory; confidence: number }>>();
|
|
99
|
+
|
|
100
|
+
for (const userStory of parsedSpec.userStories) {
|
|
101
|
+
if (this.config.intelligentMapping !== false) {
|
|
102
|
+
// Intelligent mapping (default)
|
|
103
|
+
const mappings = mapUserStoryToProjects(userStory);
|
|
104
|
+
|
|
105
|
+
if (mappings.length > 0 && mappings[0].confidence >= 0.3) {
|
|
106
|
+
const primary = mappings[0];
|
|
107
|
+
const existing = projectStories.get(primary.projectId) || [];
|
|
108
|
+
existing.push({ story: userStory, confidence: primary.confidence });
|
|
109
|
+
projectStories.set(primary.projectId, existing);
|
|
110
|
+
} else {
|
|
111
|
+
// No confident match - assign to first project or skip
|
|
112
|
+
console.warn(`⚠️ Low confidence for ${userStory.id} (${(mappings[0]?.confidence || 0) * 100}%) - assigning to ${this.config.projects[0]}`);
|
|
113
|
+
const fallback = this.config.projects[0];
|
|
114
|
+
const existing = projectStories.get(fallback) || [];
|
|
115
|
+
existing.push({ story: userStory, confidence: mappings[0]?.confidence || 0 });
|
|
116
|
+
projectStories.set(fallback, existing);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Manual mapping (user specified project in frontmatter)
|
|
120
|
+
const projectHint = this.extractProjectHint(userStory);
|
|
121
|
+
|
|
122
|
+
if (projectHint && this.config.projects.includes(projectHint)) {
|
|
123
|
+
const existing = projectStories.get(projectHint) || [];
|
|
124
|
+
existing.push({ story: userStory, confidence: 1.0 });
|
|
125
|
+
projectStories.set(projectHint, existing);
|
|
126
|
+
} else {
|
|
127
|
+
// No hint - assign to first project
|
|
128
|
+
const fallback = this.config.projects[0];
|
|
129
|
+
const existing = projectStories.get(fallback) || [];
|
|
130
|
+
existing.push({ story: userStory, confidence: 0 });
|
|
131
|
+
projectStories.set(fallback, existing);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Step 3: Create issues in each project
|
|
137
|
+
for (const [projectId, stories] of projectStories.entries()) {
|
|
138
|
+
const epicKey = epicsByProject.get(projectId);
|
|
139
|
+
|
|
140
|
+
for (const { story, confidence } of stories) {
|
|
141
|
+
const result = await this.createIssueForUserStory(projectId, story, epicKey, confidence);
|
|
142
|
+
results.push(result);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create epic for project
|
|
151
|
+
*/
|
|
152
|
+
private async createEpicForProject(parsedSpec: any, projectId: string): Promise<JiraSyncResult> {
|
|
153
|
+
const summary = `${parsedSpec.metadata.title} - ${projectId}`;
|
|
154
|
+
|
|
155
|
+
const description = `h2. ${projectId} Implementation
|
|
156
|
+
|
|
157
|
+
*Status*: ${parsedSpec.metadata.status}
|
|
158
|
+
*Priority*: ${parsedSpec.metadata.priority}
|
|
159
|
+
*Estimated Effort*: ${parsedSpec.metadata.estimatedEffort || parsedSpec.metadata.estimated_effort}
|
|
160
|
+
|
|
161
|
+
h3. Executive Summary
|
|
162
|
+
|
|
163
|
+
${parsedSpec.executiveSummary}
|
|
164
|
+
|
|
165
|
+
h3. Scope (${projectId})
|
|
166
|
+
|
|
167
|
+
This epic covers all ${projectId}-related user stories for "${parsedSpec.metadata.title}".
|
|
168
|
+
|
|
169
|
+
User stories will be added as child issues.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
🤖 Auto-generated by SpecWeave
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
const issue = await this.client.createIssue({
|
|
177
|
+
project: { key: projectId },
|
|
178
|
+
summary,
|
|
179
|
+
description,
|
|
180
|
+
issuetype: { name: this.config.itemTypeMapping?.epic || 'Epic' }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
project: projectId,
|
|
185
|
+
issueKey: issue.key,
|
|
186
|
+
issueType: 'Epic',
|
|
187
|
+
summary,
|
|
188
|
+
url: `https://${this.config.domain}/browse/${issue.key}`,
|
|
189
|
+
action: 'created'
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create issue for user story
|
|
195
|
+
*/
|
|
196
|
+
private async createIssueForUserStory(
|
|
197
|
+
projectId: string,
|
|
198
|
+
userStory: UserStory,
|
|
199
|
+
epicKey?: string,
|
|
200
|
+
confidence?: number
|
|
201
|
+
): Promise<JiraSyncResult> {
|
|
202
|
+
const summary = `${userStory.id}: ${userStory.title}`;
|
|
203
|
+
|
|
204
|
+
// Determine issue type based on story points
|
|
205
|
+
const itemType = suggestJiraItemType(userStory);
|
|
206
|
+
|
|
207
|
+
const description = `h3. ${userStory.title}
|
|
208
|
+
|
|
209
|
+
${userStory.description}
|
|
210
|
+
|
|
211
|
+
h4. Acceptance Criteria
|
|
212
|
+
|
|
213
|
+
${userStory.acceptanceCriteria.map((ac, i) => `* ${ac}`).join('\n')}
|
|
214
|
+
|
|
215
|
+
${userStory.technicalContext ? `\nh4. Technical Context\n\n${userStory.technicalContext}\n` : ''}
|
|
216
|
+
|
|
217
|
+
${confidence !== undefined ? `\n_Classification confidence: ${(confidence * 100).toFixed(0)}%_\n` : ''}
|
|
218
|
+
|
|
219
|
+
🤖 Auto-generated by SpecWeave
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
const issueData: any = {
|
|
223
|
+
project: { key: projectId },
|
|
224
|
+
summary,
|
|
225
|
+
description,
|
|
226
|
+
issuetype: { name: this.getIssueTypeName(itemType) }
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Link to epic if provided
|
|
230
|
+
if (epicKey) {
|
|
231
|
+
issueData.parent = { key: epicKey };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const issue = await this.client.createIssue(issueData);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
project: projectId,
|
|
238
|
+
issueKey: issue.key,
|
|
239
|
+
issueType: itemType,
|
|
240
|
+
summary,
|
|
241
|
+
url: `https://${this.config.domain}/browse/${issue.key}`,
|
|
242
|
+
action: 'created',
|
|
243
|
+
confidence
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get JIRA issue type name from suggested type
|
|
249
|
+
*/
|
|
250
|
+
private getIssueTypeName(itemType: 'Epic' | 'Story' | 'Task' | 'Subtask'): string {
|
|
251
|
+
const mapping = this.config.itemTypeMapping || {};
|
|
252
|
+
|
|
253
|
+
switch (itemType) {
|
|
254
|
+
case 'Epic':
|
|
255
|
+
return mapping.epic || 'Epic';
|
|
256
|
+
case 'Story':
|
|
257
|
+
return mapping.story || 'Story';
|
|
258
|
+
case 'Task':
|
|
259
|
+
return mapping.task || 'Task';
|
|
260
|
+
case 'Subtask':
|
|
261
|
+
return mapping.subtask || 'Sub-task';
|
|
262
|
+
default:
|
|
263
|
+
return 'Story';
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Extract project hint from user story (manual override)
|
|
269
|
+
*
|
|
270
|
+
* Looks for hints like:
|
|
271
|
+
* - Title: "[FE] Login UI"
|
|
272
|
+
* - Description: "Project: FE"
|
|
273
|
+
* - Technical context: "Frontend: React"
|
|
274
|
+
*/
|
|
275
|
+
private extractProjectHint(userStory: UserStory): string | undefined {
|
|
276
|
+
// Check title for [PROJECT] prefix
|
|
277
|
+
const titleMatch = userStory.title.match(/^\[([A-Z]+)\]/);
|
|
278
|
+
if (titleMatch) {
|
|
279
|
+
return titleMatch[1];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Check description for "Project: XXX"
|
|
283
|
+
const descMatch = userStory.description.match(/Project:\s*([A-Z]+)/i);
|
|
284
|
+
if (descMatch) {
|
|
285
|
+
return descMatch[1].toUpperCase();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Format JIRA sync results for display
|
|
294
|
+
*/
|
|
295
|
+
export function formatJiraSyncResults(results: JiraSyncResult[]): string {
|
|
296
|
+
const lines: string[] = [];
|
|
297
|
+
|
|
298
|
+
lines.push('📊 JIRA Multi-Project Sync Results:\n');
|
|
299
|
+
|
|
300
|
+
const byProject = new Map<string, JiraSyncResult[]>();
|
|
301
|
+
|
|
302
|
+
for (const result of results) {
|
|
303
|
+
const existing = byProject.get(result.project) || [];
|
|
304
|
+
existing.push(result);
|
|
305
|
+
byProject.set(result.project, existing);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const [project, projectResults] of byProject.entries()) {
|
|
309
|
+
lines.push(`\n**JIRA Project ${project}**:`);
|
|
310
|
+
|
|
311
|
+
for (const result of projectResults) {
|
|
312
|
+
const icon = result.action === 'created' ? '✅' : result.action === 'updated' ? '🔄' : '⏭️';
|
|
313
|
+
const confidence = result.confidence !== undefined ? ` (${(result.confidence * 100).toFixed(0)}% confidence)` : '';
|
|
314
|
+
lines.push(` ${icon} ${result.issueKey} [${result.issueType}]: ${result.summary}${confidence}`);
|
|
315
|
+
lines.push(` ${result.url}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
lines.push(`\n✅ Total: ${results.length} issues synced\n`);
|
|
320
|
+
|
|
321
|
+
// Show classification summary
|
|
322
|
+
const epicCount = results.filter(r => r.issueType === 'Epic').length;
|
|
323
|
+
const storyCount = results.filter(r => r.issueType === 'Story').length;
|
|
324
|
+
const taskCount = results.filter(r => r.issueType === 'Task').length;
|
|
325
|
+
const subtaskCount = results.filter(r => r.issueType === 'Subtask').length;
|
|
326
|
+
|
|
327
|
+
lines.push('📈 Item Type Distribution:');
|
|
328
|
+
if (epicCount > 0) lines.push(` - Epics: ${epicCount}`);
|
|
329
|
+
if (storyCount > 0) lines.push(` - Stories: ${storyCount}`);
|
|
330
|
+
if (taskCount > 0) lines.push(` - Tasks: ${taskCount}`);
|
|
331
|
+
if (subtaskCount > 0) lines.push(` - Subtasks: ${subtaskCount}`);
|
|
332
|
+
|
|
333
|
+
return lines.join('\n');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Validate JIRA projects exist
|
|
338
|
+
*
|
|
339
|
+
* @param client JIRA client
|
|
340
|
+
* @param projectKeys Array of project keys to validate
|
|
341
|
+
* @returns Validation results (missing projects)
|
|
342
|
+
*/
|
|
343
|
+
export async function validateJiraProjects(
|
|
344
|
+
client: JiraClient,
|
|
345
|
+
projectKeys: string[]
|
|
346
|
+
): Promise<string[]> {
|
|
347
|
+
const missing: string[] = [];
|
|
348
|
+
|
|
349
|
+
for (const key of projectKeys) {
|
|
350
|
+
try {
|
|
351
|
+
await client.getProject(key);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
missing.push(key);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return missing;
|
|
358
|
+
}
|