specweave 1.0.18 → 1.0.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 +48 -0
- package/dist/src/core/config/types.d.ts +15 -0
- package/dist/src/core/config/types.d.ts.map +1 -1
- package/dist/src/core/config/types.js.map +1 -1
- package/dist/src/core/increment/increment-utils.d.ts +40 -0
- package/dist/src/core/increment/increment-utils.d.ts.map +1 -1
- package/dist/src/core/increment/increment-utils.js +56 -2
- package/dist/src/core/increment/increment-utils.js.map +1 -1
- package/dist/src/core/increment/status-change-sync-trigger.d.ts +26 -2
- package/dist/src/core/increment/status-change-sync-trigger.d.ts.map +1 -1
- package/dist/src/core/increment/status-change-sync-trigger.js +70 -6
- package/dist/src/core/increment/status-change-sync-trigger.js.map +1 -1
- package/dist/src/hooks/auto-create-external-issue.d.ts +19 -0
- package/dist/src/hooks/auto-create-external-issue.d.ts.map +1 -0
- package/dist/src/hooks/auto-create-external-issue.js +54 -0
- package/dist/src/hooks/auto-create-external-issue.js.map +1 -0
- package/dist/src/integrations/jira/jira-incremental-mapper.d.ts +3 -1
- package/dist/src/integrations/jira/jira-incremental-mapper.d.ts.map +1 -1
- package/dist/src/integrations/jira/jira-incremental-mapper.js +7 -1
- package/dist/src/integrations/jira/jira-incremental-mapper.js.map +1 -1
- package/dist/src/integrations/jira/jira-mapper.d.ts +3 -1
- package/dist/src/integrations/jira/jira-mapper.d.ts.map +1 -1
- package/dist/src/integrations/jira/jira-mapper.js +7 -1
- package/dist/src/integrations/jira/jira-mapper.js.map +1 -1
- package/dist/src/sync/external-issue-auto-creator.d.ts +156 -0
- package/dist/src/sync/external-issue-auto-creator.d.ts.map +1 -0
- package/dist/src/sync/external-issue-auto-creator.js +694 -0
- package/dist/src/sync/external-issue-auto-creator.js.map +1 -0
- package/dist/src/utils/feature-id-collision.d.ts +51 -0
- package/dist/src/utils/feature-id-collision.d.ts.map +1 -1
- package/dist/src/utils/feature-id-collision.js +149 -0
- package/dist/src/utils/feature-id-collision.js.map +1 -1
- package/package.json +1 -1
- package/plugins/specweave/commands/sync-progress.md +92 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External Issue Auto-Creator (v1.0.19)
|
|
3
|
+
*
|
|
4
|
+
* Automatically creates external issues (GitHub/JIRA/ADO) for increments
|
|
5
|
+
* when they don't have linked issues.
|
|
6
|
+
*
|
|
7
|
+
* TRIGGER POINTS:
|
|
8
|
+
* 1. After increment creation (post-increment-planning hook)
|
|
9
|
+
* 2. During sync-progress if issue is missing
|
|
10
|
+
* 3. On status change (active → completed)
|
|
11
|
+
*
|
|
12
|
+
* SAFETY FEATURES:
|
|
13
|
+
* - 3-layer idempotency (frontmatter → metadata → API)
|
|
14
|
+
* - Duplicate detection before creation
|
|
15
|
+
* - Rate limiting (circuit breaker)
|
|
16
|
+
* - Non-blocking (failures don't crash the workflow)
|
|
17
|
+
*
|
|
18
|
+
* @see ADR-0139 (Unified Post-Increment GitHub Sync)
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs, existsSync } from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import yaml from 'yaml';
|
|
23
|
+
import { consoleLogger } from '../utils/logger.js';
|
|
24
|
+
import { deriveFeatureId } from '../utils/feature-id-derivation.js';
|
|
25
|
+
import { ConfigManager } from '../core/config/config-manager.js';
|
|
26
|
+
/**
|
|
27
|
+
* Auto-creates external issues for increments
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const creator = new ExternalIssueAutoCreator({ projectRoot: process.cwd() });
|
|
32
|
+
* const result = await creator.createForIncrement('0001-feature');
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export class ExternalIssueAutoCreator {
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.projectRoot = options.projectRoot;
|
|
38
|
+
this.logger = options.logger ?? consoleLogger;
|
|
39
|
+
this.configManager = new ConfigManager(this.projectRoot);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create external issues for an increment (if configured and not already created)
|
|
43
|
+
*
|
|
44
|
+
* This method:
|
|
45
|
+
* 1. Checks if auto-create is enabled in config
|
|
46
|
+
* 2. Detects which provider to use (GitHub/JIRA/ADO)
|
|
47
|
+
* 3. Checks if issues already exist (3-layer idempotency)
|
|
48
|
+
* 4. Creates issues if missing
|
|
49
|
+
* 5. Updates metadata with issue links
|
|
50
|
+
*/
|
|
51
|
+
async createForIncrement(incrementId) {
|
|
52
|
+
try {
|
|
53
|
+
// Load config
|
|
54
|
+
const config = await this.configManager.read();
|
|
55
|
+
// Check if auto-create is enabled
|
|
56
|
+
const autoCreateEnabled = this.isAutoCreateEnabled(config);
|
|
57
|
+
if (!autoCreateEnabled) {
|
|
58
|
+
return {
|
|
59
|
+
success: true,
|
|
60
|
+
provider: 'none',
|
|
61
|
+
skipped: true,
|
|
62
|
+
skipReason: 'Auto-create disabled in config (sync.autoCreateOnIncrement = false)',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Detect provider
|
|
66
|
+
const provider = this.detectProvider(config);
|
|
67
|
+
if (!provider) {
|
|
68
|
+
return {
|
|
69
|
+
success: true,
|
|
70
|
+
provider: 'none',
|
|
71
|
+
skipped: true,
|
|
72
|
+
skipReason: 'No external provider configured (GitHub/JIRA/ADO)',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Load increment info
|
|
76
|
+
const incrementInfo = await this.loadIncrementInfo(incrementId);
|
|
77
|
+
if (!incrementInfo) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
provider,
|
|
81
|
+
error: `Increment ${incrementId} not found or missing spec.md`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// Check if already has external issue (Layer 1 & 2)
|
|
85
|
+
const existingIssue = await this.checkExistingIssue(incrementId, provider);
|
|
86
|
+
if (existingIssue) {
|
|
87
|
+
this.logger.log(`✅ ${incrementId} already has ${provider} issue: ${existingIssue}`);
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
provider,
|
|
91
|
+
skipped: true,
|
|
92
|
+
skipReason: `Issue already exists: ${existingIssue}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Create issues based on provider
|
|
96
|
+
switch (provider) {
|
|
97
|
+
case 'github':
|
|
98
|
+
return await this.createGitHubIssues(incrementId, incrementInfo, config);
|
|
99
|
+
case 'jira':
|
|
100
|
+
return await this.createJiraIssues(incrementId, incrementInfo, config);
|
|
101
|
+
case 'ado':
|
|
102
|
+
return await this.createAdoIssues(incrementId, incrementInfo, config);
|
|
103
|
+
default:
|
|
104
|
+
return {
|
|
105
|
+
success: false,
|
|
106
|
+
provider: 'none',
|
|
107
|
+
error: `Unknown provider: ${provider}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
113
|
+
this.logger.error(`❌ Auto-create failed for ${incrementId}: ${errorMessage}`);
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
provider: 'none',
|
|
117
|
+
error: errorMessage,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Check if auto-create is enabled in config
|
|
123
|
+
*
|
|
124
|
+
* Checks (in order):
|
|
125
|
+
* 1. sync.autoCreateOnIncrement (new, explicit)
|
|
126
|
+
* 2. sync.autoSync (legacy)
|
|
127
|
+
* 3. sync.settings.canUpsertInternalItems (legacy)
|
|
128
|
+
*/
|
|
129
|
+
isAutoCreateEnabled(config) {
|
|
130
|
+
// New explicit option (recommended)
|
|
131
|
+
if (config.sync?.autoCreateOnIncrement !== undefined) {
|
|
132
|
+
return config.sync.autoCreateOnIncrement === true;
|
|
133
|
+
}
|
|
134
|
+
// Legacy: autoSync
|
|
135
|
+
if (config.sync?.autoSync === true) {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// Legacy: canUpsertInternalItems
|
|
139
|
+
if (config.sync?.settings?.canUpsertInternalItems === true) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
// Default: false (opt-in)
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Detect which provider to use for external sync
|
|
147
|
+
*
|
|
148
|
+
* Priority:
|
|
149
|
+
* 1. GitHub (if sync.github.enabled = true)
|
|
150
|
+
* 2. JIRA (if sync.jira.enabled = true)
|
|
151
|
+
* 3. ADO (if sync.ado.enabled = true)
|
|
152
|
+
* 4. GitHub (if issueTracker.provider = 'github')
|
|
153
|
+
* 5. JIRA (if issueTracker.provider = 'jira')
|
|
154
|
+
* 6. ADO (if issueTracker.provider = 'ado')
|
|
155
|
+
*/
|
|
156
|
+
detectProvider(config) {
|
|
157
|
+
// Check explicit provider enablement in sync config
|
|
158
|
+
if (config.sync?.github?.enabled === true) {
|
|
159
|
+
return 'github';
|
|
160
|
+
}
|
|
161
|
+
if (config.sync?.jira?.enabled === true) {
|
|
162
|
+
return 'jira';
|
|
163
|
+
}
|
|
164
|
+
if (config.sync?.ado?.enabled === true) {
|
|
165
|
+
return 'ado';
|
|
166
|
+
}
|
|
167
|
+
// Check issueTracker provider
|
|
168
|
+
const trackerProvider = config.issueTracker?.provider;
|
|
169
|
+
if (trackerProvider === 'github') {
|
|
170
|
+
return 'github';
|
|
171
|
+
}
|
|
172
|
+
if (trackerProvider === 'jira') {
|
|
173
|
+
return 'jira';
|
|
174
|
+
}
|
|
175
|
+
if (trackerProvider === 'ado') {
|
|
176
|
+
return 'ado';
|
|
177
|
+
}
|
|
178
|
+
// Check sync profiles for provider hints
|
|
179
|
+
const profiles = config.sync?.profiles || {};
|
|
180
|
+
for (const profile of Object.values(profiles)) {
|
|
181
|
+
if (profile.provider === 'github') {
|
|
182
|
+
return 'github';
|
|
183
|
+
}
|
|
184
|
+
if (profile.provider === 'jira') {
|
|
185
|
+
return 'jira';
|
|
186
|
+
}
|
|
187
|
+
if (profile.provider === 'ado' || profile.provider === 'azure-devops') {
|
|
188
|
+
return 'ado';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Load increment information from spec.md and metadata.json
|
|
195
|
+
*/
|
|
196
|
+
async loadIncrementInfo(incrementId) {
|
|
197
|
+
const incrementPath = path.join(this.projectRoot, '.specweave/increments', incrementId);
|
|
198
|
+
const specPath = path.join(incrementPath, 'spec.md');
|
|
199
|
+
const metadataPath = path.join(incrementPath, 'metadata.json');
|
|
200
|
+
if (!existsSync(specPath)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const specContent = await fs.readFile(specPath, 'utf-8');
|
|
205
|
+
// Parse frontmatter
|
|
206
|
+
const frontmatterMatch = specContent.match(/^---\n([\s\S]*?)\n---/);
|
|
207
|
+
let frontmatter = {};
|
|
208
|
+
if (frontmatterMatch) {
|
|
209
|
+
frontmatter = yaml.parse(frontmatterMatch[1]) || {};
|
|
210
|
+
}
|
|
211
|
+
// Derive feature ID
|
|
212
|
+
let featureId = frontmatter.feature_id || frontmatter.epic || frontmatter.feature || '';
|
|
213
|
+
if (!featureId) {
|
|
214
|
+
try {
|
|
215
|
+
featureId = deriveFeatureId(incrementId);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
featureId = `FS-${incrementId.substring(0, 4)}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Parse user stories from spec.md body
|
|
222
|
+
const userStories = this.parseUserStories(specContent);
|
|
223
|
+
// Load status from metadata
|
|
224
|
+
let status = 'active';
|
|
225
|
+
if (existsSync(metadataPath)) {
|
|
226
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
227
|
+
status = metadata.status || 'active';
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
id: incrementId,
|
|
231
|
+
featureId,
|
|
232
|
+
title: frontmatter.title || incrementId,
|
|
233
|
+
status,
|
|
234
|
+
userStories,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
this.logger.error(`Failed to load increment info: ${error}`);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Parse user stories from spec.md body
|
|
244
|
+
*/
|
|
245
|
+
parseUserStories(specContent) {
|
|
246
|
+
const userStories = [];
|
|
247
|
+
// Match ### US-XXX: Title patterns
|
|
248
|
+
const usRegex = /^### (US-\d+):?\s*(.+)$/gm;
|
|
249
|
+
let match;
|
|
250
|
+
while ((match = usRegex.exec(specContent)) !== null) {
|
|
251
|
+
const usId = match[1];
|
|
252
|
+
const title = match[2].trim();
|
|
253
|
+
// Try to find **Project**: field after the US header
|
|
254
|
+
const usStartIndex = match.index;
|
|
255
|
+
const nextUsMatch = specContent.substring(usStartIndex + match[0].length).match(/^### US-\d+/m);
|
|
256
|
+
const usEndIndex = nextUsMatch
|
|
257
|
+
? usStartIndex + match[0].length + (nextUsMatch.index || 0)
|
|
258
|
+
: specContent.length;
|
|
259
|
+
const usSection = specContent.substring(usStartIndex, usEndIndex);
|
|
260
|
+
const projectMatch = usSection.match(/\*\*Project\*\*:\s*(\S+)/);
|
|
261
|
+
const project = projectMatch ? projectMatch[1] : undefined;
|
|
262
|
+
userStories.push({ id: usId, title, project });
|
|
263
|
+
}
|
|
264
|
+
return userStories;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Check if increment already has external issue linked
|
|
268
|
+
*
|
|
269
|
+
* Checks:
|
|
270
|
+
* 1. metadata.json github/jira/ado fields
|
|
271
|
+
* 2. spec.md frontmatter external links
|
|
272
|
+
*/
|
|
273
|
+
async checkExistingIssue(incrementId, provider) {
|
|
274
|
+
const metadataPath = path.join(this.projectRoot, '.specweave/increments', incrementId, 'metadata.json');
|
|
275
|
+
if (!existsSync(metadataPath)) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
280
|
+
switch (provider) {
|
|
281
|
+
case 'github':
|
|
282
|
+
if (metadata.github?.issue) {
|
|
283
|
+
return `#${metadata.github.issue}`;
|
|
284
|
+
}
|
|
285
|
+
if (metadata.github?.issues?.length > 0) {
|
|
286
|
+
return `#${metadata.github.issues[0].number}`;
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
case 'jira':
|
|
290
|
+
if (metadata.jira?.issue) {
|
|
291
|
+
return metadata.jira.issue;
|
|
292
|
+
}
|
|
293
|
+
if (metadata.jira?.issues?.length > 0) {
|
|
294
|
+
return metadata.jira.issues[0].key;
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
case 'ado':
|
|
298
|
+
if (metadata.ado?.workItem) {
|
|
299
|
+
return `#${metadata.ado.workItem}`;
|
|
300
|
+
}
|
|
301
|
+
if (metadata.ado?.workItems?.length > 0) {
|
|
302
|
+
return `#${metadata.ado.workItems[0].id}`;
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Create GitHub issues for the increment
|
|
314
|
+
*/
|
|
315
|
+
async createGitHubIssues(incrementId, incrementInfo, config) {
|
|
316
|
+
try {
|
|
317
|
+
// Import GitHub client dynamically to avoid circular deps
|
|
318
|
+
const { GitHubClientV2 } = await import('../../plugins/specweave-github/lib/github-client-v2.js');
|
|
319
|
+
// Detect repo from config
|
|
320
|
+
const repoInfo = await this.detectGitHubRepo(config);
|
|
321
|
+
if (!repoInfo) {
|
|
322
|
+
return {
|
|
323
|
+
success: false,
|
|
324
|
+
provider: 'github',
|
|
325
|
+
error: 'GitHub repository not configured. Set sync.github.owner and sync.github.repo in config.json',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const client = GitHubClientV2.fromRepo(repoInfo.owner, repoInfo.repo);
|
|
329
|
+
// Check if GitHub issue already exists via API (Layer 3)
|
|
330
|
+
const searchTitle = `[${incrementInfo.featureId}]`;
|
|
331
|
+
const existingIssue = await client.searchIssueByTitle(searchTitle);
|
|
332
|
+
if (existingIssue) {
|
|
333
|
+
// Found existing issue - update metadata and return
|
|
334
|
+
await this.updateMetadataWithGitHubIssue(incrementId, existingIssue.number, existingIssue.html_url);
|
|
335
|
+
return {
|
|
336
|
+
success: true,
|
|
337
|
+
provider: 'github',
|
|
338
|
+
issueNumber: existingIssue.number,
|
|
339
|
+
issueUrl: existingIssue.html_url,
|
|
340
|
+
skipped: true,
|
|
341
|
+
skipReason: `Issue already exists on GitHub: #${existingIssue.number}`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
// Create issues for each user story
|
|
345
|
+
const createdIssues = [];
|
|
346
|
+
for (const us of incrementInfo.userStories) {
|
|
347
|
+
const body = this.buildGitHubIssueBody(incrementId, incrementInfo, us);
|
|
348
|
+
try {
|
|
349
|
+
const issue = await client.createUserStoryIssue({
|
|
350
|
+
featureId: incrementInfo.featureId,
|
|
351
|
+
userStoryId: us.id,
|
|
352
|
+
title: us.title,
|
|
353
|
+
body,
|
|
354
|
+
labels: ['specweave', 'auto-created'],
|
|
355
|
+
});
|
|
356
|
+
createdIssues.push(issue.number);
|
|
357
|
+
this.logger.log(` ✅ Created GitHub issue #${issue.number} for ${us.id}`);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
this.logger.warn(` ⚠️ Failed to create issue for ${us.id}: ${error}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (createdIssues.length === 0) {
|
|
364
|
+
// Fallback: create a single feature-level issue using createEpicIssue
|
|
365
|
+
const title = `[${incrementInfo.featureId}] ${incrementInfo.title}`;
|
|
366
|
+
const body = this.buildFeatureLevelIssueBody(incrementId, incrementInfo);
|
|
367
|
+
const issue = await client.createEpicIssue(title, body, undefined, ['specweave', 'auto-created']);
|
|
368
|
+
await this.updateMetadataWithGitHubIssue(incrementId, issue.number, issue.html_url);
|
|
369
|
+
return {
|
|
370
|
+
success: true,
|
|
371
|
+
provider: 'github',
|
|
372
|
+
issueNumber: issue.number,
|
|
373
|
+
issueUrl: issue.html_url,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// Update metadata with first created issue
|
|
377
|
+
await this.updateMetadataWithGitHubIssue(incrementId, createdIssues[0], `https://github.com/${repoInfo.owner}/${repoInfo.repo}/issues/${createdIssues[0]}`);
|
|
378
|
+
return {
|
|
379
|
+
success: true,
|
|
380
|
+
provider: 'github',
|
|
381
|
+
issueNumber: createdIssues[0],
|
|
382
|
+
issueUrl: `https://github.com/${repoInfo.owner}/${repoInfo.repo}/issues/${createdIssues[0]}`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
387
|
+
return {
|
|
388
|
+
success: false,
|
|
389
|
+
provider: 'github',
|
|
390
|
+
error: errorMessage,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Create JIRA issues for the increment
|
|
396
|
+
*/
|
|
397
|
+
async createJiraIssues(incrementId, incrementInfo, config) {
|
|
398
|
+
try {
|
|
399
|
+
// Import JIRA client dynamically
|
|
400
|
+
const { JiraClient } = await import('../integrations/jira/jira-client.js');
|
|
401
|
+
const jiraConfig = config.issueTracker || config.sync?.jira || {};
|
|
402
|
+
const domain = jiraConfig.domain;
|
|
403
|
+
const projectKey = jiraConfig.projects?.[0]?.key || jiraConfig.projectKey;
|
|
404
|
+
if (!domain || !projectKey) {
|
|
405
|
+
return {
|
|
406
|
+
success: false,
|
|
407
|
+
provider: 'jira',
|
|
408
|
+
error: 'JIRA not configured. Set issueTracker.domain and issueTracker.projects in config.json',
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const client = new JiraClient();
|
|
412
|
+
// Create Epic for the feature
|
|
413
|
+
const epicSummary = `[${incrementInfo.featureId}] ${incrementInfo.title}`;
|
|
414
|
+
const epicDescription = this.buildJiraEpicDescription(incrementId, incrementInfo);
|
|
415
|
+
const epic = await client.createIssue({
|
|
416
|
+
issueType: 'Epic',
|
|
417
|
+
summary: epicSummary,
|
|
418
|
+
description: epicDescription,
|
|
419
|
+
labels: ['specweave', 'auto-created'],
|
|
420
|
+
}, projectKey);
|
|
421
|
+
// Update metadata
|
|
422
|
+
await this.updateMetadataWithJiraIssue(incrementId, epic.key, epic.self);
|
|
423
|
+
this.logger.log(`✅ Created JIRA Epic: ${epic.key}`);
|
|
424
|
+
return {
|
|
425
|
+
success: true,
|
|
426
|
+
provider: 'jira',
|
|
427
|
+
issueNumber: epic.key,
|
|
428
|
+
issueUrl: `https://${domain}/browse/${epic.key}`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
433
|
+
return {
|
|
434
|
+
success: false,
|
|
435
|
+
provider: 'jira',
|
|
436
|
+
error: errorMessage,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Create Azure DevOps work items for the increment
|
|
442
|
+
*/
|
|
443
|
+
async createAdoIssues(incrementId, incrementInfo, config) {
|
|
444
|
+
try {
|
|
445
|
+
const { AdoClient } = await import('../integrations/ado/ado-client.js');
|
|
446
|
+
const { getAdoPat } = await import('../integrations/ado/ado-pat-provider.js');
|
|
447
|
+
const adoConfig = config.issueTracker || config.sync?.ado || {};
|
|
448
|
+
const organization = adoConfig.organization_ado || adoConfig.organization;
|
|
449
|
+
const project = adoConfig.project;
|
|
450
|
+
if (!organization || !project) {
|
|
451
|
+
return {
|
|
452
|
+
success: false,
|
|
453
|
+
provider: 'ado',
|
|
454
|
+
error: 'Azure DevOps not configured. Set issueTracker.organization_ado and issueTracker.project in config.json',
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
const pat = getAdoPat(organization);
|
|
458
|
+
if (!pat) {
|
|
459
|
+
return {
|
|
460
|
+
success: false,
|
|
461
|
+
provider: 'ado',
|
|
462
|
+
error: 'AZURE_DEVOPS_PAT not set in environment',
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const client = new AdoClient({
|
|
466
|
+
pat,
|
|
467
|
+
organization,
|
|
468
|
+
project,
|
|
469
|
+
});
|
|
470
|
+
// Create Feature work item
|
|
471
|
+
const title = `[${incrementInfo.featureId}] ${incrementInfo.title}`;
|
|
472
|
+
const description = this.buildAdoDescription(incrementId, incrementInfo);
|
|
473
|
+
const workItem = await client.createWorkItem({
|
|
474
|
+
workItemType: 'Feature',
|
|
475
|
+
title,
|
|
476
|
+
description,
|
|
477
|
+
tags: ['specweave', 'auto-created'],
|
|
478
|
+
});
|
|
479
|
+
// Update metadata
|
|
480
|
+
await this.updateMetadataWithAdoWorkItem(incrementId, workItem.id, workItem.url);
|
|
481
|
+
this.logger.log(`✅ Created ADO Feature: #${workItem.id}`);
|
|
482
|
+
return {
|
|
483
|
+
success: true,
|
|
484
|
+
provider: 'ado',
|
|
485
|
+
issueNumber: workItem.id,
|
|
486
|
+
issueUrl: workItem.url,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
491
|
+
return {
|
|
492
|
+
success: false,
|
|
493
|
+
provider: 'ado',
|
|
494
|
+
error: errorMessage,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Detect GitHub repo from config
|
|
500
|
+
*/
|
|
501
|
+
async detectGitHubRepo(config) {
|
|
502
|
+
// Check sync.github config
|
|
503
|
+
if (config.sync?.github?.owner && config.sync?.github?.repo) {
|
|
504
|
+
return {
|
|
505
|
+
owner: config.sync.github.owner,
|
|
506
|
+
repo: config.sync.github.repo,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// Check profiles
|
|
510
|
+
const profiles = config.sync?.profiles || {};
|
|
511
|
+
for (const profile of Object.values(profiles)) {
|
|
512
|
+
if (profile.provider === 'github' && profile.config?.owner && profile.config?.repo) {
|
|
513
|
+
return {
|
|
514
|
+
owner: profile.config.owner,
|
|
515
|
+
repo: profile.config.repo,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// Check issueTracker
|
|
520
|
+
if (config.issueTracker?.owner && config.issueTracker?.repo) {
|
|
521
|
+
return {
|
|
522
|
+
owner: config.issueTracker.owner,
|
|
523
|
+
repo: config.issueTracker.repo,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
// Try git remote detection
|
|
527
|
+
try {
|
|
528
|
+
const { execSync } = await import('child_process');
|
|
529
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
530
|
+
cwd: this.projectRoot,
|
|
531
|
+
encoding: 'utf-8',
|
|
532
|
+
}).trim();
|
|
533
|
+
// Parse GitHub URL
|
|
534
|
+
const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
535
|
+
if (match) {
|
|
536
|
+
return {
|
|
537
|
+
owner: match[1],
|
|
538
|
+
repo: match[2],
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// Git detection failed
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Build GitHub issue body for a user story
|
|
549
|
+
*/
|
|
550
|
+
buildGitHubIssueBody(incrementId, incrementInfo, us) {
|
|
551
|
+
return `## ${us.title}
|
|
552
|
+
|
|
553
|
+
**Feature**: ${incrementInfo.featureId}
|
|
554
|
+
**Increment**: ${incrementId}
|
|
555
|
+
**User Story**: ${us.id}
|
|
556
|
+
${us.project ? `**Project**: ${us.project}` : ''}
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
📋 See [\`spec.md\`](../../tree/develop/.specweave/increments/${incrementId}/spec.md) for full acceptance criteria.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
🤖 Auto-created by SpecWeave | Updates automatically on task completion
|
|
565
|
+
`;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Build GitHub issue body for feature-level issue
|
|
569
|
+
*/
|
|
570
|
+
buildFeatureLevelIssueBody(incrementId, incrementInfo) {
|
|
571
|
+
const userStoriesList = incrementInfo.userStories
|
|
572
|
+
.map((us) => `- [ ] ${us.id}: ${us.title}`)
|
|
573
|
+
.join('\n');
|
|
574
|
+
return `## ${incrementInfo.title}
|
|
575
|
+
|
|
576
|
+
**Feature**: ${incrementInfo.featureId}
|
|
577
|
+
**Status**: ${incrementInfo.status}
|
|
578
|
+
|
|
579
|
+
### User Stories
|
|
580
|
+
|
|
581
|
+
${userStoriesList || '_No user stories defined_'}
|
|
582
|
+
|
|
583
|
+
### Links
|
|
584
|
+
|
|
585
|
+
- **Spec**: [\`spec.md\`](../../tree/develop/.specweave/increments/${incrementId}/spec.md)
|
|
586
|
+
- **Tasks**: [\`tasks.md\`](../../tree/develop/.specweave/increments/${incrementId}/tasks.md)
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
🤖 Auto-created by SpecWeave | Updates automatically on task completion
|
|
591
|
+
`;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Build JIRA Epic description
|
|
595
|
+
*/
|
|
596
|
+
buildJiraEpicDescription(incrementId, incrementInfo) {
|
|
597
|
+
const userStoriesList = incrementInfo.userStories
|
|
598
|
+
.map((us) => `* ${us.id}: ${us.title}`)
|
|
599
|
+
.join('\n');
|
|
600
|
+
return `h2. ${incrementInfo.title}
|
|
601
|
+
|
|
602
|
+
*Feature*: ${incrementInfo.featureId}
|
|
603
|
+
*Increment*: ${incrementId}
|
|
604
|
+
|
|
605
|
+
h3. User Stories
|
|
606
|
+
|
|
607
|
+
${userStoriesList || '_No user stories defined_'}
|
|
608
|
+
|
|
609
|
+
----
|
|
610
|
+
|
|
611
|
+
🤖 Auto-created by SpecWeave
|
|
612
|
+
`;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Build ADO work item description
|
|
616
|
+
*/
|
|
617
|
+
buildAdoDescription(incrementId, incrementInfo) {
|
|
618
|
+
const userStoriesList = incrementInfo.userStories
|
|
619
|
+
.map((us) => `<li>${us.id}: ${us.title}</li>`)
|
|
620
|
+
.join('\n');
|
|
621
|
+
return `<h2>${incrementInfo.title}</h2>
|
|
622
|
+
|
|
623
|
+
<p><strong>Feature</strong>: ${incrementInfo.featureId}<br/>
|
|
624
|
+
<strong>Increment</strong>: ${incrementId}</p>
|
|
625
|
+
|
|
626
|
+
<h3>User Stories</h3>
|
|
627
|
+
<ul>
|
|
628
|
+
${userStoriesList || '<li><em>No user stories defined</em></li>'}
|
|
629
|
+
</ul>
|
|
630
|
+
|
|
631
|
+
<hr/>
|
|
632
|
+
<p>🤖 Auto-created by SpecWeave</p>
|
|
633
|
+
`;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Update metadata.json with GitHub issue
|
|
637
|
+
*/
|
|
638
|
+
async updateMetadataWithGitHubIssue(incrementId, issueNumber, issueUrl) {
|
|
639
|
+
const metadataPath = path.join(this.projectRoot, '.specweave/increments', incrementId, 'metadata.json');
|
|
640
|
+
let metadata = {};
|
|
641
|
+
if (existsSync(metadataPath)) {
|
|
642
|
+
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
643
|
+
}
|
|
644
|
+
metadata.github = {
|
|
645
|
+
issue: issueNumber,
|
|
646
|
+
url: issueUrl,
|
|
647
|
+
synced: new Date().toISOString(),
|
|
648
|
+
};
|
|
649
|
+
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Update metadata.json with JIRA issue
|
|
653
|
+
*/
|
|
654
|
+
async updateMetadataWithJiraIssue(incrementId, issueKey, issueUrl) {
|
|
655
|
+
const metadataPath = path.join(this.projectRoot, '.specweave/increments', incrementId, 'metadata.json');
|
|
656
|
+
let metadata = {};
|
|
657
|
+
if (existsSync(metadataPath)) {
|
|
658
|
+
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
659
|
+
}
|
|
660
|
+
metadata.jira = {
|
|
661
|
+
issue: issueKey,
|
|
662
|
+
url: issueUrl,
|
|
663
|
+
synced: new Date().toISOString(),
|
|
664
|
+
};
|
|
665
|
+
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Update metadata.json with ADO work item
|
|
669
|
+
*/
|
|
670
|
+
async updateMetadataWithAdoWorkItem(incrementId, workItemId, workItemUrl) {
|
|
671
|
+
const metadataPath = path.join(this.projectRoot, '.specweave/increments', incrementId, 'metadata.json');
|
|
672
|
+
let metadata = {};
|
|
673
|
+
if (existsSync(metadataPath)) {
|
|
674
|
+
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
|
|
675
|
+
}
|
|
676
|
+
metadata.ado = {
|
|
677
|
+
workItem: workItemId,
|
|
678
|
+
url: workItemUrl,
|
|
679
|
+
synced: new Date().toISOString(),
|
|
680
|
+
};
|
|
681
|
+
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Convenience function to auto-create issues for an increment
|
|
686
|
+
*/
|
|
687
|
+
export async function autoCreateExternalIssue(projectRoot, incrementId, logger) {
|
|
688
|
+
const creator = new ExternalIssueAutoCreator({
|
|
689
|
+
projectRoot,
|
|
690
|
+
logger,
|
|
691
|
+
});
|
|
692
|
+
return creator.createForIncrement(incrementId);
|
|
693
|
+
}
|
|
694
|
+
//# sourceMappingURL=external-issue-auto-creator.js.map
|