feat(ai-code-review): 新增 AI 多角色 code review action(攻防審查、findings 保存、建問題模式)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-07-17 16:53:27 +08:00
co-authored by Claude Fable 5
parent 6a0573b7c0
commit d08b97bd87
18 changed files with 2884 additions and 19 deletions
+167
View File
@@ -0,0 +1,167 @@
'use strict';
const { execFile, execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
// AI agent 工具介接:依 antigravity → codex → claude 順序偵測可用工具,
// 以非互動模式(stdin 餵提示)執行 sub agent 並取回最終回覆。
const TOOLS = [
{
name: 'antigravity',
// 需人工確認:antigravity 的非互動執行參數尚未驗證(開發機無此工具),此處先比照 codex exec 的形式。
buildArgs: ({ model }) => ['exec', ...(model ? ['-m', model] : []), '-'],
resultFrom: 'stdout',
},
{
name: 'codex',
buildArgs: ({ model, lastMessageFile }) => [
'exec',
'--skip-git-repo-check',
'--sandbox', 'read-only',
'--output-last-message', lastMessageFile,
...(model ? ['-m', model] : []),
'-',
],
resultFrom: 'lastMessageFile',
},
{
name: 'claude',
buildArgs: ({ model }) => ['-p', '--output-format', 'text', ...(model ? ['--model', model] : [])],
resultFrom: 'stdout',
},
];
/**
* 依固定優先序(antigravity → codex → claude)偵測本機可用的 AI CLI 工具。
*
* 逐一以同步方式執行 `<tool> --version`(逾時 30 秒),第一個成功者即中選,
* 並取其 stdout 第一行作為版本字串;偵測失敗(未安裝、不可執行、逾時)
* 則靜默換下一個工具。本函式不會拋出例外。
*
* 注意:antigravity 的非互動執行參數尚未驗證(需人工確認),本函式僅確認
* `--version` 可執行,不保證後續 runAgent 的參數組合正確。
*
* @returns {{ name: string, buildArgs: Function, resultFrom: string, version: string } | null}
* 中選工具的描述物件(TOOLS 項目加上 version 欄位);所有工具皆不可用時回傳 null。
* @remarks
* 使用情境:action 主流程(步驟 2)啟動審查前呼叫一次,取得工具描述後交給
* runAgent 執行;若回傳 null,主流程會記 ERR 並以失敗收場(無工具即無法審查)。
*/
function detectTool() {
for (const tool of TOOLS) {
try {
const version = execFileSync(tool.name, ['--version'], { encoding: 'utf8', timeout: 30_000 })
.trim()
.split('\n')[0];
return { ...tool, version };
} catch {
// 不可用(未安裝或無法執行)→ 換下一個。
}
}
return null;
}
/**
* 以非互動模式執行一次 sub agent:把提示從 stdin 餵給偵測到的 AI CLI 工具,
* 等子行程結束後回傳最終文字回覆。
*
* 依 tool.resultFrom 決定結果來源:stdoutantigravity、claude),或
* codex 專用的 --output-last-message 暫存檔(codex exec 的 stdout 夾雜過程
* 訊息,改讀工具寫出的最終回覆檔,讀取後即刪除)。
*
* 本函式永不 reject:任何失敗(非零退出碼、逾時、maxBuffer 超限)都以
* { ok: false, error } resolve,由呼叫端決定降級行為;並對 child.stdin
* 掛空 error handler,避免工具提早結束時 EPIPE 造成整個 action 噴例外。
*
* 注意:antigravity 的非互動參數尚未驗證(需人工確認),以該工具執行時
* 可能因參數不符而以 ok: false 收場。
*
* @param {{ name: string, buildArgs: Function, resultFrom: string }} tool
* 工具描述物件(通常來自 detectTool() 的回傳值)。
* @param {Object} options 執行選項(解構參數)。
* @param {string} [options.model] 指定模型名稱;未給時不帶模型參數,使用工具預設模型。
* @param {string} options.prompt 要餵給 agent 的完整提示文字,經 stdin 寫入。
* @param {string} [options.cwd] 子行程工作目錄;影響工具讀取檔案的相對路徑基準。
* @param {number} [options.timeoutMs=600000] 子行程逾時毫秒數(預設 10 分鐘),逾時即終止並回報 ok: false。
* @returns {Promise<{ ok: boolean, output: string, stderr: string, error: Error | null }>}
* ok 表示子行程是否成功結束;output 為最終回覆文字(codex 取自
* --output-last-message 檔,其餘取 stdout);stderr 供除錯;error 為失敗原因(成功時為 null)。
* @remarks
* 使用情境:審查流程對每個角色組好提示後呼叫本函式,
* 例如 `const r = await runAgent(tool, { model, prompt, cwd: workspace });`
* 再以 `r.ok ? extractJson(r.output) : null` 取回結構化 findings
* 失敗時記 log 並跳過該角色,不中斷整個 action。
*/
function runAgent(tool, { model, prompt, cwd, timeoutMs = 600_000 }) {
return new Promise((resolve) => {
const lastMessageFile = path.join(
os.tmpdir(),
`ai-review-${process.pid}-${Math.random().toString(36).slice(2)}.txt`,
);
const args = tool.buildArgs({ model, lastMessageFile });
const child = execFile(
tool.name,
args,
{ cwd, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 },
(error, stdout, stderr) => {
let output = stdout || '';
// codex exec 的 stdout 夾雜過程訊息,改讀 --output-last-message 寫出的最終回覆。
if (tool.resultFrom === 'lastMessageFile' && fs.existsSync(lastMessageFile)) {
const last = fs.readFileSync(lastMessageFile, 'utf8').trim();
if (last) output = last;
fs.rmSync(lastMessageFile, { force: true });
}
resolve({ ok: !error, output, stderr: stderr || '', error });
},
);
child.stdin.on('error', () => {
// 工具提早結束時避免 EPIPE 讓整個 action 噴例外。
});
child.stdin.write(prompt);
child.stdin.end();
});
}
/**
* 從 agent 的自由文字回覆中萃取 JSON,容忍 Markdown code fence 與前後雜訊。
*
* 處理順序:先取第一個 ``` 或 ```json fence 的內文;再依序以
* 「第一個 [ 到最後一個 ]」、「第一個 { 到最後一個 }」的最大範圍切片
* 嘗試 JSON.parse(陣列優先);最後退而直接 parse 整段文字。
* 本函式不會拋出例外,所有 parse 失敗一律回傳 null。
*
* @param {string} text agent 回覆的原始文字;可為空或 null/undefined。
* @returns {any | null} 解析成功的 JSON 值(通常為 findings 陣列或物件);無法解析時為 null。
* @remarks
* 使用情境:搭配 runAgent 使用——LLM 即使被要求輸出純 JSON,實務上仍常
* 包在 ```json fence 內或前後夾說明文字,例如
* `const findings = extractJson(result.output) ?? [];`
* 可穩定取回 review findings;回傳 null 時呼叫端應視為該次回覆無效並降級處理。
*/
function extractJson(text) {
if (!text) return null;
let t = text.trim();
const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(t);
if (fence) t = fence[1].trim();
for (const [open, close] of [['[', ']'], ['{', '}']]) {
const start = t.indexOf(open);
const end = t.lastIndexOf(close);
if (start !== -1 && end > start) {
try {
return JSON.parse(t.slice(start, end + 1));
} catch {
// 換下一種括號組合再試。
}
}
}
try {
return JSON.parse(t);
} catch {
return null;
}
}
module.exports = { TOOLS, detectTool, runAgent, extractJson };