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
+289 -11
View File
@@ -1,16 +1,294 @@
'use strict';
// action 啟動橫幅:輸出名稱/用途/更新時間(此區塊由 code-action-node 維護)
console.log('================================================');
console.log('Action : AI Code Review');
console.log('用途 : AI 多角色 code review:攻擊方找問題、防守方裁決誤報,結果留言到 PR 並保存 findings');
console.log('更新時間: 2026/07/17 16:49:21');
console.log('================================================');
const fs = require('fs');
const os = require('os');
const path = require('path');
// 讀取 inputnode action 會把每個 input 轉成 INPUT_<NAME> 環境變數
// (名稱大寫、空白換成底線)。action.yml 有設 default 時,runner 會先帶入 default。
const message = process.env.INPUT_MESSAGE ?? 'Hello, World!';
const { log, taipeiNow, taipeiFileStamp } = require('./lib/log');
const { loadContext } = require('./lib/context');
const gitrepo = require('./lib/gitrepo');
const gitea = require('./lib/gitea');
const agents = require('./lib/agents');
const { loadRoles, attackersOf, defendersOf } = require('./lib/roles');
const review = require('./lib/review');
const templates = require('./lib/templates');
// 設定 output:把 name=value 附加寫進 $GITHUB_OUTPUT 指向的檔案
// node action 的 output 不像 composite 需要在 action.yml 宣告 value。
const githubOutput = process.env.GITHUB_OUTPUT;
if (githubOutput) {
fs.appendFileSync(githubOutput, `message=${message}${os.EOL}`);
// ai-review-bot 的 commit 訊息前綴:步驟 1 依此判斷是否為上一回合審查的結果 commit
const BOT_COMMIT_PREFIX = 'chore: update ai-review findings [ai-review-bot]';
/**
* 保存本回合 AI review 的 findings 為 JSON 檔,並回傳 repo 相對路徑(供 commit 使用)。
*
* 檔案寫入 `<cwd>/.gitea/ai-review/findings/<台北時區時間戳>.json`
* 內容含產生時間、受審 commit、PR 編號、使用工具與模型、保留及排除的問題清單。
* 每回合產生一個新檔,不覆蓋歷史紀錄。
*
* @param {Object} params - 解構參數。
* @param {string} params.cwd - repo 根目錄(workspace)絕對路徑,findings 目錄與相對路徑皆以此為基準。
* @param {Object} params.ctx - 由 `loadContext()` 載入的執行環境 context。
* @param {string} params.ctx.headSha - 受審的 head commit SHA,寫入 payload 的 `commitSha`。
* @param {number|string} params.ctx.prNumber - PR 編號,寫入 payload 的 `prNumber`。
* @param {string} [params.ctx.model] - 指定的 AI 模型名稱;未指定時以「(工具預設)」記錄。
* @param {Object} params.tool - `agents.detectTool()` 偵測到的 AI 工具。
* @param {string} params.tool.name - 工具名稱(antigravitycodexclaude)。
* @param {string} params.tool.version - 工具版本字串。
* @param {Array<Object>} params.kept - 防守方裁決後保留的問題(findings)清單;無可審查變更時為空陣列。
* @param {Array<Object>} params.excluded - 被裁決為誤報而排除的問題清單。
* @returns {string} findings JSON 檔相對於 repo 根目錄的路徑(例如 `.gitea/ai-review/findings/xxx.json`)。
* @remarks
* 使用情境:`main()` 步驟 7 於防守方裁決、`review.sortFindings(kept)` 排序後呼叫本函式保存結果,
* 再將回傳的相對路徑交給 `commitFindings` commit 並 push 回 PR 來源分支;
* 另在步驟 3 判定無可審查變更時,也會以空清單保存一份空 findings 後以 success 收場。
* 本函式無 try/catch,檔案系統錯誤會往上拋出,由 `main().catch` 以 exit code 1 收場。
*/
function saveFindings({ cwd, ctx, tool, kept, excluded }) {
const findingsDir = path.join(cwd, '.gitea', 'ai-review', 'findings');
fs.mkdirSync(findingsDir, { recursive: true });
const findingsPath = path.join(findingsDir, `${taipeiFileStamp()}.json`);
const payload = {
generatedAt: taipeiNow(),
commitSha: ctx.headSha,
prNumber: ctx.prNumber,
tool: { name: tool.name, version: tool.version, model: ctx.model || '(工具預設)' },
findings: kept,
excluded,
};
fs.writeFileSync(findingsPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
const relativePath = path.relative(cwd, findingsPath);
log('步驟7', 'INF', `findings 已保存:${relativePath}(保留 ${kept.length} 條、排除 ${excluded.length} 條)。`);
return relativePath;
}
// 一般日誌輸出。若要讓 step 失敗,改用非零結束碼:process.exit(1)。
console.log(`message=${message}`);
/**
* 收尾:將本回合的審查結果檔(findings 檔與/或 exclusions.jsoncommit 並 push 回 PR 來源分支。
*
* commit 訊息固定為「chore: update ai-review findings [ai-review-bot][success|failure]」,
* 供下一回合 `main()` 步驟 1 比對辨識、直接回報結果而不重複審查。
* 依 `commitAndPushFindings` 的回傳值記錄不同日誌:true=已 commit/push
* false=檔案無實際變更(空 commit 防護),記「略過 commit/push」。
* commit/push 失敗(例如與開發者新 commit 競態)時僅記 WRN log,不拋出例外、不改變審查結果。
*
* @param {Object} params - 解構參數。
* @param {string} params.cwd - repo 根目錄(workspace)絕對路徑,git 操作在此目錄執行。
* @param {Object} params.ctx - 由 `loadContext()` 載入的執行環境 context。
* @param {string} params.ctx.headRef - PR 來源分支名稱(push 目標分支)。
* @param {string} params.ctx.headSha - 受審的 head commit SHA。
* @param {string} params.ctx.token - push 用的 Gitea token(必填 input)。
* @param {string} params.ctx.serverUrl - Gitea 伺服器 URL。
* @param {string} params.ctx.repository - `owner/repo` 形式的 repo 名稱。
* @param {string[]} params.files - 要 commit 的檔案 repo 相對路徑陣列(如 findings 檔、`.gitea/ai-review/exclusions.json`);全數無變更時只記 INF 略過。
* @param {'success'|'failure'} params.result - 本回合審查結果:success=無嚴重問題、failure=有嚴重問題;會拼進 commit 訊息尾端。
* @returns {void} 無回傳值;成敗僅反映在 log 上。
* @remarks
* 使用情境:`main()` 於流程尾端依 `severe.length === 0 ? 'success' : 'failure'` 決定 result、
* 依模式組出 filesToCommit(一般模式:findings 檔+有變更時的 exclusions.json
* 建問題模式:只有 exclusions.json)後呼叫本函式;另在步驟 3 判定無可審查變更且非建問題模式時,
* 也會以 result: 'success' 提交空 findings。
* 注意 commit 訊息與模組常數 `BOT_COMMIT_PREFIX` 耦合,修改前綴會使步驟 1 的快速回報失效。
*/
function commitFindings({ cwd, ctx, files, result }) {
try {
const committed = gitrepo.commitAndPushFindings(cwd, {
headRef: ctx.headRef,
headSha: ctx.headSha,
message: `${BOT_COMMIT_PREFIX}[${result}]`,
files,
token: ctx.token,
serverUrl: ctx.serverUrl,
repository: ctx.repository,
});
if (committed) {
log('收尾', 'INF', `審查結果檔已 commit 並 push 回 ${ctx.headRef}(結果:${result})。`);
} else {
log('收尾', 'INF', '審查結果檔無實際變更,略過 commit/push。');
}
} catch (err) {
// push 失敗(例如與開發者新 commit 競態)時只記錄,不改變審查結果。
log('收尾', 'WRN', `commit/push 審查結果檔失敗:${err.message}`);
}
}
/**
* AI code review 主流程:依固定 10 步驟執行多角色審查,回傳 process exit code。
*
* 流程概要:
* 1. 快速回報 — 最新 commit 若為 ai-review-bot 的結果 commit[success]/[failure]),直接回報 0/1 不重審;
* 2. 偵測 AI 工具(antigravitycodexclaude)並留言;
* 3. 讀 .reviewignore、整理 git diff 並留言(無可審查變更時:留言+保存空 findings,
* 一般模式 commit success、建問題模式略過 commit,回傳 0);
* 4–5. 攻擊方登場留言、每位攻擊方一個 sub agent 並行找問題;
* 6–7. 防守方登場留言、裁決誤報後排序並保存 findings JSON
* 並以 appendExclusions 把誤判/重複問題回寫 .gitea/ai-review/exclusions.json
* 8. 將 PR 既有舊留言標記為解決(跳過本回合留言);
* 9. 嚴重問題逐條掛在程式碼行上留言;
* 10. 警告+建議彙整為單一表格留言;
* 建問題模式(input: create-issue):保留問題另建 issuecreateIssueWithFindings)逐條留言明細;
* 收尾:組 filesToCommit —— 一般模式 commit findings 檔(+有變更的 exclusions.json)、
* 建問題模式只 commit exclusions.json、無檔案可 commit 時略過;
* commit 訊息帶結果標記(success=無嚴重問題、failure=有嚴重問題)。
*
* @returns {Promise<number>} process exit code:0=成功(無嚴重問題或無可審查變更、或偵測到 success 標記);
* 1=失敗(有嚴重問題、缺 PR 編號/token、找不到 AI 工具、或偵測到 failure 標記)。
* @remarks
* 使用情境:由本檔尾端的頂層呼叫端執行 —— `main().then((code) => process.exit(code))`
* 非預期例外由頂層 `catch` 記 ERR log 後以 exit code 1 收場,且刻意不 commit 結果標記,
* 讓下一次 workflow 觸發時重新完整審查。警告+建議等級的問題不影響成敗,只有「嚴重」會使結果為 failure;
* 建問題模式只改變問題明細的落地方式(issue 留言取代 findings 進版控),不改變成敗判定。
*/
async function main() {
const ctx = loadContext();
const cwd = ctx.workspace;
// ── 步驟 1ai-review-bot 結果 commit 快速回報 ─────────────────────────
const subject = gitrepo.latestCommitSubject(cwd);
if (subject === `${BOT_COMMIT_PREFIX}[success]`) {
log('步驟1', 'INF', '偵測到 ai-review-bot 的 success commit,直接回報成功。');
return 0;
}
if (subject === `${BOT_COMMIT_PREFIX}[failure]`) {
log('步驟1', 'ERR', '偵測到 ai-review-bot 的 failure commit,直接回報失敗。');
return 1;
}
log('步驟1', 'INF', '最新 commit 非 ai-review-bot 標記,開始審查流程。');
// ── 前置檢查:PR 事件與必填 input ──────────────────────────────────────
if (!ctx.prNumber) {
log('前置', 'ERR', '無法取得 PR 編號(本 action 僅支援 pull_request 事件)。');
return 1;
}
if (!ctx.token) {
log('前置', 'ERR', '缺少必填 inputtoken。');
return 1;
}
// 本回合發出的一般留言 id:步驟 8 標註過時時要跳過這些。
const currentRunCommentIds = new Set();
const postComment = async (body) => {
const created = await gitea.createIssueComment(ctx, body);
currentRunCommentIds.add(created.id);
return created;
};
// ── 步驟 2:偵測 AI agent 工具並留言 ──────────────────────────────────
const tool = agents.detectTool();
if (!tool) {
log('步驟2', 'ERR', '找不到可用的 AI 工具(antigravitycodexclaude)。');
return 1;
}
log('步驟2', 'INF', `選用工具:${tool.name}${tool.version})。`);
const runLink = `${ctx.serverUrl}/${ctx.repository}/actions/runs/${ctx.runId}`;
await postComment(
templates.toolComment({
toolName: tool.name,
version: tool.version,
model: ctx.model,
sha: ctx.headSha,
runNumber: ctx.runNumber,
runLink,
}),
);
// ── 步驟 3:讀取 .reviewignore、整理 git diff 並留言 ───────────────────
const ignores = review.loadReviewIgnore(cwd);
const base = gitrepo.resolveMergeBase(cwd, ctx.baseRef);
const allFiles = gitrepo.changedFiles(cwd, base);
const files = allFiles.filter((file) => !review.isIgnored(file, ignores));
const ignoredCount = allFiles.length - files.length;
log('步驟3', 'INF', `變更檔案 ${allFiles.length} 個,套用 .reviewignore 後送審 ${files.length} 個(排除 ${ignoredCount} 個)。`);
if (files.length === 0) {
// 沒有可審查的變更:留言說明、保存空 findings、以 success 收場。
await postComment(templates.nothingToReviewComment(ignoredCount));
const relativePath = saveFindings({ cwd, ctx, tool, kept: [], excluded: [] });
if (ctx.createIssue) {
// 建問題模式下 findings 不進版控,且 exclusions.json 無變更 → 沒東西可提交。
log('收尾', 'INF', '建問題模式且無可審查變更,略過 commit/push。');
} else {
commitFindings({ cwd, ctx, files: [relativePath], result: 'success' });
}
return 0;
}
const diffRows = review.collectDiffRows({ cwd, files, base, gitrepo });
await review.fillPurposes({ tool, model: ctx.model, cwd, diffRows });
await postComment(templates.diffComment(diffRows, ignoredCount));
// ── 步驟 4:攻擊方角色登場留言 ─────────────────────────────────────────
const roles = loadRoles(path.join(ctx.actionPath, 'src', 'prompts', 'roles'));
const attackers = attackersOf(roles);
const defenders = defendersOf(roles);
log('步驟4', 'INF', `攻擊方 ${attackers.length} 位、防守方 ${defenders.length} 位。`);
await postComment(templates.rolesComment({ title: '⚔️ 攻擊方登場', roles: attackers }));
// ── 步驟 5:每個攻擊方一個 sub agent 並行分析,合併問題列表 ────────────
const findings = await review.runAttackers({ tool, model: ctx.model, cwd, attackers, diffRows });
// ── 步驟 6:防守方角色登場留言 ─────────────────────────────────────────
await postComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders }));
// ── 步驟 7:防守方裁決 → 排除 → 排序 → 保存 findings ──────────────────
const { kept, excluded } = await review.runDefenders({ tool, model: ctx.model, cwd, defenders, findings });
review.sortFindings(kept);
const relativePath = saveFindings({ cwd, ctx, tool, kept, excluded });
// 誤判/重複的問題附加到 exclusions.json(之後與審查結果一起 commit)。
const exclusionsChanged = review.appendExclusions({ cwd, excluded, prNumber: ctx.prNumber });
// ── 步驟 7(分組):依嚴重等級分組(嚴重/警告+建議),組內已依檔案與行數排序 ─
const severe = kept.filter((finding) => finding.severity === '嚴重');
const others = kept.filter((finding) => finding.severity !== '嚴重');
log('步驟7', 'INF', `分組結果:嚴重 ${severe.length} 條、警告+建議 ${others.length} 條。`);
// ── 步驟 8:將 PR 既有留言標記為解決(本回合留言除外)───────────────────
await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });
// ── 步驟 9:嚴重問題逐條掛在程式碼行上留言(開發者可回覆)──────────────
if (severe.length > 0) {
await review.postSevereComments({ ctx, gitea, severe, cwd });
}
// ── 步驟 10:警告+建議彙整為單一表格留言 ──────────────────────────────
if (others.length > 0) {
await postComment(templates.othersComment(others));
log('步驟10', 'INF', `警告+建議表格留言已發布(${others.length} 條)。`);
}
// ── 建問題模式(input: create-issue):另建 issue 逐條留言問題明細 ──────
if (ctx.createIssue) {
if (kept.length > 0) {
await review.createIssueWithFindings({ ctx, gitea, tool, model: ctx.model, cwd, findings: kept });
} else {
log('建問題', 'INF', '沒有保留的問題,略過建立 issue。');
}
}
// ── 收尾:commit 並 pushsuccess=無嚴重問題、failure=有嚴重問題)───────
// 一般模式:findingsexclusions.json;建問題模式:問題明細已在 issue 留言,只 commit exclusions.json。
const result = severe.length === 0 ? 'success' : 'failure';
const filesToCommit = ctx.createIssue ? [] : [relativePath];
if (exclusionsChanged) {
filesToCommit.push(path.join('.gitea', 'ai-review', 'exclusions.json'));
}
if (filesToCommit.length > 0) {
commitFindings({ cwd, ctx, files: filesToCommit, result });
} else {
log('收尾', 'INF', '建問題模式且 exclusions.json 無變更,略過 commit/push。');
}
return result === 'success' ? 0 : 1;
}
main()
.then((code) => {
process.exit(code);
})
.catch((err) => {
// 非預期錯誤:不 commit 結果標記(讓下次觸發重新審查),以失敗收場。
log('main', 'ERR', `審查流程發生非預期錯誤:${err.stack || err.message || err}`);
process.exit(1);
});