@sk8metal/michi-cli 0.0.1 → 0.0.2

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 (47) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +24 -24
  3. package/dist/scripts/__tests__/validate-phase.test.d.ts +5 -0
  4. package/dist/scripts/__tests__/validate-phase.test.d.ts.map +1 -0
  5. package/dist/scripts/__tests__/validate-phase.test.js +162 -0
  6. package/dist/scripts/__tests__/validate-phase.test.js.map +1 -0
  7. package/dist/scripts/utils/__tests__/config-validator.test.d.ts +5 -0
  8. package/dist/scripts/utils/__tests__/config-validator.test.d.ts.map +1 -0
  9. package/dist/scripts/utils/__tests__/config-validator.test.js +247 -0
  10. package/dist/scripts/utils/__tests__/config-validator.test.js.map +1 -0
  11. package/dist/scripts/utils/__tests__/feature-name-validator.test.d.ts +5 -0
  12. package/dist/scripts/utils/__tests__/feature-name-validator.test.d.ts.map +1 -0
  13. package/dist/scripts/utils/__tests__/feature-name-validator.test.js +106 -0
  14. package/dist/scripts/utils/__tests__/feature-name-validator.test.js.map +1 -0
  15. package/dist/src/__tests__/cli.test.d.ts +5 -0
  16. package/dist/src/__tests__/cli.test.d.ts.map +1 -0
  17. package/dist/src/__tests__/cli.test.js +58 -0
  18. package/dist/src/__tests__/cli.test.js.map +1 -0
  19. package/docs/setup.md +1 -1
  20. package/package.json +5 -3
  21. package/scripts/__tests__/README.md +101 -0
  22. package/scripts/__tests__/validate-phase.test.ts +185 -0
  23. package/scripts/config/config-schema.ts +130 -0
  24. package/scripts/config/default-config.json +57 -0
  25. package/scripts/config-interactive.ts +494 -0
  26. package/scripts/confluence-sync.ts +503 -0
  27. package/scripts/create-project.ts +293 -0
  28. package/scripts/jira-sync.ts +644 -0
  29. package/scripts/list-projects.ts +85 -0
  30. package/scripts/markdown-to-confluence.ts +161 -0
  31. package/scripts/multi-project-estimate.ts +255 -0
  32. package/scripts/phase-runner.ts +303 -0
  33. package/scripts/pr-automation.ts +67 -0
  34. package/scripts/pre-flight-check.ts +285 -0
  35. package/scripts/resource-dashboard.ts +124 -0
  36. package/scripts/setup-env.sh +52 -0
  37. package/scripts/setup-existing-project.ts +381 -0
  38. package/scripts/setup-existing.sh +145 -0
  39. package/scripts/utils/__tests__/config-validator.test.ts +302 -0
  40. package/scripts/utils/__tests__/feature-name-validator.test.ts +129 -0
  41. package/scripts/utils/config-loader.ts +326 -0
  42. package/scripts/utils/config-validator.ts +347 -0
  43. package/scripts/utils/confluence-hierarchy.ts +854 -0
  44. package/scripts/utils/feature-name-validator.ts +135 -0
  45. package/scripts/utils/project-meta.ts +69 -0
  46. package/scripts/validate-phase.ts +279 -0
  47. package/scripts/workflow-orchestrator.ts +178 -0
@@ -0,0 +1,644 @@
1
+ /**
2
+ * JIRA連携スクリプト
3
+ * tasks.md から JIRA Epic/Story/Subtask を自動作成
4
+ *
5
+ * 【重要】Epic Link について:
6
+ * JIRA Cloud では Story を Epic に紐付けるには、Epic Link カスタムフィールド
7
+ * (通常 customfield_10014)を使用する必要があります。
8
+ *
9
+ * 現在の実装では parent フィールドを使用していますが、これは Subtask 専用です。
10
+ * Story 作成時に 400 エラーが発生する可能性があります。
11
+ *
12
+ * 対処方法:
13
+ * 1. JIRA 管理画面で Epic Link のカスタムフィールドIDを確認
14
+ * 2. 環境変数 JIRA_EPIC_LINK_FIELD に設定(例: customfield_10014)
15
+ * 3. または、Story 作成後に手動で Epic Link を設定
16
+ *
17
+ * 参考: https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post
18
+ */
19
+
20
+ import { readFileSync } from 'fs';
21
+ import { resolve } from 'path';
22
+ import axios from 'axios';
23
+ import { config } from 'dotenv';
24
+ import { loadProjectMeta } from './utils/project-meta.js';
25
+ import { validateFeatureNameOrThrow } from './utils/feature-name-validator.js';
26
+ import { getConfig } from './utils/config-loader.js';
27
+ import { validateForJiraSync } from './utils/config-validator.js';
28
+
29
+ config();
30
+
31
+ /**
32
+ * リクエスト間のスリープ処理(レートリミット対策)
33
+ */
34
+ function sleep(ms: number): Promise<void> {
35
+ return new Promise(resolve => setTimeout(resolve, ms));
36
+ }
37
+
38
+ /**
39
+ * リクエスト間の待機時間(ミリ秒)
40
+ * 環境変数 ATLASSIAN_REQUEST_DELAY で調整可能(デフォルト: 500ms)
41
+ */
42
+ function getRequestDelay(): number {
43
+ return parseInt(process.env.ATLASSIAN_REQUEST_DELAY || '500', 10);
44
+ }
45
+
46
+ /**
47
+ * Storyの詳細情報を抽出
48
+ */
49
+ interface StoryDetails {
50
+ title: string;
51
+ description?: string;
52
+ acceptanceCriteria?: string[];
53
+ subtasks?: string[];
54
+ dependencies?: string;
55
+ priority?: string;
56
+ estimate?: string;
57
+ assignee?: string;
58
+ dueDate?: string;
59
+ }
60
+
61
+ function extractStoryDetails(tasksContent: string, storyTitle: string): StoryDetails {
62
+ const details: StoryDetails = { title: storyTitle };
63
+
64
+ // Story セクションを抽出(ReDoS対策: [\s\S]*? → [^]*? に変更)
65
+ const escapedTitle = storyTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
66
+ const storyPattern = new RegExp(`### Story [\\d.]+: ${escapedTitle}\\n([^]*?)(?=\\n### Story|\\n## Phase|$)`, 'i');
67
+ const storyMatch = tasksContent.match(storyPattern);
68
+
69
+ if (!storyMatch) return details;
70
+
71
+ const storySection = storyMatch[1];
72
+
73
+ // 優先度抽出
74
+ const priorityMatch = storySection.match(/\*\*優先度\*\*:\s*(.+)/);
75
+ if (priorityMatch) details.priority = priorityMatch[1].trim();
76
+
77
+ // 見積もり抽出
78
+ const estimateMatch = storySection.match(/\*\*見積もり\*\*:\s*(.+)/);
79
+ if (estimateMatch) details.estimate = estimateMatch[1].trim();
80
+
81
+ // 担当抽出
82
+ const assigneeMatch = storySection.match(/\*\*担当\*\*:\s*(.+)/);
83
+ if (assigneeMatch) details.assignee = assigneeMatch[1].trim();
84
+
85
+ // 期限抽出
86
+ const dueDateMatch = storySection.match(/\*\*期限\*\*:\s*(\d{4}-\d{2}-\d{2})/);
87
+ if (dueDateMatch) details.dueDate = dueDateMatch[1];
88
+
89
+ // 説明抽出(改行あり・なし両方に対応)
90
+ const descriptionMatch = storySection.match(/\*\*説明\*\*:\s*\n?(.+?)(?=\n\*\*|$)/s);
91
+ if (descriptionMatch) details.description = descriptionMatch[1].trim();
92
+
93
+ // 完了条件抽出
94
+ const criteriaMatch = storySection.match(/\*\*完了条件\*\*:\s*\n((?:- \[.\].*\n?)+)/);
95
+ if (criteriaMatch) {
96
+ details.acceptanceCriteria = criteriaMatch[1]
97
+ .split('\n')
98
+ .filter(line => line.trim().startsWith('- ['))
99
+ .map(line => line.replace(/^- \[.\]\s*/, '').trim())
100
+ .filter(line => line.length > 0);
101
+ }
102
+
103
+ // サブタスク抽出
104
+ const subtasksMatch = storySection.match(/\*\*サブタスク\*\*:\s*\n((?:- \[.\].*\n?)+)/);
105
+ if (subtasksMatch) {
106
+ details.subtasks = subtasksMatch[1]
107
+ .split('\n')
108
+ .filter(line => line.trim().startsWith('- ['))
109
+ .map(line => line.replace(/^- \[.\]\s*/, '').trim())
110
+ .filter(line => line.length > 0);
111
+ }
112
+
113
+ // 依存関係抽出
114
+ const dependenciesMatch = storySection.match(/\*\*依存関係\*\*:\s*(.+)/);
115
+ if (dependenciesMatch) details.dependencies = dependenciesMatch[1].trim();
116
+
117
+ return details;
118
+ }
119
+
120
+ /**
121
+ * リッチなADF形式を生成
122
+ */
123
+ function createRichADF(details: StoryDetails, phaseLabel: string, githubUrl: string): any {
124
+ const content: any[] = [];
125
+
126
+ // 説明セクション
127
+ if (details.description) {
128
+ content.push({
129
+ type: 'heading',
130
+ attrs: { level: 2 },
131
+ content: [{ type: 'text', text: '説明' }]
132
+ });
133
+ content.push({
134
+ type: 'paragraph',
135
+ content: [{ type: 'text', text: details.description }]
136
+ });
137
+ }
138
+
139
+ // メタデータセクション
140
+ const metadata: string[] = [];
141
+ if (details.priority) metadata.push(`優先度: ${details.priority}`);
142
+ if (details.estimate) metadata.push(`見積もり: ${details.estimate}`);
143
+ if (details.assignee) metadata.push(`担当: ${details.assignee}`);
144
+ if (details.dependencies) metadata.push(`依存関係: ${details.dependencies}`);
145
+
146
+ if (metadata.length > 0) {
147
+ content.push({
148
+ type: 'heading',
149
+ attrs: { level: 2 },
150
+ content: [{ type: 'text', text: 'メタデータ' }]
151
+ });
152
+ metadata.forEach(item => {
153
+ content.push({
154
+ type: 'paragraph',
155
+ content: [{ type: 'text', text: item }]
156
+ });
157
+ });
158
+ }
159
+
160
+ // 完了条件セクション
161
+ if (details.acceptanceCriteria && details.acceptanceCriteria.length > 0) {
162
+ content.push({
163
+ type: 'heading',
164
+ attrs: { level: 2 },
165
+ content: [{ type: 'text', text: '完了条件' }]
166
+ });
167
+
168
+ const listItems = details.acceptanceCriteria.map(criterion => ({
169
+ type: 'listItem',
170
+ content: [{
171
+ type: 'paragraph',
172
+ content: [{ type: 'text', text: criterion }]
173
+ }]
174
+ }));
175
+
176
+ content.push({
177
+ type: 'bulletList',
178
+ content: listItems
179
+ });
180
+ }
181
+
182
+ // サブタスクセクション
183
+ if (details.subtasks && details.subtasks.length > 0) {
184
+ content.push({
185
+ type: 'heading',
186
+ attrs: { level: 2 },
187
+ content: [{ type: 'text', text: 'サブタスク' }]
188
+ });
189
+
190
+ const listItems = details.subtasks.map(subtask => ({
191
+ type: 'listItem',
192
+ content: [{
193
+ type: 'paragraph',
194
+ content: [{ type: 'text', text: subtask }]
195
+ }]
196
+ }));
197
+
198
+ content.push({
199
+ type: 'bulletList',
200
+ content: listItems
201
+ });
202
+ }
203
+
204
+ // フッター(Phase、GitHubリンク)
205
+ content.push({
206
+ type: 'rule'
207
+ });
208
+ content.push({
209
+ type: 'paragraph',
210
+ content: [
211
+ { type: 'text', text: 'Phase: ', marks: [{ type: 'strong' }] },
212
+ { type: 'text', text: phaseLabel }
213
+ ]
214
+ });
215
+ content.push({
216
+ type: 'paragraph',
217
+ content: [
218
+ { type: 'text', text: 'GitHub: ', marks: [{ type: 'strong' }] },
219
+ {
220
+ type: 'text',
221
+ text: githubUrl,
222
+ marks: [{
223
+ type: 'link',
224
+ attrs: { href: githubUrl }
225
+ }]
226
+ }
227
+ ]
228
+ });
229
+
230
+ return {
231
+ type: 'doc',
232
+ version: 1,
233
+ content: content
234
+ };
235
+ }
236
+
237
+ /**
238
+ * プレーンテキストをAtlassian Document Format(ADF)に変換
239
+ */
240
+ function textToADF(text: string): any {
241
+ // 改行で分割して段落を作成
242
+ const paragraphs = text.split('\n').filter(line => line.trim().length > 0);
243
+
244
+ return {
245
+ type: 'doc',
246
+ version: 1,
247
+ content: paragraphs.map(para => ({
248
+ type: 'paragraph',
249
+ content: [
250
+ {
251
+ type: 'text',
252
+ text: para.trim()
253
+ }
254
+ ]
255
+ }))
256
+ };
257
+ }
258
+
259
+ interface JIRAConfig {
260
+ url: string;
261
+ email: string;
262
+ apiToken: string;
263
+ }
264
+
265
+ function getJIRAConfig(): JIRAConfig {
266
+ const url = process.env.ATLASSIAN_URL;
267
+ const email = process.env.ATLASSIAN_EMAIL;
268
+ const apiToken = process.env.ATLASSIAN_API_TOKEN;
269
+
270
+ if (!url || !email || !apiToken) {
271
+ throw new Error('Missing JIRA credentials in .env');
272
+ }
273
+
274
+ return { url, email, apiToken };
275
+ }
276
+
277
+ class JIRAClient {
278
+ private baseUrl: string;
279
+ private auth: string;
280
+ private requestDelay: number;
281
+
282
+ constructor(config: JIRAConfig) {
283
+ this.baseUrl = `${config.url}/rest/api/3`;
284
+ this.auth = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
285
+ this.requestDelay = getRequestDelay();
286
+ }
287
+
288
+ /**
289
+ * JQL検索でIssueを検索
290
+ * @throws 検索エラー時は例外を再スロー(呼び出し元で処理)
291
+ */
292
+ async searchIssues(jql: string): Promise<any[]> {
293
+ // レートリミット対策: リクエスト前に待機
294
+ await sleep(this.requestDelay);
295
+
296
+ try {
297
+ const response = await axios.get(`${this.baseUrl}/search`, {
298
+ params: { jql, maxResults: 100 },
299
+ headers: {
300
+ 'Authorization': `Basic ${this.auth}`,
301
+ 'Content-Type': 'application/json'
302
+ }
303
+ });
304
+ return response.data.issues || [];
305
+ } catch (error) {
306
+ console.error('Error searching issues:', error instanceof Error ? error.message : error);
307
+ throw error; // エラーを再スローして呼び出し元で処理
308
+ }
309
+ }
310
+
311
+ async createIssue(payload: any): Promise<any> {
312
+ // レートリミット対策: リクエスト前に待機
313
+ await sleep(this.requestDelay);
314
+
315
+ const response = await axios.post(`${this.baseUrl}/issue`, payload, {
316
+ headers: {
317
+ 'Authorization': `Basic ${this.auth}`,
318
+ 'Content-Type': 'application/json'
319
+ }
320
+ });
321
+ return response.data;
322
+ }
323
+
324
+ async updateIssue(issueKey: string, payload: any): Promise<void> {
325
+ // レートリミット対策: リクエスト前に待機
326
+ await sleep(this.requestDelay);
327
+
328
+ await axios.put(`${this.baseUrl}/issue/${issueKey}`, payload, {
329
+ headers: {
330
+ 'Authorization': `Basic ${this.auth}`,
331
+ 'Content-Type': 'application/json'
332
+ }
333
+ });
334
+ }
335
+ }
336
+
337
+ async function syncTasksToJIRA(featureName: string): Promise<void> {
338
+ console.log(`Syncing tasks for feature: ${featureName}`);
339
+
340
+ // feature名のバリデーション(必須)
341
+ validateFeatureNameOrThrow(featureName);
342
+
343
+ // 実行前の必須設定値チェック
344
+ const validation = validateForJiraSync();
345
+
346
+ if (validation.info.length > 0) {
347
+ validation.info.forEach(msg => console.log(`ℹ️ ${msg}`));
348
+ }
349
+
350
+ if (validation.warnings.length > 0) {
351
+ console.warn('⚠️ Warnings:');
352
+ validation.warnings.forEach(warning => console.warn(` ${warning}`));
353
+ }
354
+
355
+ if (validation.errors.length > 0) {
356
+ console.error('❌ Configuration errors:');
357
+ validation.errors.forEach(error => console.error(` ${error}`));
358
+ const configPath = resolve('.kiro/config.json');
359
+ console.error(`\n設定ファイル: ${configPath}`);
360
+ throw new Error('JIRA同期に必要な設定値が不足しています。上記のエラーを確認して設定を修正してください。');
361
+ }
362
+
363
+ console.log(`⏳ Request delay: ${getRequestDelay()}ms (set ATLASSIAN_REQUEST_DELAY to adjust)`);
364
+
365
+ // 設定からissue type IDを取得(検索と作成の両方で使用)
366
+ const appConfig = getConfig();
367
+ const storyIssueTypeId = appConfig.jira?.issueTypes?.story || process.env.JIRA_ISSUE_TYPE_STORY;
368
+ const subtaskIssueTypeId = appConfig.jira?.issueTypes?.subtask || process.env.JIRA_ISSUE_TYPE_SUBTASK;
369
+
370
+ if (!storyIssueTypeId) {
371
+ throw new Error(
372
+ 'JIRA Story issue type ID is not configured. ' +
373
+ 'Please set JIRA_ISSUE_TYPE_STORY environment variable or configure it in .kiro/config.json. ' +
374
+ 'You can find the issue type ID in JIRA UI (Settings > Issues > Issue types) or via REST API: ' +
375
+ 'GET https://your-domain.atlassian.net/rest/api/3/issuetype'
376
+ );
377
+ }
378
+
379
+ const projectMeta = loadProjectMeta();
380
+ const tasksPath = resolve(`.kiro/specs/${featureName}/tasks.md`);
381
+ const tasksContent = readFileSync(tasksPath, 'utf-8');
382
+
383
+ const config = getJIRAConfig();
384
+ const client = new JIRAClient(config);
385
+
386
+ // spec.jsonを読み込んで既存のEpicキーを確認
387
+ const specPath = resolve(`.kiro/specs/${featureName}/spec.json`);
388
+ let spec: any = {};
389
+ try {
390
+ spec = JSON.parse(readFileSync(specPath, 'utf-8'));
391
+ } catch (error) {
392
+ console.error('spec.json not found or invalid');
393
+ }
394
+
395
+ let epic: any;
396
+
397
+ // 既存のEpicをチェック
398
+ if (spec.jira?.epicKey) {
399
+ console.log(`Existing Epic found: ${spec.jira.epicKey}`);
400
+ console.log('Skipping Epic creation (already exists)');
401
+ epic = { key: spec.jira.epicKey };
402
+ } else {
403
+ // Epic作成
404
+ console.log('Creating Epic...');
405
+ const epicSummary = `[${featureName}] ${projectMeta.projectName}`;
406
+
407
+ // 同じタイトルのEpicがすでに存在するかJQLで検索
408
+ const jql = `project = ${projectMeta.jiraProjectKey} AND issuetype = Epic AND summary ~ "${featureName}"`;
409
+ let existingEpics: any[] = [];
410
+ try {
411
+ existingEpics = await client.searchIssues(jql);
412
+ } catch (error) {
413
+ console.error('❌ Failed to search existing Epics:', error instanceof Error ? error.message : error);
414
+ console.error('⚠️ Cannot verify idempotency - Epic creation may result in duplicates');
415
+ throw new Error(`JIRA Epic search failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
416
+ }
417
+
418
+ if (existingEpics.length > 0) {
419
+ console.log(`Found existing Epic with similar title: ${existingEpics[0].key}`);
420
+ console.log('Using existing Epic instead of creating new one');
421
+ epic = existingEpics[0];
422
+ } else {
423
+ const epicDescription = `機能: ${featureName}\nGitHub: ${projectMeta.repository}/tree/main/.kiro/specs/${featureName}`;
424
+
425
+ const epicPayload = {
426
+ fields: {
427
+ project: { key: projectMeta.jiraProjectKey },
428
+ summary: epicSummary,
429
+ description: textToADF(epicDescription), // ADF形式に変換
430
+ issuetype: { name: 'Epic' },
431
+ labels: projectMeta.confluenceLabels
432
+ }
433
+ };
434
+
435
+ epic = await client.createIssue(epicPayload);
436
+ console.log(`✅ Epic created: ${epic.key}`);
437
+ }
438
+ }
439
+
440
+ // 既存のStoryを検索(重複防止)
441
+ // ラベルで検索(summary検索では "Story: タイトル" 形式に一致しないため)
442
+ // issuetype検索にはIDを使用(名前は言語依存のため)
443
+ const jql = `project = ${projectMeta.jiraProjectKey} AND issuetype = ${storyIssueTypeId} AND labels = "${featureName}"`;
444
+ let existingStories: any[] = [];
445
+ try {
446
+ existingStories = await client.searchIssues(jql);
447
+ } catch (error) {
448
+ console.error('❌ Failed to search existing Stories:', error instanceof Error ? error.message : error);
449
+ console.error('⚠️ Cannot verify idempotency - Story creation may result in duplicates');
450
+ console.error('⚠️ Continuing with story creation (duplicates may be created)...');
451
+ // 検索失敗時も処理を継続(既存ストーリーなしとして扱う)
452
+ existingStories = [];
453
+ }
454
+
455
+ const existingStorySummaries = new Set(existingStories.map((s: any) => s.fields.summary));
456
+ const existingStoryKeys = new Set(existingStories.map((s: any) => s.key));
457
+
458
+ console.log(`Found ${existingStories.length} existing stories for this feature`);
459
+
460
+ // フェーズラベル検出用の正規表現
461
+ // Phase X: フェーズ名(ラベル)の形式を検出
462
+ const phasePattern = /## Phase [\d.]+:\s*(.+?)((.+?))/;
463
+
464
+ // Story作成(フェーズ検出付きパーサー)
465
+ const lines = tasksContent.split('\n');
466
+ let currentPhaseLabel = 'implementation'; // デフォルトは実装フェーズ
467
+ const createdStories: string[] = [];
468
+
469
+ for (let i = 0; i < lines.length; i++) {
470
+ const line = lines[i];
471
+
472
+ // フェーズ検出
473
+ const phaseMatch = line.match(phasePattern);
474
+ if (phaseMatch) {
475
+ const phaseName = phaseMatch[2]; // 括弧内のラベル(例: Requirements)
476
+
477
+ // フェーズ名からラベルを決定
478
+ if (phaseName.includes('要件定義') || phaseName.toLowerCase().includes('requirements')) {
479
+ currentPhaseLabel = 'requirements';
480
+ } else if (phaseName.includes('設計') || phaseName.toLowerCase().includes('design')) {
481
+ currentPhaseLabel = 'design';
482
+ } else if (phaseName.includes('実装') || phaseName.toLowerCase().includes('implementation')) {
483
+ currentPhaseLabel = 'implementation';
484
+ } else if (phaseName.includes('試験') || phaseName.toLowerCase().includes('testing')) {
485
+ currentPhaseLabel = 'testing';
486
+ } else if (phaseName.includes('リリース準備') || phaseName.toLowerCase().includes('release-prep') || phaseName.toLowerCase().includes('release preparation')) {
487
+ currentPhaseLabel = 'release-prep';
488
+ } else if (phaseName.includes('リリース') || phaseName.toLowerCase().includes('release')) {
489
+ currentPhaseLabel = 'release';
490
+ }
491
+
492
+ console.log(`📌 Phase detected: ${phaseName} (label: ${currentPhaseLabel})`);
493
+ continue;
494
+ }
495
+
496
+ // Story検出
497
+ const storyMatch = line.match(/### Story [\d.]+: (.+)/);
498
+ if (!storyMatch) continue;
499
+
500
+ const storyTitle = storyMatch[1];
501
+ const storySummary = `Story: ${storyTitle}`;
502
+
503
+ // 既に同じタイトルのStoryが存在するかチェック
504
+ if (existingStorySummaries.has(storySummary)) {
505
+ console.log(`Skipping Story (already exists): ${storyTitle}`);
506
+ const existing = existingStories.find((s: any) => s.fields.summary === storySummary);
507
+ if (existing) {
508
+ createdStories.push(existing.key);
509
+ }
510
+ continue;
511
+ }
512
+
513
+ console.log(`Creating Story: ${storyTitle} [${currentPhaseLabel}]`);
514
+
515
+ try {
516
+ // Storyの詳細情報を抽出(新しい実装を使用)
517
+ const storyDetails = extractStoryDetails(tasksContent, storyTitle);
518
+
519
+ // GitHubリンク
520
+ const githubUrl = `${projectMeta.repository}/tree/main/.kiro/specs/${featureName}/tasks.md`;
521
+
522
+ // リッチなADF形式で説明文を生成
523
+ const richDescription = createRichADF(storyDetails, currentPhaseLabel, githubUrl);
524
+
525
+ // 優先度のマッピング(デフォルト: Medium)
526
+ const priorityMap: { [key: string]: string } = {
527
+ 'High': 'High',
528
+ 'Medium': 'Medium',
529
+ 'Low': 'Low'
530
+ };
531
+ const priority = storyDetails.priority && priorityMap[storyDetails.priority]
532
+ ? priorityMap[storyDetails.priority]
533
+ : 'Medium';
534
+
535
+ // 見積もり(Story Points)を取得
536
+ let storyPoints: number | undefined;
537
+ if (storyDetails.estimate) {
538
+ const spMatch = storyDetails.estimate.match(/(\d+)\s*SP/);
539
+ if (spMatch) {
540
+ storyPoints = parseInt(spMatch[1], 10);
541
+ }
542
+ }
543
+
544
+ // JIRAペイロードを作成(issue type IDは既に取得済み)
545
+ const storyPayload: any = {
546
+ fields: {
547
+ project: { key: projectMeta.jiraProjectKey },
548
+ summary: storySummary,
549
+ description: richDescription, // リッチなADF形式
550
+ issuetype: { id: storyIssueTypeId },
551
+ labels: [...projectMeta.confluenceLabels, featureName, currentPhaseLabel],
552
+ priority: { name: priority }
553
+ }
554
+ };
555
+
556
+ // 期限(Due Date)を設定
557
+ if (storyDetails.dueDate) {
558
+ storyPayload.fields.duedate = storyDetails.dueDate; // YYYY-MM-DD形式
559
+ }
560
+
561
+ // Story Pointsを設定(カスタムフィールド)
562
+ // 注意: JIRAプロジェクトによってカスタムフィールドIDが異なる場合があります
563
+ // 環境変数 JIRA_STORY_POINTS_FIELD で設定可能(例: customfield_10016)
564
+ if (storyPoints !== undefined) {
565
+ const storyPointsField = process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016';
566
+ storyPayload.fields[storyPointsField] = storyPoints;
567
+ }
568
+
569
+ // 担当者を設定(アカウントIDが必要な場合があるため、オプション)
570
+ // 注意: JIRAのアカウントIDが必要な場合があります
571
+ // if (storyInfo?.assignee) {
572
+ // storyPayload.fields.assignee = { name: storyInfo.assignee };
573
+ // }
574
+
575
+ const story = await client.createIssue(storyPayload);
576
+ console.log(` ✅ Story created: ${story.key} [${currentPhaseLabel}]`);
577
+
578
+ // 期限とStory Pointsの情報を表示
579
+ if (storyDetails.dueDate) {
580
+ console.log(` 期限: ${storyDetails.dueDate}`);
581
+ }
582
+ if (storyDetails.estimate) {
583
+ console.log(` 見積もり: ${storyDetails.estimate}`);
584
+ }
585
+ if (storyPoints !== undefined) {
586
+ console.log(` Story Points: ${storyPoints} SP`);
587
+ }
588
+
589
+ createdStories.push(story.key);
590
+
591
+ // 進捗表示(大量作成時の見通し向上)
592
+ if (createdStories.length % 5 === 0) {
593
+ console.log(` 📊 Progress: ${createdStories.length} stories created so far...`);
594
+ }
595
+
596
+ // Epic Linkは手動設定が必要(JIRA Cloudの制約)
597
+ console.log(` ℹ️ Epic: ${epic.key} に手動でリンクしてください`);
598
+ } catch (error: any) {
599
+ console.error(` ❌ Failed to create Story "${storyTitle}":`, error instanceof Error ? error.message : error);
600
+
601
+ // JIRA APIエラーの詳細を表示
602
+ if (error.response?.data) {
603
+ console.error(` 📋 JIRA API Error Details:`, JSON.stringify(error.response.data, null, 2));
604
+
605
+ // Story Pointsフィールドのエラーの場合、警告を表示
606
+ if (error.response.data.errors && Object.keys(error.response.data.errors).some(key => key.includes('customfield'))) {
607
+ console.error(` ⚠️ Story Pointsフィールドの設定に失敗しました。`);
608
+ console.error(` 💡 環境変数 JIRA_STORY_POINTS_FIELD を正しいカスタムフィールドIDに設定してください。`);
609
+ console.error(` 💡 JIRA管理画面でStory PointsのカスタムフィールドIDを確認してください。`);
610
+ }
611
+ }
612
+
613
+ // エラーがあっても他のStoryの作成は継続
614
+ }
615
+ }
616
+
617
+ // 新規作成数と再利用数を正確に計算
618
+ const newStoryCount = createdStories.filter(key => !existingStoryKeys.has(key)).length;
619
+ const reusedStoryCount = createdStories.filter(key => existingStoryKeys.has(key)).length;
620
+
621
+ console.log(`\n✅ JIRA sync completed`);
622
+ console.log(` Epic: ${epic.key}`);
623
+ console.log(` Stories: ${createdStories.length} processed (${newStoryCount} new, ${reusedStoryCount} reused)`);
624
+ }
625
+
626
+ // CLI実行
627
+ if (import.meta.url === `file://${process.argv[1]}`) {
628
+ const args = process.argv.slice(2);
629
+
630
+ if (args.length === 0) {
631
+ console.error('Usage: npm run jira:sync <feature-name>');
632
+ process.exit(1);
633
+ }
634
+
635
+ syncTasksToJIRA(args[0])
636
+ .then(() => process.exit(0))
637
+ .catch((error) => {
638
+ console.error('❌ JIRA sync failed:', error.message);
639
+ process.exit(1);
640
+ });
641
+ }
642
+
643
+ export { syncTasksToJIRA, JIRAClient };
644
+