Files

55 KiB
Raw Permalink Blame History

ai-code-review

ai-code-review 是一個以 Node.jsESM)撰寫、封裝為 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

功能名稱 功能描述
parseLocation 解析 finding 的 location 字串,取出檔案路徑與起始行號。
formatFindingsStats 產生 findings 統計的 Markdown 表格(新舊問題 × 各嚴重度)。
formatFindingsStatsLine 產生 findings 統計的單行文字摘要,供 log 使用。
postFindingsReview 發布單一 Gitea review(統計本文加新問題行內標註),含多層失敗降級。
saveFindings 將 findings 以格式化 JSON 寫入 findings 檔,可同時鏡像寫入。
postOldFindingsComment 一次發布所有舊問題的彙整 comment(表格呈現)。
postNewNonCriticalComment 一次發布新問題中等級非 critical 者的彙整 comment。
postNewCriticalComments 為每個新的 critical 問題各發一則 comment,優先行內標註、失敗則降級。

config.js

功能名稱 功能描述
getOpenCodeHttpsAgent 建立停用 TLS 憑證驗證的 HTTPS Agent,供連接自簽憑證的 OpenCode 服務使用。
getLLMConfig 依環境變數解析並回傳 LLM 提供者設定。

findings.js

功能名稱 功能描述
analyzeWithRole 以單一審查角色分析 Git diff,回傳結構化 findings 陣列。
loadOldFindings 從工作區讀取上一輪 findings,每筆標記為舊問題。
mergeFindings 合併新舊 findings,保留全部舊項目並附加新項目中未重複者。
sortByLevel 依嚴重度排序 findingscritical 大於 warning 大於 info)。
resolveMissingLineNumbers 對缺行號的 findings 反問審查角色定位行號並補回 location。
deduplicateWithAI 以 LLM 對 findings 做語意去重,合併同位置同性質者並保留較高等級。
loadExclusions 讀取並正規化、去重排除清單,必要時改寫為標準陣列格式。
appendExclusions 將新排除條目去重後追加寫回排除清單檔。
applyExclusions 依排除清單過濾 findings,移除命中任一排除條目者。
filterFalsePositivesWithAI 以防守方角色逐條平行裁決 findings 是否為誤報並剔除。

git.js

功能名稱 功能描述
getRepoState 讀取 git 工作目錄目前狀態,回傳 HEAD SHA、分支與 commit 時間。
getHeadCommitMessage 取得 HEAD commit 的完整訊息。
isBotAutoCommit 判斷 HEAD commit 是否為審查機器人自己產生的自動 commit。
verifyRemoteAccess 以唯讀 ls-remote 前置驗證 git 對 remote 的認證與連線。
cloneRepo 冪等地將 PR head branch 取到工作區的 repo 目錄。
commitAndPush 將審查產出的 review 檔結轉、commit 並 push 回 PR head branch。

gitea.js

功能名稱 功能描述
getBotReviewOutcome 解析文字中的機器人標記,判斷上一次自動審查的結果。
getPRDiff 取得目前 PR 的完整 Git diff,並排除不需審查的路徑。
getCommitMessageBySha 依 commit SHA 向 Gitea 查詢該 commit 的訊息。
getBranchHeadCommitMessage 取得指定分支 head commit 的訊息。
shouldSkipBotCommit 判斷目前 PR head 是否為 bot 自動提交而應跳過審查。
filterDiff 過濾 unified diff,移除路徑前綴命中排除清單的區塊。
postComment 在目前 PR 下發布一則一般留言。
postPullReviewComment 在 PR 指定檔案的指定新版行號發布一筆行內 review comment。
postPullReview 一次建立一個 PR review,本文放統計摘要、批次放行內 comments。
listPullReviews 取得目前 PR 上的所有 review 清單。
getPullReviewComments 取得指定 review 底下的所有行內 comment。
listAllReviewComments 取得目前 PR 上所有 review 的行內 comment 並展平。
resolvePullReviewComment 將指定 review comment 所屬的對話標記為已解決。
getFileContentAtRef 取得指定 ref 下某檔案的文字內容,base64 自動解碼。

json.js

功能名稱 功能描述
stripCodeFence 移除 AI 回傳文字外層的 markdown code fence 並去除前後空白。
repairJSONArrayWithAI 透過 LLM 將任意原始內容修復成可解析的 JSON 陣列字串。
validateJSONArrayFile 驗證檔案是否為合法 JSON,錯誤時嘗試以 AI 修復一次後再驗。
ensureJSONArrayFileExists 確保指定路徑存在 JSON 檔案,不存在則建立空陣列檔。

llm.js

功能名稱 功能描述
chat 對 OpenCode server 送出一次對話請求並回傳純文字回應。
chatJSON 在 chat 之上加一層 JSON 解析,抽出並解析回應中的 JSON。

log.js

功能名稱 功能描述
section 輸出最上層的區塊章節分隔標題。
step 輸出某個步驟標題(步驟代號加標題)。
line 輸出一筆縮排的中性明細列。
input 輸出階段輸入描述。
output 輸出階段輸出描述。
result 依布林結果輸出成功或失敗的把關結果列。
ok 印出成功訊息。
warn 輸出警告訊息(stderr)。
error 輸出錯誤訊息(stderr,最高嚴重層級)。

preflight.js

功能名稱 功能描述
checkRequiredEnv 檢查必要環境變數是否齊全,回傳齊全旗標與缺項清單。
verifyGiteaToken 以唯讀請求驗證 Gitea token 對該 repo 是否有讀取權限。
verifyCommentToken 驗證選用的 comment token,未提供則視為通過並標記 skipped。
verifyLLM 驗證 LLM 連線與 provider、model 設定是否可用。
runPreflight 集中執行所有唯讀前置驗證,任一失敗即回 false。

resolve.js

功能名稱 功能描述
parseBotReviewComment 將一則 review comment 反向解析回 bot finding 欄位。
groupConversations 將行內 comment 依檔案加行號收斂成對話群組。
codeWindow 擷取指定行附近含行號前綴的程式碼片段。
judgeConversations 批次請 AI 將各對話判為已解決、誤報或仍開啟。
reconcileConversations 對話收斂主流程,決定每個 finding 移除、排除或結轉。
dropResolvedFindings 以簽章比對從 findings 移除已解決對話對應的問題。
addCarriedFindings 將未解決但已遺漏的結轉問題加回 findings 並去重。

roles.js

功能名稱 功能描述
parseRoleFile 解析角色 Markdown 檔,切出 YAML frontmatter 與本文。
loadRoles 載入所有攻擊方角色並依檔名排序。
loadRole 依名稱(不分大小寫)取得單一角色,找不到回 null。
buildAnalysisPrompt 由攻擊方角色組出分析用 system prompt 字串。
buildLocateLinePrompt 組出補行號用的 system prompt 字串。
buildVerdictPrompt 由防守方角色組出單條 finding 誤報裁決的 system prompt。
getRoleIntro 由角色陣列產生團隊介紹用的 Markdown 表格。

usage.js

功能名稱 功能描述
extractUsage 將各平台 LLM 回應的 token usage 正規化為統一結構。
recordUsage 記錄一次 LLM 呼叫的 token 用量並累加進本次執行累計。
getRunUsage 取得本次執行至今的 token 累計快照(淺複本)。
resetRunUsage 重置本次執行的 token 累計(四欄位歸零)。
recordRateLimit 從 HTTP header 擷取速率配額剩餘量與上限存為快照。
getRateLimit 取得最近一次速率配額快照(淺複本)。
resetRateLimit 重置速率配額快照。
fetchAccountQuota 依 provider 查策略表取得帳號額度,失敗一律降級回報。
resolveRemainingPercent 依優先序由帳號額度或速率配額計算剩餘可用百分比。
formatUsageStats 產生 PR Review 本文用的使用量 Markdown 區塊。
formatUsageStatsLine 產生單行 log 用的使用量摘要。

使用範例

以下範例為各模組的 sibling import(同層 ESM 模組)。多數函式相依環境變數(Gitea / OpenCode 設定)與 ./config.js,於正式流程中由 action 入口統一注入。範例中標示 需人工確認 者,為依草稿行為推導之保守情境式範例。

comments.js

parseLocation

解析 finding 的 location 字串,取出檔案路徑與起始行號;支援 file:19file: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-flashapiKeys 為固定佔位 ['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 至少含 namediff 為 unified diff 字串;需 LLMchatJSON)可用。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

依嚴重度排序 findingscritical 高於 warning 高於 info),回傳新陣列、不修改輸入。未列於 LEVELS 的未知等級會被排在 critical 之前。

import { sortByLevel } from './findings.js';

const sorted = sortByLevel(findings); // critical 在前

resolveMissingLineNumbers

對缺行號(僅有檔名)的 findings,反問原審查角色依該檔 diff 定位行號,成功則把 location 補成 檔案:行號,否則保留檔名(就地修改傳入陣列)。deps 可注入(chatFn 預設 chatJSONgetRole 預設 loadRolemaxAttempts 預設 3)。任一筆失敗都不中斷、不對外拋例外。

import { resolveMissingLineNumbers } from './findings.js';

await resolveMissingLineNumbers(findings, diff); // 回傳與傳入相同參考,部分 location 已補行號

deduplicateWithAI

以 LLMPaladin 裁判)對 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_BRANCHGITEA_TOKEN 與 remote URLclone/fetch/checkout 失敗會丟例外。

import { cloneRepo } from './git.js';

const repoDir = cloneRepo('/workspace'); // → '/workspace/repo'

commitAndPush

將審查產出的 review 檔(findings / exclusions)結轉到 repo 並 commit、push 回 PR head branchcommit 訊息帶機器人標記與結果標籤。reviewOutcome'success''failure'。無變更則跳過 commitpush 失敗只記 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.mdTODO.md)。需 config 設定 GITEA_REPOSITORYPR_NUMBERGITEA_SERVER_URLGITEA_TOKENAPI 失敗會拋例外。

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 headcommit 或分支 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 支援 Markdownconfig 有 PR_NUMBERGITEA_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_NUMBERGITEA_REPOSITORYGITEA_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 須設定 OpenCodegetLLMConfig() 須回傳 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: truemissing: []

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

驗證 LLMOpenCode 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,全通過回 trueworkspace 預設取 GITHUB_WORKSPACE/workspacedeps 可注入覆寫各檢查函式以利測試。全程不發 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 才成立;否則回 nulllevel 預設 '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 不合法者一律視為 openchatFn(預設 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 欄位攤平並附上 bodycontent 須含合法 --- 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 須含 namebodyrole 為 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 personaexclusionHint 預設空字串時該行被過濾。

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 相容/GeminiOllamaOpenCode 格式。data 非物件回 nulltotal 缺漏時以 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.apiKeydeps.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 區塊,含 providermodel/呼叫次數、千分位 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()));