specweave 0.8.18 → 0.8.20

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.
Files changed (34) hide show
  1. package/CLAUDE.md +48 -12
  2. package/dist/cli/commands/migrate-to-profiles.js +2 -2
  3. package/dist/cli/commands/migrate-to-profiles.js.map +1 -1
  4. package/dist/cli/helpers/issue-tracker/ado.d.ts.map +1 -1
  5. package/dist/cli/helpers/issue-tracker/ado.js +17 -5
  6. package/dist/cli/helpers/issue-tracker/ado.js.map +1 -1
  7. package/dist/cli/helpers/issue-tracker/types.d.ts +3 -0
  8. package/dist/cli/helpers/issue-tracker/types.d.ts.map +1 -1
  9. package/dist/cli/helpers/issue-tracker/types.js.map +1 -1
  10. package/dist/core/credentials-manager.d.ts +3 -0
  11. package/dist/core/credentials-manager.d.ts.map +1 -1
  12. package/dist/core/credentials-manager.js +69 -9
  13. package/dist/core/credentials-manager.js.map +1 -1
  14. package/dist/core/sync/bidirectional-engine.d.ts +110 -0
  15. package/dist/core/sync/bidirectional-engine.d.ts.map +1 -0
  16. package/dist/core/sync/bidirectional-engine.js +356 -0
  17. package/dist/core/sync/bidirectional-engine.js.map +1 -0
  18. package/dist/integrations/ado/ado-client.d.ts +4 -0
  19. package/dist/integrations/ado/ado-client.d.ts.map +1 -1
  20. package/dist/integrations/ado/ado-client.js +18 -0
  21. package/dist/integrations/ado/ado-client.js.map +1 -1
  22. package/package.json +1 -1
  23. package/plugins/specweave-ado/lib/project-selector.d.ts +42 -0
  24. package/plugins/specweave-ado/lib/project-selector.d.ts.map +1 -0
  25. package/plugins/specweave-ado/lib/project-selector.js +211 -0
  26. package/plugins/specweave-ado/lib/project-selector.js.map +1 -0
  27. package/plugins/specweave-ado/lib/project-selector.ts +317 -0
  28. package/plugins/specweave-github/lib/github-client.ts +28 -0
  29. package/plugins/specweave-github/lib/repo-selector.ts +329 -0
  30. package/plugins/specweave-jira/lib/project-selector.ts +323 -0
  31. package/plugins/specweave-jira/lib/reorganization-detector.ts +359 -0
  32. package/plugins/specweave-jira/lib/setup-wizard.ts +256 -0
  33. package/README.md.bak +0 -304
  34. package/plugins/specweave-jira/lib/jira-client-v2.ts +0 -529
@@ -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
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Smart Jira Setup Wizard
3
+ *
4
+ * Intelligent credential detection and setup flow:
5
+ * 1. Check .env for credentials (uses credentialsManager)
6
+ * 2. Interactive prompt only if missing
7
+ * 3. Never ask twice for same credentials
8
+ */
9
+
10
+ import inquirer from 'inquirer';
11
+ import { credentialsManager, JiraCredentials } from '../../../src/core/credentials-manager.js';
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export { JiraCredentials } from '../../../src/core/credentials-manager.js';
18
+
19
+ export interface CredentialDetectionResult {
20
+ found: boolean;
21
+ credentials?: JiraCredentials;
22
+ source?: 'env' | 'interactive';
23
+ }
24
+
25
+ // ============================================================================
26
+ // Credential Detection
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Smart credential detection - uses existing credentialsManager
31
+ */
32
+ export async function detectJiraCredentials(): Promise<CredentialDetectionResult> {
33
+ if (credentialsManager.hasJiraCredentials()) {
34
+ try {
35
+ const credentials = credentialsManager.getJiraCredentials();
36
+ console.log('✅ Found Jira credentials in .env');
37
+ return {
38
+ found: true,
39
+ credentials,
40
+ source: 'env',
41
+ };
42
+ } catch (error) {
43
+ // Credentials exist but invalid format
44
+ return { found: false };
45
+ }
46
+ }
47
+
48
+ return { found: false };
49
+ }
50
+
51
+ // ============================================================================
52
+ // Interactive Setup
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Interactive Jira credential setup
57
+ * Only runs if credentials not found
58
+ */
59
+ export async function setupJiraCredentials(): Promise<JiraCredentials> {
60
+ console.log('\n🔧 Jira Setup Wizard\n');
61
+
62
+ // Check for existing credentials first
63
+ const detected = await detectJiraCredentials();
64
+
65
+ if (detected.found) {
66
+ // Ask user if they want to use existing or re-enter
67
+ const { useExisting } = await inquirer.prompt([
68
+ {
69
+ type: 'confirm',
70
+ name: 'useExisting',
71
+ message: `Found credentials in ${detected.source}. Use these credentials?`,
72
+ default: true,
73
+ },
74
+ ]);
75
+
76
+ if (useExisting) {
77
+ return detected.credentials!;
78
+ }
79
+
80
+ console.log('\n📝 Enter new credentials:\n');
81
+ } else {
82
+ console.log('⚠️ No Jira credentials found\n');
83
+ console.log('📝 Let\'s set up your Jira connection:\n');
84
+ }
85
+
86
+ // Interactive credential entry
87
+ const answers = await inquirer.prompt([
88
+ {
89
+ type: 'list',
90
+ name: 'setupType',
91
+ message: 'How would you like to connect to Jira?',
92
+ choices: [
93
+ {
94
+ name: 'Cloud (*.atlassian.net)',
95
+ value: 'cloud',
96
+ },
97
+ {
98
+ name: 'Server/Data Center (self-hosted)',
99
+ value: 'server',
100
+ },
101
+ ],
102
+ },
103
+ {
104
+ type: 'input',
105
+ name: 'domain',
106
+ message: (answers: any) =>
107
+ answers.setupType === 'cloud'
108
+ ? 'Jira domain (e.g., mycompany.atlassian.net):'
109
+ : 'Jira server URL (e.g., jira.mycompany.com):',
110
+ validate: (value: string) => {
111
+ if (!value) return 'Domain is required';
112
+ if (answers.setupType === 'cloud' && !value.includes('.atlassian.net')) {
113
+ return 'Cloud domain must end with .atlassian.net';
114
+ }
115
+ return true;
116
+ },
117
+ },
118
+ {
119
+ type: 'input',
120
+ name: 'email',
121
+ message: 'Email address:',
122
+ validate: (value: string) => {
123
+ if (!value) return 'Email is required';
124
+ if (!value.includes('@')) return 'Must be a valid email';
125
+ return true;
126
+ },
127
+ },
128
+ {
129
+ type: 'password',
130
+ name: 'apiToken',
131
+ message: 'API token:',
132
+ mask: '*',
133
+ validate: (value: string) => {
134
+ if (!value) return 'API token is required';
135
+ if (value.length < 10) return 'API token seems too short';
136
+ return true;
137
+ },
138
+ },
139
+ ]);
140
+
141
+ const credentials: JiraCredentials = {
142
+ domain: answers.domain,
143
+ email: answers.email,
144
+ apiToken: answers.apiToken,
145
+ };
146
+
147
+ // Test connection
148
+ console.log('\n🔍 Testing connection...');
149
+ const isValid = await testJiraConnection(credentials);
150
+
151
+ if (!isValid) {
152
+ console.log('❌ Failed to connect to Jira');
153
+ console.log('💡 Please check your credentials and try again\n');
154
+
155
+ const { retry } = await inquirer.prompt([
156
+ {
157
+ type: 'confirm',
158
+ name: 'retry',
159
+ message: 'Would you like to try again?',
160
+ default: true,
161
+ },
162
+ ]);
163
+
164
+ if (retry) {
165
+ return setupJiraCredentials();
166
+ }
167
+
168
+ throw new Error('Jira connection failed');
169
+ }
170
+
171
+ console.log('✅ Connection successful!\n');
172
+
173
+ // Save to .env using credentialsManager
174
+ await saveCredentialsToEnv(credentials);
175
+
176
+ return credentials;
177
+ }
178
+
179
+ /**
180
+ * Test Jira connection with credentials
181
+ */
182
+ async function testJiraConnection(credentials: JiraCredentials): Promise<boolean> {
183
+ try {
184
+ const https = await import('https');
185
+
186
+ const auth = Buffer.from(`${credentials.email}:${credentials.apiToken}`).toString('base64');
187
+
188
+ return new Promise((resolve) => {
189
+ const req = https.request(
190
+ {
191
+ hostname: credentials.domain,
192
+ path: '/rest/api/3/myself',
193
+ method: 'GET',
194
+ headers: {
195
+ Authorization: `Basic ${auth}`,
196
+ 'Content-Type': 'application/json',
197
+ },
198
+ },
199
+ (res) => {
200
+ resolve(res.statusCode === 200);
201
+ }
202
+ );
203
+
204
+ req.on('error', () => resolve(false));
205
+ req.setTimeout(5000, () => {
206
+ req.destroy();
207
+ resolve(false);
208
+ });
209
+ req.end();
210
+ });
211
+ } catch (error) {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Save credentials to .env using credentialsManager
218
+ */
219
+ async function saveCredentialsToEnv(credentials: JiraCredentials): Promise<void> {
220
+ console.log('💡 Save credentials to .env for future use\n');
221
+
222
+ const { saveToEnv } = await inquirer.prompt([
223
+ {
224
+ type: 'confirm',
225
+ name: 'saveToEnv',
226
+ message: 'Save credentials to .env file?',
227
+ default: true,
228
+ },
229
+ ]);
230
+
231
+ if (saveToEnv) {
232
+ credentialsManager.saveToEnvFile({ jira: credentials });
233
+ console.log('✅ Credentials saved to .env');
234
+ console.log('✅ .env added to .gitignore');
235
+ } else {
236
+ console.log('⚠️ Credentials not saved. You\'ll need to enter them again next time.');
237
+ }
238
+ }
239
+
240
+ // ============================================================================
241
+ // Export Helpers
242
+ // ============================================================================
243
+
244
+ /**
245
+ * Get Jira credentials - smart detection with fallback to interactive setup
246
+ */
247
+ export async function getJiraCredentials(): Promise<JiraCredentials> {
248
+ const detected = await detectJiraCredentials();
249
+
250
+ if (detected.found) {
251
+ return detected.credentials!;
252
+ }
253
+
254
+ // Not found - run interactive setup
255
+ return setupJiraCredentials();
256
+ }