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.
- package/CLAUDE.md +48 -12
- 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/ado.d.ts.map +1 -1
- package/dist/cli/helpers/issue-tracker/ado.js +17 -5
- package/dist/cli/helpers/issue-tracker/ado.js.map +1 -1
- package/dist/cli/helpers/issue-tracker/types.d.ts +3 -0
- package/dist/cli/helpers/issue-tracker/types.d.ts.map +1 -1
- package/dist/cli/helpers/issue-tracker/types.js.map +1 -1
- package/dist/core/credentials-manager.d.ts +3 -0
- package/dist/core/credentials-manager.d.ts.map +1 -1
- package/dist/core/credentials-manager.js +69 -9
- package/dist/core/credentials-manager.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/integrations/ado/ado-client.d.ts +4 -0
- package/dist/integrations/ado/ado-client.d.ts.map +1 -1
- package/dist/integrations/ado/ado-client.js +18 -0
- package/dist/integrations/ado/ado-client.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave-ado/lib/project-selector.d.ts +42 -0
- package/plugins/specweave-ado/lib/project-selector.d.ts.map +1 -0
- package/plugins/specweave-ado/lib/project-selector.js +211 -0
- package/plugins/specweave-ado/lib/project-selector.js.map +1 -0
- package/plugins/specweave-ado/lib/project-selector.ts +317 -0
- package/plugins/specweave-github/lib/github-client.ts +28 -0
- package/plugins/specweave-github/lib/repo-selector.ts +329 -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/README.md.bak +0 -304
- 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
|
+
}
|