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 +26 -2
- package/package.json +1 -1
- package/src/commands/auto-dev.js +1 -66
- package/src/commands/dev-from-issue.js +180 -0
- package/src/commands/write-and-test.js +150 -8
- package/src/core/test-runner.js +52 -8
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
|
|
144
|
-
.
|
|
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
package/src/commands/auto-dev.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/src/core/test-runner.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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(
|
|
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
|
|