ai-git-tools 2.0.57 → 2.0.59

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
@@ -15,6 +15,10 @@ import { commitCommand } from '../src/commands/commit.js';
15
15
  import { commitAllCommand } from '../src/commands/commit-all.js';
16
16
  import { prCommand } from '../src/commands/pr.js';
17
17
  import { initCommand } from '../src/commands/init.js';
18
+ import { planIssueCommand } from '../src/commands/plan-issue.js';
19
+ import { generateCodeCommand } from '../src/commands/generate-code.js';
20
+ import { writeAndTestCommand } from '../src/commands/write-and-test.js';
21
+ import { autoDevCommand } from '../src/commands/auto-dev.js';
18
22
 
19
23
  // 讀取 package.json 獲取版本號
20
24
  const __filename = fileURLToPath(import.meta.url);
@@ -87,6 +91,7 @@ program
87
91
  .option('--no-confirm', '跳過確認直接創建')
88
92
  .option('--auto-labels', '自動添加 Labels (預設啟用)')
89
93
  .option('--include-impact', '在 PR 中包含影響範圍分析和注意事項 (預設關閉)')
94
+ .option('--force-new', '強制創建新 PR,不更新現有 PR')
90
95
  .action(async (options) => {
91
96
  try {
92
97
  await prCommand(options);
@@ -96,4 +101,77 @@ program
96
101
  }
97
102
  });
98
103
 
104
+ // Plan Issue 命令
105
+ program
106
+ .command('plan-issue')
107
+ .description('AI 讀取 GitHub Issue 並生成結構化的實現計畫')
108
+ .requiredOption('--number <number>', 'Issue 編號')
109
+ .option('--no-confirm', '跳過確認,自動返回計畫')
110
+ .option('--model <model>', '指定 AI 模型')
111
+ .option('-v, --verbose', '顯示 Issue 詳細內容')
112
+ .action(async (options) => {
113
+ try {
114
+ await planIssueCommand(options);
115
+ process.exit(0);
116
+ } catch (error) {
117
+ process.exit(1);
118
+ }
119
+ });
120
+
121
+ // Generate Code 命令
122
+ program
123
+ .command('generate-code')
124
+ .description('AI 依據 GitHub Issue 生成符合專案風格的代碼')
125
+ .requiredOption('--issue <number>', 'Issue 編號')
126
+ .requiredOption('--file <path>', '目標檔案路徑(不可已存在)')
127
+ .option('--context <description>', '額外說明或補充需求')
128
+ .option('--max-lines <number>', '最大行數限制(預設 500)', '500')
129
+ .option('--no-confirm', '跳過預覽確認,直接寫入')
130
+ .option('--model <model>', '指定 AI 模型')
131
+ .action(async (options) => {
132
+ try {
133
+ await generateCodeCommand(options);
134
+ process.exit(0);
135
+ } catch (error) {
136
+ process.exit(1);
137
+ }
138
+ });
139
+
140
+ // Write And Test 命令
141
+ program
142
+ .command('write-and-test')
143
+ .description('AI 為指定檔案生成 Jest 測試,並自動執行與修復(最多 2 次)')
144
+ .requiredOption('--file <path>', '原始碼路徑')
145
+ .option('--test-type <type>', '測試類型:unit 或 component(預設 unit)', 'unit')
146
+ .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
147
+ .option('--model <model>', '指定 AI 模型')
148
+ .action(async (options) => {
149
+ try {
150
+ await writeAndTestCommand(options);
151
+ process.exit(0);
152
+ } catch (error) {
153
+ process.exit(1);
154
+ }
155
+ });
156
+
157
+ // Auto Dev 整合命令
158
+ program
159
+ .command('auto-dev')
160
+ .description('一鍵自動化:從 GitHub Issue 到代碼生成、測試與提交')
161
+ .requiredOption('--issue <number>', 'GitHub Issue 編號')
162
+ .option('--file <path>', '目標檔案路徑(若無則提示)')
163
+ .option('--context <description>', '額外說明或補充需求')
164
+ .option('--test-type <type>', '測試類型:unit 或 component(預設 unit)', 'unit')
165
+ .option('--max-lines <number>', '最大行數限制(預設 500)', '500')
166
+ .option('--no-confirm', '全自動模式,跳過所有確認')
167
+ .option('--model <model>', '指定 AI 模型')
168
+ .action(async (options) => {
169
+ try {
170
+ await autoDevCommand(options);
171
+ process.exit(0);
172
+ } catch (error) {
173
+ process.exit(1);
174
+ }
175
+ });
176
+
99
177
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.57",
3
+ "version": "2.0.59",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  "ai-git-tools": "./bin/cli.js"
9
9
  },
10
10
  "scripts": {
11
- "test": "echo \"Error: no test specified\" && exit 1",
11
+ "test": "jest",
12
+ "test:watch": "jest --watch",
12
13
  "prepare": "chmod +x bin/cli.js",
13
14
  "lint": "eslint src/**/*.js",
14
15
  "format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\"",
@@ -40,13 +41,15 @@
40
41
  ],
41
42
  "dependencies": {
42
43
  "@github/copilot-sdk": "0.1.30",
43
- "commander": "^12.0.0",
44
44
  "chalk": "^5.3.0",
45
- "ora": "^8.0.1",
46
- "inquirer": "^9.2.0"
45
+ "commander": "^12.0.0",
46
+ "inquirer": "^9.2.0",
47
+ "ora": "^8.0.1"
47
48
  },
48
49
  "devDependencies": {
50
+ "@jest/globals": "^29.7.0",
49
51
  "eslint": "^8.57.0",
52
+ "jest": "^29.7.0",
50
53
  "prettier": "^3.2.0"
51
54
  },
52
55
  "repository": {
@@ -56,5 +59,15 @@
56
59
  "bugs": {
57
60
  "url": "https://github.com/YisoTsao/ai-git-tools/issues"
58
61
  },
59
- "homepage": "https://github.com/YisoTsao/ai-git-tools#readme"
62
+ "homepage": "https://github.com/YisoTsao/ai-git-tools#readme",
63
+ "jest": {
64
+ "testEnvironment": "node",
65
+ "transform": {},
66
+ "extensionsToTreatAsEsm": [
67
+ ".js"
68
+ ],
69
+ "moduleNameMapper": {
70
+ "^(\\.{1,2}/.+)\\.js$": "$1"
71
+ }
72
+ }
60
73
  }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * auto-dev 整合命令
3
+ * 將 plan-issue、generate-code、write-and-test 串連起來
4
+ * 支援全自動模式(--no-confirm)和分步互動模式
5
+ */
6
+
7
+ import { resolve, dirname, basename, join } from 'path';
8
+ import inquirer from 'inquirer';
9
+ import { IssueReader } from '../core/issue-reader.js';
10
+ import { CodeGenerator } from '../core/code-generator.js';
11
+ import { TestGenerator } from '../core/test-generator.js';
12
+ import { TestRunner } from '../core/test-runner.js';
13
+ import { AIClient } from '../core/ai-client.js';
14
+ import { GitOperations } from '../core/git-operations.js';
15
+ import { Logger } from '../utils/logger.js';
16
+
17
+ export async function autoDevCommand(options = {}) {
18
+ const logger = new Logger();
19
+
20
+ const issueNumber = options.issue;
21
+ if (!issueNumber) {
22
+ logger.error('請提供 Issue 編號(--issue <number>)');
23
+ throw new Error('缺少 Issue 編號');
24
+ }
25
+
26
+ const noConfirm = options.noConfirm;
27
+ let filePath = options.file;
28
+
29
+ logger.header('auto-dev 整合工作流');
30
+ if (noConfirm) {
31
+ console.log('模式:全自動(跳過所有確認)');
32
+ } else {
33
+ console.log('模式:分步互動(每步完成後詢問)');
34
+ }
35
+ console.log('');
36
+
37
+ // ============================================================
38
+ // 第一步:讀取 Issue
39
+ // ============================================================
40
+ logger.step(`正在讀取 Issue #${issueNumber}...`);
41
+ const issue = await IssueReader.readIssue(issueNumber);
42
+ logger.success(`Issue:${issue.title}`);
43
+ console.log(`作者:${issue.author} | URL:${issue.url}`);
44
+ console.log('');
45
+
46
+ // ============================================================
47
+ // 第二步:生成計畫
48
+ // ============================================================
49
+ logger.step('正在分析 Issue 並生成實現計畫...');
50
+ const planPrompt = buildPlanPrompt(issue);
51
+ const plan = await AIClient.sendAndWait(planPrompt, options.model);
52
+ logger.section('AI 生成的實現計畫');
53
+ console.log(plan);
54
+ console.log('');
55
+
56
+ // ============================================================
57
+ // 第三步:詢問是否繼續(若非全自動)
58
+ // ============================================================
59
+ if (!noConfirm) {
60
+ const { proceed } = await inquirer.prompt([{
61
+ type: 'confirm',
62
+ name: 'proceed',
63
+ message: '計畫確認無誤,是否繼續進行代碼生成?',
64
+ default: true,
65
+ }]);
66
+
67
+ if (!proceed) {
68
+ logger.info('已取消。');
69
+ return;
70
+ }
71
+ }
72
+
73
+ // ============================================================
74
+ // 第四步:詢問目標檔案路徑(若未指定)
75
+ // ============================================================
76
+ if (!filePath) {
77
+ const { file } = await inquirer.prompt([{
78
+ type: 'input',
79
+ name: 'file',
80
+ message: '請輸入要生成的檔案路徑(例如:src/core/new-feature.js):',
81
+ validate: (input) => input.trim().length > 0 ? true : '路徑不能為空',
82
+ }]);
83
+ filePath = file;
84
+ }
85
+
86
+ const absoluteFilePath = resolve(process.cwd(), filePath);
87
+ console.log('');
88
+
89
+ // ============================================================
90
+ // 第五步:生成代碼
91
+ // ============================================================
92
+ logger.step(`正在生成代碼:${filePath}`);
93
+ let codeResult;
94
+ try {
95
+ codeResult = await CodeGenerator.generateFile(issue, absoluteFilePath, {
96
+ maxLines: parseInt(options.maxLines || '500', 10),
97
+ language: filePath.endsWith('.ts') ? 'ts' : 'js',
98
+ extraContext: options.context || '',
99
+ model: options.model,
100
+ });
101
+ logger.success(`代碼已生成:${codeResult.linesCount} 行`);
102
+ } catch (error) {
103
+ logger.error(`代碼生成失敗:${error.message}`);
104
+ throw error;
105
+ }
106
+
107
+ console.log('');
108
+
109
+ // ============================================================
110
+ // 第六步:詢問是否執行測試(若非全自動)
111
+ // ============================================================
112
+ if (!noConfirm) {
113
+ const { runTest } = await inquirer.prompt([{
114
+ type: 'confirm',
115
+ name: 'runTest',
116
+ message: '代碼已生成,是否為該檔案生成並執行測試?',
117
+ default: true,
118
+ }]);
119
+
120
+ if (!runTest) {
121
+ logger.success('完成!代碼已生成。可稍後手動運行:');
122
+ console.log(` ai-git-tools write-and-test --file ${filePath}`);
123
+ return;
124
+ }
125
+ }
126
+
127
+ // ============================================================
128
+ // 第七步:生成與執行測試(自動修復最多 2 次)
129
+ // ============================================================
130
+ const testFilePath = deriveTestFilePath(absoluteFilePath);
131
+ const maxFixes = 2;
132
+
133
+ logger.step('正在生成測試...');
134
+ await TestGenerator.generateTests(
135
+ absoluteFilePath,
136
+ testFilePath,
137
+ options.testType || 'unit',
138
+ options.model
139
+ );
140
+ logger.success(`測試已生成:${testFilePath}`);
141
+ console.log('');
142
+
143
+ let lastErrors = [];
144
+ for (let attempt = 0; attempt <= maxFixes; attempt++) {
145
+ if (attempt > 0) {
146
+ logger.step(`進行第 ${attempt}/${maxFixes} 次自動修復...`);
147
+ await TestGenerator.generateFix(absoluteFilePath, lastErrors, attempt, options.model);
148
+ logger.success('修復代碼已更新。');
149
+ }
150
+
151
+ logger.step(`執行測試${attempt > 0 ? `(第 ${attempt} 次修復後)` : ''}...`);
152
+ const result = await TestRunner.runTests(testFilePath);
153
+
154
+ if (result.success) {
155
+ logger.success('所有測試通過! 🎉');
156
+ console.log('');
157
+ break;
158
+ }
159
+
160
+ lastErrors = result.errors;
161
+ if (attempt < maxFixes) {
162
+ logger.warning(`測試失敗(${result.errors.length} 個錯誤),嘗試自動修復...`);
163
+ } else {
164
+ logger.warning(`測試在第 ${maxFixes} 次修復後仍未通過`);
165
+ console.log('');
166
+ lastErrors.slice(0, 2).forEach((e, i) => {
167
+ console.log(`\n[錯誤 ${i + 1}]`);
168
+ console.log(e.split('\n').slice(0, 8).join('\n'));
169
+ });
170
+ console.log('');
171
+ logger.error('自動修復失敗,需要手動介入。');
172
+ return;
173
+ }
174
+ }
175
+
176
+ // ============================================================
177
+ // 第八步:自動 Commit
178
+ // ============================================================
179
+ logger.step('正在自動提交...');
180
+ try {
181
+ GitOperations.exec(`git add "${absoluteFilePath}" "${testFilePath}"`, { silent: true });
182
+ const commitMsg = `feat(auto-dev): Issue #${issue.number} - ${issue.title}\n\n生成檔案:\n- ${filePath}\n- ${testFilePath.replace(process.cwd(), '.')}`;
183
+ GitOperations.exec(`git commit -m "${commitMsg.split('\n')[0]}"`, { silent: true });
184
+ logger.success('已自動提交');
185
+ } catch {
186
+ logger.warning('自動 commit 失敗(可能已有相同 commit 或尚未 staged)');
187
+ }
188
+
189
+ console.log('');
190
+ logger.section('✅ 自動化工作流完成');
191
+ console.log(`Issue #${issue.number}:${issue.title}`);
192
+ console.log(`生成檔案:${filePath}`);
193
+ console.log(`測試檔案:${testFilePath.replace(process.cwd(), '.')}`);
194
+ console.log(`合計行數:${codeResult.linesCount + ' (程式碼)'}`);
195
+ }
196
+
197
+ /**
198
+ * 建立計畫生成 prompt
199
+ */
200
+ function buildPlanPrompt(issue) {
201
+ return `你是一位軟體架構師。請閱讀以下 GitHub Issue 並建立詳細的實現計畫。
202
+
203
+ ## Issue #${issue.number}
204
+ 標題:${issue.title}
205
+
206
+ 描述:
207
+ ${issue.body || '(無描述)'}
208
+
209
+ ${issue.labels.length ? `標籤:${issue.labels.join(', ')}` : ''}
210
+
211
+ ## 輸出要求
212
+ 請輸出包含以下內容的實現計畫:
213
+
214
+ 1. **摘要** - 一句話說明要做什麼
215
+ 2. **需要新增/修改的檔案** - 列出所有相關檔案路徑和各自的職責
216
+ 3. **實現步驟** - 有序的任務清單,說明每個步驟的目的
217
+ 4. **注意事項** - 潛在風險或需要留意的事項(若有)
218
+
219
+ 請使用繁體中文,格式清晰,簡潔有力。`.trim();
220
+ }
221
+
222
+ /**
223
+ * 依據原始檔案路徑推導測試檔案路徑
224
+ */
225
+ function deriveTestFilePath(sourceFilePath) {
226
+ const dir = dirname(sourceFilePath);
227
+ const name = basename(sourceFilePath).replace(/\.(js|ts|mjs|cjs)$/, '');
228
+ const ext = sourceFilePath.endsWith('.ts') ? '.ts' : '.js';
229
+ return join(dir, '__tests__', `${name}.test${ext}`);
230
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * generate-code 命令
3
+ * 讀取 GitHub Issue,依照指定路徑生成符合專案風格的代碼
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { resolve } from 'path';
8
+ import inquirer from 'inquirer';
9
+ import { IssueReader } from '../core/issue-reader.js';
10
+ import { CodeGenerator } from '../core/code-generator.js';
11
+ import { Logger } from '../utils/logger.js';
12
+
13
+ export async function generateCodeCommand(options = {}) {
14
+ const logger = new Logger();
15
+
16
+ // 驗證必要參數
17
+ const issueNumber = options.issue;
18
+ const filePath = options.file;
19
+ const extraContext = options.context || '';
20
+ const maxLines = parseInt(options.maxLines || '500', 10);
21
+
22
+ if (!issueNumber) {
23
+ logger.error('請提供 Issue 編號(--issue <number>)');
24
+ throw new Error('缺少 Issue 編號');
25
+ }
26
+ if (!filePath) {
27
+ logger.error('請提供目標檔案路徑(--file <path>)');
28
+ throw new Error('缺少目標檔案路徑');
29
+ }
30
+
31
+ const absolutePath = resolve(process.cwd(), filePath);
32
+
33
+ logger.header(`generate-code`);
34
+ console.log(`Issue:#${issueNumber}`);
35
+ console.log(`目標檔案:${absolutePath}`);
36
+ console.log(`最大行數:${maxLines}`);
37
+ console.log('');
38
+
39
+ // 1. 讀取 Issue
40
+ logger.step(`正在讀取 Issue #${issueNumber}...`);
41
+ const issue = await IssueReader.readIssue(issueNumber);
42
+ logger.info(`Issue:${issue.title}`);
43
+
44
+ // 2. 詢問確認後再生成
45
+ if (!options.noConfirm) {
46
+ const { confirmed } = await inquirer.prompt([{
47
+ type: 'confirm',
48
+ name: 'confirmed',
49
+ message: `確認為檔案 ${filePath} 生成代碼?`,
50
+ default: true,
51
+ }]);
52
+
53
+ if (!confirmed) {
54
+ logger.info('已取消。');
55
+ return;
56
+ }
57
+ }
58
+
59
+ // 3. 生成代碼
60
+ logger.step('正在生成代碼中,請稍候...\n');
61
+
62
+ const result = await CodeGenerator.generateFile(issue, absolutePath, {
63
+ maxLines,
64
+ language: absolutePath.endsWith('.ts') ? 'ts' : 'js',
65
+ extraContext,
66
+ model: options.model,
67
+ });
68
+
69
+ // 4. 顯示結果
70
+ logger.section(`生成完成`);
71
+ logger.success(`檔案已寫入:${result.filePath}`);
72
+ logger.info(`共 ${result.linesCount} 行`);
73
+
74
+ // 5. 預覽前 20 行
75
+ try {
76
+ const preview = readFileSync(result.filePath, 'utf-8').split('\n').slice(0, 20).join('\n');
77
+ console.log('\n--- 預覽(前 20 行)---');
78
+ console.log(preview);
79
+ console.log('---');
80
+ } catch {
81
+ // 忽略預覽讀取失敗
82
+ }
83
+
84
+ console.log('\n💡 下一步:執行 write-and-test 為此檔案生成並執行測試');
85
+ console.log(` ai-git-tools write-and-test --file ${filePath}`);
86
+
87
+ return result;
88
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * plan-issue 命令
3
+ * 讀取 GitHub Issue,使用 AI 生成結構化的實現計畫
4
+ */
5
+
6
+ import inquirer from 'inquirer';
7
+ import { IssueReader } from '../core/issue-reader.js';
8
+ import { AIClient } from '../core/ai-client.js';
9
+ import { Logger } from '../utils/logger.js';
10
+
11
+ export async function planIssueCommand(options = {}) {
12
+ const logger = new Logger();
13
+
14
+ const issueNumber = options.number || options.issue;
15
+ if (!issueNumber) {
16
+ logger.error('請提供 Issue 編號(--number <number>)');
17
+ throw new Error('缺少 Issue 編號');
18
+ }
19
+
20
+ logger.header(`plan-issue #${issueNumber}`);
21
+
22
+ // 1. 讀取 Issue
23
+ logger.step(`正在讀取 Issue #${issueNumber}...`);
24
+ const issue = await IssueReader.readIssue(issueNumber);
25
+
26
+ logger.section(`Issue #${issue.number}:${issue.title}`);
27
+ if (options.verbose) {
28
+ console.log(`作者:${issue.author}`);
29
+ console.log(`標籤:${issue.labels.join(', ') || '(無)'}`);
30
+ console.log(`URL:${issue.url}`);
31
+ console.log(`\n描述:\n${issue.body || '(無描述)'}`);
32
+ }
33
+
34
+ // 2. 呼叫 AI 生成計畫
35
+ logger.step('正在分析 Issue 並生成實現計畫...\n');
36
+ const prompt = buildPlanPrompt(issue);
37
+ const plan = await AIClient.sendAndWait(prompt, options.model);
38
+
39
+ // 3. 顯示計畫
40
+ logger.section('AI 生成的實現計畫');
41
+ console.log(plan);
42
+ console.log('');
43
+
44
+ // 4. 詢問確認(除非 --no-confirm)
45
+ if (options.noConfirm) {
46
+ logger.success('自動模式:跳過確認,繼續進行代碼生成。');
47
+ return { issue, plan };
48
+ }
49
+
50
+ const { proceed } = await inquirer.prompt([{
51
+ type: 'confirm',
52
+ name: 'proceed',
53
+ message: '是否確認此計畫並繼續進行代碼生成?',
54
+ default: false,
55
+ }]);
56
+
57
+ if (proceed) {
58
+ logger.success('計畫已確認!可以使用 generate-code 命令開始生成代碼。');
59
+ console.log('');
60
+ console.log(` ai-git-tools generate-code --issue ${issue.number} --file <目標檔案路徑> --context <描述>`);
61
+ } else {
62
+ logger.info('已取消。請根據上方計畫手動進行後續操作。');
63
+ }
64
+
65
+ return { issue, plan };
66
+ }
67
+
68
+ /**
69
+ * 建立計畫生成 prompt
70
+ */
71
+ function buildPlanPrompt(issue) {
72
+ return `你是一位軟體架構師。請閱讀以下 GitHub Issue 並建立詳細的實現計畫。
73
+
74
+ ## Issue #${issue.number}
75
+ 標題:${issue.title}
76
+
77
+ 描述:
78
+ ${issue.body || '(無描述)'}
79
+
80
+ ${issue.labels.length ? `標籤:${issue.labels.join(', ')}` : ''}
81
+
82
+ ## 輸出要求
83
+ 請輸出包含以下內容的實現計畫:
84
+
85
+ 1. **摘要** - 一句話說明要做什麼
86
+ 2. **需要新增/修改的檔案** - 列出所有相關檔案路徑和各自的職責
87
+ 3. **實現步驟** - 有序的任務清單,說明每個步驟的目的
88
+ 4. **注意事項** - 潛在風險或需要留意的事項(若有)
89
+
90
+ 請使用繁體中文,格式清晰,簡潔有力。`.trim();
91
+ }
@@ -36,7 +36,7 @@ function checkGHAuth(logger) {
36
36
  /**
37
37
  * PR 命令主函數(完全照抄 scripts/ai-auto-pr.mjs)
38
38
  */
39
- export async function prCommand() {
39
+ export async function prCommand(options = {}) {
40
40
  const logger = new Logger();
41
41
 
42
42
  // ── 第一步:確認 gh CLI 已登入 ──────────────────────────
@@ -46,6 +46,7 @@ export async function prCommand() {
46
46
  logger.header('AI Auto PR Generator (v2.0 Enhanced)');
47
47
 
48
48
  // 載入配置(使用 scripts/ 的配置載入邏輯)
49
+
49
50
  const config = await loadConfig();
50
51
 
51
52
  if (config.output.verbose) {
@@ -54,6 +55,11 @@ export async function prCommand() {
54
55
  console.log(` Max Diff Length: ${config.ai.maxDiffLength}`);
55
56
  }
56
57
 
58
+ // 將命令行選項合併到配置中
59
+ if (options.forceNew) {
60
+ config.forceNew = true;
61
+ }
62
+
57
63
  // 執行工作流程(使用 scripts/ 的完整工作流)
58
64
  const workflow = new PRWorkflow(config);
59
65
  await workflow.execute();
@@ -0,0 +1,128 @@
1
+ /**
2
+ * write-and-test 命令
3
+ * 為指定檔案生成 Jest 測試,執行測試,失敗時自動修復(最多 2 次)
4
+ */
5
+
6
+ import { resolve, basename, dirname, join } from 'path';
7
+ import { existsSync } from 'fs';
8
+ import { execSync } from 'child_process';
9
+ import inquirer from 'inquirer';
10
+ import { TestGenerator } from '../core/test-generator.js';
11
+ import { TestRunner } from '../core/test-runner.js';
12
+ import { Logger } from '../utils/logger.js';
13
+
14
+ export async function writeAndTestCommand(options = {}) {
15
+ const logger = new Logger();
16
+
17
+ const filePath = options.file;
18
+ const testType = options.testType || 'unit';
19
+ const maxFixes = parseInt(options.maxFixes || '2', 10);
20
+
21
+ if (!filePath) {
22
+ logger.error('請提供原始碼路徑(--file <path>)');
23
+ throw new Error('缺少原始碼路徑');
24
+ }
25
+
26
+ const absoluteSourcePath = resolve(process.cwd(), filePath);
27
+ if (!existsSync(absoluteSourcePath)) {
28
+ logger.error(`原始碼不存在:${absoluteSourcePath}`);
29
+ throw new Error(`找不到原始碼:${absoluteSourcePath}`);
30
+ }
31
+
32
+ // 推導測試檔案路徑
33
+ const testFilePath = deriveTestFilePath(absoluteSourcePath);
34
+
35
+ logger.header('write-and-test');
36
+ console.log(`原始碼 :${absoluteSourcePath}`);
37
+ console.log(`測試檔案:${testFilePath}`);
38
+ console.log(`測試類型:${testType}`);
39
+ console.log(`最大修復:${maxFixes} 次`);
40
+ console.log('');
41
+
42
+ // 1. 詢問是否繼續
43
+ const { confirmed } = await inquirer.prompt([{
44
+ type: 'confirm',
45
+ name: 'confirmed',
46
+ message: `確認為 ${filePath} 生成 ${testType} 測試並執行?`,
47
+ default: true,
48
+ }]);
49
+
50
+ if (!confirmed) {
51
+ logger.info('已取消。');
52
+ return;
53
+ }
54
+
55
+ // 2. 生成測試
56
+ logger.step('正在生成測試代碼...');
57
+ await TestGenerator.generateTests(absoluteSourcePath, testFilePath, testType, options.model);
58
+ logger.success(`測試已寫入:${testFilePath}`);
59
+
60
+ // 3. 執行測試 + 自動修復循環
61
+ let lastErrors = [];
62
+ for (let attempt = 0; attempt <= maxFixes; attempt++) {
63
+ if (attempt > 0) {
64
+ logger.step(`正在進行第 ${attempt}/${maxFixes} 次自動修復...`);
65
+ await TestGenerator.generateFix(absoluteSourcePath, lastErrors, attempt, options.model);
66
+ logger.success('修復代碼已更新。');
67
+ }
68
+
69
+ logger.step(`正在執行測試${attempt > 0 ? `(第 ${attempt} 次修復後)` : ''}...`);
70
+ const result = await TestRunner.runTests(testFilePath);
71
+
72
+ if (result.success) {
73
+ logger.success('所有測試通過! 🎉');
74
+
75
+ // 4. 自動 commit 測試檔案
76
+ await commitTestFile(testFilePath, absoluteSourcePath, logger);
77
+ return { success: true, testFilePath };
78
+ }
79
+
80
+ lastErrors = result.errors;
81
+ logger.warning(`測試失敗(${result.errors.length} 個錯誤):`);
82
+ result.errors.forEach((e) => {
83
+ console.log('');
84
+ console.log(e.split('\n').slice(0, 10).join('\n'));
85
+ });
86
+
87
+ if (attempt >= maxFixes) {
88
+ // 已達最大嘗試次數,升級給使用者
89
+ logger.section('⚠️ 自動修復失敗,需要人工介入');
90
+ console.log(`已嘗試自動修復 ${maxFixes} 次,測試仍未通過。`);
91
+ console.log('');
92
+ console.log('請手動查看以下錯誤並修正:');
93
+ lastErrors.forEach((e, i) => {
94
+ console.log(`\n[錯誤 ${i + 1}]`);
95
+ console.log(e);
96
+ });
97
+ console.log('');
98
+ console.log(`原始碼路徑:${absoluteSourcePath}`);
99
+ console.log(`測試路徑 :${testFilePath}`);
100
+ return { success: false, testFilePath, errors: lastErrors };
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 依據原始檔案路徑推導測試檔案路徑
107
+ * e.g. src/core/foo.js → src/core/__tests__/foo.test.js
108
+ */
109
+ function deriveTestFilePath(sourceFilePath) {
110
+ const dir = dirname(sourceFilePath);
111
+ const name = basename(sourceFilePath).replace(/\.(js|ts|mjs|cjs)$/, '');
112
+ const ext = sourceFilePath.endsWith('.ts') ? '.ts' : '.js';
113
+ return join(dir, '__tests__', `${name}.test${ext}`);
114
+ }
115
+
116
+ /**
117
+ * 將測試檔案 commit 到 git
118
+ */
119
+ async function commitTestFile(testFilePath, sourceFilePath, logger) {
120
+ try {
121
+ execSync(`git add "${testFilePath}"`, { stdio: 'pipe' });
122
+ const name = basename(sourceFilePath);
123
+ execSync(`git commit -m "test: 為 ${name} 新增自動生成的測試"`, { stdio: 'pipe' });
124
+ logger.success('測試檔案已自動 commit。');
125
+ } catch (error) {
126
+ logger.warning('無法自動 commit 測試檔案(可能沒有 staged 變更或不在 git 倉庫中)。');
127
+ }
128
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * CodeGenerator 服務
3
+ * 使用 AIClient 生成符合專案風格的代碼,並執行 ESLint 驗證後寫入檔案
4
+ */
5
+
6
+ import { writeFileSync, existsSync, mkdirSync } from 'fs';
7
+ import { dirname } from 'path';
8
+ import { execSync } from 'child_process';
9
+ import { AIClient } from './ai-client.js';
10
+ import { RepoContext } from './repo-context.js';
11
+
12
+ export class CodeGenerator {
13
+ /**
14
+ * 生成指定檔案的代碼
15
+ * @param {object} issueContext - Issue 資料 { number, title, body, labels }
16
+ * @param {string} filePath - 要生成的目標檔案路徑(絕對路徑)
17
+ * @param {object} constraints - 限制條件
18
+ * @param {number} constraints.maxLines - 最大行數(預設 500)
19
+ * @param {string} constraints.language - 語言(預設 'js')
20
+ * @param {string} constraints.extraContext - 額外的使用者描述
21
+ * @param {string} constraints.model - AI 模型(可選)
22
+ * @returns {Promise<{ filePath, linesCount }>}
23
+ */
24
+ static async generateFile(issueContext, filePath, constraints = {}) {
25
+ const { maxLines = 500, language = 'js', extraContext = '', model } = constraints;
26
+
27
+ // 防止覆蓋現有檔案
28
+ if (existsSync(filePath)) {
29
+ throw new Error(`檔案已存在,拒絕覆蓋:${filePath}\n請選擇一個新的目標路徑。`);
30
+ }
31
+
32
+ // 取得專案上下文
33
+ const conventions = RepoContext.getProjectConventions();
34
+ const examples = RepoContext.getSimilarFileExamples(filePath, 2);
35
+
36
+ // 建立 prompt
37
+ const prompt = CodeGenerator._buildPrompt({
38
+ issueContext,
39
+ filePath,
40
+ maxLines,
41
+ language,
42
+ extraContext,
43
+ conventions,
44
+ examples,
45
+ });
46
+
47
+ // 呼叫 AI
48
+ const rawCode = await AIClient.sendAndWait(prompt, model);
49
+ const code = CodeGenerator._cleanCode(rawCode);
50
+
51
+ // 行數驗證
52
+ const lines = code.split('\n').length;
53
+ if (lines > maxLines) {
54
+ throw new Error(`生成的代碼超過行數限制(${lines} 行 > ${maxLines} 行)`);
55
+ }
56
+
57
+ // 確保目錄存在
58
+ mkdirSync(dirname(filePath), { recursive: true });
59
+
60
+ // 寫入暫時檔案並 lint
61
+ writeFileSync(filePath, code, 'utf-8');
62
+ CodeGenerator._lintFile(filePath);
63
+
64
+ return { filePath, linesCount: lines };
65
+ }
66
+
67
+ /**
68
+ * 建立代碼生成 prompt
69
+ */
70
+ static _buildPrompt({ issueContext, filePath, maxLines, language, extraContext, conventions, examples }) {
71
+ return `你是一位 ${language.toUpperCase()} 專家。請依照以下需求生成代碼。
72
+
73
+ ## GitHub Issue
74
+ 標題:${issueContext.title}
75
+ 描述:
76
+ ${issueContext.body || '(無描述)'}
77
+ ${issueContext.labels?.length ? `標籤:${issueContext.labels.join(', ')}` : ''}
78
+
79
+ ## 目標檔案
80
+ ${filePath}
81
+
82
+ ## 專案約定
83
+ ${conventions}
84
+
85
+ ## 代碼範例(相似檔案)
86
+ ${examples}
87
+
88
+ ## 使用者補充說明
89
+ ${extraContext || '(無)'}
90
+
91
+ ## 生成規則
92
+ - 最多 ${maxLines} 行
93
+ - 使用 ESM (import/export) 語法
94
+ - 包含必要的 JSDoc 註解
95
+ - 僅輸出程式碼本身,不要 markdown 區塊(\`\`\`)
96
+ - 不要輸出任何說明文字,直接輸出可執行的程式碼`.trim();
97
+ }
98
+
99
+ /**
100
+ * 移除 AI 回傳中的 markdown code fence
101
+ */
102
+ static _cleanCode(raw) {
103
+ return raw
104
+ .replace(/^```[\w]*\n?/gm, '')
105
+ .replace(/\n?```$/gm, '')
106
+ .trim();
107
+ }
108
+
109
+ /**
110
+ * 對指定檔案執行 ESLint(若存在 eslint 設定)
111
+ * 不強制中止——只在 lint 有 error 時拋出例外
112
+ */
113
+ static _lintFile(filePath) {
114
+ try {
115
+ execSync(`npx eslint --no-eslintrc --rule '{"no-undef": "warn"}' "${filePath}"`, {
116
+ encoding: 'utf-8',
117
+ stdio: 'pipe',
118
+ });
119
+ } catch (error) {
120
+ const output = error.stdout || error.stderr || '';
121
+ // 若 eslint 回傳 exit code 1 表示有 error
122
+ if (error.status === 1 && output.includes('error')) {
123
+ throw new Error(`ESLint 驗證失敗:\n${output}`);
124
+ }
125
+ // exit code 2 為 eslint 設定問題,忽略
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * IssueReader 服務
3
+ * 透過 gh CLI 從 GitHub 讀取 Issue 詳情
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+
8
+ export class IssueReader {
9
+ /**
10
+ * 讀取指定 Issue 的詳情
11
+ * @param {number|string} issueNumber - Issue 編號
12
+ * @returns {{ number, title, body, labels, author, url }}
13
+ */
14
+ static async readIssue(issueNumber) {
15
+ if (!issueNumber || isNaN(Number(issueNumber))) {
16
+ throw new Error(`無效的 Issue 編號:${issueNumber}`);
17
+ }
18
+
19
+ const json = IssueReader._fetchIssueJson(issueNumber);
20
+ return IssueReader._parseIssue(json);
21
+ }
22
+
23
+ /**
24
+ * 使用 gh CLI 抓取 Issue 的 JSON 資料
25
+ */
26
+ static _fetchIssueJson(issueNumber) {
27
+ try {
28
+ const raw = execSync(
29
+ `gh issue view ${issueNumber} --json number,title,body,labels,author,url`,
30
+ { encoding: 'utf-8', stdio: 'pipe' }
31
+ );
32
+ return JSON.parse(raw.trim());
33
+ } catch (error) {
34
+ if (error.message.includes('Could not resolve') || error.message.includes('not found')) {
35
+ throw new Error(`Issue #${issueNumber} 不存在或無法存取`);
36
+ }
37
+ if (error.message.includes('gh: command not found') || error.message.includes('not found: gh')) {
38
+ throw new Error('找不到 gh CLI,請先安裝:https://cli.github.com');
39
+ }
40
+ throw new Error(`讀取 Issue 失敗:${error.message}`);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 解析 gh CLI 回傳的 JSON 為標準結構
46
+ */
47
+ static _parseIssue(json) {
48
+ const labels = (json.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
49
+ return {
50
+ number: json.number,
51
+ title: json.title || '',
52
+ body: json.body || '',
53
+ labels,
54
+ author: json.author?.login || json.author || '',
55
+ url: json.url || '',
56
+ };
57
+ }
58
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * RepoContext 服務
3
+ * 掃描專案目錄結構,提供代碼生成所需的上下文
4
+ */
5
+
6
+ import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
7
+ import { join, extname, relative, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const ROOT = join(__dirname, '..', '..');
13
+
14
+ // 要排除的目錄
15
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.nyc_output']);
16
+ // 支援的程式碼副檔名
17
+ const CODE_EXTS = new Set(['.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx']);
18
+
19
+ export class RepoContext {
20
+ /**
21
+ * 依據目標檔案路徑尋找相似的現有檔案片段
22
+ * @param {string} targetPath - 要生成的目標檔案路徑(相對或絕對)
23
+ * @param {number} maxExamples - 最多回傳幾個範例,預設 3
24
+ * @returns {string} 格式化的範例文字
25
+ */
26
+ static getSimilarFileExamples(targetPath, maxExamples = 3) {
27
+ const ext = extname(targetPath);
28
+ const allFiles = RepoContext._scanFiles(ROOT);
29
+ const codeFiles = allFiles.filter(
30
+ (f) => extname(f) === ext && !f.includes('.test.') && !f.includes('.spec.')
31
+ );
32
+
33
+ // 按路徑相似度排序,取前 N 個
34
+ const sorted = codeFiles
35
+ .filter((f) => f !== targetPath)
36
+ .slice(0, maxExamples);
37
+
38
+ if (sorted.length === 0) return '(無相似範例)';
39
+
40
+ return sorted
41
+ .map((f) => {
42
+ try {
43
+ const content = readFileSync(f, 'utf-8');
44
+ const lines = content.split('\n').slice(0, 40).join('\n');
45
+ const relPath = relative(ROOT, f);
46
+ return `// 範例檔案:${relPath}\n${lines}`;
47
+ } catch {
48
+ return null;
49
+ }
50
+ })
51
+ .filter(Boolean)
52
+ .join('\n\n---\n\n');
53
+ }
54
+
55
+ /**
56
+ * 取得專案主要約定(依據 package.json 及目錄結構判斷)
57
+ * @returns {string}
58
+ */
59
+ static getProjectConventions() {
60
+ const hints = [];
61
+
62
+ // 讀取 package.json
63
+ const pkgPath = join(ROOT, 'package.json');
64
+ if (existsSync(pkgPath)) {
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
67
+ hints.push(`模組系統:${pkg.type === 'module' ? 'ESM (import/export)' : 'CommonJS (require)'}`);
68
+ if (pkg.dependencies?.typescript || pkg.devDependencies?.typescript) {
69
+ hints.push('語言:TypeScript');
70
+ } else {
71
+ hints.push('語言:JavaScript');
72
+ }
73
+ const testDep = pkg.devDependencies?.jest || pkg.dependencies?.jest;
74
+ if (testDep) hints.push('測試框架:Jest');
75
+ } catch {
76
+ // 忽略解析錯誤
77
+ }
78
+ }
79
+
80
+ // 目錄結構提示
81
+ const srcExists = existsSync(join(ROOT, 'src'));
82
+ if (srcExists) hints.push('原始碼位於 src/ 目錄');
83
+
84
+ return hints.length > 0 ? hints.join('\n') : '(無法自動偵測專案約定)';
85
+ }
86
+
87
+ /**
88
+ * 遞迴掃描目錄,回傳所有程式碼檔案的絕對路徑
89
+ */
90
+ static _scanFiles(dir) {
91
+ const results = [];
92
+ let entries;
93
+ try {
94
+ entries = readdirSync(dir);
95
+ } catch {
96
+ return results;
97
+ }
98
+ for (const name of entries) {
99
+ if (EXCLUDED_DIRS.has(name)) continue;
100
+ const full = join(dir, name);
101
+ try {
102
+ const stat = statSync(full);
103
+ if (stat.isDirectory()) {
104
+ results.push(...RepoContext._scanFiles(full));
105
+ } else if (CODE_EXTS.has(extname(name))) {
106
+ results.push(full);
107
+ }
108
+ } catch {
109
+ // 忽略無法存取的路徑
110
+ }
111
+ }
112
+ return results;
113
+ }
114
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * TestGenerator 服務
3
+ * 為指定的原始碼檔案使用 AIClient 生成 Jest 測試
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ import { dirname } from 'path';
8
+ import { AIClient } from './ai-client.js';
9
+
10
+ export class TestGenerator {
11
+ /**
12
+ * 為原始檔案生成測試並寫入測試檔案
13
+ * @param {string} sourceFilePath - 原始碼路徑(絕對路徑)
14
+ * @param {string} testFilePath - 測試檔案輸出路徑(絕對路徑)
15
+ * @param {'unit'|'component'} testType - 測試類型(預設 'unit')
16
+ * @param {string} [model] - AI 模型(可選)
17
+ * @returns {Promise<{ testFilePath, linesCount }>}
18
+ */
19
+ static async generateTests(sourceFilePath, testFilePath, testType = 'unit', model) {
20
+ let sourceCode;
21
+ try {
22
+ sourceCode = readFileSync(sourceFilePath, 'utf-8');
23
+ } catch {
24
+ throw new Error(`無法讀取原始碼:${sourceFilePath}`);
25
+ }
26
+
27
+ const prompt = TestGenerator._buildPrompt(sourceCode, sourceFilePath, testType);
28
+ const rawTests = await AIClient.sendAndWait(prompt, model);
29
+ const testCode = TestGenerator._cleanCode(rawTests);
30
+
31
+ // 確保目錄存在
32
+ mkdirSync(dirname(testFilePath), { recursive: true });
33
+ writeFileSync(testFilePath, testCode, 'utf-8');
34
+
35
+ return { testFilePath, linesCount: testCode.split('\n').length };
36
+ }
37
+
38
+ /**
39
+ * 使用錯誤訊息重新生成修復後的原始碼
40
+ * @param {string} sourceFilePath - 原始碼路徑
41
+ * @param {string[]} testErrors - Jest 錯誤訊息陣列
42
+ * @param {number} attempt - 第幾次修復嘗試(1 或 2)
43
+ * @param {string} [model] - AI 模型(可選)
44
+ * @returns {Promise<void>}
45
+ */
46
+ static async generateFix(sourceFilePath, testErrors, attempt, model) {
47
+ let sourceCode;
48
+ try {
49
+ sourceCode = readFileSync(sourceFilePath, 'utf-8');
50
+ } catch {
51
+ throw new Error(`無法讀取原始碼:${sourceFilePath}`);
52
+ }
53
+
54
+ const prompt = TestGenerator._buildFixPrompt(sourceCode, testErrors, attempt);
55
+ const rawFixed = await AIClient.sendAndWait(prompt, model);
56
+ const fixedCode = TestGenerator._cleanCode(rawFixed);
57
+
58
+ writeFileSync(sourceFilePath, fixedCode, 'utf-8');
59
+ }
60
+
61
+ /**
62
+ * 建立測試生成 prompt
63
+ */
64
+ static _buildPrompt(sourceCode, sourceFilePath, testType) {
65
+ return `你是一位 Jest 測試專家。請為以下 ${testType === 'component' ? 'React 元件' : '模組'} 撰寫全面的 ${testType} 測試。
66
+
67
+ ## 原始碼(${sourceFilePath})
68
+ \`\`\`
69
+ ${sourceCode}
70
+ \`\`\`
71
+
72
+ ## 測試要求
73
+ 1. 使用現代 Jest 語法(describe / it / expect)
74
+ 2. 涵蓋所有主要函數及邊界情況
75
+ 3. Mock 所有外部依賴(檔案系統、網路、子程序等)
76
+ 4. 使用 beforeEach / afterEach 管理測試狀態
77
+ 5. 測試名稱使用繁體中文描述
78
+
79
+ ## 輸出規則
80
+ - 只輸出可直接執行的測試程式碼
81
+ - 不要 markdown 區塊(\`\`\`)、不要任何說明文字
82
+ - 第一行必須是 import 或 require 陳述式`.trim();
83
+ }
84
+
85
+ /**
86
+ * 建立修復 prompt
87
+ */
88
+ static _buildFixPrompt(sourceCode, testErrors, attempt) {
89
+ return `這是第 ${attempt} 次修復嘗試。以下是目前的原始碼和失敗的測試錯誤,請修正原始碼使測試通過。
90
+
91
+ ## 當前原始碼
92
+ \`\`\`
93
+ ${sourceCode}
94
+ \`\`\`
95
+
96
+ ## 測試錯誤
97
+ ${testErrors.join('\n')}
98
+
99
+ ## 修復規則
100
+ - 只修改原始碼,不修改測試
101
+ - 保留原有功能,只修正造成測試失敗的部分
102
+ - 只輸出修正後的完整原始碼,不要任何說明文字
103
+ - 不要 markdown 區塊(\`\`\`)`.trim();
104
+ }
105
+
106
+ /**
107
+ * 移除 AI 回傳中的 markdown code fence
108
+ */
109
+ static _cleanCode(raw) {
110
+ return raw
111
+ .replace(/^```[\w]*\n?/gm, '')
112
+ .replace(/\n?```$/gm, '')
113
+ .trim();
114
+ }
115
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * TestRunner 服務
3
+ * 執行 Jest 測試、解析結果,並在失敗時觸發自動修復(最多 2 次)
4
+ */
5
+
6
+ import { execSync } from 'child_process';
7
+ import { existsSync } from 'fs';
8
+
9
+ export class TestRunner {
10
+ /**
11
+ * 執行指定的測試檔案
12
+ * @param {string} testFilePath - 測試檔案路徑(絕對路徑)
13
+ * @returns {Promise<{ success: boolean, errors: string[] }>}
14
+ */
15
+ static async runTests(testFilePath) {
16
+ if (!existsSync(testFilePath)) {
17
+ return { success: false, errors: [`測試檔案不存在:${testFilePath}`] };
18
+ }
19
+
20
+ try {
21
+ execSync(`npx jest "${testFilePath}" --no-coverage --forceExit`, {
22
+ encoding: 'utf-8',
23
+ stdio: 'pipe',
24
+ timeout: 120000, // 2 分鐘超時
25
+ });
26
+ return { success: true, errors: [] };
27
+ } catch (error) {
28
+ const output = (error.stdout || '') + (error.stderr || '');
29
+ const errors = TestRunner._parseErrors(output);
30
+ return { success: false, errors };
31
+ }
32
+ }
33
+
34
+ /**
35
+ * 解析 Jest 輸出,提取失敗訊息
36
+ * @param {string} output
37
+ * @returns {string[]}
38
+ */
39
+ static _parseErrors(output) {
40
+ const errors = [];
41
+ const lines = output.split('\n');
42
+
43
+ let inFailBlock = false;
44
+ let currentError = [];
45
+
46
+ for (const line of lines) {
47
+ // 偵測失敗區塊的開始
48
+ if (line.includes('● ') || line.includes('FAIL ')) {
49
+ if (currentError.length > 0) {
50
+ errors.push(currentError.join('\n').trim());
51
+ currentError = [];
52
+ }
53
+ inFailBlock = true;
54
+ }
55
+
56
+ if (inFailBlock) {
57
+ currentError.push(line);
58
+
59
+ // 限制每個錯誤區塊最多 30 行,防止輸出過長
60
+ if (currentError.length >= 30) {
61
+ errors.push(currentError.join('\n').trim());
62
+ currentError = [];
63
+ inFailBlock = false;
64
+ }
65
+ }
66
+ }
67
+
68
+ if (currentError.length > 0) {
69
+ errors.push(currentError.join('\n').trim());
70
+ }
71
+
72
+ // 若未能解析出具體錯誤,回傳原始輸出的前 50 行
73
+ if (errors.length === 0) {
74
+ errors.push(lines.slice(0, 50).join('\n'));
75
+ }
76
+
77
+ return errors;
78
+ }
79
+ }
@@ -193,20 +193,22 @@ export class GitHubAPI {
193
193
  * 創建或更新 PR
194
194
  */
195
195
  async createOrUpdatePR(params) {
196
- const { title, body, baseBranch, headBranch, reviewers } = params;
196
+ const { title, body, baseBranch, headBranch, reviewers, forceNew } = params;
197
197
  const bodyFile = '/tmp/pr-body.md';
198
198
  writeFileSync(bodyFile, body);
199
199
 
200
200
  try {
201
- // 檢查 PR 是否已存在
201
+ // 檢查 PR 是否已存在(除非強制創建新 PR)
202
202
  let existingPRUrl = null;
203
- try {
204
- existingPRUrl = execSync(
205
- `gh pr list --head "${headBranch}" --base "${baseBranch}" --json url --jq '.[0].url'`,
206
- { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
207
- ).trim();
208
- } catch (error) {
209
- // PR 不存在
203
+ if (!forceNew) {
204
+ try {
205
+ existingPRUrl = execSync(
206
+ `gh pr list --head "${headBranch}" --base "${baseBranch}" --json url --jq '.[0].url'`,
207
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
208
+ ).trim();
209
+ } catch (error) {
210
+ // PR 不存在
211
+ }
210
212
  }
211
213
 
212
214
  const escapedTitle = title.replace(/"/g, '\\"');
@@ -422,6 +422,7 @@ export class PRWorkflow {
422
422
  headBranch,
423
423
  reviewers,
424
424
  config: this.config,
425
+ forceNew: this.config.forceNew,
425
426
  });
426
427
  }
427
428