ai-git-tools 2.0.62 → 2.0.64

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/bin/cli.js CHANGED
@@ -17,6 +17,7 @@ import { prCommand } from '../src/commands/pr.js';
17
17
  import { initCommand } from '../src/commands/init.js';
18
18
  import { planIssueCommand } from '../src/commands/plan-issue.js';
19
19
  import { generateCodeCommand } from '../src/commands/generate-code.js';
20
+ import { devFromIssueCommand } from '../src/commands/dev-from-issue.js';
20
21
  import { writeAndTestCommand } from '../src/commands/write-and-test.js';
21
22
  import { autoDevCommand } from '../src/commands/auto-dev.js';
22
23
 
@@ -137,11 +138,32 @@ program
137
138
  }
138
139
  });
139
140
 
141
+ // Dev From Issue 命令(plan-issue + generate-code 合體)
142
+ program
143
+ .command('dev-from-issue')
144
+ .description('AI 讀取 Issue → 生成計畫 → 生成代碼(完成後繼續執行 write-and-test)')
145
+ .requiredOption('--issue <number>', 'GitHub Issue 編號')
146
+ .option('--file <path>', '目標檔案路徑(若無則自動推斷)')
147
+ .option('--context <description>', '額外說明或補充需求')
148
+ .option('--max-lines <number>', '最大行數限制(預設 500)', '500')
149
+ .option('--model <model>', '指定 AI 模型')
150
+ .action(async (options) => {
151
+ try {
152
+ await devFromIssueCommand(options);
153
+ process.exit(0);
154
+ } catch (error) {
155
+ console.error(`\n[錯誤] ${error.message}`);
156
+ process.exit(1);
157
+ }
158
+ });
159
+
140
160
  // Write And Test 命令
141
161
  program
142
162
  .command('write-and-test')
143
- .description('AI 為指定檔案生成 Jest 測試,並自動執行與修復(最多 2 次)')
144
- .requiredOption('--file <path>', '原始碼路徑')
163
+ .description('AI 為檔案生成測試(單元/元件),自動執行與修復,完成後發報告到 Issue(將自動讀取 dev-from-issue 的結果)')
164
+ .option('--file <path>', '原始碼路徑(對沒特別指定則自動從上次 dev-from-issue 讀取)')
165
+ .option('--issue <number>', '完成後發佈報告到此 Issue(對沒特別指定則自動從上次 dev-from-issue 讀取)')
166
+ .option('--issue <number>', '完成後將測試報告與流程圖發布到此 Issue 留言')
145
167
  .option('--test-type <type>', '測試類型:auto、unit、component 或 both(可用逗號指定多個)', 'auto')
146
168
  .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
147
169
  .option('--no-confirm', '跳過確認直接生成並執行測試')
@@ -151,6 +173,7 @@ program
151
173
  await writeAndTestCommand(options);
152
174
  process.exit(0);
153
175
  } catch (error) {
176
+ console.error(`\n[錯誤] ${error.message}`);
154
177
  process.exit(1);
155
178
  }
156
179
  });
@@ -172,6 +195,7 @@ program
172
195
  await autoDevCommand(options);
173
196
  process.exit(0);
174
197
  } catch (error) {
198
+ console.error(`\n[錯誤] ${error.message}`);
175
199
  process.exit(1);
176
200
  }
177
201
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.62",
3
+ "version": "2.0.64",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -5,13 +5,11 @@
5
5
  */
6
6
 
7
7
  import { resolve } from 'path';
8
- import { existsSync, readFileSync } from 'fs';
9
- import { execSync } from 'child_process';
10
8
  import inquirer from 'inquirer';
11
9
  import { IssueReader } from '../core/issue-reader.js';
12
10
  import { CodeGenerator } from '../core/code-generator.js';
13
11
  import { AIClient } from '../core/ai-client.js';
14
- import { writeAndTestCommand, TEST_TYPE_CHOICES, normalizeTestTypeSelection, resolveRequestedTestTypes } from './write-and-test.js';
12
+ import { writeAndTestCommand, TEST_TYPE_CHOICES, normalizeTestTypeSelection } from './write-and-test.js';
15
13
  import { Logger } from '../utils/logger.js';
16
14
 
17
15
  export async function autoDevCommand(options = {}) {
@@ -123,11 +121,6 @@ export async function autoDevCommand(options = {}) {
123
121
  requestedTestType: options.testType,
124
122
  });
125
123
 
126
- const resolvedRuntimeTestTypes = resolveRequestedTestTypes(absoluteFilePath, selectedTestType);
127
-
128
- logger.step('正在驗證專案可用的測試腳本...');
129
- ensureJestAvailable(resolvedRuntimeTestTypes);
130
- logger.success('已確認可執行 Jest 測試。');
131
124
  console.log('');
132
125
 
133
126
  // ============================================================
@@ -261,61 +254,3 @@ async function resolveTestTypeForAutoDev({ noConfirm, requestedTestType }) {
261
254
 
262
255
  return testType;
263
256
  }
264
-
265
- function ensureJestAvailable(runtimeTestType = ['unit']) {
266
- const packageJson = readProjectPackageJson();
267
-
268
- try {
269
- execSync('npx jest --version', {
270
- encoding: 'utf-8',
271
- stdio: 'pipe',
272
- cwd: process.cwd(),
273
- });
274
- ensureComponentTestDependencies(packageJson, runtimeTestType);
275
- return;
276
- } catch {
277
- const hasJestDependency = Boolean(
278
- packageJson.dependencies?.jest || packageJson.devDependencies?.jest
279
- );
280
-
281
- if (!hasJestDependency) {
282
- throw new Error('目前專案未安裝 Jest,請先安裝後再執行 auto-dev。');
283
- }
284
-
285
- ensureComponentTestDependencies(packageJson, runtimeTestType);
286
-
287
- throw new Error('偵測到 Jest 依賴,但無法執行 npx jest,請檢查安裝或 lockfile 狀態。');
288
- }
289
- }
290
-
291
- function readProjectPackageJson() {
292
- const packageJsonPath = resolve(process.cwd(), 'package.json');
293
- if (!existsSync(packageJsonPath)) {
294
- throw new Error('目前目錄沒有 package.json,無法執行 Jest 測試。');
295
- }
296
-
297
- try {
298
- return JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
299
- } catch {
300
- throw new Error('無法讀取目前專案的 package.json,請確認 Jest 已安裝。');
301
- }
302
- }
303
-
304
- function ensureComponentTestDependencies(packageJson, runtimeTestType) {
305
- if (!runtimeTestType.includes('component')) {
306
- return;
307
- }
308
-
309
- const hasJsdomDependency = Boolean(
310
- packageJson.devDependencies?.['jest-environment-jsdom']
311
- || packageJson.dependencies?.['jest-environment-jsdom']
312
- );
313
- const hasTestingLibraryDependency = Boolean(
314
- packageJson.devDependencies?.['@testing-library/react']
315
- || packageJson.dependencies?.['@testing-library/react']
316
- );
317
-
318
- if (!hasJsdomDependency || !hasTestingLibraryDependency) {
319
- throw new Error('元件測試需要 jest-environment-jsdom 與 @testing-library/react,請先安裝後再執行 auto-dev。');
320
- }
321
- }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * dev-from-issue 命令
3
+ * 讀取 GitHub Issue → 生成實現計畫 → 生成代碼
4
+ * 相當於 plan-issue + generate-code 的合體,並引導你下一步執行 write-and-test
5
+ */
6
+
7
+ import { resolve, join } from 'path';
8
+ import { writeFileSync } from 'fs';
9
+ import inquirer from 'inquirer';
10
+ import { IssueReader } from '../core/issue-reader.js';
11
+ import { CodeGenerator } from '../core/code-generator.js';
12
+ import { AIClient } from '../core/ai-client.js';
13
+ import { Logger } from '../utils/logger.js';
14
+
15
+ const STATE_FILE = '.ai-git-dev-state.json';
16
+
17
+ export function saveDevState(issueNumber, filePath) {
18
+ const statePath = join(process.cwd(), STATE_FILE);
19
+ writeFileSync(statePath, JSON.stringify({ issueNumber: String(issueNumber), filePath }, null, 2), 'utf-8');
20
+ }
21
+
22
+ export async function devFromIssueCommand(options = {}) {
23
+ const logger = new Logger();
24
+
25
+ const issueNumber = options.issue;
26
+
27
+ if (!issueNumber) {
28
+ logger.error('請提供 Issue 編號(--issue <number>)');
29
+ throw new Error('缺少 Issue 編號');
30
+ }
31
+
32
+ logger.header(`dev-from-issue`);
33
+
34
+ // ============================================================
35
+ // 第一步:讀取 Issue
36
+ // ============================================================
37
+ logger.step(`正在讀取 Issue #${issueNumber}...`);
38
+ const issue = await IssueReader.readIssue(issueNumber);
39
+ logger.section(`Issue #${issue.number}:${issue.title}`);
40
+ console.log(`作者:${issue.author} | URL:${issue.url}`);
41
+ if (issue.labels.length) {
42
+ console.log(`標籤:${issue.labels.join(', ')}`);
43
+ }
44
+ console.log('');
45
+
46
+ // ============================================================
47
+ // 第二步:生成實現計畫
48
+ // ============================================================
49
+ logger.step('正在分析 Issue 並生成實現計畫...');
50
+ const plan = await AIClient.sendAndWait(buildPlanPrompt(issue), options.model);
51
+ logger.section('AI 實現計畫');
52
+ console.log(plan);
53
+ console.log('');
54
+
55
+ // ============================================================
56
+ // 第三步:決定目標檔案路徑
57
+ // ============================================================
58
+ let filePath = options.file;
59
+
60
+ if (!filePath) {
61
+ filePath = await inferTargetFilePath(issue, plan, options.model);
62
+ if (filePath) {
63
+ logger.info(`自動推斷目標檔案:${filePath}`);
64
+ }
65
+ }
66
+
67
+ if (!filePath) {
68
+ const { file } = await inquirer.prompt([{
69
+ type: 'input',
70
+ name: 'file',
71
+ message: '自動推斷失敗,請輸入要生成的檔案路徑(例如:src/components/MyComponent.tsx):',
72
+ validate: (input) => input.trim().length > 0 ? true : '路徑不能為空',
73
+ }]);
74
+ filePath = file;
75
+ }
76
+
77
+ const absoluteFilePath = resolve(process.cwd(), filePath);
78
+
79
+ // ============================================================
80
+ // 第五步:生成代碼
81
+ // ============================================================
82
+ logger.step(`正在生成代碼:${filePath}`);
83
+ let result;
84
+ try {
85
+ result = await CodeGenerator.generateFile(issue, absoluteFilePath, {
86
+ maxLines: parseInt(options.maxLines || '500', 10),
87
+ language: /\.(ts|tsx)$/.test(filePath) ? 'ts' : 'js',
88
+ extraContext: options.context || '',
89
+ model: options.model,
90
+ });
91
+ } catch (error) {
92
+ logger.error(`代碼生成失敗:${error.message}`);
93
+ throw error;
94
+ }
95
+
96
+ saveDevState(issueNumber, filePath);
97
+
98
+ logger.success(`代碼已寫入:${result.filePath}(${result.linesCount} 行)`);
99
+ console.log('');
100
+ logger.section('✅ 開發完成');
101
+ console.log(`Issue:#${issue.number} ${issue.title}`);
102
+ console.log(`檔案 :${filePath}`);
103
+ console.log('');
104
+ console.log('💡 下一步:執行測試並產生報告');
105
+ console.log(' ai-git-tools write-and-test');
106
+ }
107
+
108
+ // ============================================================
109
+ // 工具函數
110
+ // ============================================================
111
+
112
+ function buildPlanPrompt(issue) {
113
+ return `你是一位軟體架構師。請閱讀以下 GitHub Issue 並建立詳細的實現計畫。
114
+
115
+ ## Issue #${issue.number}
116
+ 標題:${issue.title}
117
+
118
+ 描述:
119
+ ${issue.body || '(無描述)'}
120
+
121
+ ${issue.labels.length ? `標籤:${issue.labels.join(', ')}` : ''}
122
+
123
+ ## 輸出要求
124
+ 請輸出包含以下內容的實現計畫:
125
+
126
+ 1. **摘要** - 一句話說明要做什麼
127
+ 2. **需要新增/修改的檔案** - 列出所有相關檔案路徑和各自的職責
128
+ 3. **實現步驟** - 有序的任務清單,說明每個步驟的目的
129
+ 4. **注意事項** - 潛在風險或需要留意的事項(若有)
130
+
131
+ 請使用繁體中文,格式清晰,簡潔有力。`.trim();
132
+ }
133
+
134
+ async function inferTargetFilePath(issue, plan, model) {
135
+ const planCandidates = extractFileCandidates(plan);
136
+
137
+ if (planCandidates.length === 1) {
138
+ return planCandidates[0];
139
+ }
140
+
141
+ if (planCandidates.length > 1) {
142
+ return planCandidates.find((c) => c.startsWith('src/')) || planCandidates[0];
143
+ }
144
+
145
+ const prompt = `請根據以下 GitHub Issue 與實作計畫,推斷「最主要的實作檔案路徑」。
146
+
147
+ ## 規則
148
+ - 只回傳單一檔案路徑
149
+ - 優先回傳 src/ 底下的實作檔,不要回傳測試檔、文件檔、設定檔
150
+ - 副檔名限制為 .js、.ts、.jsx、.tsx、.mjs、.cjs
151
+ - 不要包含程式碼區塊、不要加說明
152
+
153
+ ## Issue #${issue.number}
154
+ 標題:${issue.title}
155
+ 描述:
156
+ ${issue.body || '(無描述)'}
157
+
158
+ ## 實作計畫
159
+ ${plan}`.trim();
160
+
161
+ const inferred = (await AIClient.sendAndWait(prompt, model)).trim().split(/\s+/)[0];
162
+ return isValidTargetPath(inferred) ? inferred : null;
163
+ }
164
+
165
+ function extractFileCandidates(plan) {
166
+ const matches = plan.match(/([A-Za-z0-9_./-]+\.(?:js|ts|jsx|tsx|mjs|cjs))/g) || [];
167
+ return [...new Set(matches.filter(isValidTargetPath))].filter((candidate) => {
168
+ const normalized = candidate.toLowerCase();
169
+ return !normalized.includes('__tests__/')
170
+ && !normalized.includes('.test.')
171
+ && !normalized.includes('.spec.')
172
+ && !normalized.endsWith('.config.js');
173
+ });
174
+ }
175
+
176
+ function isValidTargetPath(value) {
177
+ return typeof value === 'string'
178
+ && /^[A-Za-z0-9_./-]+\.(js|ts|jsx|tsx|mjs|cjs)$/.test(value)
179
+ && !value.startsWith('/');
180
+ }
@@ -4,12 +4,27 @@
4
4
  */
5
5
 
6
6
  import { resolve, basename, dirname, join } from 'path';
7
- import { existsSync, readFileSync } from 'fs';
7
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
8
+ import { execSync } from 'child_process';
9
+ import { tmpdir } from 'os';
8
10
  import inquirer from 'inquirer';
9
11
  import { TestGenerator } from '../core/test-generator.js';
10
12
  import { TestRunner } from '../core/test-runner.js';
13
+ import { AIClient } from '../core/ai-client.js';
11
14
  import { Logger } from '../utils/logger.js';
12
15
 
16
+ const STATE_FILE = '.ai-git-dev-state.json';
17
+
18
+ function loadDevState() {
19
+ const statePath = join(process.cwd(), STATE_FILE);
20
+ if (!existsSync(statePath)) return null;
21
+ try {
22
+ return JSON.parse(readFileSync(statePath, 'utf-8'));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
13
28
  export const TEST_TYPE_CHOICES = [
14
29
  {
15
30
  name: '寫測試(自動判斷 Jest 單元測試 / 元件測試)',
@@ -91,11 +106,15 @@ function formatTestTypes(testTypes) {
91
106
  export async function writeAndTestCommand(options = {}) {
92
107
  const logger = new Logger();
93
108
 
94
- const filePath = options.file;
109
+ // 自動從 state 檔讀取 file / issue(指令列將覆蓋)
110
+ const state = loadDevState();
111
+ const filePath = options.file || state?.filePath;
112
+ const issueNumber = options.issue || state?.issueNumber;
113
+ const fromState = !options.file && state?.filePath;
95
114
  const maxFixes = parseInt(options.maxFixes || '2', 10);
96
115
 
97
116
  if (!filePath) {
98
- logger.error('請提供原始碼路徑(--file <path>)');
117
+ logger.error('請提供原始碼路徑(--file <path>)或先執行 dev-from-issue');
99
118
  throw new Error('缺少原始碼路徑');
100
119
  }
101
120
 
@@ -105,23 +124,30 @@ export async function writeAndTestCommand(options = {}) {
105
124
  throw new Error(`找不到原始碼:${absoluteSourcePath}`);
106
125
  }
107
126
 
108
- const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || 'auto');
127
+ // 自動從 state 讀取時,測試類型預設為 both
128
+ const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || (fromState ? 'both' : 'auto'));
109
129
  const testTargets = resolvedTestTypes.map((testType) => ({
110
130
  testType,
111
131
  testFilePath: deriveTestFilePath(absoluteSourcePath, testType),
112
132
  }));
113
133
 
114
134
  logger.header('write-and-test');
135
+ if (fromState) {
136
+ logger.info('從上一步 dev-from-issue 自動讀取');
137
+ }
115
138
  console.log(`原始碼 :${absoluteSourcePath}`);
116
139
  console.log(`測試類型:${formatTestTypes(resolvedTestTypes)}`);
117
140
  testTargets.forEach(({ testType, testFilePath }) => {
118
141
  console.log(`${testType === 'component' ? '元件測試' : '單元測試'}:${testFilePath}`);
119
142
  });
120
143
  console.log(`最大修復:${maxFixes} 次`);
144
+ if (issueNumber) {
145
+ console.log(`報告 Issue:#${issueNumber}`);
146
+ }
121
147
  console.log('');
122
148
 
123
- // 1. 詢問是否繼續
124
- if (!options.noConfirm) {
149
+ // 1. 詢問是否繼續(自動從 state 讀取時跳過)
150
+ if (!options.noConfirm && !fromState) {
125
151
  const { confirmed } = await inquirer.prompt([{
126
152
  type: 'confirm',
127
153
  name: 'confirmed',
@@ -164,11 +190,18 @@ export async function writeAndTestCommand(options = {}) {
164
190
 
165
191
  if (result.success) {
166
192
  logger.success('所有測試通過! 🎉');
167
- return {
193
+ const testResult = {
168
194
  success: true,
169
195
  testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
170
196
  testTypes: generatedTests.map(({ testType }) => testType),
197
+ attempts: attempt,
171
198
  };
199
+ if (options.issue) {
200
+ await postTestReportToIssue(options.issue, absoluteSourcePath, testResult, options.model, logger);
201
+ } else if (issueNumber) {
202
+ await postTestReportToIssue(issueNumber, absoluteSourcePath, testResult, options.model, logger);
203
+ }
204
+ return testResult;
172
205
  }
173
206
 
174
207
  lastErrors = result.errors;
@@ -193,16 +226,125 @@ export async function writeAndTestCommand(options = {}) {
193
226
  generatedTests.forEach(({ testFilePath }) => {
194
227
  console.log(`測試路徑 :${testFilePath}`);
195
228
  });
196
- return {
229
+ const failedResult = {
197
230
  success: false,
198
231
  testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
199
232
  errors: lastErrors,
200
233
  testTypes: generatedTests.map(({ testType }) => testType),
234
+ attempts: maxFixes,
201
235
  };
236
+ if (options.issue) {
237
+ await postTestReportToIssue(options.issue, absoluteSourcePath, failedResult, options.model, logger);
238
+ } else if (issueNumber) {
239
+ await postTestReportToIssue(issueNumber, absoluteSourcePath, failedResult, options.model, logger);
240
+ }
241
+ return failedResult;
202
242
  }
203
243
  }
204
244
  }
205
245
 
246
+ // ============================================================
247
+ // GitHub Issue 測試報告留言
248
+ // ============================================================
249
+
250
+ /**
251
+ * 生成 mermaid 流程圖(基於原始碼邏輯)
252
+ */
253
+ async function generateMermaidDiagram(sourceFilePath, model) {
254
+ const sourceFileName = basename(sourceFilePath);
255
+ let sourceCode = '';
256
+ try {
257
+ sourceCode = readFileSync(sourceFilePath, 'utf-8').split('\n').slice(0, 150).join('\n');
258
+ } catch {
259
+ return null;
260
+ }
261
+
262
+ const prompt = `你是一位軟體工程師。請閱讀以下原始碼,生成一個 Mermaid flowchart 圖,描述這個模組的主要邏輯流程。
263
+
264
+ ## 規則
265
+ - 只輸出 mermaid 程式碼區塊(含 \`\`\`mermaid 與結尾 \`\`\`),不要有任何說明
266
+ - 使用 flowchart TD 格式
267
+ - 節點文字使用繁體中文
268
+ - 保持簡潔,最多 20 個節點
269
+
270
+ ## 檔案:${sourceFileName}
271
+
272
+ \`\`\`
273
+ ${sourceCode}
274
+ \`\`\``.trim();
275
+
276
+ try {
277
+ const raw = await AIClient.sendAndWait(prompt, model);
278
+ const match = raw.match(/```mermaid[\s\S]*?```/);
279
+ return match ? match[0] : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ /**
286
+ * 將測試報告與 mermaid 流程圖發佈到 GitHub Issue 留言
287
+ */
288
+ async function postTestReportToIssue(issueNumber, sourceFilePath, testResult, model, logger) {
289
+ logger.step(`正在將測試報告發佈到 Issue #${issueNumber}...`);
290
+
291
+ const typeLabels = { unit: 'Jest 單元測試', component: '元件測試', auto: '自動判斷' };
292
+ const testTypesStr = testResult.testTypes.map((t) => typeLabels[t] || t).join('、');
293
+ const statusEmoji = testResult.success ? '✅' : '❌';
294
+ const statusText = testResult.success
295
+ ? `通過(${testResult.attempts > 0 ? `經過 ${testResult.attempts} 次自動修復` : '一次通過'})`
296
+ : `失敗(已嘗試自動修復 ${testResult.attempts} 次)`;
297
+
298
+ const relativeSourcePath = sourceFilePath.replace(process.cwd() + '/', '');
299
+ const relativeTestPaths = testResult.testFilePaths
300
+ .map((p) => p.replace(process.cwd() + '/', ''))
301
+ .map((p) => `- \`${p}\``)
302
+ .join('\n');
303
+
304
+ let errorSection = '';
305
+ if (!testResult.success && testResult.errors?.length) {
306
+ const errorSummary = testResult.errors
307
+ .slice(0, 3)
308
+ .map((e, i) => `**錯誤 ${i + 1}**\n\`\`\`\n${e.split('\n').slice(0, 15).join('\n')}\n\`\`\``)
309
+ .join('\n\n');
310
+ errorSection = `\n### 錯誤摘要\n\n${errorSummary}\n`;
311
+ }
312
+
313
+ // 生成 mermaid
314
+ const mermaidSection = testResult.success
315
+ ? await (async () => {
316
+ const diagram = await generateMermaidDiagram(sourceFilePath, model);
317
+ return diagram ? `\n---\n\n## 📊 邏輯流程圖\n\n${diagram}\n` : '';
318
+ })()
319
+ : '';
320
+
321
+ const body = `## ${statusEmoji} 測試報告
322
+
323
+ **原始碼**:\`${relativeSourcePath}\`
324
+ **測試類型**:${testTypesStr}
325
+ **結果**:${statusText}
326
+
327
+ ### 測試檔案
328
+
329
+ ${relativeTestPaths}
330
+ ${errorSection}${mermaidSection}`;
331
+
332
+ const tmpFile = join(tmpdir(), `ai-git-tools-comment-${Date.now()}.md`);
333
+ try {
334
+ writeFileSync(tmpFile, body, 'utf-8');
335
+ execSync(`gh issue comment ${issueNumber} --body-file "${tmpFile}"`, {
336
+ encoding: 'utf-8',
337
+ stdio: 'pipe',
338
+ cwd: process.cwd(),
339
+ });
340
+ logger.success(`測試報告已發佈到 Issue #${issueNumber}`);
341
+ } catch (error) {
342
+ logger.warning(`無法發佈到 Issue(${error.message}),請確認 gh CLI 已登入且有 Issue 存取權限。`);
343
+ } finally {
344
+ try { unlinkSync(tmpFile); } catch { /* 忽略清理失敗 */ }
345
+ }
346
+ }
347
+
206
348
  /**
207
349
  * 依據原始檔案路徑推導測試檔案路徑
208
350
  */
@@ -1,12 +1,46 @@
1
1
  /**
2
2
  * TestRunner 服務
3
- * 執行 Jest 測試、解析結果,並在失敗時觸發自動修復(最多 2 次)
3
+ * 執行 Jest / bun test 測試、解析結果,並在失敗時觸發自動修復(最多 2 次)
4
4
  */
5
5
 
6
6
  import { execSync } from 'child_process';
7
- import { existsSync } from 'fs';
7
+ import { existsSync, readFileSync } from 'fs';
8
8
  import { resolve } from 'path';
9
9
 
10
+ /**
11
+ * 偵測目前專案使用的測試執行器
12
+ * 優先順序:bun test > vitest > jest
13
+ */
14
+ function detectTestRunner() {
15
+ const packageJsonPath = resolve(process.cwd(), 'package.json');
16
+ if (!existsSync(packageJsonPath)) {
17
+ return 'jest';
18
+ }
19
+
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
22
+ const allDeps = {
23
+ ...pkg.dependencies,
24
+ ...pkg.devDependencies,
25
+ };
26
+
27
+ // 偵測 bun:test 腳本含 bun test,或安裝了 bun
28
+ const testScript = pkg.scripts?.test || '';
29
+ if (testScript.includes('bun test')) {
30
+ return 'bun';
31
+ }
32
+
33
+ // 偵測 vitest
34
+ if (allDeps.vitest) {
35
+ return 'vitest';
36
+ }
37
+
38
+ return 'jest';
39
+ } catch {
40
+ return 'jest';
41
+ }
42
+ }
43
+
10
44
  export class TestRunner {
11
45
  /**
12
46
  * 執行指定的測試檔案
@@ -21,22 +55,32 @@ export class TestRunner {
21
55
  return { success: false, errors: [`測試檔案不存在:${missingTestFilePath}`] };
22
56
  }
23
57
 
58
+ const runner = detectTestRunner();
59
+
24
60
  try {
25
- const absoluteTestFilePaths = testFilePaths
26
- .map((filePath) => `"${resolve(filePath)}"`)
27
- .join(' ');
61
+ const absoluteTestFilePaths = testFilePaths.map((filePath) => `"${resolve(filePath)}"`);
62
+
63
+ let cmd;
64
+ if (runner === 'bun') {
65
+ // bun test 接受多個檔案作為參數
66
+ cmd = `bun test ${absoluteTestFilePaths.join(' ')}`;
67
+ } else if (runner === 'vitest') {
68
+ cmd = `npx vitest run ${absoluteTestFilePaths.join(' ')}`;
69
+ } else {
70
+ cmd = `npx jest --runInBand --runTestsByPath ${absoluteTestFilePaths.join(' ')} --no-coverage`;
71
+ }
28
72
 
29
- execSync(`npx jest --runInBand --runTestsByPath ${absoluteTestFilePaths} --no-coverage`, {
73
+ execSync(cmd, {
30
74
  encoding: 'utf-8',
31
75
  stdio: 'pipe',
32
76
  timeout: 120000, // 2 分鐘超時
33
77
  cwd: process.cwd(),
34
78
  });
35
- return { success: true, errors: [] };
79
+ return { success: true, errors: [], runner };
36
80
  } catch (error) {
37
81
  const output = (error.stdout || '') + (error.stderr || '');
38
82
  const errors = TestRunner._parseErrors(output);
39
- return { success: false, errors };
83
+ return { success: false, errors, runner };
40
84
  }
41
85
  }
42
86