ai-git-tools 2.0.67 → 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.67",
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",
@@ -108,7 +108,7 @@ export async function writeAndTestCommand(options = {}) {
108
108
  // 自動從 state 檔讀取 file / issue(指令列將覆蓋)
109
109
  const state = loadDevState();
110
110
  const issueNumber = options.issue || state?.issueNumber;
111
- const maxFixes = parseInt(options.maxFixes || '2', 10);
111
+ const maxFixes = parseInt(options.maxFixes || '3', 10);
112
112
 
113
113
  // 決定要處理的檔案清單
114
114
  let sourceFiles = [];
@@ -143,14 +143,20 @@ export async function writeAndTestCommand(options = {}) {
143
143
 
144
144
  const allResults = [];
145
145
  for (const filePath of sourceFiles) {
146
- const result = await processOneFile({ filePath, issueNumber, maxFixes, fromState, fromGit, options, logger });
147
- allResults.push(result);
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);
148
153
  }
149
154
 
150
155
  return allResults.length === 1 ? allResults[0] : allResults;
151
156
  }
152
157
 
153
- async function processOneFile({ filePath, issueNumber, maxFixes, fromState, fromGit, options, logger }) {
158
+ async function processOneFile({ filePath, maxFixes, fromState, fromGit, options, logger }) {
159
+ const testDir = options.testDir || '__tests__';
154
160
  const absoluteSourcePath = resolve(process.cwd(), filePath);
155
161
  if (!existsSync(absoluteSourcePath)) {
156
162
  logger.error(`原始碼不存在:${absoluteSourcePath}`);
@@ -161,7 +167,7 @@ async function processOneFile({ filePath, issueNumber, maxFixes, fromState, from
161
167
  const resolvedTestTypes = resolveRequestedTestTypes(absoluteSourcePath, options.testType || (fromState || fromGit ? 'both' : 'auto'));
162
168
  const testTargets = resolvedTestTypes.map((testType) => ({
163
169
  testType,
164
- testFilePath: deriveTestFilePath(absoluteSourcePath, testType),
170
+ testFilePath: deriveTestFilePath(absoluteSourcePath, testType, testDir),
165
171
  }));
166
172
 
167
173
  console.log(`── ${filePath}`);
@@ -201,18 +207,12 @@ async function processOneFile({ filePath, issueNumber, maxFixes, fromState, from
201
207
 
202
208
  if (result.success) {
203
209
  logger.success('所有測試通過! 🎉');
204
- const testResult = {
210
+ return {
205
211
  success: true,
206
212
  testFilePaths: generatedTests.map(({ testFilePath }) => testFilePath),
207
213
  testTypes: generatedTests.map(({ testType }) => testType),
208
214
  attempts: attempt,
209
215
  };
210
- if (options.issue) {
211
- await postTestReportToIssue(options.issue, absoluteSourcePath, testResult, options.model, logger);
212
- } else if (issueNumber) {
213
- await postTestReportToIssue(issueNumber, absoluteSourcePath, testResult, options.model, logger);
214
- }
215
- return testResult;
216
216
  }
217
217
 
218
218
  lastErrors = result.errors;
@@ -244,11 +244,6 @@ async function processOneFile({ filePath, issueNumber, maxFixes, fromState, from
244
244
  testTypes: generatedTests.map(({ testType }) => testType),
245
245
  attempts: maxFixes,
246
246
  };
247
- if (options.issue) {
248
- await postTestReportToIssue(options.issue, absoluteSourcePath, failedResult, options.model, logger);
249
- } else if (issueNumber) {
250
- await postTestReportToIssue(issueNumber, absoluteSourcePath, failedResult, options.model, logger);
251
- }
252
247
  return failedResult;
253
248
  }
254
249
  }
@@ -294,59 +289,84 @@ ${sourceCode}
294
289
  }
295
290
 
296
291
  /**
297
- * 將測試報告與 mermaid 流程圖發佈到 GitHub Issue 留言
292
+ * 將所有檔案的測試結果彙整為一則 Issue 留言,並為通過的檔案附上 mermaid 流程圖
293
+ * @param {Array<{filePath,success,testFilePaths,testTypes,attempts,errors?}>} allResults
298
294
  */
299
- async function postTestReportToIssue(issueNumber, sourceFilePath, testResult, model, logger) {
295
+ async function postAggregatedReport(issueNumber, allResults, model, logger) {
296
+ if (!allResults || allResults.length === 0) return;
297
+
300
298
  logger.step(`正在將測試報告發佈到 Issue #${issueNumber}...`);
301
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;
302
304
  const typeLabels = { unit: 'Jest 單元測試', component: '元件測試', auto: '自動判斷' };
303
- const testTypesStr = testResult.testTypes.map((t) => typeLabels[t] || t).join('、');
304
- const statusEmoji = testResult.success ? '✅' : '❌';
305
- const statusText = testResult.success
306
- ? `通過(${testResult.attempts > 0 ? `經過 ${testResult.attempts} 次自動修復` : '一次通過'})`
307
- : `失敗(已嘗試自動修復 ${testResult.attempts} 次)`;
308
-
309
- const relativeSourcePath = sourceFilePath.replace(process.cwd() + '/', '');
310
- const relativeTestPaths = testResult.testFilePaths
311
- .map((p) => p.replace(process.cwd() + '/', ''))
312
- .map((p) => `- \`${p}\``)
313
- .join('\n');
314
-
315
- let errorSection = '';
316
- if (!testResult.success && testResult.errors?.length) {
317
- const errorSummary = testResult.errors
318
- .slice(0, 3)
319
- .map((e, i) => `**錯誤 ${i + 1}**\n\`\`\`\n${e.split('\n').slice(0, 15).join('\n')}\n\`\`\``)
320
- .join('\n\n');
321
- errorSection = `\n### 錯誤摘要\n\n${errorSummary}\n`;
322
- }
323
305
 
324
- // 生成 mermaid
325
- const mermaidSection = testResult.success
326
- ? await (async () => {
327
- const diagram = await generateMermaidDiagram(sourceFilePath, model);
328
- return diagram ? `\n---\n\n## 📊 邏輯流程圖\n\n${diagram}\n` : '';
329
- })()
330
- : '';
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
+ });
331
323
 
332
- 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
+ }
333
341
 
334
- **原始碼**:\`${relativeSourcePath}\`
335
- **測試類型**:${testTypesStr}
336
- **結果**:${statusText}
342
+ return section;
343
+ });
337
344
 
338
- ### 測試檔案
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
+ }
355
+
356
+ const mermaidBlock =
357
+ mermaidSections.length > 0
358
+ ? `\n---\n\n## 📊 邏輯流程圖\n${mermaidSections.join('\n')}\n`
359
+ : '';
339
360
 
340
- ${relativeTestPaths}
341
- ${errorSection}${mermaidSection}`;
361
+ const body = header + tableRows.join('\n') + fileDetails.join('') + mermaidBlock;
342
362
 
343
- const tmpFile = join(tmpdir(), `ai-git-tools-comment-${Date.now()}.md`);
363
+ const tmpFile = join(tmpdir(), `ai-git-tools-report-${Date.now()}.md`);
344
364
  try {
345
365
  writeFileSync(tmpFile, body, 'utf-8');
346
366
  execSync(`gh issue comment ${issueNumber} --body-file "${tmpFile}"`, {
347
367
  encoding: 'utf-8',
348
368
  stdio: 'pipe',
349
- cwd: process.cwd(),
369
+ cwd,
350
370
  });
351
371
  logger.success(`測試報告已發佈到 Issue #${issueNumber}`);
352
372
  } catch (error) {
@@ -358,61 +378,68 @@ ${errorSection}${mermaidSection}`;
358
378
 
359
379
  /**
360
380
  * 依據原始檔案路徑推導測試檔案路徑
381
+ * @param {string} testDir - 測試輸出目錄:'__tests__'(預設) 或 相對專案根的路徑(如 'tests')
361
382
  */
362
- export function deriveTestFilePath(sourceFilePath, testType = 'unit') {
363
- const dir = dirname(sourceFilePath);
383
+ export function deriveTestFilePath(sourceFilePath, testType = 'unit', testDir = '__tests__') {
384
+ const cwd = process.cwd();
364
385
  const name = basename(sourceFilePath).replace(/\.(js|jsx|ts|tsx|mjs|cjs)$/, '');
365
386
  const ext = /\.(ts|tsx)$/.test(sourceFilePath) ? '.ts' : '.js';
366
387
  const suffix = testType === 'component' ? 'component' : 'unit';
367
- 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}`);
368
397
  }
369
398
 
370
399
  /**
371
400
  * 透過 git 偵測所有異動的原始碼檔案(回傳陣列)
372
- * 優先順序:staged → unstaged → untracked最新 commit
401
+ * 優先順序:staged → 工作區(unstaged+untracked)整個 branch(vs main/master) 最近 10 commits
373
402
  * 排除測試檔、設定檔、lock 檔
374
403
  */
375
404
  async function detectChangedSourceFiles(logger) {
376
405
  const SOURCE_EXTENSIONS = /\.(js|jsx|ts|tsx|mjs|cjs)$/;
377
406
  const EXCLUDED = /(__tests__|\.test\.|\.spec\.|\.config\.|node_modules|dist\/|\.lock$|package\.json)/;
407
+ const cwd = process.cwd();
378
408
 
379
409
  function filterSourceFiles(raw) {
380
410
  return raw
381
411
  .split('\n')
382
- .map((f) => f.trim().replace(/^[MADRCU?!\s]+/, ''))
383
- .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 });
384
418
  }
385
419
 
386
420
  try {
387
421
  // 1. staged 檔案
388
- const staged = filterSourceFiles(
389
- execSync('git diff --cached --name-only', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
390
- );
391
- if (staged.length > 0) {
392
- logger.info(`偵測到 staged 異動 ${staged.length} 個檔案`);
393
- return staged;
394
- }
422
+ const staged = filterSourceFiles(run('git diff --cached --name-only'));
395
423
 
396
- // 2. unstaged 工作區異動(含 untracked 合併)
397
- const unstaged = filterSourceFiles(
398
- execSync('git diff --name-only', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
399
- );
400
- const untracked = filterSourceFiles(
401
- execSync('git ls-files --others --exclude-standard', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
402
- );
424
+ // 2. unstaged + untracked
425
+ const unstaged = filterSourceFiles(run('git diff --name-only'));
426
+ const untracked = filterSourceFiles(run('git ls-files --others --exclude-standard'));
403
427
  const working = [...new Set([...unstaged, ...untracked])];
404
- if (working.length > 0) {
405
- logger.info(`偵測到工作區異動 ${working.length} 個檔案`);
406
- return working;
407
- }
408
428
 
409
- // 3. 最新 commit 的異動
410
- const lastCommit = filterSourceFiles(
411
- execSync('git diff-tree --no-commit-id -r --name-only HEAD', { encoding: 'utf-8', stdio: 'pipe', cwd: process.cwd() })
412
- );
413
- if (lastCommit.length > 0) {
414
- logger.info(`偵測到最後 commit 異動 ${lastCommit.length} 個檔案`);
415
- return lastCommit;
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;
416
443
  }
417
444
  } catch {
418
445
  // git 取得失敗,直接回傳空陣列
@@ -420,3 +447,23 @@ async function detectChangedSourceFiles(logger) {
420
447
 
421
448
  return [];
422
449
  }
450
+
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;
461
+ }
462
+ }
463
+ // 最後 fallback:最近 10 commits
464
+ try {
465
+ return filterSourceFiles(run('git diff --name-only HEAD~10...HEAD'));
466
+ } catch {
467
+ return [];
468
+ }
469
+ }