'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 使用)。 * * 檔案寫入 `/.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 - 工具名稱(antigravity/codex/claude)。 * @param {string} params.tool.version - 工具版本字串。 * @param {Array} params.kept - 防守方裁決後保留的問題(findings)清單;無可審查變更時為空陣列。 * @param {Array} 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.json)commit 並 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 工具(antigravity/codex/claude)並留言; * 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):保留問題另建 issue(createIssueWithFindings)逐條留言明細; * 收尾:組 filesToCommit —— 一般模式 commit findings 檔(+有變更的 exclusions.json)、 * 建問題模式只 commit exclusions.json、無檔案可 commit 時略過; * commit 訊息帶結果標記(success=無嚴重問題、failure=有嚴重問題)。 * * @returns {Promise} 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; // ── 步驟 1:ai-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', '缺少必填 input:token。'); 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 工具(antigravity/codex/claude)。'); 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 並 push(success=無嚴重問題、failure=有嚴重問題)─────── // 一般模式:findings+exclusions.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); });