Files
ai-code-review/readme.md
T

30 KiB
Raw Permalink Blame History

AI Code Review

更新時間:2026/07/17 16:49:21

AI 多角色 code review 的 Gitea node actionnode24、零外部相依):以攻擊方六角色(🔮 Mage 邏輯、🗡️ Assassin 安全、 Rogue 效率、🎼 Bard 風格、🧪 Maya 測試、🧰 Leo 可維護性)並行找問題、防守方(🛡️ Paladin)裁決誤報,結果留言到 PR、保存 findings,並以 bot commit 標記審查結果([success][failure])供下次觸發快速回報。

使用方式

# .gitea/workflows/review.yaml(呼叫端範例)
name: AI-REVIEW
on:
  pull_request:
    branches: [master, develop]
    types: [opened, synchronize]
jobs:
  review:
    runs-on: ubuntu
    steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0                     # 需完整歷史以計算 merge-base
    - uses: https://gitea.jsc.idv.tw/node-actions/ai-code-review@v1
      with:
        token: ${{ secrets.GITHUB_TOKEN }} # 必填:PR 留言與 push findings 用
        model: ''                          # 選填:指定 AI 模型(空=工具預設)
        create-issue: 'false'              # 選填:'true' 時問題另建 issue 追蹤
input 必填 預設 說明
token Gitea API tokenPR 留言與 push findings 用;呼叫端以 secrets 傳入)
model '' 指定 AI 工具使用的模型(空值=各工具預設)
create-issue 'false' 'true' 時建立 issue 逐條留言問題明細,收尾只 commit exclusions.json

審查流程(10 步驟):

flowchart TD
    S1[1 判斷 bot commit 標記] -->|命中| E0[直接回報 success/failure]
    S1 -->|未命中| S2[2 偵測 AI 工具並留言]
    S2 --> S3[3 讀 .reviewignore 整理 diff 並留言]
    S3 --> S4[4 攻擊方登場留言]
    S4 --> S5[5 攻擊方 sub agent 並行找問題]
    S5 --> S6[6 防守方登場留言]
    S6 --> S7[7 防守方裁決 → 保存 findings  誤判回寫 exclusions.json]
    S7 --> S8[8 舊留言標記解決]
    S8 --> S9[9 嚴重問題逐條掛行留言]
    S9 --> S10[10 警告+建議彙整表格留言]
    S10 --> E1[收尾 commit/push  exit code]

專案列表

專案描述表

專案名稱 專案描述
ai-code-review Gitea node action:提供台北時區日誌工具、runner 上下文載入、git diffcommit 操作、Gitea REST API 客戶端(留言/reviewissue/標籤)、AI CLI 工具偵測與 sub agent 執行、角色提示載入、固定留言模板,以及多角色審查編排(攻擊方找問題、防守方裁決、findings 保存、誤判回寫、建問題模式)

參考專案表

專案名稱 參考專案列表
ai-code-review

NuGet 套件表

專案名稱 NuGet 套件列表
ai-code-review

功能列表

ai-code-review

功能名稱 功能描述
log.taipeiNow 取得台北時區 yyyy/MM/dd HH:mm:ss 時間字串
log.taipeiFileStamp 取得檔名用時間戳 yyyy-MM-dd-HH:mm:ss
log.taipeiFromIso 將 ISO 時間字串轉為台北時區顯示字串
log.log 以統一格式輸出一行日誌
context.loadContext 彙整 runner 環境變數與事件 payload 為執行上下文
gitrepo.latestCommitSubject 取得最新 commit 的訊息標題
gitrepo.resolveMergeBase 解析 base 分支與 HEAD 的 merge-base
gitrepo.changedFiles 列出 base 與 HEAD 之間有變更的檔案
gitrepo.fileDiff 取得單一檔案的 git diff 內容
gitrepo.fileLastUpdatedIso 取得檔案最後一次 commit 的 ISO 時間
gitrepo.commitAndPushFindings 以 bot 身分 commit 結果檔並 push 回 PR 來源分支
gitea.whoAmI 取得 token 對應的使用者(bot 身分)
gitea.createCommentOnIssue 對指定編號 issue/PR 新增一般留言
gitea.createIssueComment 對本次 PR 新增一般留言
gitea.listLabels 列出存取庫可用標籤
gitea.createIssue 在存取庫建立 issue(可掛標籤)
gitea.listIssueComments 列出 PR 全部一般留言(自動分頁)
gitea.editIssueComment 編輯既有一般留言
gitea.createReview 建立 code review 並掛行內留言
gitea.listReviews 列出 PR 全部 review(自動分頁)
gitea.listReviewComments 列出某 review 的全部行內留言
gitea.tryResolveReviewComment 盡力將行內留言標記為已解決
agents.detectTool 依優先序偵測可用的 AI CLI 工具
agents.runAgent 非互動執行一次 sub agent 並取回回覆
agents.extractJson 從 agent 回覆萃取 JSON(容忍雜訊)
roles.loadRoles 載入角色提示檔並解析 frontmatter
roles.attackersOf 過濾出攻擊方角色
roles.defendersOf 過濾出防守方角色
templates.toolComment 產生步驟 2 審查工具留言
templates.diffComment 產生步驟 3 變更摘要留言
templates.rolesComment 產生步驟 46 角色登場留言
templates.severeCommentBody 產生步驟 9 單條嚴重問題留言
templates.severeReviewBody 產生步驟 9 嚴重問題 review 總覽
templates.othersComment 產生步驟 10 警告+建議彙整表格留言
templates.issueBody 產生建問題模式的 issue 本文
templates.issueFindingComment 產生建問題模式單條問題的 issue 留言
templates.nothingToReviewComment 產生無可審查變更留言
review.loadReviewIgnore 讀取 .reviewignore 忽略前綴清單
review.isIgnored 判斷檔案是否忽略不送審
review.collectDiffRows 整理送審 diff 資料列(含長度上限)
review.fillPurposes 以 AI 補齊每個檔案的一行用途描述
review.runAttackers 攻擊方 sub agent 並行找問題並合併列表
review.runDefenders 防守方 sub agent 裁決保留或排除
review.sortFindings 依嚴重度→檔案→行號排序 findings
review.appendExclusions 誤判問題附加到 exclusions.json
review.sortFindingsForIssue 依檔案→嚴重度→行號排序(建問題模式)
review.selectLabels 以 AI 從可用標籤挑選 issue 標籤
review.createIssueWithFindings 建 issue 並逐條留言問題明細
review.resolveOldComments 將 PR 舊留言標記為解決/過時
review.postSevereComments 嚴重問題逐條掛行留言(含降級)

使用範例

log.taipeiNow

將指定時間(省略時為現在)轉為台北時區(Asia/Taipei)的 yyyy/MM/dd HH:mm:ss 字串,輸出不受主機系統時區影響;供日誌時間戳與 findings 產生時間使用。

const { taipeiNow } = require('./src/lib/log');
taipeiNow();                      // '2026/07/17 16:46:13'
taipeiNow(new Date('2026-01-01')); // '2026/01/01 08:00:00'

log.taipeiFileStamp

產生檔名用時間戳 yyyy-MM-dd-HH:mm:ss(空白換成 -);findings 檔案即以此命名。注意輸出含 :,Linux 檔名合法、不可移植到 Windows。

const { taipeiFileStamp } = require('./src/lib/log');
taipeiFileStamp(); // '2026-07-17-16:46:13' → .gitea/ai-review/findings/2026-07-17-16:46:13.json

log.taipeiFromIso

將 ISO 8601 時間字串轉為台北時區顯示字串;輸入為空或無法解析時回傳佔位符「—」不丟例外,適合直接嵌進留言表格。

const { taipeiFromIso } = require('./src/lib/log');
taipeiFromIso('2026-07-17T06:30:05Z'); // '2026/07/17 14:30:05'
taipeiFromIso('');                     // '—'

log.log

以統一格式 [yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息 輸出一行日誌到 stdout;stage 為空時省略階段區塊,等級約定限 INF/WRN/ERRTRCDBG。

const { log } = require('./src/lib/log');
log('步驟3', 'INF', '變更檔案 5 個,送審 3 個。');
// [2026/07/17 16:46:13][步驟3][INF]: 變更檔案 5 個,送審 3 個。

context.loadContext

彙整 runner 注入的 GITHUB_* 環境變數、INPUT_* 輸入參數與事件 payload,組出審查流程所需的完整上下文(repo、PR 編號/標題/描述、head/base、token、model、createIssue、workspace、actionPath 等)。前置條件:於 Actions runner 環境執行;呼叫端應檢查 prNumbertoken 是否有值。

const { loadContext } = require('./src/lib/context');
const ctx = loadContext();
if (!ctx.prNumber || !ctx.token) process.exit(1); // 非 PR 事件或缺 token

gitrepo.latestCommitSubject

取得目前 HEAD 最新 commit 的訊息標題;主流程步驟 1 以此比對 chore: update ai-review findings [ai-review-bot][success|failure] 決定是否直接回報結果。

const gitrepo = require('./src/lib/gitrepo');
const subject = gitrepo.latestCommitSubject(process.cwd());

gitrepo.resolveMergeBase

先嘗試 git fetch origin <baseRef>(失敗靜默沿用本地資料),再以 git merge-base origin/<baseRef> HEAD 取得共同祖先,作為 diff 比較基準,避免把 base 分支後續演進算進 PR 變更。

const base = gitrepo.resolveMergeBase(cwd, 'master'); // '3f2a…'40 碼 SHA

gitrepo.changedFiles

列出 base 與 HEAD 之間有變更的檔案(repo 相對路徑陣列);結果再經 .reviewignore 過濾後逐檔送審。

const files = gitrepo.changedFiles(cwd, base); // ['src/index.js', 'action.yml']

gitrepo.fileDiff

取得單一檔案在 base 與 HEAD 之間的 unified diff 原始文字(無變更時為空字串),供組進攻擊方提示。

const diff = gitrepo.fileDiff(cwd, base, 'src/index.js');

gitrepo.fileLastUpdatedIso

取得檔案最後一次 commit 的 ISO 8601 時間;查不到(未 commit、git 失敗)回空字串,由呼叫端以「—」佔位。

const iso = gitrepo.fileLastUpdatedIso(cwd, 'src/index.js'); // '2026-07-17T15:00:00+08:00'

gitrepo.commitAndPushFindings

ai-review-bot 身分將指定檔案 commit 並 push 回 PR 來源分支;HEAD 停在 merge commit 時先 detach 到 head sha,暫存區無差異時不建空 commit(回傳 false),origin push 失敗改用帶 token 的 URL 重試(該 URL 絕不可輸出到日誌)。

const committed = gitrepo.commitAndPushFindings(cwd, {
  headRef: 'feature/x', headSha: ctx.headSha,
  message: 'chore: update ai-review findings [ai-review-bot][success]',
  files: ['.gitea/ai-review/findings/2026-07-17-16:46:13.json'],
  token: ctx.token, serverUrl: ctx.serverUrl, repository: ctx.repository,
}); // true=已推送、false=無變更略過

gitea.whoAmI

取得 token 對應的使用者(GET /user),即 bot 身分;步驟 8 以 login 比對留言作者辨識本 action 發過的留言。

const gitea = require('./src/lib/gitea');
const me = await gitea.whoAmI(ctx); // { id, login, ... }

gitea.createCommentOnIssue

對指定編號的 issue(或 PR,Gitea 兩者共用留言機制)新增一般留言;建問題模式逐條留言問題明細即用本函式。

await gitea.createCommentOnIssue(ctx, issue.number, '🔴 嚴重|...');

gitea.createIssueComment

對本次 PRctx.prNumber)新增一般留言;為 createCommentOnIssue 的便捷包裝,主流程各步驟的留言都經由它發出。

const created = await gitea.createIssueComment(ctx, '## 📋 變更摘要 ...');
// created.id 記入本回合留言集合,步驟 8 標註過時時跳過

gitea.listLabels

列出存取庫可用標籤(自動分頁);建問題模式先取得標籤,再交給 review.selectLabels 由 AI 挑選。

const labels = await gitea.listLabels(ctx); // [{ id, name, color }, ...]

gitea.createIssue

在存取庫建立 issuelabels(標籤 id 陣列)僅在非空時帶入。建問題模式以 PR 標題/描述為內容建立追蹤 issue。

const issue = await gitea.createIssue(ctx, { title: 'PR 標題', body: '…', labels: [3, 7] });
// issue.number 供後續逐條留言

gitea.listIssueComments

列出 PR 全部一般留言(自動分頁,每頁 50 筆);步驟 8 據此找出 bot 舊留言標註〔已過時〕。

const comments = await gitea.listIssueComments(ctx);

gitea.editIssueComment

以新內容整段覆寫既有一般留言(留言 id 於 repo 層級定位);步驟 8 用來替舊留言加上〔已過時〕前綴。

await gitea.editIssueComment(ctx, comment.id, `> 〔已過時〕…\n\n${comment.body}`);

gitea.createReview

建立 event 為 COMMENT 的 code review,並把行內留言逐條掛在檔案行號上;步驟 9 以單一 review 送出全部嚴重問題。若行號不在 PR diff 內會整包失敗,呼叫端(review.postSevereComments)會降級為一般留言。

await gitea.createReview(ctx, '## 🔴 嚴重問題(共 2 條)…', [
  { path: 'src/a.js', new_position: 42, body: '…' },
]);

gitea.listReviews

列出 PR 全部 review(自動分頁);步驟 8 據此逐一取出行內留言嘗試解決。

const reviews = await gitea.listReviews(ctx);

gitea.listReviewComments

列出指定 review 底下的全部行內留言(單次呼叫、未分頁)。

const comments = await gitea.listReviewComments(ctx, reviews[0].id);

gitea.tryResolveReviewComment

盡力將行內留言標記為已解決;resolve endpoint 依 Gitea 版本不一定存在(需人工確認),任何失敗一律回 false 不丟錯,呼叫端第一次失敗即停止嘗試。

const ok = await gitea.tryResolveReviewComment(ctx, reviewId, commentId);
if (!ok) { /* 版本不支援 → 記 WRN 後放棄後續 resolve */ }

agents.detectTool

依 antigravity → codex → claude 優先序,以 <tool> --version(30 秒逾時)偵測可用工具,第一個成功者中選並附版本字串;全部不可用回 null(主流程記 ERR 失敗收場)。antigravity 的非互動參數尚未驗證(需人工確認)。

const agents = require('./src/lib/agents');
const tool = agents.detectTool(); // { name: 'codex', version: 'codex-cli 0.144.5', ... } | null

agents.runAgent

以非互動模式執行一次 sub agent:提示從 stdin 餵入,codex 改讀 --output-last-message 暫存檔取最終回覆。永不 reject——逾時、非零退出碼都以 { ok: false } resolve,由呼叫端降級。

const res = await agents.runAgent(tool, { model: '', prompt: '…', cwd, timeoutMs: 600000 });
const data = res.ok ? agents.extractJson(res.output) : null;

agents.extractJson

從 agent 自由文字回覆萃取 JSON:先剝 code fence、再以「陣列優先」的最大範圍切片嘗試 parse;失敗一律回 null 不丟例外。

agents.extractJson('```json\n[{"a":1}]\n```'); // [{ a: 1 }]
agents.extractJson('雜訊 {"b":2} 雜訊');        // { b: 2 }
agents.extractJson('不是 JSON');                // null

roles.loadRoles

載入目錄下全部 *.md 角色提示檔(依檔名排序),解析開頭 --- 包夾的輕量 frontmatter(單行「鍵: 值」),回傳 { file, meta, body, raw } 陣列。

const { loadRoles } = require('./src/lib/roles');
const roles = loadRoles(path.join(ctx.actionPath, 'src', 'prompts', 'roles'));
// roles[0].meta => { name: 'Assassin', side: 'attack', focus: 'security', ... }

roles.attackersOf

過濾出 meta.side === 'attack' 的攻擊方角色(現況 6 位:AssassinBardLeoMageMayaRogue),保留檔名排序、不改原陣列。

const attackers = attackersOf(roles); // 6 位攻擊方

roles.defendersOf

過濾出 meta.side === 'defend' 的防守方角色(現況 1 位:Paladinfocus: verdict)。

const defenders = defendersOf(roles); // [Paladin]

templates.toolComment

產生步驟 2 的審查工具留言:工具/版本/模型/審查 commit/Run Job 連結表格+審查管線 mermaid 流程圖;開頭含隱藏標記供步驟 8 辨識。

const body = templates.toolComment({
  toolName: 'codex', version: 'codex-cli 0.144.5', model: '',
  sha: ctx.headSha, runNumber: ctx.runNumber,
  runLink: `${ctx.serverUrl}/${ctx.repository}/actions/runs/${ctx.runId}`,
});

templates.diffComment

產生步驟 3 的變更摘要留言:四欄表格(檔案/用途/git diff 長度/最後更新時間),截斷送審的檔案加註,結尾統計送審與排除數。

const body = templates.diffComment(diffRows, ignoredCount);

templates.rolesComment

產生步驟 4/6 共用的角色登場留言:三欄表格(角色/面向/個性),面向以「中文(原文)」並列。

const body = templates.rolesComment({ title: '⚔️ 攻擊方登場', roles: attackers });

templates.severeCommentBody

產生步驟 9 單條嚴重問題的留言內容(程式碼片段/問題/修改建議/建議寫法,結尾提示可回覆);降級為一般留言時以 withLocation: true 在內文標明檔案與行號。

const body = templates.severeCommentBody(finding, snippet);
const fallback = templates.severeCommentBody(finding, snippet, { withLocation: true });

templates.severeReviewBody

產生步驟 9 嚴重問題 review 的總覽 body(標明總數,說明逐條掛行)。

await gitea.createReview(ctx, templates.severeReviewBody(severe.length), comments);

templates.othersComment

產生步驟 10 的警告+建議彙整表格留言(等級/審查員/檔案名稱/問題起訖行數/問題描述/修改建議),儲存格經防呆逸出。

const body = templates.othersComment(others); // others=非嚴重的保留問題

templates.issueBody

產生建問題模式新 issue 的本文:PR 描述為主體(缺省以「(PR 無描述)」佔位),尾端附追溯引言標明來源 PR。

const body = templates.issueBody({ prNumber: ctx.prNumber, prBody: ctx.prBody });

templates.issueFindingComment

產生建問題模式單條問題的 issue 留言(固定模板:嚴重等級/位置起訖行數/問題描述/修改建議/建議寫法);issue 留言無法掛行,位置一律以內文標明。

await gitea.createCommentOnIssue(ctx, issue.number, templates.issueFindingComment(finding));

templates.nothingToReviewComment

產生「無可審查變更」留言:套用 .reviewignore 後送審清單為空時取代變更摘要,宣告本回合視為審查通過。

const body = templates.nothingToReviewComment(ignoredCount);

review.loadReviewIgnore

讀取 repo 根目錄的 .reviewignore(每行一個路徑前綴、# 註解、空行略過);檔案不存在回空陣列。

const review = require('./src/lib/review');
const ignores = review.loadReviewIgnore(cwd); // ['.gitea/', 'README.md', ...]

review.isIgnored

判斷檔案是否忽略不送審:任何深度的 node_modules/ 一律排除(內建保險),其餘依前綴比對。

const files = allFiles.filter((f) => !review.isIgnored(f, ignores));

review.collectDiffRows

為每個送審檔案取得 diff 並計算統計(行數/字元數/最後更新時間),套用單檔 16,000/總量 160,000 字元送審上限(超限記 WRN、不靜默截斷);purpose 先以「—」佔位。

const diffRows = review.collectDiffRows({ cwd, files, base, gitrepo });

review.fillPurposes

以選定 AI 工具為每個送審檔案產生一行用途描述並就地寫回 diffRows[].purpose;失敗只記 WRN 保留「—」,不阻斷流程。

await review.fillPurposes({ tool, model: ctx.model, cwd, diffRows });

review.runAttackers

步驟 5:每位攻擊方角色一個 sub agent 並行分析 diff,回覆經檢核標準化後合併為單一問題列表並編派 F001… 流水號;單一角色失敗只記 WRN 以空結果代替。

const findings = await review.runAttackers({ tool, model: ctx.model, cwd, attackers, diffRows });

review.runDefenders

步驟 7:每位防守方角色一個 sub agent 配合 exclusions.json 與歷史 findings 裁決;「全部防守方都判可排除」才移除,拿不準一律保留,每條附 verdicts 供追溯。

const { kept, excluded } = await review.runDefenders({ tool, model: ctx.model, cwd, defenders, findings });

review.sortFindings

就地排序:嚴重→警告→建議,再依檔案路徑、起始行遞增;供 findings 保存與步驟 9/10 分組留言使用。

review.sortFindings(kept);

review.appendExclusions

把防守方判定排除(誤判/重複)的問題附加到 .gitea/ai-review/exclusions.json(含各防守方理由與來源 PR 編號);既有檔案壞損或非陣列時不動原檔、記 WRN(需人工確認)。回傳是否有寫入,決定收尾是否一併 commit。

const changed = review.appendExclusions({ cwd, excluded, prNumber: ctx.prNumber });

review.sortFindingsForIssue

建問題模式的就地排序:檔案路徑→嚴重等級(嚴重→建議)→起始行,讓 issue 留言同檔集中、便於逐檔處理。

const sorted = [...kept];
review.sortFindingsForIssue(sorted);

review.selectLabels

以 AI 依 PR 標題/描述與問題列表摘要,從存取庫可用標籤挑選子集合(白名單過濾幻覺名稱後轉標籤 id);無標籤或失敗一律回空陣列不阻斷。

const labelIds = await review.selectLabels({ tool, model, cwd, labels, prTitle, prBody, findings });

review.createIssueWithFindings

建問題模式主流程:AI 挑標籤 → 建立 issue(標題=PR 標題、本文=PR 描述+追溯)→ 問題依檔案→嚴重度排序逐條留言到 issue;建 issue 失敗記 ERR 回 null 不阻斷主流程。

if (ctx.createIssue && kept.length > 0) {
  await review.createIssueWithFindings({ ctx, gitea, tool, model: ctx.model, cwd, findings: kept });
}

review.resolveOldComments

步驟 8:bot 舊一般留言(非本回合)編輯加〔已過時〕前綴;review 行內留言盡力呼叫 resolve API,第一次失敗即判定版本不支援並停止。任何失敗只記 WRN 不阻斷。

await review.resolveOldComments({ ctx, gitea, currentRunCommentIds });

review.postSevereComments

步驟 9:嚴重問題以單一 code review 逐條掛在對應程式碼行上(含問題區塊程式碼片段,最多 40 行);建立 review 失敗時降級為一般留言逐條發布並在內文標明位置。

if (severe.length > 0) {
  await review.postSevereComments({ ctx, gitea, severe, cwd });
}