ai-git-tools 2.0.66 → 2.0.68

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
@@ -164,8 +164,8 @@ program
164
164
  .option('--file <path>', '原始碼路徑(對沒特別指定則自動從上次 dev-from-issue 讀取)')
165
165
  .option('--issue <number>', '完成後發佈報告到此 Issue(對沒特別指定則自動從上次 dev-from-issue 讀取)')
166
166
  .option('--test-type <type>', '測試類型:auto、unit、component 或 both(可用逗號指定多個)', 'auto')
167
- .option('--max-fixes <number>', '最大自動修復次數(預設 2)', '2')
168
- .option('--no-confirm', '跳過確認直接生成並執行測試')
167
+ .option('--test-dir <path>', '測試輸出目錄(預設 __tests__;指定 tests 則放在專案根目錄)', '__tests__')
168
+ .option('--max-fixes <number>', '最大自動修復次數(預設 3)', '3')
169
169
  .option('--model <model>', '指定 AI 模型')
170
170
  .action(async (options) => {
171
171
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-git-tools",
3
- "version": "2.0.66",
3
+ "version": "2.0.68",
4
4
  "description": "AI-powered Git automation tools for commit messages and PR generation",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -7,7 +7,6 @@ import { resolve, basename, dirname, join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
8
8
  import { execSync } from 'child_process';
9
9
  import { tmpdir } from 'os';
10
- import inquirer from 'inquirer';
11
10
  import { TestGenerator } from '../core/test-generator.js';
12
11
  import { TestRunner } from '../core/test-runner.js';
13
12
  import { AIClient } from '../core/ai-client.js';
@@ -108,69 +107,78 @@ export async function writeAndTestCommand(options = {}) {
108
107
 
109
108
  // 自動從 state 檔讀取 file / issue(指令列將覆蓋)
110
109
  const state = loadDevState();
111
- let filePath = options.file || state?.filePath;
112
110
  const issueNumber = options.issue || state?.issueNumber;
113
- const fromState = !options.file && Boolean(state?.filePath);
114
- const maxFixes = parseInt(options.maxFixes || '2', 10);
111
+ const maxFixes = parseInt(options.maxFixes || '3', 10);
115
112
 
116
- // 若仍無 filePath,改用 git 偵測最近異動的原始碼檔案
113
+ // 決定要處理的檔案清單
114
+ let sourceFiles = [];
115
+ let fromState = false;
117
116
  let fromGit = false;
118
- if (!filePath) {
119
- filePath = await detectChangedSourceFile(logger);
120
- fromGit = Boolean(filePath);
117
+
118
+ if (options.file) {
119
+ // 明確指定單一檔案
120
+ sourceFiles = [options.file];
121
+ } else if (state?.filePath) {
122
+ // 從上一步 dev-from-issue 讀取
123
+ sourceFiles = [state.filePath];
124
+ fromState = true;
125
+ } else {
126
+ // 從 git 偵測所有異動的原始碼檔案
127
+ sourceFiles = await detectChangedSourceFiles(logger);
128
+ fromGit = sourceFiles.length > 0;
121
129
  }
122
130
 
123
- if (!filePath) {
131
+ if (sourceFiles.length === 0) {
124
132
  logger.error('偵測不到異動的原始碼,請用 --file 指定目標檔案。');
125
133
  throw new Error('缺少原始碼路徑');
126
134
  }
127
135
 
136
+ logger.header('write-and-test');
137
+ if (fromState) {
138
+ logger.info('從上一步 dev-from-issue 自動讀取');
139
+ } else if (fromGit) {
140
+ logger.info(`從 git 異動記錄偵測到 ${sourceFiles.length} 個檔案`);
141
+ }
142
+ console.log('');
143
+
144
+ const allResults = [];
145
+ for (const filePath of sourceFiles) {
146
+ const result = await processOneFile({ filePath, maxFixes, fromState, fromGit, options, logger });
147
+ allResults.push({ filePath, ...result });
148
+ }
149
+
150
+ // 所有檔案處理完成後,才發射一則聚合報告
151
+ if (issueNumber) {
152
+ await postAggregatedReport(issueNumber, allResults, options.model, logger);
153
+ }
154
+
155
+ return allResults.length === 1 ? allResults[0] : allResults;
156
+ }
157
+
158
+ async function processOneFile({ filePath, maxFixes, fromState, fromGit, options, logger }) {
159
+ const testDir = options.testDir || '__tests__';
128
160
  const absoluteSourcePath = resolve(process.cwd(), filePath);
129
161
  if (!existsSync(absoluteSourcePath)) {
130
162
  logger.error(`原始碼不存在:${absoluteSourcePath}`);
131
163
  throw new Error(`找不到原始碼:${absoluteSourcePath}`);
132
164
  }
133
165
 
134
- // 自動從 state 或 git 讀取時,測試類型預設為 both
166
+ // 自動偵測時預設 both,手動指定時依 --test-type
135
167
  const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || (fromState || fromGit ? 'both' : 'auto'));
136
168
  const testTargets = resolvedTestTypes.map((testType) => ({
137
169
  testType,
138
- testFilePath: deriveTestFilePath(absoluteSourcePath, testType),
170
+ testFilePath: deriveTestFilePath(absoluteSourcePath, testType, testDir),
139
171
  }));
140
172
 
141
- logger.header('write-and-test');
142
- if (fromState) {
143
- logger.info('從上一步 dev-from-issue 自動讀取');
144
- } else if (fromGit) {
145
- logger.info('從 git 異動記錄自動偵測');
146
- }
147
- console.log(`原始碼 :${absoluteSourcePath}`);
148
- console.log(`測試類型:${formatTestTypes(resolvedTestTypes)}`);
173
+ console.log(`── ${filePath}`);
174
+ console.log(` 測試類型:${formatTestTypes(resolvedTestTypes)}`);
149
175
  testTargets.forEach(({ testType, testFilePath }) => {
150
- console.log(`${testType === 'component' ? '元件測試' : '單元測試'}:${testFilePath}`);
176
+ console.log(` ${testType === 'component' ? '元件測試' : '單元測試'}:${testFilePath}`);
151
177
  });
152
- console.log(`最大修復:${maxFixes} 次`);
153
- if (issueNumber) {
154
- console.log(`報告 Issue:#${issueNumber}`);
155
- }
156
178
  console.log('');
157
179
 
158
- // 1. 詢問是否繼續(自動從 state 讀取時跳過)
159
- if (!options.noConfirm && !fromState) {
160
- const { confirmed } = await inquirer.prompt([{
161
- type: 'confirm',
162
- name: 'confirmed',
163
- message: `確認為 ${filePath} 生成 ${formatTestTypes(resolvedTestTypes)} 並執行?`,
164
- default: true,
165
- }]);
166
-
167
- if (!confirmed) {
168
- logger.info('已取消。');
169
- return;
170
- }
171
- }
172
180
 
173
- // 2. 生成測試
181
+ // 1. 生成測試
174
182
  logger.step('正在生成測試代碼...');
175
183
  const generatedTests = [];
176
184
  for (const { testType, testFilePath } of testTargets) {
@@ -185,7 +193,7 @@ export async function writeAndTestCommand(options = {}) {
185
193
  }
186
194
  logger.info(`實際測試類型:${formatTestTypes(generatedTests.map(({ testType }) => testType))}`);
187
195
 
188
- // 3. 執行測試 + 自動修復循環
196
+ // 2. 執行測試 + 自動修復循環
189
197
  let lastErrors = [];
190
198
  for (let attempt = 0; attempt <= maxFixes; attempt++) {
191
199
  if (attempt > 0) {
@@ -199,18 +207,12 @@ export async function writeAndTestCommand(options = {}) {
199
207
 
200
208
  if (result.success) {
201
209
  logger.success('所有測試通過! 🎉');
202
- const testResult = {
210
+ return {
203
211
  success: true,
204
212
  testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
205
213
  testTypes: generatedTests.map(({ testType }) => testType),
206
214
  attempts: attempt,
207
215
  };
208
- if (options.issue) {
209
- await postTestReportToIssue(options.issue, absoluteSourcePath, testResult, options.model, logger);
210
- } else if (issueNumber) {
211
- await postTestReportToIssue(issueNumber, absoluteSourcePath, testResult, options.model, logger);
212
- }
213
- return testResult;
214
216
  }
215
217
 
216
218
  lastErrors = result.errors;
@@ -242,11 +244,6 @@ export async function writeAndTestCommand(options = {}) {
242
244
  testTypes: generatedTests.map(({ testType }) => testType),
243
245
  attempts: maxFixes,
244
246
  };
245
- if (options.issue) {
246
- await postTestReportToIssue(options.issue, absoluteSourcePath, failedResult, options.model, logger);
247
- } else if (issueNumber) {
248
- await postTestReportToIssue(issueNumber, absoluteSourcePath, failedResult, options.model, logger);
249
- }
250
247
  return failedResult;
251
248
  }
252
249
  }
@@ -292,59 +289,84 @@ ${sourceCode}
292
289
  }
293
290
 
294
291
  /**
295
- * 將測試報告與 mermaid 流程圖發佈到 GitHub Issue 留言
292
+ * 將所有檔案的測試結果彙整為一則 Issue 留言,並為通過的檔案附上 mermaid 流程圖
293
+ * @param {Array<{filePath,success,testFilePaths,testTypes,attempts,errors?}>} allResults
296
294
  */
297
- async function postTestReportToIssue(issueNumber, sourceFilePath, testResult, model, logger) {
295
+ async function postAggregatedReport(issueNumber, allResults, model, logger) {
296
+ if (!allResults || allResults.length === 0) return;
297
+
298
298
  logger.step(`正在將測試報告發佈到 Issue #${issueNumber}...`);
299
299
 
300
+ const cwd = process.cwd();
301
+ const total = allResults.length;
302
+ const passed = allResults.filter((r) => r.success).length;
303
+ const failed = total - passed;
300
304
  const typeLabels = { unit: 'Jest 單元測試', component: '元件測試', auto: '自動判斷' };
301
- const testTypesStr = testResult.testTypes.map((t) => typeLabels[t] || t).join('、');
302
- const statusEmoji = testResult.success ? '✅' : '❌';
303
- const statusText = testResult.success
304
- ? `通過(${testResult.attempts > 0 ? `經過 ${testResult.attempts} 次自動修復` : '一次通過'})`
305
- : `失敗(已嘗試自動修復 ${testResult.attempts} 次)`;
306
-
307
- const relativeSourcePath = sourceFilePath.replace(process.cwd() + '/', '');
308
- const relativeTestPaths = testResult.testFilePaths
309
- .map((p) => p.replace(process.cwd() + '/', ''))
310
- .map((p) => `- \`${p}\``)
311
- .join('\n');
312
-
313
- let errorSection = '';
314
- if (!testResult.success && testResult.errors?.length) {
315
- const errorSummary = testResult.errors
316
- .slice(0, 3)
317
- .map((e, i) => `**錯誤 ${i + 1}**\n\`\`\`\n${e.split('\n').slice(0, 15).join('\n')}\n\`\`\``)
318
- .join('\n\n');
319
- errorSection = `\n### 錯誤摘要\n\n${errorSummary}\n`;
320
- }
321
305
 
322
- // 生成 mermaid
323
- const mermaidSection = testResult.success
324
- ? await (async () => {
325
- const diagram = await generateMermaidDiagram(sourceFilePath, model);
326
- return diagram ? `\n---\n\n## 📊 邏輯流程圖\n\n${diagram}\n` : '';
327
- })()
328
- : '';
306
+ // 摘要表格
307
+ const summaryEmoji = failed === 0 ? '✅' : '⚠️';
308
+ const header =
309
+ `## ${summaryEmoji} 測試報告總覽\n\n` +
310
+ `**異動檔案**:${total} 個|**通過**:${passed}|**失敗**:${failed}\n\n` +
311
+ `| 狀態 | 檔案 | 測試類型 | 結果 |\n` +
312
+ `| :---: | --- | --- | --- |\n`;
313
+
314
+ const tableRows = allResults.map((r) => {
315
+ const relPath = r.filePath.replace(cwd + '/', '');
316
+ const testTypesStr = (r.testTypes || []).map((t) => typeLabels[t] || t).join('、');
317
+ const statusEmoji = r.success ? '✅' : '❌';
318
+ const resultStr = r.success
319
+ ? r.attempts > 0 ? `通過(修復 ${r.attempts} 次)` : '一次通過'
320
+ : `失敗(已嘗試修復 ${r.attempts} 次)`;
321
+ return `| ${statusEmoji} | \`${relPath}\` | ${testTypesStr} | ${resultStr} |`;
322
+ });
329
323
 
330
- const body = `## ${statusEmoji} 測試報告
324
+ // 個別檔案詳情
325
+ const fileDetails = allResults.map((r) => {
326
+ const relPath = r.filePath.replace(cwd + '/', '');
327
+ const statusEmoji = r.success ? '✅' : '❌';
328
+ const relTestPaths = (r.testFilePaths || [])
329
+ .map((p) => `- \`${p.replace(cwd + '/', '')}\``)
330
+ .join('\n');
331
+
332
+ let section = `\n---\n\n### ${statusEmoji} \`${relPath}\`\n\n**測試檔案**:\n${relTestPaths}\n`;
333
+
334
+ if (!r.success && r.errors?.length) {
335
+ const errorSummary = r.errors
336
+ .slice(0, 2)
337
+ .map((e, i) => `**錯誤 ${i + 1}**\n\`\`\`\n${e.split('\n').slice(0, 12).join('\n')}\n\`\`\``)
338
+ .join('\n\n');
339
+ section += `\n**錯誤摘要**:\n\n${errorSummary}\n`;
340
+ }
331
341
 
332
- **原始碼**:\`${relativeSourcePath}\`
333
- **測試類型**:${testTypesStr}
334
- **結果**:${statusText}
342
+ return section;
343
+ });
344
+
345
+ // 為通過的檔案生成 mermaid 流程圖
346
+ const mermaidSections = [];
347
+ for (const r of allResults.filter((r) => r.success)) {
348
+ const absPath = resolve(cwd, r.filePath);
349
+ const diagram = await generateMermaidDiagram(absPath, model);
350
+ if (diagram) {
351
+ const relPath = r.filePath.replace(cwd + '/', '');
352
+ mermaidSections.push(`\n### \`${relPath}\`\n\n${diagram}`);
353
+ }
354
+ }
335
355
 
336
- ### 測試檔案
356
+ const mermaidBlock =
357
+ mermaidSections.length > 0
358
+ ? `\n---\n\n## 📊 邏輯流程圖\n${mermaidSections.join('\n')}\n`
359
+ : '';
337
360
 
338
- ${relativeTestPaths}
339
- ${errorSection}${mermaidSection}`;
361
+ const body = header + tableRows.join('\n') + fileDetails.join('') + mermaidBlock;
340
362
 
341
- const tmpFile = join(tmpdir(), `ai-git-tools-comment-${Date.now()}.md`);
363
+ const tmpFile = join(tmpdir(), `ai-git-tools-report-${Date.now()}.md`);
342
364
  try {
343
365
  writeFileSync(tmpFile, body, 'utf-8');
344
366
  execSync(`gh issue comment ${issueNumber} --body-file "${tmpFile}"`, {
345
367
  encoding: 'utf-8',
346
368
  stdio: 'pipe',
347
- cwd: process.cwd(),
369
+ cwd,
348
370
  });
349
371
  logger.success(`測試報告已發佈到 Issue #${issueNumber}`);
350
372
  } catch (error) {
@@ -356,70 +378,92 @@ ${errorSection}${mermaidSection}`;
356
378
 
357
379
  /**
358
380
  * 依據原始檔案路徑推導測試檔案路徑
381
+ * @param {string} testDir - 測試輸出目錄:'__tests__'(預設) 或 相對專案根的路徑(如 'tests')
359
382
  */
360
- export function deriveTestFilePath(sourceFilePath, testType = 'unit') {
361
- const dir = dirname(sourceFilePath);
383
+ export function deriveTestFilePath(sourceFilePath, testType = 'unit', testDir = '__tests__') {
384
+ const cwd = process.cwd();
362
385
  const name = basename(sourceFilePath).replace(/\.(js|jsx|ts|tsx|mjs|cjs)$/, '');
363
386
  const ext = /\.(ts|tsx)$/.test(sourceFilePath) ? '.ts' : '.js';
364
387
  const suffix = testType === 'component' ? 'component' : 'unit';
365
- return join(dir, '__tests__', `${name}.${suffix}.test${ext}`);
388
+
389
+ if (testDir === '__tests__') {
390
+ // 預設:放在原始檔旁的 __tests__ 子目錄
391
+ return join(dirname(sourceFilePath), '__tests__', `${name}.${suffix}.test${ext}`);
392
+ }
393
+
394
+ // 自訂目錄:放在專案根 testDir 底下,保留原始檔的相對路徑結構
395
+ const relDir = dirname(sourceFilePath).replace(cwd, '').replace(/^\//, '');
396
+ return join(cwd, testDir, relDir, `${name}.${suffix}.test${ext}`);
366
397
  }
367
398
 
368
399
  /**
369
- * 透過 git 偵測最近異動的原始碼檔案
370
- * 優先順序:git staged → git unstaged → git 最新一筆 commit
400
+ * 透過 git 偵測所有異動的原始碼檔案(回傳陣列)
401
+ * 優先順序:staged → 工作區(unstaged+untracked)整個 branch(vs main/master) → 最近 10 commits
371
402
  * 排除測試檔、設定檔、lock 檔
372
403
  */
373
- async function detectChangedSourceFile(logger) {
404
+ async function detectChangedSourceFiles(logger) {
374
405
  const SOURCE_EXTENSIONS = /\.(js|jsx|ts|tsx|mjs|cjs)$/;
375
406
  const EXCLUDED = /(__tests__|\.test\.|\.spec\.|\.config\.|node_modules|dist\/|\.lock$|package\.json)/;
407
+ const cwd = process.cwd();
376
408
 
377
- function filterSourceFiles(files) {
378
- return files
409
+ function filterSourceFiles(raw) {
410
+ return raw
379
411
  .split('\n')
380
- .map((f) => f.trim().replace(/^[MADRCU?!\s]+/, ''))
381
- .filter((f) => f && SOURCE_EXTENSIONS.test(f) && !EXCLUDED.test(f) && existsSync(resolve(process.cwd(), f)));
412
+ .map((f) => f.trim().replace(/^[MADRCU?!\s]+/, '').replace(/^"(.*)"$/, '$1'))
413
+ .filter((f) => f && SOURCE_EXTENSIONS.test(f) && !EXCLUDED.test(f) && existsSync(resolve(cwd, f)));
414
+ }
415
+
416
+ function run(cmd) {
417
+ return execSync(cmd, { encoding: 'utf-8', stdio: 'pipe', cwd });
382
418
  }
383
419
 
384
420
  try {
385
421
  // 1. staged 檔案
386
- const staged = filterSourceFiles(
387
- execSync('git diff --cached --name-only', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
388
- );
389
- if (staged.length > 0) {
390
- logger.info(`偵測到 staged 異動:${staged[0]}`);
391
- return staged[0];
392
- }
393
-
394
- // 2. unstaged 工作區異動
395
- const unstaged = filterSourceFiles(
396
- execSync('git diff --name-only', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
397
- );
398
- if (unstaged.length > 0) {
399
- logger.info(`偵測到工作區異動:${unstaged[0]}`);
400
- return unstaged[0];
422
+ const staged = filterSourceFiles(run('git diff --cached --name-only'));
423
+
424
+ // 2. unstaged + untracked
425
+ const unstaged = filterSourceFiles(run('git diff --name-only'));
426
+ const untracked = filterSourceFiles(run('git ls-files --others --exclude-standard'));
427
+ const working = [...new Set([...unstaged, ...untracked])];
428
+
429
+ // 3. 整個 branch 相對於 upstream / main / master 的變更
430
+ const branchFiles = getBranchChangedFiles(filterSourceFiles, run);
431
+
432
+ // 合併全部(去重);staged > working > branch
433
+ const all = [...new Set([...staged, ...working, ...branchFiles])];
434
+
435
+ if (all.length > 0) {
436
+ const breakdown = [
437
+ staged.length && `staged ${staged.length}`,
438
+ working.length && `工作區 ${working.length}`,
439
+ branchFiles.length && `branch ${branchFiles.length}`,
440
+ ].filter(Boolean).join('、');
441
+ logger.info(`偵測到 ${all.length} 個異動檔案(${breakdown})`);
442
+ return all;
401
443
  }
444
+ } catch {
445
+ // git 取得失敗,直接回傳空陣列
446
+ }
402
447
 
403
- // 3. untracked 新增檔案
404
- const untracked = filterSourceFiles(
405
- execSync('git ls-files --others --exclude-standard', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
406
- );
407
- if (untracked.length > 0) {
408
- logger.info(`偵測到新增檔案:${untracked[0]}`);
409
- return untracked[0];
410
- }
448
+ return [];
449
+ }
411
450
 
412
- // 4. 最新 commit 的異動
413
- const lastCommit = filterSourceFiles(
414
- execSync('git diff-tree --no-commit-id -r --name-only HEAD', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
415
- );
416
- if (lastCommit.length > 0) {
417
- logger.info(`偵測到最後 commit 異動:${lastCommit[0]}`);
418
- return lastCommit[0];
451
+ function getBranchChangedFiles(filterSourceFiles, run) {
452
+ // 依序嘗試找到 merge-base,再比較 HEAD
453
+ const baseCandidates = ['@{u}', 'origin/main', 'origin/master', 'main', 'master'];
454
+ for (const base of baseCandidates) {
455
+ try {
456
+ const mergeBase = run(`git merge-base HEAD ${base}`).trim();
457
+ const files = filterSourceFiles(run(`git diff --name-only ${mergeBase}...HEAD`));
458
+ if (files.length > 0) return files;
459
+ } catch {
460
+ continue;
419
461
  }
462
+ }
463
+ // 最後 fallback:最近 10 commits
464
+ try {
465
+ return filterSourceFiles(run('git diff --name-only HEAD~10...HEAD'));
420
466
  } catch {
421
- // git 取得失敗(非 git 專案等),直接回傳 null
467
+ return [];
422
468
  }
423
-
424
- return null;
425
469
  }