Files
ai-code-review/src/index.js
T

295 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 path = require('path');
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');
// 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;
}
/**
* 收尾:將本回合的審查結果檔(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);
});