specweave 0.8.17 ā 0.8.19
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 +119 -27
- package/README.md +11 -3
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +74 -20
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrate-to-profiles.js +2 -2
- package/dist/cli/commands/migrate-to-profiles.js.map +1 -1
- package/dist/cli/helpers/issue-tracker/index.d.ts.map +1 -1
- package/dist/cli/helpers/issue-tracker/index.js +8 -3
- package/dist/cli/helpers/issue-tracker/index.js.map +1 -1
- package/dist/cli/helpers/issue-tracker/utils.d.ts +5 -2
- package/dist/cli/helpers/issue-tracker/utils.d.ts.map +1 -1
- package/dist/cli/helpers/issue-tracker/utils.js +13 -6
- package/dist/cli/helpers/issue-tracker/utils.js.map +1 -1
- package/dist/core/sync/bidirectional-engine.d.ts +110 -0
- package/dist/core/sync/bidirectional-engine.d.ts.map +1 -0
- package/dist/core/sync/bidirectional-engine.js +356 -0
- package/dist/core/sync/bidirectional-engine.js.map +1 -0
- package/dist/utils/agents-md-compiler.js +2 -2
- package/dist/utils/env-multi-project-parser.d.ts +210 -0
- package/dist/utils/env-multi-project-parser.d.ts.map +1 -0
- package/dist/utils/env-multi-project-parser.js +406 -0
- package/dist/utils/env-multi-project-parser.js.map +1 -0
- package/package.json +1 -1
- package/plugins/specweave/hooks/post-first-increment.sh +79 -0
- package/plugins/specweave/skills/increment-planner/SKILL.md +50 -16
- package/plugins/specweave/skills/plugin-expert/SKILL.md +344 -0
- package/plugins/specweave/skills/specweave-framework/SKILL.md +2 -2
- package/plugins/specweave/skills/translator/SKILL.md +29 -0
- package/plugins/specweave/skills/translator/SKILL.md.bak +172 -0
- package/plugins/specweave-jira/lib/project-selector.ts +323 -0
- package/plugins/specweave-jira/lib/reorganization-detector.ts +359 -0
- package/plugins/specweave-jira/lib/setup-wizard.ts +256 -0
- package/src/templates/.gitignore.template +1 -0
- package/plugins/specweave-jira/lib/jira-client-v2.ts +0 -529
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jira Project Selector with Pagination
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Fetches all Jira projects via API
|
|
6
|
+
* - Interactive multi-select with search
|
|
7
|
+
* - Manual project key entry (comma-separated)
|
|
8
|
+
* - Handles large project lists (50+ projects)
|
|
9
|
+
* - Validates project keys
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import inquirer from 'inquirer';
|
|
13
|
+
import { JiraClient } from '../../../src/integrations/jira/jira-client.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export interface ProjectSelectionResult {
|
|
20
|
+
selectedKeys: string[];
|
|
21
|
+
method: 'interactive' | 'manual' | 'all';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProjectSelectorOptions {
|
|
25
|
+
/** Allow manual entry of project keys */
|
|
26
|
+
allowManualEntry?: boolean;
|
|
27
|
+
|
|
28
|
+
/** Allow "Select All" option */
|
|
29
|
+
allowSelectAll?: boolean;
|
|
30
|
+
|
|
31
|
+
/** Pre-select these project keys */
|
|
32
|
+
preSelected?: string[];
|
|
33
|
+
|
|
34
|
+
/** Minimum projects to select (0 = optional) */
|
|
35
|
+
minSelection?: number;
|
|
36
|
+
|
|
37
|
+
/** Maximum projects to select (undefined = unlimited) */
|
|
38
|
+
maxSelection?: number;
|
|
39
|
+
|
|
40
|
+
/** Page size for pagination */
|
|
41
|
+
pageSize?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Jira Project Fetching
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fetch all Jira projects from API
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchAllJiraProjects(
|
|
52
|
+
client: JiraClient
|
|
53
|
+
): Promise<any[]> {
|
|
54
|
+
console.log('š Fetching Jira projects...');
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const projects = await client.getProjects();
|
|
58
|
+
|
|
59
|
+
console.log(`ā
Found ${projects.length} Jira projects\n`);
|
|
60
|
+
|
|
61
|
+
return projects;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('ā Failed to fetch Jira projects:', (error as Error).message);
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Interactive Project Selection
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Interactive project selector with search and pagination
|
|
74
|
+
*/
|
|
75
|
+
export async function selectJiraProjects(
|
|
76
|
+
client: JiraClient,
|
|
77
|
+
options: ProjectSelectorOptions = {}
|
|
78
|
+
): Promise<ProjectSelectionResult> {
|
|
79
|
+
const {
|
|
80
|
+
allowManualEntry = true,
|
|
81
|
+
allowSelectAll = true,
|
|
82
|
+
preSelected = [],
|
|
83
|
+
minSelection = 1,
|
|
84
|
+
maxSelection,
|
|
85
|
+
pageSize = 15,
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
// Fetch all projects
|
|
89
|
+
const allProjects = await fetchAllJiraProjects(client);
|
|
90
|
+
|
|
91
|
+
if (allProjects.length === 0) {
|
|
92
|
+
console.log('ā ļø No Jira projects found');
|
|
93
|
+
return {
|
|
94
|
+
selectedKeys: [],
|
|
95
|
+
method: 'interactive',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Show project overview
|
|
100
|
+
console.log('š Available Jira Projects:\n');
|
|
101
|
+
console.log(` Total: ${allProjects.length} projects\n`);
|
|
102
|
+
|
|
103
|
+
// Decide selection method
|
|
104
|
+
const { selectionMethod } = await inquirer.prompt([
|
|
105
|
+
{
|
|
106
|
+
type: 'list',
|
|
107
|
+
name: 'selectionMethod',
|
|
108
|
+
message: 'How would you like to select projects?',
|
|
109
|
+
choices: [
|
|
110
|
+
{
|
|
111
|
+
name: `š Interactive (browse and select from ${allProjects.length} projects)`,
|
|
112
|
+
value: 'interactive',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'āļø Manual entry (type project keys)',
|
|
116
|
+
value: 'manual',
|
|
117
|
+
},
|
|
118
|
+
...(allowSelectAll
|
|
119
|
+
? [
|
|
120
|
+
{
|
|
121
|
+
name: `⨠Select all (${allProjects.length} projects)`,
|
|
122
|
+
value: 'all',
|
|
123
|
+
},
|
|
124
|
+
]
|
|
125
|
+
: []),
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
if (selectionMethod === 'all') {
|
|
131
|
+
return {
|
|
132
|
+
selectedKeys: allProjects.map((p) => p.key),
|
|
133
|
+
method: 'all',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (selectionMethod === 'manual') {
|
|
138
|
+
return await manualProjectEntry(allProjects, minSelection, maxSelection);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Interactive selection
|
|
142
|
+
return await interactiveProjectSelection(
|
|
143
|
+
allProjects,
|
|
144
|
+
preSelected,
|
|
145
|
+
minSelection,
|
|
146
|
+
maxSelection,
|
|
147
|
+
pageSize
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Interactive project selection with checkbox
|
|
153
|
+
*/
|
|
154
|
+
async function interactiveProjectSelection(
|
|
155
|
+
allProjects: any[],
|
|
156
|
+
preSelected: string[],
|
|
157
|
+
minSelection: number,
|
|
158
|
+
maxSelection: number | undefined,
|
|
159
|
+
pageSize: number
|
|
160
|
+
): Promise<ProjectSelectionResult> {
|
|
161
|
+
console.log('š” Use <space> to select, <a> to toggle all, <i> to invert\n');
|
|
162
|
+
|
|
163
|
+
const choices = allProjects.map((p) => ({
|
|
164
|
+
name: formatProjectChoice(p),
|
|
165
|
+
value: p.key,
|
|
166
|
+
checked: preSelected.includes(p.key),
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
// Add manual entry option at the end
|
|
170
|
+
choices.push(
|
|
171
|
+
new inquirer.Separator(),
|
|
172
|
+
{
|
|
173
|
+
name: 'āļø Enter project keys manually instead',
|
|
174
|
+
value: '__MANUAL__',
|
|
175
|
+
checked: false,
|
|
176
|
+
} as any
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const { selectedKeys } = await inquirer.prompt([
|
|
180
|
+
{
|
|
181
|
+
type: 'checkbox',
|
|
182
|
+
name: 'selectedKeys',
|
|
183
|
+
message: `Select Jira projects (${minSelection}${maxSelection ? `-${maxSelection}` : '+'} required):`,
|
|
184
|
+
choices,
|
|
185
|
+
pageSize,
|
|
186
|
+
loop: false,
|
|
187
|
+
validate: (selected: string[]) => {
|
|
188
|
+
const actualSelected = selected.filter((k) => k !== '__MANUAL__');
|
|
189
|
+
|
|
190
|
+
if (actualSelected.length < minSelection) {
|
|
191
|
+
return `Please select at least ${minSelection} project(s)`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (maxSelection && actualSelected.length > maxSelection) {
|
|
195
|
+
return `Please select at most ${maxSelection} project(s)`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return true;
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
// Check if user chose manual entry
|
|
204
|
+
if (selectedKeys.includes('__MANUAL__')) {
|
|
205
|
+
return await manualProjectEntry(allProjects, minSelection, maxSelection);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(`\nā
Selected ${selectedKeys.length} projects: ${selectedKeys.join(', ')}\n`);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
selectedKeys,
|
|
212
|
+
method: 'interactive',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Manual project key entry (comma-separated)
|
|
218
|
+
*/
|
|
219
|
+
async function manualProjectEntry(
|
|
220
|
+
allProjects: any[],
|
|
221
|
+
minSelection: number,
|
|
222
|
+
maxSelection: number | undefined
|
|
223
|
+
): Promise<ProjectSelectionResult> {
|
|
224
|
+
console.log('\nš Enter project keys manually\n');
|
|
225
|
+
console.log('š” Format: Comma-separated project keys (e.g., SCRUM,PROD,MOBILE)\n');
|
|
226
|
+
|
|
227
|
+
if (allProjects.length > 0) {
|
|
228
|
+
console.log('Available project keys:');
|
|
229
|
+
console.log(
|
|
230
|
+
allProjects
|
|
231
|
+
.map((p) => p.key)
|
|
232
|
+
.join(', ')
|
|
233
|
+
.substring(0, 100) + (allProjects.length > 20 ? '...' : '')
|
|
234
|
+
);
|
|
235
|
+
console.log('');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { manualKeys } = await inquirer.prompt([
|
|
239
|
+
{
|
|
240
|
+
type: 'input',
|
|
241
|
+
name: 'manualKeys',
|
|
242
|
+
message: 'Project keys:',
|
|
243
|
+
validate: (input: string) => {
|
|
244
|
+
if (!input.trim()) {
|
|
245
|
+
return 'Please enter at least one project key';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const keys = input
|
|
249
|
+
.split(',')
|
|
250
|
+
.map((k) => k.trim().toUpperCase())
|
|
251
|
+
.filter((k) => k.length > 0);
|
|
252
|
+
|
|
253
|
+
if (keys.length < minSelection) {
|
|
254
|
+
return `Please enter at least ${minSelection} project key(s)`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (maxSelection && keys.length > maxSelection) {
|
|
258
|
+
return `Please enter at most ${maxSelection} project key(s)`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Validate format (uppercase letters/numbers only)
|
|
262
|
+
const invalidKeys = keys.filter((k) => !/^[A-Z0-9]+$/.test(k));
|
|
263
|
+
if (invalidKeys.length > 0) {
|
|
264
|
+
return `Invalid project key format: ${invalidKeys.join(', ')}. Use uppercase letters/numbers only.`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
const selectedKeys = manualKeys
|
|
273
|
+
.split(',')
|
|
274
|
+
.map((k: string) => k.trim().toUpperCase())
|
|
275
|
+
.filter((k: string) => k.length > 0);
|
|
276
|
+
|
|
277
|
+
// Warn about unknown projects
|
|
278
|
+
const knownKeys = allProjects.map((p) => p.key);
|
|
279
|
+
const unknownKeys = selectedKeys.filter((k) => !knownKeys.includes(k));
|
|
280
|
+
|
|
281
|
+
if (unknownKeys.length > 0) {
|
|
282
|
+
console.log(`\nā ļø Unknown project keys (will be used anyway): ${unknownKeys.join(', ')}`);
|
|
283
|
+
console.log(' Make sure these projects exist in your Jira instance.\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
console.log(`ā
Selected ${selectedKeys.length} projects: ${selectedKeys.join(', ')}\n`);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
selectedKeys,
|
|
290
|
+
method: 'manual',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ============================================================================
|
|
295
|
+
// Helpers
|
|
296
|
+
// ============================================================================
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Format project choice for display
|
|
300
|
+
*/
|
|
301
|
+
function formatProjectChoice(project: any): string {
|
|
302
|
+
const type = project.projectTypeKey || 'unknown';
|
|
303
|
+
const lead = project.lead?.displayName || 'No lead';
|
|
304
|
+
|
|
305
|
+
return `${project.key.padEnd(10)} - ${project.name} (${type}, lead: ${lead})`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Quick project selector - select single project
|
|
310
|
+
*/
|
|
311
|
+
export async function selectSingleJiraProject(
|
|
312
|
+
client: JiraClient,
|
|
313
|
+
message: string = 'Select Jira project:'
|
|
314
|
+
): Promise<string> {
|
|
315
|
+
const result = await selectJiraProjects(client, {
|
|
316
|
+
allowManualEntry: true,
|
|
317
|
+
allowSelectAll: false,
|
|
318
|
+
minSelection: 1,
|
|
319
|
+
maxSelection: 1,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return result.selectedKeys[0];
|
|
323
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jira Reorganization Detector
|
|
3
|
+
*
|
|
4
|
+
* Detects when users reorganize work in Jira:
|
|
5
|
+
* - Moved issues (different project)
|
|
6
|
+
* - Split stories (one story ā multiple)
|
|
7
|
+
* - Merged stories (multiple ā one)
|
|
8
|
+
* - Reparented issues (changed epic)
|
|
9
|
+
* - Deleted issues
|
|
10
|
+
*
|
|
11
|
+
* Helps SpecWeave stay in sync with Jira-side changes
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { JiraClient, JiraIssue } from '../../../src/integrations/jira/jira-client.js';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
export type ReorganizationType =
|
|
21
|
+
| 'MOVED_PROJECT'
|
|
22
|
+
| 'SPLIT'
|
|
23
|
+
| 'MERGED'
|
|
24
|
+
| 'REPARENTED'
|
|
25
|
+
| 'DELETED'
|
|
26
|
+
| 'RENAMED';
|
|
27
|
+
|
|
28
|
+
export interface ReorganizationEvent {
|
|
29
|
+
type: ReorganizationType;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
description: string;
|
|
32
|
+
|
|
33
|
+
// Original issue(s)
|
|
34
|
+
originalKeys: string[];
|
|
35
|
+
|
|
36
|
+
// New issue(s)
|
|
37
|
+
newKeys?: string[];
|
|
38
|
+
|
|
39
|
+
// Additional context
|
|
40
|
+
fromProject?: string;
|
|
41
|
+
toProject?: string;
|
|
42
|
+
fromEpic?: string;
|
|
43
|
+
toEpic?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReorganizationDetectionResult {
|
|
47
|
+
detected: boolean;
|
|
48
|
+
events: ReorganizationEvent[];
|
|
49
|
+
summary: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Reorganization Detector
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
export class JiraReorganizationDetector {
|
|
57
|
+
constructor(private client: JiraClient) {}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Detect all reorganization events for tracked issues
|
|
61
|
+
*/
|
|
62
|
+
async detectReorganization(
|
|
63
|
+
trackedIssueKeys: string[],
|
|
64
|
+
lastSyncTimestamp?: string
|
|
65
|
+
): Promise<ReorganizationDetectionResult> {
|
|
66
|
+
console.log(`\nš Checking for reorganization (${trackedIssueKeys.length} issues)...\n`);
|
|
67
|
+
|
|
68
|
+
const events: ReorganizationEvent[] = [];
|
|
69
|
+
|
|
70
|
+
for (const key of trackedIssueKeys) {
|
|
71
|
+
try {
|
|
72
|
+
const issue = await this.client.getIssue(key);
|
|
73
|
+
|
|
74
|
+
// Check for moves
|
|
75
|
+
const moveEvent = this.detectMove(key, issue);
|
|
76
|
+
if (moveEvent) {
|
|
77
|
+
events.push(moveEvent);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for splits
|
|
81
|
+
const splitEvents = await this.detectSplit(key, issue);
|
|
82
|
+
events.push(...splitEvents);
|
|
83
|
+
|
|
84
|
+
// Check for merges
|
|
85
|
+
const mergeEvent = await this.detectMerge(key, issue);
|
|
86
|
+
if (mergeEvent) {
|
|
87
|
+
events.push(mergeEvent);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check for reparenting
|
|
91
|
+
const reparentEvent = this.detectReparent(key, issue, lastSyncTimestamp);
|
|
92
|
+
if (reparentEvent) {
|
|
93
|
+
events.push(reparentEvent);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
} catch (error: any) {
|
|
97
|
+
// Issue might be deleted
|
|
98
|
+
if (error.message.includes('404') || error.message.includes('does not exist')) {
|
|
99
|
+
events.push({
|
|
100
|
+
type: 'DELETED',
|
|
101
|
+
timestamp: new Date().toISOString(),
|
|
102
|
+
description: `Issue ${key} was deleted from Jira`,
|
|
103
|
+
originalKeys: [key],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Generate summary
|
|
110
|
+
const summary = this.generateSummary(events);
|
|
111
|
+
|
|
112
|
+
console.log(events.length > 0 ? 'ā ļø Reorganization detected!' : 'ā
No reorganization detected');
|
|
113
|
+
console.log(summary);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
detected: events.length > 0,
|
|
117
|
+
events,
|
|
118
|
+
summary,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ==========================================================================
|
|
123
|
+
// Detection Methods
|
|
124
|
+
// ==========================================================================
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Detect if issue moved to different project
|
|
128
|
+
*/
|
|
129
|
+
private detectMove(originalKey: string, issue: JiraIssue): ReorganizationEvent | null {
|
|
130
|
+
const currentProject = issue.key.split('-')[0];
|
|
131
|
+
const originalProject = originalKey.split('-')[0];
|
|
132
|
+
|
|
133
|
+
if (currentProject !== originalProject) {
|
|
134
|
+
return {
|
|
135
|
+
type: 'MOVED_PROJECT',
|
|
136
|
+
timestamp: issue.fields.updated,
|
|
137
|
+
description: `Issue moved from ${originalProject} to ${currentProject}`,
|
|
138
|
+
originalKeys: [originalKey],
|
|
139
|
+
newKeys: [issue.key],
|
|
140
|
+
fromProject: originalProject,
|
|
141
|
+
toProject: currentProject,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Detect if story was split into multiple stories
|
|
150
|
+
*/
|
|
151
|
+
private async detectSplit(
|
|
152
|
+
originalKey: string,
|
|
153
|
+
issue: JiraIssue
|
|
154
|
+
): Promise<ReorganizationEvent[]> {
|
|
155
|
+
const events: ReorganizationEvent[] = [];
|
|
156
|
+
|
|
157
|
+
// Check for "split from" or "cloned from" links
|
|
158
|
+
const issueLinks = issue.fields.issuelinks || [];
|
|
159
|
+
|
|
160
|
+
for (const link of issueLinks) {
|
|
161
|
+
const linkType = link.type?.name?.toLowerCase() || '';
|
|
162
|
+
|
|
163
|
+
// Jira uses various link types for splits
|
|
164
|
+
if (
|
|
165
|
+
linkType.includes('split') ||
|
|
166
|
+
linkType.includes('cloned') ||
|
|
167
|
+
linkType.includes('child'
|
|
168
|
+
)
|
|
169
|
+
) {
|
|
170
|
+
const relatedIssue = link.outwardIssue || link.inwardIssue;
|
|
171
|
+
|
|
172
|
+
if (relatedIssue && relatedIssue.key !== originalKey) {
|
|
173
|
+
events.push({
|
|
174
|
+
type: 'SPLIT',
|
|
175
|
+
timestamp: issue.fields.updated,
|
|
176
|
+
description: `Story ${originalKey} was split into ${relatedIssue.key}`,
|
|
177
|
+
originalKeys: [originalKey],
|
|
178
|
+
newKeys: [relatedIssue.key],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return events;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Detect if multiple stories were merged
|
|
189
|
+
*/
|
|
190
|
+
private async detectMerge(
|
|
191
|
+
originalKey: string,
|
|
192
|
+
issue: JiraIssue
|
|
193
|
+
): Promise<ReorganizationEvent | null> {
|
|
194
|
+
const issueLinks = issue.fields.issuelinks || [];
|
|
195
|
+
|
|
196
|
+
for (const link of issueLinks) {
|
|
197
|
+
const linkType = link.type?.name?.toLowerCase() || '';
|
|
198
|
+
|
|
199
|
+
// Check for "duplicate of" or "merged into" links
|
|
200
|
+
if (
|
|
201
|
+
linkType.includes('duplicate') ||
|
|
202
|
+
linkType.includes('merged') ||
|
|
203
|
+
linkType.includes('closed')
|
|
204
|
+
) {
|
|
205
|
+
const targetIssue = link.inwardIssue;
|
|
206
|
+
|
|
207
|
+
if (targetIssue && issue.fields.status.name.toLowerCase() === 'closed') {
|
|
208
|
+
return {
|
|
209
|
+
type: 'MERGED',
|
|
210
|
+
timestamp: issue.fields.updated,
|
|
211
|
+
description: `Story ${originalKey} was merged into ${targetIssue.key}`,
|
|
212
|
+
originalKeys: [originalKey],
|
|
213
|
+
newKeys: [targetIssue.key],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Detect if issue was moved to different epic
|
|
224
|
+
*/
|
|
225
|
+
private detectReparent(
|
|
226
|
+
originalKey: string,
|
|
227
|
+
issue: JiraIssue,
|
|
228
|
+
lastSyncTimestamp?: string
|
|
229
|
+
): ReorganizationEvent | null {
|
|
230
|
+
// Check if issue has parent (epic)
|
|
231
|
+
const currentParent = issue.fields.parent?.key;
|
|
232
|
+
|
|
233
|
+
// We need to track previous parent from metadata
|
|
234
|
+
// For now, just detect if parent exists and was recently updated
|
|
235
|
+
if (currentParent && lastSyncTimestamp) {
|
|
236
|
+
const updatedAt = new Date(issue.fields.updated);
|
|
237
|
+
const lastSync = new Date(lastSyncTimestamp);
|
|
238
|
+
|
|
239
|
+
if (updatedAt > lastSync) {
|
|
240
|
+
// Parent might have changed (we'd need to store previous parent to be sure)
|
|
241
|
+
return {
|
|
242
|
+
type: 'REPARENTED',
|
|
243
|
+
timestamp: issue.fields.updated,
|
|
244
|
+
description: `Issue ${originalKey} may have been reparented to ${currentParent}`,
|
|
245
|
+
originalKeys: [originalKey],
|
|
246
|
+
toEpic: currentParent,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ==========================================================================
|
|
255
|
+
// Helpers
|
|
256
|
+
// ==========================================================================
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Generate human-readable summary of reorganization events
|
|
260
|
+
*/
|
|
261
|
+
private generateSummary(events: ReorganizationEvent[]): string {
|
|
262
|
+
if (events.length === 0) {
|
|
263
|
+
return '\n No reorganization detected\n';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const summary: string[] = ['\nš Reorganization Summary:\n'];
|
|
267
|
+
|
|
268
|
+
const byType = events.reduce((acc, event) => {
|
|
269
|
+
acc[event.type] = (acc[event.type] || 0) + 1;
|
|
270
|
+
return acc;
|
|
271
|
+
}, {} as Record<string, number>);
|
|
272
|
+
|
|
273
|
+
for (const [type, count] of Object.entries(byType)) {
|
|
274
|
+
summary.push(` ${this.getTypeIcon(type as ReorganizationType)} ${type}: ${count}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
summary.push('\nš Details:\n');
|
|
278
|
+
|
|
279
|
+
for (const event of events) {
|
|
280
|
+
summary.push(` ${this.getTypeIcon(event.type)} ${event.description}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
summary.push('');
|
|
284
|
+
|
|
285
|
+
return summary.join('\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get emoji icon for event type
|
|
290
|
+
*/
|
|
291
|
+
private getTypeIcon(type: ReorganizationType): string {
|
|
292
|
+
switch (type) {
|
|
293
|
+
case 'MOVED_PROJECT':
|
|
294
|
+
return 'š¦';
|
|
295
|
+
case 'SPLIT':
|
|
296
|
+
return 'āļø';
|
|
297
|
+
case 'MERGED':
|
|
298
|
+
return 'š';
|
|
299
|
+
case 'REPARENTED':
|
|
300
|
+
return 'š';
|
|
301
|
+
case 'DELETED':
|
|
302
|
+
return 'šļø';
|
|
303
|
+
case 'RENAMED':
|
|
304
|
+
return 'āļø';
|
|
305
|
+
default:
|
|
306
|
+
return 'ā¢';
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// Reorganization Handler
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Handle reorganization events by updating SpecWeave increment
|
|
317
|
+
*/
|
|
318
|
+
export async function handleReorganization(
|
|
319
|
+
events: ReorganizationEvent[],
|
|
320
|
+
incrementId: string,
|
|
321
|
+
projectRoot: string = process.cwd()
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
if (events.length === 0) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
console.log(`\nš§ Handling ${events.length} reorganization events...\n`);
|
|
328
|
+
|
|
329
|
+
for (const event of events) {
|
|
330
|
+
switch (event.type) {
|
|
331
|
+
case 'MOVED_PROJECT':
|
|
332
|
+
console.log(` ā Updated project mapping: ${event.fromProject} ā ${event.toProject}`);
|
|
333
|
+
// Update metadata with new project/key
|
|
334
|
+
break;
|
|
335
|
+
|
|
336
|
+
case 'SPLIT':
|
|
337
|
+
console.log(` ā Added new story from split: ${event.newKeys?.join(', ')}`);
|
|
338
|
+
// Add new user story to spec.md
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
case 'MERGED':
|
|
342
|
+
console.log(` ā Marked story as merged: ${event.originalKeys[0]} ā ${event.newKeys?.[0]}`);
|
|
343
|
+
// Update spec.md to mark as merged
|
|
344
|
+
break;
|
|
345
|
+
|
|
346
|
+
case 'REPARENTED':
|
|
347
|
+
console.log(` ā Updated epic link: ${event.toEpic}`);
|
|
348
|
+
// Update metadata
|
|
349
|
+
break;
|
|
350
|
+
|
|
351
|
+
case 'DELETED':
|
|
352
|
+
console.log(` ā ļø Story deleted from Jira: ${event.originalKeys[0]}`);
|
|
353
|
+
// Mark as deleted in spec.md (don't remove, just mark)
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
console.log('\nā
Reorganization handled\n');
|
|
359
|
+
}
|