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 +2 -2
- package/package.json +1 -1
- package/src/commands/write-and-test.js +133 -86
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('--
|
|
168
|
-
.option('--
|
|
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
|
@@ -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 || '
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
292
|
+
* 將所有檔案的測試結果彙整為一則 Issue 留言,並為通過的檔案附上 mermaid 流程圖
|
|
293
|
+
* @param {Array<{filePath,success,testFilePaths,testTypes,attempts,errors?}>} allResults
|
|
298
294
|
*/
|
|
299
|
-
async function
|
|
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
|
-
//
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
341
|
-
${errorSection}${mermaidSection}`;
|
|
361
|
+
const body = header + tableRows.join('\n') + fileDetails.join('') + mermaidBlock;
|
|
342
362
|
|
|
343
|
-
const tmpFile = join(tmpdir(), `ai-git-tools-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 →
|
|
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(
|
|
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
|
|
397
|
-
const unstaged = filterSourceFiles(
|
|
398
|
-
|
|
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.
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
}
|