ai-git-tools 2.0.60 → 2.0.62

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
@@ -142,7 +142,7 @@ program
142
142
  .command('write-and-test')
143
143
  .description('AI 為指定檔案生成 Jest 測試,並自動執行與修復(最多 2 次)')
144
144
  .requiredOption('--file <path>', '原始碼路徑')
145
- .option('--test-type <type>', '測試類型:auto、unit 或 component(預設 auto)', 'auto')
145
+ .option('--test-type <type>', '測試類型:auto、unit、componentboth(可用逗號指定多個)', 'auto')
146
146
  .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
147
147
  .option('--no-confirm', '跳過確認直接生成並執行測試')
148
148
  .option('--model <model>', '指定 AI 模型')
@@ -158,11 +158,11 @@ program
158
158
  // Auto Dev 整合命令
159
159
  program
160
160
  .command('auto-dev')
161
- .description('一鍵自動化:從 GitHub Issue 到代碼生成、測試與提交')
161
+ .description('一鍵自動化:從 GitHub Issue 到代碼生成與測試修正')
162
162
  .requiredOption('--issue <number>', 'GitHub Issue 編號')
163
163
  .option('--file <path>', '目標檔案路徑(若無則自動推斷)')
164
164
  .option('--context <description>', '額外說明或補充需求')
165
- .option('--test-type <type>', '測試類型:auto、unit 或 component(預設 auto)', 'auto')
165
+ .option('--test-type <type>', '測試類型:auto、unit、componentboth(預設 auto)', 'auto')
166
166
  .option('--max-lines <number>', '最大行數限制(預設 500)', '500')
167
167
  .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
168
168
  .option('--no-confirm', '全自動模式,跳過所有確認')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.60",
3
+ "version": "2.0.62",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,8 +11,7 @@ import inquirer from 'inquirer';
11
11
  import { IssueReader } from '../core/issue-reader.js';
12
12
  import { CodeGenerator } from '../core/code-generator.js';
13
13
  import { AIClient } from '../core/ai-client.js';
14
- import { GitOperations } from '../core/git-operations.js';
15
- import { writeAndTestCommand } from './write-and-test.js';
14
+ import { writeAndTestCommand, TEST_TYPE_CHOICES, normalizeTestTypeSelection, resolveRequestedTestTypes } from './write-and-test.js';
16
15
  import { Logger } from '../utils/logger.js';
17
16
 
18
17
  export async function autoDevCommand(options = {}) {
@@ -107,7 +106,7 @@ export async function autoDevCommand(options = {}) {
107
106
  try {
108
107
  codeResult = await CodeGenerator.generateFile(issue, absoluteFilePath, {
109
108
  maxLines: parseInt(options.maxLines || '500', 10),
110
- language: filePath.endsWith('.ts') ? 'ts' : 'js',
109
+ language: getLanguageFromFilePath(filePath),
111
110
  extraContext: options.context || '',
112
111
  model: options.model,
113
112
  });
@@ -119,8 +118,15 @@ export async function autoDevCommand(options = {}) {
119
118
 
120
119
  console.log('');
121
120
 
121
+ const selectedTestType = await resolveTestTypeForAutoDev({
122
+ noConfirm,
123
+ requestedTestType: options.testType,
124
+ });
125
+
126
+ const resolvedRuntimeTestTypes = resolveRequestedTestTypes(absoluteFilePath, selectedTestType);
127
+
122
128
  logger.step('正在驗證專案可用的測試腳本...');
123
- ensureJestAvailable();
129
+ ensureJestAvailable(resolvedRuntimeTestTypes);
124
130
  logger.success('已確認可執行 Jest 測試。');
125
131
  console.log('');
126
132
 
@@ -129,7 +135,7 @@ export async function autoDevCommand(options = {}) {
129
135
  // ============================================================
130
136
  const testResult = await writeAndTestCommand({
131
137
  file: filePath,
132
- testType: options.testType || 'auto',
138
+ testType: selectedTestType,
133
139
  maxFixes: options.maxFixes || '2',
134
140
  model: options.model,
135
141
  noConfirm: true,
@@ -141,25 +147,15 @@ export async function autoDevCommand(options = {}) {
141
147
  return;
142
148
  }
143
149
 
144
- // ============================================================
145
- // 第七步:自動 Commit
146
- // ============================================================
147
- logger.step('正在自動提交...');
148
- try {
149
- GitOperations.exec(`git add "${absoluteFilePath}" "${testResult.testFilePath}"`, { silent: true });
150
- const commitMsg = `feat(auto-dev): Issue #${issue.number} - ${issue.title}\n\n生成檔案:\n- ${filePath}\n- ${testResult.testFilePath.replace(process.cwd(), '.')}`;
151
- GitOperations.exec(`git commit -m "${commitMsg.split('\n')[0]}"`, { silent: true });
152
- logger.success('已自動提交');
153
- } catch {
154
- logger.warning('自動 commit 失敗(可能已有相同 commit 或尚未 staged)');
155
- }
156
-
157
150
  console.log('');
158
151
  logger.section('✅ 自動化工作流完成');
159
152
  console.log(`Issue #${issue.number}:${issue.title}`);
160
153
  console.log(`生成檔案:${filePath}`);
161
- console.log(`測試檔案:${testResult.testFilePath.replace(process.cwd(), '.')}`);
162
- console.log(`測試類型:${testResult.testType}`);
154
+ testResult.testFilePaths.forEach((testFilePath) => {
155
+ console.log(`測試檔案:${testFilePath.replace(process.cwd(), '.')}`);
156
+ });
157
+ console.log(`測試類型:${testResult.testTypes.join('、')}`);
158
+ console.log('狀態:已完成代碼與測試流程,未自動 commit');
163
159
  console.log(`合計行數:${codeResult.linesCount + ' (程式碼)'}`);
164
160
  }
165
161
 
@@ -244,26 +240,40 @@ function isValidTargetPath(value) {
244
240
  && !value.startsWith('/');
245
241
  }
246
242
 
247
- function ensureJestAvailable() {
243
+ function getLanguageFromFilePath(filePath) {
244
+ return /\.(ts|tsx)$/.test(filePath) ? 'ts' : 'js';
245
+ }
246
+
247
+ async function resolveTestTypeForAutoDev({ noConfirm, requestedTestType }) {
248
+ const defaultTestType = normalizeTestTypeSelection(requestedTestType || 'auto')[0] || 'auto';
249
+
250
+ if (noConfirm) {
251
+ return requestedTestType || 'auto';
252
+ }
253
+
254
+ const { testType } = await inquirer.prompt([{
255
+ type: 'list',
256
+ name: 'testType',
257
+ message: '請選擇這次要執行的測試方式:',
258
+ choices: TEST_TYPE_CHOICES,
259
+ default: defaultTestType === 'auto' ? 'both' : defaultTestType,
260
+ }]);
261
+
262
+ return testType;
263
+ }
264
+
265
+ function ensureJestAvailable(runtimeTestType = ['unit']) {
266
+ const packageJson = readProjectPackageJson();
267
+
248
268
  try {
249
269
  execSync('npx jest --version', {
250
270
  encoding: 'utf-8',
251
271
  stdio: 'pipe',
252
272
  cwd: process.cwd(),
253
273
  });
274
+ ensureComponentTestDependencies(packageJson, runtimeTestType);
275
+ return;
254
276
  } catch {
255
- const packageJsonPath = resolve(process.cwd(), 'package.json');
256
- if (!existsSync(packageJsonPath)) {
257
- throw new Error('目前目錄沒有 package.json,無法執行 Jest 測試。');
258
- }
259
-
260
- let packageJson;
261
- try {
262
- packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
263
- } catch {
264
- throw new Error('無法讀取目前專案的 package.json,請確認 Jest 已安裝。');
265
- }
266
-
267
277
  const hasJestDependency = Boolean(
268
278
  packageJson.dependencies?.jest || packageJson.devDependencies?.jest
269
279
  );
@@ -272,6 +282,40 @@ function ensureJestAvailable() {
272
282
  throw new Error('目前專案未安裝 Jest,請先安裝後再執行 auto-dev。');
273
283
  }
274
284
 
285
+ ensureComponentTestDependencies(packageJson, runtimeTestType);
286
+
275
287
  throw new Error('偵測到 Jest 依賴,但無法執行 npx jest,請檢查安裝或 lockfile 狀態。');
276
288
  }
277
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
+ }
@@ -8,6 +8,7 @@ import { resolve } from 'path';
8
8
  import inquirer from 'inquirer';
9
9
  import { IssueReader } from '../core/issue-reader.js';
10
10
  import { CodeGenerator } from '../core/code-generator.js';
11
+ import { writeAndTestCommand, TEST_TYPE_CHOICES, normalizeTestTypeSelection } from './write-and-test.js';
11
12
  import { Logger } from '../utils/logger.js';
12
13
 
13
14
  export async function generateCodeCommand(options = {}) {
@@ -61,7 +62,7 @@ export async function generateCodeCommand(options = {}) {
61
62
 
62
63
  const result = await CodeGenerator.generateFile(issue, absolutePath, {
63
64
  maxLines,
64
- language: absolutePath.endsWith('.ts') ? 'ts' : 'js',
65
+ language: /\.(ts|tsx)$/.test(absolutePath) ? 'ts' : 'js',
65
66
  extraContext,
66
67
  model: options.model,
67
68
  });
@@ -84,5 +85,31 @@ export async function generateCodeCommand(options = {}) {
84
85
  console.log('\n💡 下一步:執行 write-and-test 為此檔案生成並執行測試');
85
86
  console.log(` ai-git-tools write-and-test --file ${filePath}`);
86
87
 
88
+ if (!options.noConfirm) {
89
+ const { nextAction } = await inquirer.prompt([{
90
+ type: 'list',
91
+ name: 'nextAction',
92
+ message: '代碼已生成,接下來要如何處理測試?',
93
+ choices: [
94
+ ...TEST_TYPE_CHOICES,
95
+ {
96
+ name: '先不執行測試',
97
+ value: 'skip',
98
+ },
99
+ ],
100
+ default: 'both',
101
+ }]);
102
+
103
+ if (nextAction !== 'skip') {
104
+ await writeAndTestCommand({
105
+ file: filePath,
106
+ testType: normalizeTestTypeSelection(nextAction).join(','),
107
+ maxFixes: options.maxFixes || '2',
108
+ model: options.model,
109
+ noConfirm: true,
110
+ });
111
+ }
112
+ }
113
+
87
114
  return result;
88
115
  }
@@ -4,18 +4,94 @@
4
4
  */
5
5
 
6
6
  import { resolve, basename, dirname, join } from 'path';
7
- import { existsSync } from 'fs';
8
- import { execSync } from 'child_process';
7
+ import { existsSync, readFileSync } from 'fs';
9
8
  import inquirer from 'inquirer';
10
9
  import { TestGenerator } from '../core/test-generator.js';
11
10
  import { TestRunner } from '../core/test-runner.js';
12
11
  import { Logger } from '../utils/logger.js';
13
12
 
13
+ export const TEST_TYPE_CHOICES = [
14
+ {
15
+ name: '寫測試(自動判斷 Jest 單元測試 / 元件測試)',
16
+ value: 'auto',
17
+ },
18
+ {
19
+ name: '寫 Jest 單元測試',
20
+ value: 'unit',
21
+ },
22
+ {
23
+ name: '寫元件測試',
24
+ value: 'component',
25
+ },
26
+ {
27
+ name: '同時寫 Jest 單元測試 + 元件測試',
28
+ value: 'both',
29
+ },
30
+ ];
31
+
32
+ export function normalizeTestTypeSelection(testType = 'auto') {
33
+ const rawValues = Array.isArray(testType)
34
+ ? testType
35
+ : String(testType)
36
+ .split(',')
37
+ .map((value) => value.trim())
38
+ .filter(Boolean);
39
+
40
+ const normalizedValues = rawValues.length > 0 ? rawValues : ['auto'];
41
+ const resolvedValues = normalizedValues.flatMap((value) => {
42
+ const normalized = value.toLowerCase();
43
+
44
+ if (normalized === 'auto' || normalized === 'unit' || normalized === 'component') {
45
+ return normalized;
46
+ }
47
+
48
+ if (normalized === 'both' || normalized === 'all') {
49
+ return ['unit', 'component'];
50
+ }
51
+
52
+ if (normalized === 'jest' || normalized === 'jest-unit' || normalized === 'unit-test') {
53
+ return 'unit';
54
+ }
55
+
56
+ if (normalized === 'component-test' || normalized === 'react-component') {
57
+ return 'component';
58
+ }
59
+
60
+ throw new Error(`不支援的測試類型:${value}`);
61
+ });
62
+
63
+ return [...new Set(resolvedValues)];
64
+ }
65
+
66
+ export function resolveRequestedTestTypes(sourceFilePath, requestedTestType = 'auto') {
67
+ const normalizedTestTypes = normalizeTestTypeSelection(requestedTestType);
68
+
69
+ if (!normalizedTestTypes.includes('auto')) {
70
+ return normalizedTestTypes;
71
+ }
72
+
73
+ const sourceCode = readFileSync(sourceFilePath, 'utf-8');
74
+ const inferredTestType = TestGenerator.resolveTestType(sourceFilePath, sourceCode, 'auto');
75
+
76
+ return [...new Set(
77
+ normalizedTestTypes.flatMap((testType) => (testType === 'auto' ? inferredTestType : testType))
78
+ )];
79
+ }
80
+
81
+ function formatTestTypes(testTypes) {
82
+ const labels = {
83
+ auto: '自動判斷',
84
+ unit: 'Jest 單元測試',
85
+ component: '元件測試',
86
+ };
87
+
88
+ return testTypes.map((testType) => labels[testType] || testType).join('、');
89
+ }
90
+
14
91
  export async function writeAndTestCommand(options = {}) {
15
92
  const logger = new Logger();
16
93
 
17
94
  const filePath = options.file;
18
- const requestedTestType = options.testType || 'auto';
19
95
  const maxFixes = parseInt(options.maxFixes || '2', 10);
20
96
 
21
97
  if (!filePath) {
@@ -29,13 +105,18 @@ export async function writeAndTestCommand(options = {}) {
29
105
  throw new Error(`找不到原始碼:${absoluteSourcePath}`);
30
106
  }
31
107
 
32
- // 推導測試檔案路徑
33
- const testFilePath = deriveTestFilePath(absoluteSourcePath);
108
+ const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || 'auto');
109
+ const testTargets = resolvedTestTypes.map((testType) => ({
110
+ testType,
111
+ testFilePath: deriveTestFilePath(absoluteSourcePath, testType),
112
+ }));
34
113
 
35
114
  logger.header('write-and-test');
36
115
  console.log(`原始碼 :${absoluteSourcePath}`);
37
- console.log(`測試檔案:${testFilePath}`);
38
- console.log(`測試類型:${requestedTestType}`);
116
+ console.log(`測試類型:${formatTestTypes(resolvedTestTypes)}`);
117
+ testTargets.forEach(({ testType, testFilePath }) => {
118
+ console.log(`${testType === 'component' ? '元件測試' : '單元測試'}:${testFilePath}`);
119
+ });
39
120
  console.log(`最大修復:${maxFixes} 次`);
40
121
  console.log('');
41
122
 
@@ -44,7 +125,7 @@ export async function writeAndTestCommand(options = {}) {
44
125
  const { confirmed } = await inquirer.prompt([{
45
126
  type: 'confirm',
46
127
  name: 'confirmed',
47
- message: `確認為 ${filePath} 生成 ${requestedTestType} 測試並執行?`,
128
+ message: `確認為 ${filePath} 生成 ${formatTestTypes(resolvedTestTypes)} 並執行?`,
48
129
  default: true,
49
130
  }]);
50
131
 
@@ -56,14 +137,18 @@ export async function writeAndTestCommand(options = {}) {
56
137
 
57
138
  // 2. 生成測試
58
139
  logger.step('正在生成測試代碼...');
59
- const generatedTest = await TestGenerator.generateTests(
60
- absoluteSourcePath,
61
- testFilePath,
62
- requestedTestType,
63
- options.model
64
- );
65
- logger.success(`測試已寫入:${testFilePath}`);
66
- logger.info(`實際測試類型:${generatedTest.testType}`);
140
+ const generatedTests = [];
141
+ for (const { testType, testFilePath } of testTargets) {
142
+ const generatedTest = await TestGenerator.generateTests(
143
+ absoluteSourcePath,
144
+ testFilePath,
145
+ testType,
146
+ options.model
147
+ );
148
+ generatedTests.push(generatedTest);
149
+ logger.success(`測試已寫入:${testFilePath}`);
150
+ }
151
+ logger.info(`實際測試類型:${formatTestTypes(generatedTests.map(({ testType }) => testType))}`);
67
152
 
68
153
  // 3. 執行測試 + 自動修復循環
69
154
  let lastErrors = [];
@@ -75,16 +160,15 @@ export async function writeAndTestCommand(options = {}) {
75
160
  }
76
161
 
77
162
  logger.step(`正在執行測試${attempt > 0 ? `(第 ${attempt} 次修復後)` : ''}...`);
78
- const result = await TestRunner.runTests(testFilePath);
163
+ const result = await TestRunner.runTests(generatedTests.map(({ testFilePath }) => testFilePath));
79
164
 
80
165
  if (result.success) {
81
166
  logger.success('所有測試通過! 🎉');
82
-
83
- // 4. 自動 commit 測試檔案
84
- if (!options.skipCommit) {
85
- await commitGeneratedFiles(testFilePath, absoluteSourcePath, logger);
86
- }
87
- return { success: true, testFilePath, testType: generatedTest.testType };
167
+ return {
168
+ success: true,
169
+ testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
170
+ testTypes: generatedTests.map(({ testType }) => testType),
171
+ };
88
172
  }
89
173
 
90
174
  lastErrors = result.errors;
@@ -106,12 +190,14 @@ export async function writeAndTestCommand(options = {}) {
106
190
  });
107
191
  console.log('');
108
192
  console.log(`原始碼路徑:${absoluteSourcePath}`);
109
- console.log(`測試路徑 :${testFilePath}`);
193
+ generatedTests.forEach(({ testFilePath }) => {
194
+ console.log(`測試路徑 :${testFilePath}`);
195
+ });
110
196
  return {
111
197
  success: false,
112
- testFilePath,
198
+ testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
113
199
  errors: lastErrors,
114
- testType: generatedTest.testType,
200
+ testTypes: generatedTests.map(({ testType }) => testType),
115
201
  };
116
202
  }
117
203
  }
@@ -119,25 +205,11 @@ export async function writeAndTestCommand(options = {}) {
119
205
 
120
206
  /**
121
207
  * 依據原始檔案路徑推導測試檔案路徑
122
- * e.g. src/core/foo.js → src/core/__tests__/foo.test.js
123
208
  */
124
- function deriveTestFilePath(sourceFilePath) {
209
+ export function deriveTestFilePath(sourceFilePath, testType = 'unit') {
125
210
  const dir = dirname(sourceFilePath);
126
- const name = basename(sourceFilePath).replace(/\.(js|ts|mjs|cjs)$/, '');
127
- const ext = sourceFilePath.endsWith('.ts') ? '.ts' : '.js';
128
- return join(dir, '__tests__', `${name}.test${ext}`);
129
- }
130
-
131
- /**
132
- * 將原始碼與測試檔案一併 commit 到 git
133
- */
134
- async function commitGeneratedFiles(testFilePath, sourceFilePath, logger) {
135
- try {
136
- execSync(`git add "${sourceFilePath}" "${testFilePath}"`, { stdio: 'pipe' });
137
- const name = basename(sourceFilePath);
138
- execSync(`git commit -m "test: 為 ${name} 新增自動生成的測試"`, { stdio: 'pipe' });
139
- logger.success('原始碼與測試檔案已自動 commit。');
140
- } catch {
141
- logger.warning('無法自動 commit 產生的檔案(可能沒有 staged 變更或不在 git 倉庫中)。');
142
- }
211
+ const name = basename(sourceFilePath).replace(/\.(js|jsx|ts|tsx|mjs|cjs)$/, '');
212
+ const ext = /\.(ts|tsx)$/.test(sourceFilePath) ? '.ts' : '.js';
213
+ const suffix = testType === 'component' ? 'component' : 'unit';
214
+ return join(dir, '__tests__', `${name}.${suffix}.test${ext}`);
143
215
  }
@@ -10,17 +10,23 @@ import { resolve } from 'path';
10
10
  export class TestRunner {
11
11
  /**
12
12
  * 執行指定的測試檔案
13
- * @param {string} testFilePath - 測試檔案路徑(絕對路徑)
13
+ * @param {string|string[]} testFilePath - 測試檔案路徑(絕對路徑)
14
14
  * @returns {Promise<{ success: boolean, errors: string[] }>}
15
15
  */
16
16
  static async runTests(testFilePath) {
17
- if (!existsSync(testFilePath)) {
18
- return { success: false, errors: [`測試檔案不存在:${testFilePath}`] };
17
+ const testFilePaths = Array.isArray(testFilePath) ? testFilePath : [testFilePath];
18
+ const missingTestFilePath = testFilePaths.find((filePath) => !existsSync(filePath));
19
+
20
+ if (missingTestFilePath) {
21
+ return { success: false, errors: [`測試檔案不存在:${missingTestFilePath}`] };
19
22
  }
20
23
 
21
24
  try {
22
- const absoluteTestFilePath = resolve(testFilePath);
23
- execSync(`npx jest --runInBand --runTestsByPath "${absoluteTestFilePath}" --no-coverage`, {
25
+ const absoluteTestFilePaths = testFilePaths
26
+ .map((filePath) => `"${resolve(filePath)}"`)
27
+ .join(' ');
28
+
29
+ execSync(`npx jest --runInBand --runTestsByPath ${absoluteTestFilePaths} --no-coverage`, {
24
30
  encoding: 'utf-8',
25
31
  stdio: 'pipe',
26
32
  timeout: 120000, // 2 分鐘超時