Reviewed-on: docker-actions/ai-code-review#10
ai-code-review
ai-code-review 是一個以 Node.js(ESM)撰寫、封裝為 Gitea Docker action 的 AI 程式碼審查執行器。它會取得目前 Pull Request 的 Git diff、過濾掉不需審查的 CI/文件路徑,再交由多個審查角色(multi-role)的 LLM 進行分析,產生結構化 findings;接著對 findings 進行語意去重、AI 誤報過濾、行號定位、與排除清單比對,最後將結果以 Gitea PR review 與留言形式發布、並把 findings/exclusions 結轉回 PR head branch。流程同時統計 token 用量與帳號額度,並提供完整的前置檢查(preflight)與機器人自我觸發迴圈防護。
更新時間:2026/06/26 11:34:46
專案列表
專案描述
| 專案名稱 | 專案描述 |
|---|---|
| ai-code-review | 整合 Gitea PR、多角色 LLM 審查、findings 合併去重與誤報過濾、使用量統計的 Node.js Docker action 執行器。 |
參考專案
| 專案名稱 | 參考專案列表 |
|---|---|
| ai-code-review | 無 |
NuGet 套件
| 專案名稱 | NuGet 套件列表 |
|---|---|
| ai-code-review | axios ^1.6.7 js-yaml ^4.1.0 |
功能列表
comments.js
config.js
| 功能名稱 | 功能描述 |
|---|---|
| getOpenCodeHttpsAgent | 建立停用 TLS 憑證驗證的 HTTPS Agent,供連接自簽憑證的 OpenCode 服務使用。 |
| getLLMConfig | 依環境變數解析並回傳 LLM 提供者設定。 |
findings.js
git.js
gitea.js
json.js
llm.js
| 功能名稱 | 功能描述 |
|---|---|
| chat | 對 OpenCode server 送出一次對話請求並回傳純文字回應。 |
| chatJSON | 在 chat 之上加一層 JSON 解析,抽出並解析回應中的 JSON。 |
log.js
| 功能名稱 | 功能描述 |
|---|---|
| section | 輸出最上層的區塊章節分隔標題。 |
| step | 輸出某個步驟標題(步驟代號加標題)。 |
| line | 輸出一筆縮排的中性明細列。 |
| input | 輸出階段輸入描述。 |
| output | 輸出階段輸出描述。 |
| result | 依布林結果輸出成功或失敗的把關結果列。 |
| ok | 印出成功訊息。 |
| warn | 輸出警告訊息(stderr)。 |
| error | 輸出錯誤訊息(stderr,最高嚴重層級)。 |
preflight.js
resolve.js
roles.js
usage.js
使用範例
以下範例為各模組的 sibling import(同層 ESM 模組)。多數函式相依環境變數(Gitea / OpenCode 設定)與
./config.js,於正式流程中由 action 入口統一注入。範例中標示需人工確認者,為依草稿行為推導之保守情境式範例。
comments.js
parseLocation
解析 finding 的 location 字串,取出檔案路徑與起始行號;支援 file:19 與 file:70-82(範圍取起始行)。非字串、含逗號(多檔)、或無行號格式時回傳 null,不丟例外。
import { parseLocation } from './comments.js';
parseLocation('src/a.js:42'); // → { file: 'src/a.js', line: 42 }
parseLocation('src/a.js:70-82'); // → { file: 'src/a.js', line: 70 }
parseLocation('a.js,b.js:1'); // → null
formatFindingsStats
產生 findings 統計的 Markdown 表格(新問題/舊問題 × 嚴重/警告/建議/無法標示)。findings 須為陣列(非陣列拋 TypeError);is_new === false 計入舊問題,其餘計入新問題。空陣列仍輸出各欄為 0 的表格。
import { formatFindingsStats } from './comments.js';
const findings = [
{ is_new: true, level: 'critical' },
{ is_new: false, level: 'info' },
];
const table = formatFindingsStats(findings); // 含表頭、分隔列與新舊兩資料列的 Markdown
formatFindingsStatsLine
產生 findings 統計的單行文字摘要,供 log 使用;新舊判定同 formatFindingsStats。
import { formatFindingsStatsLine } from './comments.js';
import { line } from './log.js';
line(formatFindingsStatsLine(findings)); // 形如「新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...」
postFindingsReview
發布單一 Gitea review:本文放統計摘要、新問題(is_new !== false)加行內標註,舊問題僅計入統計。含「整批 review → 僅 summary → 一般 comment」多層失敗降級。需 Gitea 設定就緒(供預設的 postPullReview / postPullReviewComment / postComment);deps 可注入以利測試。
import { postFindingsReview } from './comments.js';
// 正式流程:使用模組預設依賴
await postFindingsReview(findings);
// 測試:注入假的發布依賴
await postFindingsReview(findings, {
postReview: async () => ({ id: 1 }),
postIssue: async () => ({ id: 2 }),
});
saveFindings
將 findings 以縮排 2 的格式化 JSON 寫入 path.join(workspace, FINDINGS_PATH),可同時鏡像寫入 mirrorDir(為 null 或與 workspace 相同時不重複寫)。中間目錄會自動建立;I/O 失敗時拋 fs 例外。
import { saveFindings } from './comments.js';
saveFindings('/workspace/repo', findings); // 寫入 repo 內 findings 檔
saveFindings('/workspace/repo', findings, '/workspace'); // 同時鏡像到另一目錄
postOldFindingsComment
一次發布所有舊問題(!f.is_new,未設定者也算舊問題)的彙整 comment(表格呈現)。無舊問題時僅 log 並提前返回;postComment 失敗則例外向外傳播。
import { postOldFindingsComment } from './comments.js';
await postOldFindingsComment(findings); // 發布「## 📋 舊有未解決問題(N 筆)」
postNewNonCriticalComment
一次發布新問題中等級非 critical 者(is_new && level !== 'critical')的彙整 comment。無符合項目時僅 log 並提前返回。
import { postNewNonCriticalComment } from './comments.js';
await postNewNonCriticalComment(findings); // 發布「## 🔍 新發現問題(N 筆)」
postNewCriticalComments
為每個新的 critical 問題(is_new && level === 'critical')各發一則 comment,優先以行內標註檔案與行數,失敗(常因該行不在 diff 範圍)則 warn 後降級為一般 comment。deps 可注入;降級用 postIssue 失敗時例外向外傳播。
import { postNewCriticalComments } from './comments.js';
await postNewCriticalComments(findings);
config.js
getOpenCodeHttpsAgent
建立並回傳停用 TLS 憑證驗證(rejectUnauthorized: false)的 HTTPS Agent,供連接自簽或無效憑證的 OpenCode 服務使用。每次呼叫回傳全新實例(不快取),建議呼叫端重用。僅限受信任內部環境(有 MITM 風險)。
import axios from 'axios';
import { getOpenCodeHttpsAgent } from './config.js';
const httpsAgent = getOpenCodeHttpsAgent();
await axios.get('https://opencode.internal/health', { httpsAgent });
getLLMConfig
依環境變數即時解析並回傳 LLM 提供者設定。設了 OPENCODE_BASE_URL 時回傳 OpenCode 設定(model 取自 OPENCODE_MODEL,預設 gemini-2.5-flash,apiKeys 為固定佔位 ['opencode']);未設則回傳「無提供者」設定。不丟例外,由呼叫端判斷 provider 是否為 null。
import { getLLMConfig } from './config.js';
const cfg = getLLMConfig();
// 設了 OPENCODE_BASE_URL → { provider: 'opencode', apiKeys: ['opencode'], baseURL, model }
// 未設 → { provider: null, apiKeys: [], baseURL: null, model: null }
if (!cfg.provider) throw new Error('未設定 LLM 提供者');
findings.js
analyzeWithRole
以單一審查角色分析 Git diff,回傳僅保留同時具 level / location / suggestion、並統一 role 名稱、補 is_new: true 的 findings 陣列。role 至少含 name;diff 為 unified diff 字串;需 LLM(chatJSON)可用。chatJSON 失敗或回傳非陣列時例外向外拋出。
import { analyzeWithRole } from './findings.js';
import { getPRDiff } from './gitea.js';
const diff = await getPRDiff();
const findings = await analyzeWithRole({ name: 'security' }, diff);
loadOldFindings
從工作區內 FINDINGS_PATH 讀取上一輪 findings,每筆標記 is_new=false。需提供 repo clone 後的目錄;讀取或解析失敗安全降級為空陣列並輸出診斷 log。
import { loadOldFindings } from './findings.js';
const old = loadOldFindings('/workspace/repo'); // 每筆已標 is_new=false
mergeFindings
合併新舊 findings,保留全部舊項目並附加新項目中未重複者(去重鍵為 role + location + suggestion 前 50 字,新項彼此亦去重)。兩參數皆為陣列,不丟例外。
import { mergeFindings } from './findings.js';
const merged = mergeFindings(oldFindings, newFindings); // [...old, ...新項去重]
sortByLevel
依嚴重度排序 findings(critical 高於 warning 高於 info),回傳新陣列、不修改輸入。未列於 LEVELS 的未知等級會被排在 critical 之前。
import { sortByLevel } from './findings.js';
const sorted = sortByLevel(findings); // critical 在前
resolveMissingLineNumbers
對缺行號(僅有檔名)的 findings,反問原審查角色依該檔 diff 定位行號,成功則把 location 補成 檔案:行號,否則保留檔名(就地修改傳入陣列)。deps 可注入(chatFn 預設 chatJSON、getRole 預設 loadRole、maxAttempts 預設 3)。任一筆失敗都不中斷、不對外拋例外。
import { resolveMissingLineNumbers } from './findings.js';
await resolveMissingLineNumbers(findings, diff); // 回傳與傳入相同參考,部分 location 已補行號
deduplicateWithAI
以 LLM(Paladin 裁判)對 findings 做語意去重,合併「同位置加同問題本質」者並保留較高等級。空陣列直接回傳;LLM 回傳空陣列、非陣列或拋錯時一律經 fallback 保留全部原始 findings,不對外拋例外。
import { deduplicateWithAI } from './findings.js';
const deduped = await deduplicateWithAI(findings);
loadExclusions
從工作區內 EXCLUSIONS_PATH 讀取並正規化、去重排除清單;若原檔非頂層陣列格式,順手改寫為標準陣列並可同步到 mirror(具寫入副作用)。repoState 僅供診斷 log。不存在或讀寫過程拋錯時降級為空陣列。
import { loadExclusions } from './findings.js';
const exclusions = loadExclusions('/workspace/repo', repoState, '/workspace');
appendExclusions
將新排除條目去重後追加到 exclusions.json,以標準頂層陣列寫回 workspace 與 mirror(去重以「檔案路徑加正規化原文」為準)。newEntries 空或未提供時直接回 null;寫檔階段失敗會向外拋出。
import { appendExclusions } from './findings.js';
const merged = appendExclusions('/workspace/repo', newEntries, '/workspace');
// newEntries 為空 → 回 null;無新增 → 回既有陣列
applyExclusions
依排除清單過濾 findings,命中任一排除條目者被移除(location 只比檔案路徑、role 省略視為萬用、文字雙向包含且僅在排除條目未指定路徑也未指定角色時才以文字把關)。空排除清單時不過濾,不丟例外。
import { applyExclusions } from './findings.js';
const kept = applyExclusions(findings, exclusions); // 輸出前後筆數 log
filterFalsePositivesWithAI
以防守方角色(Paladin)逐條平行裁決 findings 是否為誤報,剔除誤報、保留成立者。空陣列直接回傳;exclusions(預設 [])用於組裝提示讓相似問題更易判為誤報;chatFn(預設 chatJSON)可注入。任一裁決失敗時保守保留該問題。
import { filterFalsePositivesWithAI } from './findings.js';
const real = await filterFalsePositivesWithAI(findings, exclusions);
git.js
以下函式接受
_spawnSync等依賴注入參數(預設為真實實作),正常使用時省略即可。
getRepoState
讀取指定 git 工作目錄的目前狀態,回傳 HEAD SHA、短 SHA、目前分支與 commit 時間(ISO 8601)。任一查詢失敗時對應欄位為空字串,不丟例外。
import { getRepoState } from './git.js';
const state = getRepoState('/workspace/repo');
// { repoDir, branch, headSha, shortSha, commitTime }
getHeadCommitMessage
取得 HEAD commit 的完整訊息(含 subject 與 body)。repoDir 為 git 工作目錄;失敗時回傳空字串。
import { getHeadCommitMessage } from './git.js';
const message = getHeadCommitMessage('/workspace/repo');
isBotAutoCommit
判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit(訊息含 [ai-review-bot] 標記),用於避免自我觸發迴圈。HEAD 訊息含標記回傳 true,否則(含讀取失敗)回傳 false。
import { isBotAutoCommit } from './git.js';
if (isBotAutoCommit('/workspace/repo')) {
// 上一筆 commit 為 bot 自動提交,跳過審查
}
verifyRemoteAccess
以與 push 相同的 askpass 認證機制執行唯讀 git ls-remote,前置驗證 git 對 remote 的認證與連線是否可用。workspace 須可寫(暫存 askpass 腳本);config 需有 GITEA_TOKEN、remote URL;驗證 ref 取 PR_HEAD_BRANCH,未設定則退回 HEAD。成功回 { ok: true },失敗回 { ok: false, error },不丟例外。
import { verifyRemoteAccess } from './git.js';
const r = verifyRemoteAccess('/workspace');
if (!r.ok) console.error(r.error);
cloneRepo
將 PR head branch 取到 <workspace>/repo(冪等):不存在則 --depth=1 淺層 clone,已存在則 fetch 加 checkout 取最新。config 需有 PR_HEAD_BRANCH、GITEA_TOKEN 與 remote URL;clone/fetch/checkout 失敗會丟例外。
import { cloneRepo } from './git.js';
const repoDir = cloneRepo('/workspace'); // → '/workspace/repo'
commitAndPush
將審查產出的 review 檔(findings / exclusions)結轉到 repo 並 commit、push 回 PR head branch,commit 訊息帶機器人標記與結果標籤。reviewOutcome 為 'success' 或 'failure'。無變更則跳過 commit;push 失敗只記 warning,其餘步驟例外皆被吞掉,整體不丟例外。
import { commitAndPush } from './git.js';
await commitAndPush('/workspace', '/workspace/repo', undefined, null, 'success');
gitea.js
本模組所有 axios 請求皆使用
rejectUnauthorized: false的 httpsAgent(容許自簽憑證)。PR 編號、repo、token 等取自./config.js。
getBotReviewOutcome
解析文字中的 [ai-review-bot][success|failure] 標記,判斷上一次自動審查的結果。純函式、無 API;命中標記且有後綴回傳對應小寫結果,否則回傳 'unknown'。
import { getBotReviewOutcome } from './gitea.js';
getBotReviewOutcome('chore: [ai-review-bot][failure] ...'); // → 'failure'
getBotReviewOutcome('一般 commit'); // → 'unknown'
getPRDiff
取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(.gitea/、.github/、README.md、TODO.md)。需 config 設定 GITEA_REPOSITORY、PR_NUMBER、GITEA_SERVER_URL、GITEA_TOKEN;API 失敗會拋例外。
import { getPRDiff } from './gitea.js';
const diff = await getPRDiff(); // 已過濾的 diff 文字
getCommitMessageBySha
依 commit SHA 向 Gitea 查詢該 commit 的訊息。sha falsy 時直接回空字串不發 API;查無或失敗時回空字串(記 warning,不拋例外)。
import { getCommitMessageBySha } from './gitea.js';
const msg = await getCommitMessageBySha('a4a0f75');
getBranchHeadCommitMessage
取得指定分支 head commit 的訊息(先查分支取 SHA,再查該 commit),預設使用 PR_HEAD_BRANCH。查無或失敗時回空字串,不拋例外。
import { getBranchHeadCommitMessage } from './gitea.js';
const msg = await getBranchHeadCommitMessage('feature/x'); // 省略參數則用 PR_HEAD_BRANCH
shouldSkipBotCommit
判斷目前 PR head(commit 或分支 head)的訊息是否帶 [ai-review-bot] 標記,是則代表 bot 自動提交應跳過審查。可不帶參數(使用 config 預設);命中回 true,否則 false。
import { shouldSkipBotCommit } from './gitea.js';
if (await shouldSkipBotCommit()) return; // 跳過本輪審查
filterDiff
過濾 unified diff,移除檔案路徑前綴命中 excludePrefixes 的區塊後重新接合。純函式;資料夾以 / 結尾。
import { filterDiff } from './gitea.js';
const cleaned = filterDiff(rawDiff, ['.gitea/', '.github/', 'README.md']);
postComment
在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言)。body 支援 Markdown;config 有 PR_NUMBER、GITEA_REPOSITORY,優先使用 GITEA_COMMENT_TOKEN。請求失敗會拋例外。
import { postComment } from './gitea.js';
const comment = await postComment('## AI 審查摘要\n...');
postPullReviewComment
在 PR 指定檔案的指定新版行號發布一筆行內 review comment(建立只含單一 comment 的 COMMENT review)。line 為新版檔案行號(diff 右側)且須在 diff 範圍內;超出範圍或請求失敗會拋例外(呼叫端可降級為一般留言)。
import { postPullReviewComment } from './gitea.js';
await postPullReviewComment({ path: 'src/a.js', line: 42, body: '這裡有風險' });
postPullReview
一次建立一個 PR review,本文放統計摘要、comments 批次放多筆行內 review comments({ path, body, new_position? },行號須在 diff 範圍內)。任一行號超出 diff 或請求失敗會拋例外。
import { postPullReview } from './gitea.js';
await postPullReview({
body: '## 審查統計\n...',
comments: [{ path: 'src/a.js', body: '建議修正', new_position: 42 }],
});
listPullReviews
取得目前 PR 上的所有 review 清單。需 config 有 PR_NUMBER、GITEA_REPOSITORY、GITEA_TOKEN;非陣列回應時回 [],請求失敗會拋例外。
import { listPullReviews } from './gitea.js';
const reviews = await listPullReviews();
getPullReviewComments
取得指定 review 底下的所有行內 comment。reviewId 為 number 或 string;非陣列回應時回 [],請求失敗會拋例外。
import { getPullReviewComments } from './gitea.js';
const comments = await getPullReviewComments(123);
listAllReviewComments
取得目前 PR 上所有 review 的行內 comment 並展平為單一陣列。單一 review 取 comment 失敗會記 warning 並略過,但 listPullReviews 失敗會拋例外。
import { listAllReviewComments } from './gitea.js';
const all = await listAllReviewComments();
resolvePullReviewComment
將指定 review comment 所屬的對話標記為已解決(resolve)。優先使用 GITEA_COMMENT_TOKEN;請求失敗會拋例外。
import { resolvePullReviewComment } from './gitea.js';
await resolvePullReviewComment(456);
getFileContentAtRef
取得指定 ref(預設 PR head)下某檔案的文字內容,base64 內容自動解碼為 UTF-8。filePath 為 repo 內相對路徑;檔案不存在、非文字或請求失敗時回空字串,不拋例外。
import { getFileContentAtRef } from './gitea.js';
const content = await getFileContentAtRef('src/a.js'); // 用 PR head
const atSha = await getFileContentAtRef('src/a.js', 'a4a0f75'); // 指定 ref
json.js
stripCodeFence
移除 AI 回傳文字外層的 markdown code fence(如 ```json 區塊)並去除前後空白,使內容可直接交給 JSON.parse。非字串會以 String() 轉型;純函式,不丟例外。
import { stripCodeFence } from './json.js';
const clean = stripCodeFence('```json\n[1,2,3]\n```'); // → '[1,2,3]'
JSON.parse(clean);
repairJSONArrayWithAI
透過 LLM 將任意原始內容修復成「可直接 JSON.parse 的 JSON 陣列」字串。fullPath / label 僅供提示詞參考(不讀檔);chatFn 可注入(預設 chat)。回傳經 fence 清理後的修復字串(不保證合法,需呼叫端再驗證);chatFn 失敗時例外向上拋出。
import { repairJSONArrayWithAI } from './json.js';
const fixed = await repairJSONArrayWithAI('/workspace/repo/findings.json', 'findings', rawText);
validateJSONArrayFile
驗證指定檔案是否為合法 JSON,格式錯誤時嘗試以 AI 修復一次後再次驗證(repairer 可注入)。檔案大小上限約 1 MB。回傳 { exists, valid, repaired };修復後仍失敗則拋例外。
import { validateJSONArrayFile } from './json.js';
const r = await validateJSONArrayFile('/workspace/repo/.gitea/ai-review/findings.json', 'findings');
// { exists: true, valid: true, repaired: false }
ensureJSONArrayFileExists
確保指定路徑存在一個 JSON 檔案,不存在則建立內容為 "[]\n" 的空陣列檔(父目錄自動建立)。同步函式,不驗證既有檔案內容。本次新建回 true,原本即存在回 false;建立目錄或寫檔失敗會拋 IO 例外。
import { ensureJSONArrayFileExists } from './json.js';
const created = ensureJSONArrayFileExists('/workspace/repo/.gitea/ai-review/findings.json', 'findings');
llm.js
chat
對 OpenCode server 送出一次對話請求(建立 session → 送訊息 → 抽取回應),回傳模型純文字回應並記錄用量。環境/config 須設定 OpenCode(getLLMConfig() 須回傳 provider/baseURL/model,缺 provider 會拋錯)。OpenCode 呼叫失敗時記錄錯誤並以 process.exit(1) 終止整個行程(不會回傳)。
import { chat } from './llm.js';
const text = await chat('你是程式碼審查員', '請審查以下 diff ...');
chatJSON
在 chat 之上加一層 JSON 解析,抽出回應中的 JSON 片段並 JSON.parse,解析失敗時回傳空陣列(不拋解析錯誤)。前置條件同 chat,預期回應為 JSON(多為陣列)。
import { chatJSON } from './llm.js';
const findings = await chatJSON('你是審查員,只輸出 JSON 陣列', diff);
// 解析失敗 → []
log.js
log 模組為純輸出工具,皆無回傳值;以下合併示範。
section
輸出最上層的「區塊/章節」分隔標題(前綴空行加 === 標題 ===),用於切分執行流程中彼此獨立的大段落。
import { section } from './log.js';
section('AI Code Review 開始');
step
輸出某個「步驟」標題(前綴空行加 [步驟代號] 標題),層級介於 section 與細項之間。簽名為 step(stepName, title)。
import { step } from './log.js';
step('Step3', '多角色分析');
line
輸出一筆縮排的中性明細列( - 訊息),用於列出不帶成敗語意的資訊。
import { line } from './log.js';
line('已載入 3 個審查角色');
input
輸出「階段輸入」描述( ← 輸入:訊息),標示此步驟吃進什麼資料。
import { input } from './log.js';
input('PR diff,共 1200 行');
output
輸出「階段輸出」描述( → 輸出:訊息),與 input 對應標示此步驟產出什麼。
import { output } from './log.js';
output('findings 共 5 筆');
result
依布林結果以 ✅ 成功 或 ❌ 失敗 為前綴輸出一筆把關結果列(成敗皆寫 stdout)。簽名為 result(passed, message)。
import { result } from './log.js';
result(true, 'Gitea token 驗證通過');
result(false, 'LLM 連線失敗');
ok
輸出一筆成功/完成的單向訊息( ✓ 訊息)。
import { ok } from './log.js';
ok('前置檢查通過');
warn
透過 console.warn 輸出一筆警告訊息( ! 訊息)到 stderr,用於非致命但需注意的狀況。
import { warn } from './log.js';
warn('行內標註失敗,降級為一般留言');
error
透過 console.error 輸出一筆錯誤訊息( x 訊息)到 stderr,為最高嚴重層級。
import { error } from './log.js';
error('Gitea token 驗證失敗');
preflight.js
checkRequiredEnv
檢查 code review 所需的必要環境變數(GITEA_TOKEN / GITEA_REPOSITORY / PR_NUMBER)是否齊全,缺項即列出。純函式,預設取模組層級常數、可注入覆寫;回傳 { ok, missing },全齊時 ok: true、missing: []。
import { checkRequiredEnv } from './preflight.js';
const { ok, missing } = checkRequiredEnv();
if (!ok) throw new Error('缺少環境變數: ' + missing.join(', '));
verifyGiteaToken
以唯讀 GET /repos/{repo} 探測,驗證 Gitea token 有效且對該 repo 有讀取權限。成功回 { ok: true },失敗回 { ok: false, error },不丟例外。
import { verifyGiteaToken } from './preflight.js';
const r = await verifyGiteaToken();
if (!r.ok) console.error(r.error);
verifyCommentToken
驗證選用的 comment token(以 GET /user 探測);token 為 falsy 時直接回 { ok: true, skipped: true } 不發請求,有 token 時成功回 { ok: true }、失敗回 { ok: false, error },不丟例外。
import { verifyCommentToken } from './preflight.js';
const r = await verifyCommentToken(); // 未設 comment token → { ok: true, skipped: true }
verifyLLM
驗證 LLM(OpenCode server)設定可用:確認 provider、base URL、health 端點連線,且 OpenCode 已列出指定 provider 與 model(設定來自 getLLMConfig())。通過回 { ok: true, provider },失敗回對應 error,不丟例外。
import { verifyLLM } from './preflight.js';
const r = await verifyLLM();
if (!r.ok) console.error(r.error);
runPreflight
集中執行所有唯讀前置驗證(環境變數、Gitea token、comment token、git 遠端、LLM),任一失敗即記錄錯誤並回 false,全通過回 true。workspace 預設取 GITHUB_WORKSPACE 或 /workspace;deps 可注入覆寫各檢查函式以利測試。全程不發 comment。
import { runPreflight } from './preflight.js';
if (!(await runPreflight())) process.exit(1); // 通過才繼續主流程
resolve.js
parseBotReviewComment
將一則 PR review comment 內文反向解析回 bot 產生的 finding 欄位(兼容 review comment 與行內 critical comment 兩種格式)。body 須為字串且含 **,並至少能取得 level 或 role、且有 suggestion 或 problem 才成立;否則回 null(level 預設 'warning'、role 預設 'AI Review')。
import { parseBotReviewComment } from './resolve.js';
const finding = parseBotReviewComment(comment.body); // { level, role, problem, suggestion } | null
groupConversations
將 PR 行內 review comment 依「檔案路徑加行號」收斂成對話群組,任一則帶 resolver 即整段視為已解決,並嘗試解析對應 bot finding。傳入 Gitea 行內 comment 陣列(容許 null);無 path 的留言會被略過。
import { groupConversations } from './resolve.js';
import { listAllReviewComments } from './gitea.js';
const groups = groupConversations(await listAllReviewComments());
// 每組:{ key, path, line, commentIds, bodies, resolved, botFinding, thread }
codeWindow
擷取指定行附近的程式碼片段(每行含 1-based 行號前綴),供 AI 對照判斷問題是否已解決。content 為 falsy 回 '';lineNum 非有限數或小於等於 0 時以第 1 行為中心,radius 預設 20。
import { codeWindow } from './resolve.js';
const window = codeWindow(fileContent, 42); // 預設半徑 20
const wide = codeWindow(fileContent, 42, 10); // 上下各 10 行
judgeConversations
批次請 AI(Paladin 裁判)將每個對話判為 resolved / false_positive / open,回傳與輸入等長且依 idx 對齊的結果。items 為空回 [];AI 回傳非陣列、缺漏或 verdict 不合法者一律視為 open;chatFn(預設 chatJSON)拋例外會向上傳遞。
import { judgeConversations } from './resolve.js';
const verdicts = await judgeConversations(groups); // [{ idx, verdict }, ...]
reconcileConversations
對話收斂主流程:取得 PR 所有行內 comment、呼叫 Gitea resolve 關閉未解決留言、取最新程式碼交 AI 判斷,決定每個 finding 去向(移除/寫入 exclusions/結轉保留)。deps 可注入 listComments / resolveComment / getFileContent / judge;任一外部呼叫失敗皆降級,不中斷 pipeline。
import { reconcileConversations } from './resolve.js';
const { resolvedFindings, excludedFindings, carriedFindings } = await reconcileConversations();
dropResolvedFindings
從 findings 移除「已解決對話」對應的問題,以「檔案路徑加正規化建議內容」簽章比對以避免行號漂移誤判。findings 須為陣列;resolvedFindings 為空時原樣回傳,否則回傳過濾後的新陣列。
import { dropResolvedFindings } from './resolve.js';
const remaining = dropResolvedFindings(findings, resolvedFindings);
addCarriedFindings
將「未解決對話」對應、但目前 findings 已遺漏的問題加回(補齊結轉問題),以簽章去重。findings 須為陣列;carriedFindings 為空時原樣回傳,有新增時輸出 log 並回傳新陣列。
import { addCarriedFindings } from './resolve.js';
const all = addCarriedFindings(findings, carriedFindings);
roles.js
parseRoleFile
解析單一角色 Markdown 檔,將 CRLF 正規化後以 --- 切出 YAML frontmatter 與本文,frontmatter 欄位攤平並附上 body。content 須含合法 --- frontmatter 區塊,否則拋 Error('角色檔缺少 frontmatter');純字串處理無 IO。
import { parseRoleFile } from './roles.js';
import fs from 'node:fs';
const role = parseRoleFile(fs.readFileSync('prompts/roles/security.md', 'utf8'));
// { name, side, badge, focus, personality, body, ... }
loadRoles
載入所有「攻擊方」角色(frontmatter side === 'attack'),依檔名排序,供 Step3 產生 findings。首次呼叫會觸發 prompts/roles/*.md 的同步檔案讀取(含快取)。
import { loadRoles } from './roles.js';
const roles = loadRoles(); // 攻擊方角色陣列
loadRole
依 frontmatter name(比對不分大小寫)取得單一角色,不分攻防皆可查得,找不到回 null(不拋例外)。
import { loadRole } from './roles.js';
const role = loadRole('paladin'); // → 角色物件 | null
buildAnalysisPrompt
由攻擊方角色定義組出分析用 system prompt 字串,含徽章/名稱/面向/個性/審查重點,並要求以固定 JSON 陣列格式回傳帶 檔案路徑:行號 的 findings。role 須含 name、body;role 為 null/undefined 會拋 TypeError。
import { buildAnalysisPrompt } from './roles.js';
import { chatJSON } from './llm.js';
const system = buildAnalysisPrompt(role);
const findings = await chatJSON(system, diff);
buildLocateLinePrompt
組出「補行號」system prompt 字串:當先前 finding 的 location 只有檔名缺行號時,請 LLM 對照該檔 Git Diff 找出實際行號,並只回 {"line": 數字}(找不到回 {"line": 0})。role 可省略/為 null(名稱退回 'AI Review')。
import { buildLocateLinePrompt } from './roles.js';
const system = buildLocateLinePrompt(role);
buildVerdictPrompt
由防守方角色定義組出「單條 finding 誤報裁決」system prompt 字串,要求判定成立/誤報並只回 {"verdict","reason"},無法確定一律回 "confirmed"。role 為空值時退回通用 Paladin persona;exclusionHint 預設空字串時該行被過濾。
import { buildVerdictPrompt } from './roles.js';
const system = buildVerdictPrompt(role, '相似問題曾被標記為誤報');
getRoleIntro
由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格(角色/面向/個性三欄),常用於 PR 留言或報告開頭。roles 須為可迭代陣列,空陣列回只有標題與表頭的表格。
import { getRoleIntro, loadRoles } from './roles.js';
const introTable = getRoleIntro(loadRoles());
usage.js
extractUsage
將各平台 LLM 回應的 token usage 正規化為統一結構,依序嘗試 OpenAI 相容/Gemini/Ollama/OpenCode 格式。data 非物件回 null;total 缺漏時以 prompt 加 completion 推算;數值經安全轉換不回 NaN,皆不命中回 null。
import { extractUsage } from './usage.js';
const usage = extractUsage(apiResponse); // { promptTokens, completionTokens, totalTokens } | null
recordUsage
記錄一次 LLM 呼叫的 token 用量並累加進本次執行的全域累計(runUsage),即使無法解析仍計一次呼叫。副作用變更模組層級狀態;回傳本次解析出的 usage 或 null,不丟例外。
import { recordUsage } from './usage.js';
recordUsage(apiResponse.data); // 直接傳入原始回應,內部會嘗試解析
getRunUsage
取得本次執行至今的 token 累計快照(淺複本,修改不影響內部狀態)。無參數、無副作用。
import { getRunUsage } from './usage.js';
const run = getRunUsage(); // { calls, promptTokens, completionTokens, totalTokens }
resetRunUsage
重置本次執行的 token 累計(四欄位歸零),主要供測試在案例間隔離狀態;無回傳值。
import { resetRunUsage } from './usage.js';
resetRunUsage();
recordRateLimit
從 HTTP 回應 header 擷取速率配額剩餘量與上限,優先採 token 維度、退而採 requests 維度,存為「最近一次」快照。headers 非物件直接 return;兩維度皆缺則不更新;成功時設 hasData=true 並覆蓋前次。
import { recordRateLimit } from './usage.js';
recordRateLimit(apiResponse.headers);
getRateLimit
取得最近一次速率配額快照(淺複本)。無參數、無副作用。
import { getRateLimit } from './usage.js';
const rl = getRateLimit(); // { hasData, remaining, limit, kind }
resetRateLimit
重置速率配額快照(hasData=false,其餘為 null),主要供測試使用;無回傳值。
import { resetRateLimit } from './usage.js';
resetRateLimit();
fetchAccountQuota
依 provider 查策略表取得帳號額度(多數官方平台僅憑 API key 無法取得,會誠實回報原因),任何失敗都降級為 { available: false, reason }。config.apiKeys 為陣列時取第一個否則取 config.apiKey;deps.get 可注入(預設 axios.get);保證不丟例外。
import { fetchAccountQuota } from './usage.js';
import { getLLMConfig } from './config.js';
const cfg = getLLMConfig();
const quota = await fetchAccountQuota(cfg.provider, cfg);
// { available, used?, limit?, remaining?, reason? }
resolveRemainingPercent
依優先序計算「剩餘可用百分比」:先帳號額度(有有效上限),再速率配額(rate header)。兩者上限/剩餘皆無效(null/0/負/NaN/Infinity)時回 { percent: null, reason };無副作用,不丟例外。
import { resolveRemainingPercent, getRateLimit } from './usage.js';
const r = resolveRemainingPercent(quota, getRateLimit());
// { percent, basis, remaining, limit, unit } | { percent: null, reason }
formatUsageStats
產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊,含 provider/model/呼叫次數、千分位 token 表格與剩餘可用百分比。簽名為 formatUsageStats(provider, model, usage, quota, rate)。
import { formatUsageStats, getRunUsage, getRateLimit, fetchAccountQuota } from './usage.js';
import { getLLMConfig } from './config.js';
const cfg = getLLMConfig();
const quota = await fetchAccountQuota(cfg.provider, cfg);
const block = formatUsageStats(cfg.provider, cfg.model, getRunUsage(), quota, getRateLimit());
formatUsageStatsLine
產生單行 log 用的使用量摘要(token 用量加剩餘可用百分比),與 formatUsageStats 不同在於 token 數字直接內插、不做千分位格式化。參數與 formatUsageStats 相同,無副作用。
import { formatUsageStatsLine, getRunUsage, getRateLimit } from './usage.js';
import { line } from './log.js';
import { getLLMConfig } from './config.js';
const cfg = getLLMConfig();
line(formatUsageStatsLine(cfg.provider, cfg.model, getRunUsage(), null, getRateLimit()));