Files
ai-code-review/readme.md
T
jiantw83 0eb30cf9d4
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 19s
CI / 3. RESULT (pull_request) Skipped
refresh review pipeline
2026-08-07 06:05:58 +00:00

80 KiB
Raw Permalink Blame History

AI Code Review

更新時間:2026/08/07 13:51:53

專案列表

專案名稱 專案描述
AI Code Review Gitea Docker 容器 action:對 PR 的 diff 派多個角色進行 AI 程式碼審查,產生 findings 並依對話收斂、排除規則與 AI 誤報裁決收斂結果;負責 Gitea PR API(diff/comment/review/resolve)串接、CLIProxyAPI 對話與 usage/額度統計、git clone/commit/push 持久化 findings,以及執行前的 token/LLM/git 遠端前置驗證。
專案名稱 參考專案列表
AI Code Review 無
專案名稱 npm 套件列表
AI Code Review axios ^1.6.7
js-yaml ^4.1.0

功能列表

AI Code Review

功能名稱 功能描述
parseLocation 解析 finding 的 location 字串,取出檔案路徑與起始行號,供行內 comment 定位使用。
formatFindingsStats 產生新舊問題依嚴重等級(嚴重/警告/建議/無法標示)分類統計的 Markdown 表格。
formatFindingsStatsLine 產生與統計表相同內容的單行文字摘要,供 log 輸出使用。
postFindingsReview 發布整批 findings 的 Gitea review(摘要+行內 comment),並提供多層降級機制。
saveFindings 將 findings 陣列以 JSON 格式寫入 workspace(及可選的鏡像目錄)。
postOldFindingsComment 發布所有舊有未解決問題的彙總 comment。
postNewNonCriticalComment 發布新問題中非 critical 等級者的彙總 comment。
postNewCriticalComments 針對每個新的 critical 問題逐筆發布行內 comment,無法定位或失敗時降級為一般 comment。
getInsecureHttpsAgent 取得關閉 TLS 憑證驗證的 HTTPS Agent 單例,供連接自簽憑證的內部服務使用。
getLLMConfig 依環境變數解析目前可用的 CLIProxyAPI 設定(base URL、model、API key)。
analyzeWithRole 用指定角色分析 diff,呼叫 LLM 產生該角色視角下的 findings 陣列。
normalizeText 將文字正規化(NFKC、轉小寫、壓縮空白)為比對用形式,並以快取加速重複呼叫。
loadOldFindings 讀取來源分支的舊 findings 檔案,標記為非新問題並記錄診斷日誌。
mergeFindings 依 role/location/suggestion 組成的 key 合併新舊 findings 並去重。
sortByLevel 依 critical/warning/info 順序排序 findings。
resolveMissingLineNumbers 對只有檔名缺行號的 findings,反問原角色依 diff 補上行號。
deduplicateWithAI 呼叫 LLM 對 findings 做語意去重,合併同位置同問題本質的重複項。
loadExclusions 讀取並正規化 exclusions 檔案,相容多種舊格式並就地修正為標準陣列。
appendExclusions 將新的排除條目去重後追加寫入 exclusions.json。
applyExclusions 依 exclusions 規則過濾 findings,移除符合排除條件的問題。
filterFalsePositivesWithAI 由防守方角色逐條裁決 findings 是否為誤報並剔除。
getBotReviewOutcome 解析文字中的 [ai-review-bot] 標記,回傳 success/failure/unknown。
parseReviewIgnore 把 .reviewignore 文字解析成排除前綴陣列。
getReviewIgnore 讀取並解析 PR 的 .reviewignore,沒有規則時退回內建預設清單。
getPRDiff 取得目前 PR 的 diff,並套用 .reviewignore 與內建過濾規則。
getCommitMessageBySha 依 commit SHA 向 Gitea 查詢該 commit 的訊息。
getBranchHeadCommitMessage 讀取指定分支 head commit 的訊息。
shouldSkipBotCommit 判斷目前 PR head 是否為 bot 自動提交,決定是否跳過審查。
filterDiff 過濾 unified diff 中不需要審查的路徑區塊。
postComment 在 PR 下發布一則一般 Markdown 留言。
postPullReviewComment 對 PR 指定檔案行號發送單筆行內 review comment。
postPullReview 建立包含摘要與多筆行內 comment 的 PR review。
listPullReviews 列出目前 PR 的所有 review。
getPullReviewComments 依 review ID 取得該 review 底下的所有行內 comment。
listAllReviewComments 彙整目前 PR 所有 review 的行內 comments 成單一陣列。
resolvePullReviewComment 解決指定 review comment 所屬的對話。
getFileContentAtRef 讀取指定 ref 下檔案的文字內容(自動 base64 解碼)。
getRepoState 讀取指定 git repo 目錄的 HEAD SHA、分支與 commit 時間等狀態快照。
getHeadCommitMessage 讀取 HEAD commit 的完整 commit message。
isBotAutoCommit 判斷 HEAD commit 是否為 AI Review bot 自動產生的 commit。
verifyRemoteAccess 用 git ls-remote 驗證 remote 認證與連線是否可用。
cloneRepo 以可重入方式將 PR head branch clone/fetch 到工作目錄。
commitAndPush 將 findings/exclusions 結轉到 repo 並 commit、push 回 PR head branch。
stripCodeFence 移除文字外層的 markdown code fence 並清理前後空白。
repairJSONArrayWithAI 透過 LLM 將原始內容修復成可直接 JSON.parse 的 JSON 陣列字串。
validateJSONArrayFile 驗證 JSON 檔案是否合法,格式錯誤時嘗試以 AI 修復一次。
ensureJSONArrayFileExists 確保指定路徑存在 JSON 檔案,不存在時建立空陣列檔。
mapWithConcurrency 以可控併發數並行處理陣列項目並保序回傳結果。
extractMeaningfulError 從 CLI/HTTP 原始輸出中擷取最有用的錯誤訊息片段。
chat 呼叫 CLIProxyAPI 送出對話請求並回傳純文字回應。
chatJSON 呼叫 chat 取得回應後,將文字解析為 JSON。
extractBalancedJSON 從指定索引以括號平衡方式擷取完整的 JSON 子字串。
extractJSONText 從雜訊文字中盡力抽出可被 JSON.parse 解析的片段。
section 輸出最上層的區塊分隔標題,切分整體執行流程。
step 輸出流程中某個步驟的標題。
line 輸出一行縮排的中性明細資訊。
input 輸出「階段輸入」描述,標示目前步驟吃進了什麼資料。
output 輸出「階段輸出」描述,標示目前步驟產出了什麼結果。
result 依布林結果輸出成功或失敗的檢查/把關結果列。
ok 輸出一筆成功/完成訊息。
warn 輸出一筆警告訊息(寫入 stderr)。
error 輸出一筆錯誤訊息(寫入 stderr)。
main AI Code Review Pipeline 的總指揮,依序執行 Step1~Step11 並依結果決定 exit code。
checkRequiredEnv 檢查 code review 所需的必要環境變數是否齊全。
verifyGiteaToken 驗證 Gitea token 有效且對指定 repo 有讀取權限。
verifyCommentToken 驗證選用的 comment token(GITEA_COMMENT_TOKEN)是否可用。
fetchLLMModels 呼叫 CLIProxyAPI 的 /v1/models,確認 proxy 可用與模型清單可讀。
verifyLLM 驗證 LLM proxy 設定可用,且設定的模型在可用清單內。
runPreflight 執行所有前置驗證(環境變數、Gitea token、comment token、git 遠端、LLM proxy)。
parseBotReviewComment 嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。
groupConversations 把 PR 上的行內 review comment 依檔案路徑+行號收斂成對話。
codeWindow 取目標行附近的程式碼片段,供 AI 對照判斷問題是否已解決。
judgeConversations 批次請 AI 將每個對話判為 resolved / false_positive / open。
isSafeRepoPath 安全守衛:判定路徑是否為 repo 內的相對路徑,拒絕路徑穿越。
reconcileConversations 對話收斂主流程:關閉未解決 comment,並依 AI 判斷把 findings 分流為已修復/誤報/仍成立。
dropResolvedFindings 從 findings 中移除已判定為「已解決對話」對應的問題。
addCarriedFindings 把仍成立但目前 findings 清單中遺漏的問題加回。
parseRoleFile 解析角色 Markdown 檔內容,拆出 frontmatter 與本文。
loadRoles 載入所有「攻擊方」角色定義(side === 'attack')。
loadRole 依名稱(不分大小寫)取得單一角色定義。
buildAnalysisPrompt 由攻擊方角色定義組出分析 diff 用的 system prompt。
buildLocateLinePrompt 組出「補行號」用的 system prompt。
buildVerdictPrompt 由防守方角色定義組出單條 finding 誤報裁決用的 system prompt。
getRoleIntro 由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格。
extractUsage 把各平台回應中的 token usage 正規化成統一格式。
recordUsage 記錄一次 LLM 呼叫的 usage 並累加進模組層級統計。
getRunUsage 取得本次執行至今的 token 累計(複本)。
resetRunUsage 重置本次執行的 token 累計(測試用)。
recordRateLimit 從回應 header 擷取速率配額剩餘量/上限並記錄。
getRateLimit 取得最近一次的速率配額快照(複本)。
resetRateLimit 重置速率配額快照(測試用)。
fetchAccountQuota 取得指定平台的帳號額度資訊,任何失敗都降級回報無法取得。
resolveRemainingPercent 依優先序(帳號額度→速率配額)計算「剩餘可用百分比」。
formatUsageStats 產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊。
formatUsageStatsLine 產生單行 log 用的使用量摘要文字。

使用範例

parseLocation

解析 finding 的 location 欄位,取出檔案路徑與(起始)行號,供行內 comment 標註使用。支援 "file:19"(單行)與 "file:70-82"(範圍,僅取起始行);若 location 非字串、包含逗號(代表對應多個檔案),或無法比對出行號格式,一律回傳 null,呼叫端應據此降級為一般(非行內)comment。

  • 參數:location(string)- finding 的位置字串。
  • 回傳:{ file: string, line: number } | null。
import { parseLocation } from './src/comments.js';

parseLocation('src/config.js:57');
// => { file: 'src/config.js', line: 57 }

parseLocation('src/config.js:70-82');
// => { file: 'src/config.js', line: 70 }(範圍格式僅取起始行)

parseLocation('a.js:1,b.js:2');
// => null(多檔案不支援)

formatFindingsStats

產生 findings 統計的 Markdown 表格:以 is_new === false 判定為舊問題、其餘為新問題,分別統計嚴重(critical)/警告(warning)/建議(info)/無法標示(level 不在三者之內)四欄的筆數,輸出含表頭、分隔列與兩筆資料列的表格字串。空陣列時仍會輸出表格(各欄為 0 筆)。

  • 參數:findings(Array<object>)- 審查問題陣列。
  • 回傳:string(Markdown 表格)。
import { formatFindingsStats } from './src/comments.js';

const findings = [
  { is_new: true, level: 'critical' },
  { is_new: true, level: 'warning' },
  { is_new: false, level: 'info' },
];
console.log(formatFindingsStats(findings));
// | 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |
// | --- | --- | --- | --- | --- |
// | 新問題 | 1 筆 | 1 筆 | 0 筆 | 0 筆 |
// | 舊問題 | 0 筆 | 0 筆 | 1 筆 | 0 筆 |

formatFindingsStatsLine

產生與 formatFindingsStats 相同統計邏輯(新/舊問題 × 嚴重/警告/建議/無法標示)的單行純文字摘要,供 log 輸出使用,格式如 新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...。

  • 參數:findings(Array<object>)- 審查問題陣列。
  • 回傳:string(單行摘要)。
import { formatFindingsStatsLine } from './src/comments.js';

const line = formatFindingsStatsLine([{ is_new: true, level: 'critical' }]);
console.log(line);
// => 新: 嚴重1 / 警告0 / 建議0 / 無法標示0;舊: 嚴重0 / 警告0 / 建議0 / 無法標示0

postFindingsReview

發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」。降級順序:① 整批 postReview(含 comments)失敗 → ② 僅 body 的 postReview(comments 為空)失敗 → ③ postIssue(body)(此步未包 try/catch,失敗會直接向外拋出);走完任一步不再失敗後,會逐筆嘗試 postInline 補發行內 comment,單筆失敗只記錄警告並略過。

  • 參數:findings(Array<object>)- 本次審查的完整 findings;deps(object,可選)- 可覆寫 postReview/postInline/postIssue/summaryFindings/commentFindings/usageSection,供測試注入。
  • 回傳:Promise<void>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { postFindingsReview } from './src/comments.js';

const findings = [
  { level: 'critical', role: 'Paladin', location: 'src/a.js:10', suggestion: '修正邊界檢查', is_new: true },
];
await postFindingsReview(findings, { usageSection: '## 使用量\n...' });
// 依序嘗試發布整批 review → summary review → 一般 comment,並逐筆補發行內 comment

saveFindings

將 findings 陣列以 JSON.stringify(findings, null, 2) 序列化並補結尾換行後,寫入 workspace/.gitea/ai-review/findings.json;若提供且不同於 workspace 的 mirrorDir,會同時寫入該鏡像目錄的相同路徑。寫入前會建立必要的父目錄;本函式為同步阻塞呼叫且未做例外防護,fs 錯誤會直接向外拋出。

  • 參數:workspace(string)、findings(Array<object>)、mirrorDir(?string,預設 null)。
  • 回傳:void。
import { saveFindings } from './src/comments.js';

saveFindings('/workspace', [{ level: 'warning', location: 'a.js:1', suggestion: '...' }], '/workspace/repo');
// 同時寫入 /workspace/.gitea/ai-review/findings.json 與 /workspace/repo/.gitea/ai-review/findings.json

postOldFindingsComment

以 !f.is_new(is_new 為 false/undefined/其他 falsy 值皆視為舊問題)篩選出舊問題,若有則發布一則彙總 comment(表格依 findings 原始順序,未依等級排序);若無舊問題則只記錄一行 log 並直接 return,不會呼叫 postComment。

  • 參數:findings(Array<object>)。
  • 回傳:Promise<void>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { postOldFindingsComment } from './src/comments.js';

await postOldFindingsComment([{ is_new: false, level: 'warning', role: 'Scout', location: 'a.js:5', suggestion: '...' }]);
// 發布「## 📋 舊有未解決問題(1 筆)」comment

postNewNonCriticalComment

以 f.is_new && f.level !== 'critical'(is_new 須為 truthy 才算新問題)篩選出新的非嚴重問題,若有則發布一則彙總 comment;無則只記錄 log 並直接 return。

  • 參數:findings(Array<object>)。
  • 回傳:Promise<void>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { postNewNonCriticalComment } from './src/comments.js';

await postNewNonCriticalComment([{ is_new: true, level: 'warning', role: 'Scout', location: 'a.js:5', suggestion: '...' }]);
// 發布「## 🔍 新發現問題(1 筆)」comment

postNewCriticalComments

以 f.is_new && f.level === 'critical' 篩選出新的嚴重問題,逐筆處理:location 可解析出行號且 postInline 成功時只發行內 comment;否則(無法解析,或 postInline 失敗)改用 postIssue 發一般 comment(此呼叫未包 try/catch,失敗會中斷迴圈)。

  • 參數:findings(Array<object>)、deps(object,可選,覆寫 postInline/postIssue)。
  • 回傳:Promise<void>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { postNewCriticalComments } from './src/comments.js';

await postNewCriticalComments([
  { is_new: true, level: 'critical', role: 'Paladin', location: 'src/a.js:12', suggestion: '修正 SQL Injection 風險' },
]);
// 對 src/a.js:12 發一則行內 review comment;若無法定位則改發一般 comment

getInsecureHttpsAgent

取得一個關閉 TLS 憑證驗證(rejectUnauthorized: false)的 HTTPS Agent 單例,供連接使用自簽或無效憑證的內部服務(如自架 Gitea、CLIProxyAPI)時使用;首次呼叫建立後於模組層級快取重複使用。僅限受信任的內部環境,需要完整 TLS 安全性時請改用預設 https.Agent。

  • 參數:無。
  • 回傳:import('https').Agent。
import { getInsecureHttpsAgent } from './src/config.js';
import axios from 'axios';

const httpsAgent = getInsecureHttpsAgent();
await axios.get('https://internal-gitea.example/api/v1/user', { httpsAgent });

getLLMConfig

依環境變數解析並回傳 CLIProxyAPI 設定:INPUT_CLI_PROXY_API/CLI_PROXY_API 作 base URL(trim 並去尾斜線),INPUT_MODEL/MODEL/OPENCODE_MODEL 依序 fallback 作模型名稱,INPUT_CLI_PROXY_API_KEY/CLI_PROXY_API_KEY 作金鑰。base URL 無法解析時 provider/baseURL 回 null、apiKeys 回空陣列,但 model(若有)仍會回傳。

  • 參數:無。
  • 回傳:{ provider, apiKeys, baseURL, model, command }。
import { getLLMConfig } from './src/config.js';

// 環境變數:CLI_PROXY_API=https://proxy.example, MODEL=gpt-4o, CLI_PROXY_API_KEY=sk-xxx
const cfg = getLLMConfig();
// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: 'gpt-4o', command: null }

analyzeWithRole

用單一角色分析 diff:呼叫 chatJSON 取得該角色視角下的 code review 問題,過濾出同時具備 level/location/suggestion 的有效 findings,並補上 role(一律覆寫為角色定義的 name,避免 LLM 自填不一致名稱)與 is_new: true。chatJSON(LLM)失敗時例外直接向外拋出,不做降級。

  • 參數:role({name: string})、diff(string)。
  • 回傳:Promise<Array<object>>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { analyzeWithRole } from './src/findings.js';
import { loadRole } from './src/roles.js';

const role = loadRole('Scout');
const findings = await analyzeWithRole(role, diffText);
// => [{ level: 'warning', role: 'Scout', location: 'a.js:12', problem: '...', suggestion: '...', is_new: true }, ...]

normalizeText

將任意值正規化為比對用形式:先安全轉字串(非字串轉空字串),再做 NFKC 正規化、轉小寫,把所有標點/符號/空白字元壓縮成單一空白、去頭尾空白。因常對相同字串重複呼叫(findings × exclusions 笛卡爾積比對),以模組層級 Map 對字串輸入做快取,程序生命週期內不會清除。

  • 參數:value(*)。
  • 回傳:string。
import { normalizeText } from './src/findings.js';

normalizeText('  這裡有 SQL Injection!! ');
// => '這裡有 sql injection'

loadOldFindings

讀取來源分支 clone 出的工作目錄下 FINDINGS_PATH(.gitea/ai-review/findings.json),每筆標記 is_new: false;並記錄檔案大小/修改時間等診斷日誌。檔案不存在或讀取失敗時視為空陣列,不拋例外。

  • 參數:workspace(string)。
  • 回傳:Array<object>。
import { loadOldFindings } from './src/findings.js';

const old = loadOldFindings('/workspace/repo');
// => [{ level: 'warning', location: 'a.js:5', suggestion: '...', is_new: false }, ...]

mergeFindings

以 role + location + suggestion 前 50 字 組成的字串為 key,將 newFindings 中與 oldFindings(或先出現的 newFindings 自身)key 相同者去除;oldFindings 本身不互相去重,回傳 [...oldFindings, ...去重後的 newFindings],不修改傳入的兩個陣列。

  • 參數:oldFindings(Array<object>)、newFindings(Array<object>)。
  • 回傳:Array<object>。
import { mergeFindings } from './src/findings.js';

const merged = mergeFindings(
  [{ role: 'Scout', location: 'a.js:5', suggestion: '既有問題' }],
  [{ role: 'Scout', location: 'a.js:5', suggestion: '既有問題' }, { role: 'Paladin', location: 'b.js:1', suggestion: '新問題' }],
);
// => [{...既有問題}, {...新問題}](重複的新問題被濾除)

sortByLevel

依 critical > warning > info 順序排序 findings,回傳新陣列,不修改傳入陣列。等級不在三者之內的項目因 indexOf 回傳 -1,會被排到 critical 之前(最前面)。

  • 參數:findings(Array<object>)。
  • 回傳:Array<object>。
import { sortByLevel } from './src/findings.js';

sortByLevel([{ level: 'info' }, { level: 'critical' }, { level: 'warning' }]);
// => [{ level: 'critical' }, { level: 'warning' }, { level: 'info' }]

resolveMissingLineNumbers

對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號(每條最多 maxAttempts 次,預設 3 次),成功則就地修改(mutate)該 finding 的 location 為 檔案:行號,否則保留原檔名。各條 finding 以獨立 LLM 呼叫並行定位,併發上限見 concurrency(預設 LLM_CONCURRENCY)。

  • 參數:findings(Array<object>)、diff(string)、deps(object,可選:chatFn/getRole/maxAttempts/concurrency)。
  • 回傳:Promise<Array<object>>(與輸入相同參照)。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { resolveMissingLineNumbers } from './src/findings.js';

const findings = [{ role: 'Scout', location: 'src/a.js', problem: '...', suggestion: '...' }];
await resolveMissingLineNumbers(findings, diffText);
// findings[0].location 可能被就地改為 'src/a.js:42'

deduplicateWithAI

呼叫 LLM(Paladin 角色)進行語意去重:合併「同位置+同問題本質」的重複 findings,重複者保留等級較高者。為避免幻覺,結果逐筆以 (location + suggestion 前 50 字) 對應回原始 findings;對應不到、空結果、非陣列或數量超過輸入者,皆整批降級為保留所有原始 findings。

  • 參數:findings(Array<object>)。
  • 回傳:Promise<Array<object>>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { deduplicateWithAI } from './src/findings.js';

const deduped = await deduplicateWithAI(mergedFindings);
// => 去重後的原始 finding 物件陣列;AI 失敗時原樣回傳 mergedFindings

loadExclusions

讀取來源分支工作目錄下 EXCLUSIONS_PATH(.gitea/ai-review/exclusions.json),正規化並去重後回傳。若偵測到舊格式({ exclusions: [] } 或 { excluded_findings: [] }),會就地覆寫為標準頂層陣列(並同步寫入 mirrorWorkspace,若提供且路徑不同)。檔案不存在或讀取失敗時皆視為空陣列,不拋例外。

  • 參數:workspace(string)、repoState(?object,僅供診斷日誌)、mirrorWorkspace(?string)。
  • 回傳:Array<object>。
import { loadExclusions } from './src/findings.js';

const exclusions = loadExclusions('/workspace/repo', { branch: 'feature/x', shortSha: 'abc1234' }, '/workspace');
// => [{ location: 'a.js', role: 'Scout', text: '...', textKey: '...', fingerprint: '...' }, ...]

appendExclusions

把新的排除條目(raw 形式)append 到 exclusions.json,以「檔案路徑(location 冒號前段)+ normalizeText 後的原文」為簽名去重後,以頂層陣列格式寫回 workspace(及提供且路徑不同的 mirrorWorkspace)。newEntries 為空時直接回傳 null;若全部重複則回傳既有陣列且不寫檔。

  • 參數:workspace(string)、newEntries(Array<object>)、mirrorWorkspace(?string)。
  • 回傳:Array<object> | null。
import { appendExclusions } from './src/findings.js';

appendExclusions('/workspace/repo', [
  { location: 'a.js:10', role: 'Scout', original_finding: '此處誤報', reason: 'AI 對話收斂判定為誤報' },
], '/workspace');
// => 合併後的完整排除條目陣列,並寫入 /workspace/repo 與 /workspace 的 exclusions.json

applyExclusions

套用排除規則過濾 findings:對每個 exclusion,locationMatches(只比對檔案路徑,忽略行號)且 roleMatches(未指定則萬用)且(exclusion 同時未指定 filePath 與 role 時才比對正規化後文字是否互相包含,否則直接視為符合)即視為命中並剔除。exclusions 為空時原樣回傳新陣列(不修改原輸入)。

  • 參數:findings(Array<object>)、exclusions(Array<object>)。
  • 回傳:Array<object>。
import { applyExclusions } from './src/findings.js';

const filtered = applyExclusions(
  [{ location: 'a.js:5', role: 'Scout', suggestion: '此處為既知誤報' }],
  [{ filePath: 'a.js', role: 'Scout' }],
);
// => [](同檔案同角色即視為命中,文字比對被略過)

filterFalsePositivesWithAI

由「防守方」角色(固定為 Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者;多筆時各派一個裁決任務平行處理(併發上限 LLM_CONCURRENCY)。任一筆裁決失敗時保守保留該問題,不中斷整體流程。

  • 參數:findings(Array<object>)、exclusions(Array<object>,預設 [],用於引導相似誤報更寬鬆判定)、chatFn(Function,預設 chatJSON)。
  • 回傳:Promise<Array<object>>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { filterFalsePositivesWithAI } from './src/findings.js';

const kept = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
// => 裁決為「非誤報」而保留下來的 finding 陣列

getBotReviewOutcome

解析文字中的 [ai-review-bot][success|failure] 標記,判斷上一次自動審查結果;用於 commit 訊息或留言內容,無標記或無後綴時視為未知。

  • 參數:message(string)。
  • 回傳:'success' | 'failure' | 'unknown'。
import { getBotReviewOutcome } from './src/gitea.js';

getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][failure]');
// => 'failure'
getBotReviewOutcome('一般 commit 訊息');
// => 'unknown'

parseReviewIgnore

解析 .reviewignore 文字為排除前綴陣列(gitignore 風格):每行一個路徑前綴,trim 後略過空行與 # 開頭的註解行。

  • 參數:text(string)。
  • 回傳:string[]。
import { parseReviewIgnore } from './src/gitea.js';

parseReviewIgnore('# 註解\n.gitea/\n\nREADME.md\n');
// => ['.gitea/', 'README.md']

getReviewIgnore

從被審 PR 的 head ref 取得 .reviewignore 並解析為排除清單;檔案不存在或為空時退回 DEFAULT_REVIEW_IGNORE。成功套用自訂規則時會輸出一行 log。

  • 參數:無。
  • 回傳:Promise<string[]>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { getReviewIgnore } from './src/gitea.js';

const patterns = await getReviewIgnore();
// => ['.gitea/', '.github/', 'README.md', ...](無自訂 .reviewignore 時為預設清單)

getPRDiff

取得目前 PR 的完整 unified diff(GET /repos/{repo}/pulls/{index}.diff),並依 .reviewignore(讀不到時用內建預設)排除不需審查的路徑。

  • 參數:無。
  • 回傳:Promise<string>。
  • 例外:Gitea API 請求失敗時拋出。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { getPRDiff } from './src/gitea.js';

const diff = await getPRDiff();
// => 過濾後的 unified diff 文字,供各角色分析使用

getCommitMessageBySha

依 commit SHA 向 Gitea 查詢該 commit 的訊息(GET /repos/{repo}/git/commits/{sha});失敗或 sha 為空時不拋例外,記錄警告並回傳空字串。

  • 參數:sha(string)。
  • 回傳:Promise<string>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。
import { getCommitMessageBySha } from './src/gitea.js';

const message = await getCommitMessageBySha('abcdef1234567890');
// => commit 訊息文字,查無或失敗時為 ''

getBranchHeadCommitMessage

取得指定分支 head commit 的訊息:先查 GET /repos/{repo}/branches/{branch} 取 SHA,再查該 commit;失敗或 branch 為空時不拋例外,回傳空字串。

  • 參數:branch(string,預設 PR_HEAD_BRANCH)。
  • 回傳:Promise<string>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。
import { getBranchHeadCommitMessage } from './src/gitea.js';

const message = await getBranchHeadCommitMessage('feature/x');

shouldSkipBotCommit

判斷目前 PR head(commit 或分支 head)的訊息是否帶 [ai-review-bot] 標記;若是,代表本次變更為 bot 自動提交,呼叫端應跳過審查以避免自我審查迴圈。內部查詢失敗會降級為空字串(視為未命中),正常情況下不會拋例外。

  • 參數:options.sha(string,預設 PR_HEAD_SHA)、options.branch(string,預設 PR_HEAD_BRANCH)。
  • 回傳:Promise<boolean>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { shouldSkipBotCommit } from './src/gitea.js';

if (await shouldSkipBotCommit()) {
  // 本次為 bot 自動提交,跳過本輪審查
}

filterDiff

過濾 unified diff,移除檔案路徑前綴命中 excludePrefixes 的區塊;以每個 diff --git 行為界切割,並一律強制排除任何深度的 node_modules/。diff 必須為字串,否則拋出 TypeError。

  • 參數:diff(string)、excludePrefixes(string[],預設 [])。
  • 回傳:string。
import { filterDiff } from './src/gitea.js';

const filtered = filterDiff(rawDiff, ['.gitea/', 'README.md']);
// => 移除 .gitea/ 與 README.md 相關區塊、且一律排除 node_modules/ 後的 diff 文字

postComment

在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言),透過 POST /repos/{repo}/issues/{index}/comments,優先使用 GITEA_COMMENT_TOKEN 授權。

  • 參數:body(string)。
  • 回傳:Promise<object>。
  • 例外:請求失敗時拋出。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { postComment } from './src/gitea.js';

await postComment('## 🔍 新發現問題(1 筆)\n\n...');

postPullReviewComment

在 PR 指定檔案的指定新版行號發布一筆行內 review comment(建立只含單一 comment 的 COMMENT review);該行不在 diff 範圍時 Gitea 會回錯誤而拋例外,呼叫端可降級為一般留言。

  • 參數:{ path, line, body }。
  • 回傳:Promise<object>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { postPullReviewComment } from './src/gitea.js';

await postPullReviewComment({ path: 'src/a.js', line: 42, body: '**建議**:補上邊界檢查' });

postPullReview

建立一個 PR review:本文放摘要,comments 批次放多筆行內 review comments;透過 POST /repos/{repo}/pulls/{index}/reviews(event=COMMENT),優先使用 GITEA_COMMENT_TOKEN。

  • 參數:{ body, comments }(comments 預設 [])。
  • 回傳:Promise<object>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { postPullReview } from './src/gitea.js';

await postPullReview({
  body: '## AI Code Review 統計\n...',
  comments: [{ path: 'src/a.js', body: '**建議**:...', new_position: 10 }],
});

listPullReviews

取得目前 PR 上所有 review(GET /repos/{repo}/pulls/{index}/reviews);回應非陣列時回傳空陣列以保證型別一致。

  • 參數:無。
  • 回傳:Promise<object[]>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { listPullReviews } from './src/gitea.js';

const reviews = await listPullReviews();

getPullReviewComments

取得指定 review 底下的所有行內 comment(GET /repos/{repo}/pulls/{index}/reviews/{id}/comments)。

  • 參數:reviewId(number | string)。
  • 回傳:Promise<object[]>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { getPullReviewComments } from './src/gitea.js';

const comments = await getPullReviewComments(123);

listAllReviewComments

取得目前 PR 上所有 review 的行內 comment 並展平為單一陣列;單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程,最後輸出統計日誌。

  • 參數:無。
  • 回傳:Promise<object[]>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { listAllReviewComments } from './src/gitea.js';

const allComments = await listAllReviewComments();

resolvePullReviewComment

解決(resolve)指定 review comment 所屬的對話,對應 Gitea API POST /repos/{repo}/pulls/comments/{id}/resolve,使用 GITEA_COMMENT_TOKEN 授權。

  • 參數:commentId(number | string)。
  • 回傳:Promise<object>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。
import { resolvePullReviewComment } from './src/gitea.js';

await resolvePullReviewComment(456);

getFileContentAtRef

取得指定 ref(預設 PR head)下某檔案的文字內容,透過 Gitea contents API,base64 內容會自動解碼為 UTF-8 字串;檔案不存在、非文字或請求失敗時不拋例外,記錄警告並回傳空字串。

  • 參數:filePath(string)、ref(string,預設 PR_HEAD_SHA||PR_HEAD_BRANCH)。
  • 回傳:Promise<string>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { getFileContentAtRef } from './src/gitea.js';

const content = await getFileContentAtRef('.reviewignore');

getRepoState

讀取指定 repo 目錄的目前狀態(HEAD SHA、短 SHA、目前分支、commit 時間 %cI);所有查詢皆採容錯讀取,任一失敗對應欄位即為空字串,不會丟出例外。

  • 參數:repoDir(string)、_spawnSync(測試用依賴注入,預設 spawnSync)。
  • 回傳:{ repoDir, branch, headSha, shortSha, commitTime }。
import { getRepoState } from './src/git.js';

const state = getRepoState('/workspace/repo');
// => { repoDir: '/workspace/repo', branch: 'feature/x', headSha: 'abc123...', shortSha: 'abc123', commitTime: '2026-08-07T13:41:28+08:00' }

getHeadCommitMessage

取得 HEAD commit 的完整 commit message(%B,含 subject 與 body),容錯讀取,失敗時回傳空字串。

  • 參數:repoDir(string)、_spawnSync(測試用依賴注入)。
  • 回傳:string。
import { getHeadCommitMessage } from './src/git.js';

const message = getHeadCommitMessage('/workspace/repo');

isBotAutoCommit

判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit(commit message 含 BOT_COMMIT_MARKER:[ai-review-bot]),常用於避免機器人 commit 反覆觸發新一輪審查。

  • 參數:repoDir(string)、_spawnSync(測試用依賴注入)。
  • 回傳:boolean。
import { isBotAutoCommit } from './src/git.js';

if (isBotAutoCommit('/workspace/repo')) {
  // 跳過本輪審查
}

verifyRemoteAccess

用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 git ls-remote,驗證 git 對 remote 的認證與連線是否可用(不寫入任何東西);查詢分支為 PR_HEAD_BRANCH || 'HEAD'。所有例外皆在內部捕捉,不會向外拋出。

  • 參數:workspace(string)、_spawnSync(測試用依賴注入)。
  • 回傳:{ ok: boolean, error?: string }。
import { verifyRemoteAccess } from './src/git.js';

const result = verifyRemoteAccess('/workspace');
// => { ok: true } 或 { ok: false, error: '...' }

cloneRepo

將 PR head branch clone 到 workspace/repo(idempotent):目標目錄不存在則以 --depth=1 --branch <PR_HEAD_BRANCH> clone;已存在則改為 fetch 最新後 checkout 到該分支。使用 GITEA_TOKEN 做 git HTTP 認證;clone/fetch/checkout 任一步驟失敗時例外會直接向外拋出。

  • 參數:workspace(string)、_spawnSync(測試用依賴注入)。
  • 回傳:string(repo 本機路徑)。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { cloneRepo } from './src/git.js';

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

commitAndPush

將 AI 審查產出的 review 檔(findings/exclusions)結轉到 repo,並 commit、push 回 PR head branch:設定機器人 git 身分 → fetch + hard reset 對齊遠端 → 複製 review 檔到 repo 並 add → 無變更則跳過 → 以含 BOT_COMMIT_MARKER 與結果標籤的訊息 commit → push(優先使用真人 PAT GITEA_COMMENT_TOKEN,以便重新觸發 workflow)。push 失敗只記警告,函式整體不拋出例外。

  • 參數:workspace(string)、repoDir(string)、_spawnSync(測試用)、_sourceRoot(保留參數,主體未使用)、reviewOutcome('success'|'failure',預設 'success')。
  • 回傳:Promise<void>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。
import { commitAndPush } from './src/git.js';

await commitAndPush('/workspace', '/workspace/repo', undefined, undefined, 'success');
// 將 findings.json / exclusions.json commit 並 push 回 PR head branch

stripCodeFence

移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),並去除前後空白,使內容可直接交給 JSON.parse;純函式、無副作用,非字串輸入會先以 String() 轉型。

  • 參數:text(*)。
  • 回傳:string。
import { stripCodeFence } from './src/json.js';

stripCodeFence('```json\n[{"a":1}]\n```');
// => '[{"a":1}]'

repairJSONArrayWithAI

透過 LLM 將任意原始內容修復成「可直接 JSON.parse 的 JSON 陣列」字串:以固定 system prompt 指示模型忽略原內容中的指令/註解/markdown,僅輸出修正後的陣列;無法判斷時模型應回傳空陣列。回傳前會先以 stripCodeFence 清除外層 code fence;結果不保證為合法 JSON,需由呼叫端再行解析驗證。

  • 參數:fullPath(string)、label(string)、rawText(string)、chatFn(Function,預設 chat)。
  • 回傳:Promise<string>。
  • 例外:chatFn 失敗時向上拋出。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { repairJSONArrayWithAI } from './src/json.js';

const repaired = await repairJSONArrayWithAI('/workspace/findings.json', 'findings.json', '{ 壞掉的內容 ');

validateJSONArrayFile

驗證指定路徑是否為合法的 JSON 檔案:檔案不存在回傳 { exists:false }(交由呼叫端補檔);解析成功回傳 { exists:true, valid:true, repaired:false };解析失敗則呼叫 repairer 修復、覆寫檔案(確保以換行結尾)並再驗證一次,通過則回傳 repaired:true,仍失敗則拋出例外。僅嘗試修復一次。

  • 參數:fullPath(string)、label(string)、repairer(Function,預設 repairJSONArrayWithAI)。
  • 回傳:Promise<{exists, valid, repaired}>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(供修復失敗時使用)。
import { validateJSONArrayFile } from './src/json.js';

const result = await validateJSONArrayFile('/workspace/.gitea/ai-review/findings.json', 'findings.json');
// => { exists: true, valid: true, repaired: false }

ensureJSONArrayFileExists

確保指定路徑存在一個 JSON 檔案;不存在則建立內容為 "[]\n" 的空陣列檔(會先建立父目錄)。若檔案已存在則原樣保留、不檢查內容是否合法。為同步函式。

  • 參數:fullPath(string)、label(string)。
  • 回傳:boolean(是否為本次新建)。
import { ensureJSONArrayFileExists } from './src/json.js';

const created = ensureJSONArrayFileExists('/workspace/.gitea/ai-review/exclusions.json', 'exclusions.json');
// => true(新建)或 false(原本即存在)

mapWithConcurrency

對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫;limit <= 0、非數字或大於項目數時「不限制」(全部並行)。fn 需自行處理例外,否則 reject 會使本函式立即向外拋出(其他已啟動的併發工作不會被取消,只是結果被捨棄)。

  • 參數:items(T[])、limit(number)、fn((item, index) => Promise<R>)。
  • 回傳:Promise<R[]>。
import { mapWithConcurrency } from './src/llm.js';

const results = await mapWithConcurrency([1, 2, 3], 2, async (n) => n * 2);
// => [2, 4, 6](同時最多 2 個併發)

extractMeaningfulError

從 proxy API 錯誤輸出中抽出「真正有意義的錯誤」:先抽出看起來像錯誤的行(含 ERROR/unauthorized/rate limit 等關鍵字),抽不到再退取尾段;回傳長度受 limit 限制。

  • 參數:raw(string)、limit(number,預設 1000)。
  • 回傳:string。
import { extractMeaningfulError } from './src/llm.js';

extractMeaningfulError('some noise\nERROR: rate limit exceeded\nmore noise');
// => 'ERROR: rate limit exceeded'

chat

對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應:未偵測到 proxy 設定時拋錯;不含任何重試邏輯,失敗一次即向外拋出(重新包裝為精簡訊息的 Error)。成功時記錄一次 usage 呼叫。

  • 參數:systemPrompt(string)、userContent(string)。
  • 回傳:Promise<string>。
  • 例外:設定缺失、API 呼叫失敗或回應無文字內容時拋出。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API / MODEL)。
import { chat } from './src/llm.js';

const reply = await chat('你是程式碼審查員', '請審查以下 diff:...');

chatJSON

對 CLIProxyAPI 送出對話並將回應解析為 JSON:先呼叫 chat 取得文字回應,再經 extractJSONText 抽出 JSON 片段後 JSON.parse。僅 JSON 解析失敗時容錯(回傳空陣列 []);若 chat() 本身失敗,例外會直接向外傳播。

  • 參數:systemPrompt(string)、userContent(string)。
  • 回傳:Promise<any>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { chatJSON } from './src/llm.js';

const findings = await chatJSON('請以 JSON 陣列回覆審查結果', diffText);
// => [] 或解析後的 JSON 值

extractBalancedJSON

從指定索引起,以括號平衡方式擷取一段完整配對的 JSON 子字串;依起始字元判定為物件或陣列,逐字元計數巢狀深度並正確略過字串字面值與跳脫字元,深度歸零時回傳完整片段,找不到配對時回傳 null。

  • 參數:text(*)、startIndex(number,應指向 { 或 [)。
  • 回傳:string | null。
import { extractBalancedJSON } from './src/llm.js';

extractBalancedJSON('前言 [1, 2, {"a": "]"}] 後言', 3);
// => '[1, 2, {"a": "]"}]'

extractJSONText

從可能夾雜雜訊或被 code fence 包裹的文字中,盡力抽出可被 JSON.parse 解析的片段:先去除外層 fence;若整段即為合法 JSON 直接回傳;否則由左至右尋找每個 {/[ 起點,以括號平衡擷取候選片段並試解析,回傳第一個成功者;全數失敗則回傳去 fence 後的原文。

  • 參數:text(*)。
  • 回傳:string。
import { extractJSONText } from './src/llm.js';

extractJSONText('這是回應:```json\n[{"level":"info"}]\n```');
// => '[{"level":"info"}]'

section

輸出最上層的「區塊/章節」分隔標題(前綴空行 + [時間][INF]: === 標題 ===),用於切分整個執行流程中彼此獨立的大段落,讓 CI log 在視覺上分群。

  • 參數:title(string)。
  • 回傳:void。
import { section } from './src/log.js';

section('AI Code Review Pipeline');
// => 印出:\n[2026/08/07 13:51:53][INF]: === AI Code Review Pipeline ===

step

輸出某個「步驟」的標題(前綴空行 + [步驟代號] 標題),適合在一個 section 之下標示流程中的各個有序步驟。

  • 參數:stepName(string)、title(string)。
  • 回傳:void。
import { step } from './src/log.js';

step('Step2', '前置驗證(驗證相關設定)');

line

輸出一筆縮排的一般明細列( - 訊息),用於列出不帶語意成敗的中性資訊。

  • 參數:message(string)。
  • 回傳:void。
import { line } from './src/log.js';

line('已套用 .reviewignore:3 條排除規則');

input

輸出「階段輸入」描述( ← 輸入:訊息),標示目前步驟吃進了什麼資料。

  • 參數:message(string)。
  • 回傳:void。
import { input } from './src/log.js';

input('repo=owner/name  PR=#12  feature/x → develop');

output

輸出「階段輸出」描述( → 輸出:訊息),標示目前步驟產出了什麼結果。

  • 參數:message(string)。
  • 回傳:void。
import { output } from './src/log.js';

output('新 findings 3 筆(嚴重1 / 警告1 / 建議1 / 無法標示0)');

result

輸出一筆檢查/把關結果列,依 passed 以 ✅ 成功 或 ❌ 失敗 為前綴;成功寫 INF 等級、失敗寫 ERR 等級,但一律輸出至 stdout(不因失敗改寫 stderr)。

  • 參數:passed(boolean)、message(string)。
  • 回傳:void。
import { result } from './src/log.js';

result(true, '前置驗證通過');
result(false, '發現 1 個嚴重問題,workflow 失敗(exit 1)');

ok

輸出一筆成功/完成訊息( ✓ 訊息),用於確認某項動作已順利完成的正向回饋。

  • 參數:message(string)。
  • 回傳:void。
import { ok } from './src/log.js';

ok('GITEA_TOKEN 可讀取 repo owner/name');

warn

輸出一筆警告訊息( ! 訊息),透過 console.warn 寫入 stderr;用於流程仍可繼續、但需要提醒注意的非致命狀況。

  • 參數:message(string)。
  • 回傳:void。
import { warn } from './src/log.js';

warn('對話收斂失敗(繼續執行): timeout');

error

輸出一筆錯誤訊息( x 訊息),透過 console.error 寫入 stderr;用於明確的失敗或例外狀況,是日誌中最高的嚴重層級。

  • 參數:message(string)。
  • 回傳:void。
import { error } from './src/log.js';

error('缺少必要環境變數: GITEA_TOKEN, PR_NUMBER');

main

AI Code Review Pipeline 的總指揮:依序串接 Step1~Step11(啟動、前置驗證、自動提交檢查、PR 對話收斂、角色平行分析、findings 合併去重、排除規則與誤報過濾、寫入 findings 並發布 Review、JSON 格式驗證、commit/push、嚴重問題把關)。結果主要透過 process.exit() 決定 workflow 成敗,而非以回傳值傳遞;未被攔截的未預期例外會由頂層 main().catch(...) 接住並以 process.exit(1) 結束。

  • 參數:無。
  • 回傳:Promise<void>(正常走完且無嚴重問題時 resolve;多數結束路徑直接 process.exit())。
// 範例為示意,實際執行需搭配完整的 Gitea / CLIProxyAPI 環境變數,
// 通常由 entrypoint.sh 以 `node src/main.js` 直接啟動整個 pipeline,不建議手動 import 呼叫。
import { main } from './src/main.js';

await main();

checkRequiredEnv

檢查 code review 所需的必要環境變數是否齊全(GITEA_TOKEN、GITEA_REPOSITORY、PR_NUMBER、CLI_PROXY_API),缺任何一項即列出缺少項目。CLI_PROXY_API 一律直接讀環境變數,不受 opts 覆寫。

  • 參數:opts.token/opts.repo/opts.pr(可選,供測試注入)。
  • 回傳:{ ok: boolean, missing: string[] }。
import { checkRequiredEnv } from './src/preflight.js';

checkRequiredEnv({ token: '', repo: 'owner/name', pr: '12' });
// => { ok: false, missing: ['GITEA_TOKEN', ...(若 CLI_PROXY_API 環境變數也未設)] }

verifyGiteaToken

驗證 Gitea token 有效且對指定 repo 有讀取權限:透過唯讀的 GET /repos/{repo} 探測;任何錯誤都被攔截並轉為回傳值,不會 throw。

  • 參數:token(string,預設 GITEA_TOKEN)、repo(string,預設 GITEA_REPOSITORY)。
  • 回傳:Promise<{ok:true} | {ok:false, error:string}>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。
import { verifyGiteaToken } from './src/preflight.js';

const result = await verifyGiteaToken();
// => { ok: true } 或 { ok: false, error: 'HTTP 401 ...' }

verifyCommentToken

驗證選用的 comment token(GITEA_COMMENT_TOKEN)是否可用:未提供時直接視為通過並標記 skipped:true(之後 comment 會沿用主 token);有提供則以 GET /user 探測。

  • 參數:token(string,預設 GITEA_COMMENT_TOKEN)。
  • 回傳:Promise<{ok:true, skipped?:true} | {ok:false, error:string}>。
// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。
import { verifyCommentToken } from './src/preflight.js';

const result = await verifyCommentToken();

fetchLLMModels

呼叫 CLIProxyAPI 的 /v1/models,確認 proxy 可用與模型清單可讀;baseURL 未設定、連線錯誤、401 認證失效或非 2xx 回應都會被轉為結構化的失敗結果。

  • 參數:deps.fetchImpl(預設 fetch)、deps.baseURL(預設 CLI_PROXY_API)、deps.apiKey(預設 CLI_PROXY_API_KEY)。
  • 回傳:Promise<{ok:true, slugs:string[]} | {ok:false, error:string}>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { fetchLLMModels } from './src/preflight.js';

const result = await fetchLLMModels();
// => { ok: true, slugs: ['gpt-4o', 'claude-sonnet-5', ...] }

verifyLLM

驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI 且已解析出 model,額外向模型清單端點確認 proxy 可連線且設定的 model 在可用清單內(不送 prompt)。

  • 參數:deps.fetchLLMModelsFn(預設 fetchLLMModels)。
  • 回傳:Promise<{ok:true, provider, command, model, models?} | {ok:false, provider?, command?, model?, error}>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { verifyLLM } from './src/preflight.js';

const result = await verifyLLM();

runPreflight

執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端(ls-remote)、LLM proxy;全程唯讀,不發布任何 comment,任一檢查失敗即記錄錯誤並回傳 false。各檢查可經 deps 注入覆寫,方便單元測試。

  • 參數:workspace(string,預設 GITHUB_WORKSPACE||'/workspace')、deps(可覆寫各檢查函式)。
  • 回傳:Promise<boolean>。
// 範例為示意,實際呼叫需搭配完整的 Gitea / CLIProxyAPI 環境變數。
import { runPreflight } from './src/preflight.js';

const passed = await runPreflight('/workspace');
if (!passed) process.exit(1);

parseBotReviewComment

嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位:同時支援 review comment(嚴重等級/審查員/問題/建議)與行內 critical comment(等級/審查員/建議)兩種格式。不符合格式(如人工自由留言)時回傳 null。

  • 參數:body(string)。
  • 回傳:{level, role, problem, suggestion} | null。
import { parseBotReviewComment } from './src/resolve.js';

parseBotReviewComment('**嚴重等級**:🔴 嚴重\n**審查員**:Paladin\n**問題**:缺少輸入驗證\n**建議**:補上檢查');
// => { level: 'critical', role: 'Paladin', problem: '缺少輸入驗證', suggestion: '補上檢查' }

groupConversations

把 PR 上的行內 review comment 依「檔案路徑 + 行號」收斂成對話(同一處的留言與回覆視為一段對話);對話只要任一則 comment 帶有 resolver 即視為已解決,同時嘗試解析出對應的每一則 bot finding。缺少 path 的留言會整筆跳過。

  • 參數:comments(Gitea PR review comments 原始陣列)。
  • 回傳:Array<{key, path, line, commentIds, bodies, resolved, botFinding, botFindings, thread}>。
import { groupConversations } from './src/resolve.js';

const conversations = groupConversations(rawComments);
// => [{ key: 'a.js|10', path: 'a.js', line: 10, resolved: false, botFindings: [...], thread: '...' }, ...]

codeWindow

取目標行附近的程式碼片段(含 1-based 行號前綴),讓 AI 對照判斷問題是否已解決;content 為空時回傳空字串,lineNum 非正數或非有限數時退回以檔案第一行為中心。

  • 參數:content(string)、lineNum(number)、radius(number,預設 CODE_WINDOW_RADIUS=20)。
  • 回傳:string。
import { codeWindow } from './src/resolve.js';

const snippet = codeWindow(fileContent, 42, 5);
// => '38: ...\n39: ...\n...\n47: ...'(第 42 行上下各 5 行)

judgeConversations

批次請 AI 將每個對話判為 resolved / false_positive / open;AI 回傳非陣列、缺漏或不合法的 idx,對應結果一律降級為 open(寧可保留)。

  • 參數:items(Array<{idx, path, line, thread, code}>)、chatFn(預設 chatJSON)。
  • 回傳:Promise<Array<{idx, verdict}>>。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。
import { judgeConversations } from './src/resolve.js';

const verdicts = await judgeConversations([{ idx: 0, path: 'a.js', line: 10, thread: '...', code: '...' }]);
// => [{ idx: 0, verdict: 'resolved' }]

isSafeRepoPath

安全守衛:判定路徑是否為 repo 內的相對路徑(拒絕絕對路徑、Windows 磁碟機前綴與含 .. 的路徑穿越),用於防止以外部 PR 檔名讀取 repo 外的檔案。

  • 參數:p(string)。
  • 回傳:boolean。
import { isSafeRepoPath } from './src/resolve.js';

isSafeRepoPath('src/a.js');   // => true
isSafeRepoPath('../../etc/passwd'); // => false
isSafeRepoPath('/etc/passwd');      // => false

reconcileConversations

對話收斂主流程:取得 PR 所有行內 review comment,把每個未解決 comment 一律呼叫 Gitea resolve API 關閉;再以「檔案路徑+行號」收斂成對話、取最新程式碼交 AI 判斷,依判斷結果把對應 findings 分流為 resolvedFindings(已修復)/excludedFindings(誤報,寫入 exclusions)/carriedFindings(仍成立)。任一外部呼叫失敗都降級保守處理(視為 open),不中斷整體 pipeline。

  • 參數:deps(可覆寫 listComments/resolveComment/getFileContent/judge,供測試注入)。
  • 回傳:Promise<{resolvedFindings, excludedFindings, carriedFindings, resolvedCount, falsePositiveCount, openCount, closedCount, unresolvedCount}>。
// 範例為示意,實際呼叫需搭配完整的 Gitea / CLIProxyAPI 環境變數。
import { reconcileConversations } from './src/resolve.js';

const reconcile = await reconcileConversations();
// => { resolvedFindings: [...], excludedFindings: [...], carriedFindings: [...], closedCount: 3, ... }

dropResolvedFindings

從 findings 中移除「已解決對話」對應的問題,以檔案路徑+建議內容比對(對行號漂移不敏感);resolvedFindings 為空時回傳 findings 原引用(未複製)。

  • 參數:findings(Array<object>)、resolvedFindings(Array<object>,預設 [])。
  • 回傳:Array<object>。
import { dropResolvedFindings } from './src/resolve.js';

const remaining = dropResolvedFindings(oldFindings, reconcile.resolvedFindings);

addCarriedFindings

把「未解決對話」對應、但目前 findings 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準);有實際新增項目時輸出一行提示 log。

  • 參數:findings(Array<object>)、carriedFindings(Array<object>,預設 [])。
  • 回傳:Array<object>。
import { addCarriedFindings } from './src/resolve.js';

const updated = addCarriedFindings(oldFindings, reconcile.carriedFindings);

parseRoleFile

解析單一角色 Markdown 檔內容,拆出前置 YAML frontmatter(徽章、代表色、面向、個性等欄位,會攤平到回傳物件)與本文(body,已去除頭尾空白);缺少合法 --- frontmatter 區塊時拋出例外。

  • 參數:content(string)。
  • 回傳:{name?, side?, focus?, badge?, color?, personality?, body, ...}。
  • 例外:缺 frontmatter 或 YAML 格式錯誤時拋出。
import { parseRoleFile } from './src/roles.js';

const role = parseRoleFile('---\nname: Scout\nside: attack\nfocus: 安全性\n---\n審查重點...');
// => { name: 'Scout', side: 'attack', focus: '安全性', body: '審查重點...' }

loadRoles

載入所有「攻擊方」角色(frontmatter side === 'attack'),依檔名排序;供 Step5(角色分析產生 findings)階段使用。防守方角色(如 Paladin)不在回傳之列。

  • 參數:無。
  • 回傳:Array<object>。
import { loadRoles } from './src/roles.js';

const roles = loadRoles();
// => [{ name: 'Scout', side: 'attack', ... }, { name: 'Sentinel', side: 'attack', ... }, ...]

loadRole

依 frontmatter name 取得單一角色(比對不分大小寫),不分攻擊方/防守方,找不到回傳 null。

  • 參數:name(string)。
  • 回傳:object | null。
import { loadRole } from './src/roles.js';

const paladin = loadRole('paladin');
// => { name: 'Paladin', side: 'defense', ... }

buildAnalysisPrompt

由攻擊方角色定義組出其分析用 system prompt:套用角色的徽章、名稱、面向(缺省「綜合」)、個性與審查重點本文,並附上固定指示——分析 diff 僅針對新增/修改處找問題,以固定 JSON 陣列格式(level/role/location/problem/suggestion)回傳,強制每條問題帶 檔案路徑:行號。

  • 參數:role(object,需含 name/body)。
  • 回傳:string。
  • 例外:role 為 null/undefined 時拋出 TypeError。
import { buildAnalysisPrompt } from './src/roles.js';
import { loadRole } from './src/roles.js';

const prompt = buildAnalysisPrompt(loadRole('Scout'));

buildLocateLinePrompt

組出「補行號」用的 system prompt:用於某角色先前提出的 finding 其 location 只有檔名、缺行號時,請 LLM 對照該檔 diff 找出實際行號,只回 {"line": 數字}(找不到回 {"line": 0})。role 可省略或為 null,此時名稱退回 'AI Review'。

  • 參數:role(object | null | undefined,可選)。
  • 回傳:string。
import { buildLocateLinePrompt } from './src/roles.js';

const prompt = buildLocateLinePrompt({ name: 'Scout', focus: '安全性' });

buildVerdictPrompt

由防守方角色定義組出「單條 finding 誤報裁決」用的 system prompt:要求對一條攻擊方 finding 判定「成立 / 誤報」,只回 {"verdict", "reason"},無法確定時一律回 "confirmed"(寧可保留)。role 為空值時退回固定的通用裁判 persona(🛡️ Paladin)。

  • 參數:role(object | null | undefined)、exclusionHint(string,預設 '')。
  • 回傳:string。
import { buildVerdictPrompt } from './src/roles.js';
import { loadRole } from './src/roles.js';

const prompt = buildVerdictPrompt(loadRole('Paladin'), '已知誤報清單:...');

getRoleIntro

由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格(角色/面向/個性三欄),通常用於 PR 留言/審查報告開頭呈現參與審查的角色陣容。

  • 參數:roles(Array<object>)。
  • 回傳:string(Markdown 表格)。
  • 例外:roles 非可迭代值時拋出 TypeError。
import { getRoleIntro } from './src/roles.js';
import { loadRoles } from './src/roles.js';

console.log(getRoleIntro(loadRoles()));
// ## 🤖 AI Code Review 團隊
// | 👤 角色 | 🎯 面向 | 🧠 個性 |
// |--------|--------|--------|
// | **🔍 Scout** | 安全性 | ... |

extractUsage

把各平台回應中的 token usage 正規化成 { promptTokens, completionTokens, totalTokens }:支援 OpenAI 相容 usage、OpenAI Responses、Gemini usageMetadata、Ollama 原生欄位、OpenCode tokens。回應中沒有任何可辨識的 usage 時回傳 null。

  • 參數:data(*)。
  • 回傳:{promptTokens, completionTokens, totalTokens} | null。
import { extractUsage } from './src/usage.js';

extractUsage({ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 } });
// => { promptTokens: 100, completionTokens: 50, totalTokens: 150 }

recordUsage

記錄一次 LLM 呼叫的 usage(無法解析時仍計一次呼叫,但 token 計 0),並累加進模組級 runUsage。

  • 參數:data(*)。
  • 回傳:{promptTokens, completionTokens, totalTokens} | null。
import { recordUsage } from './src/usage.js';

recordUsage({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } });

getRunUsage

取得本次執行至今的 token 累計(複本),修改回傳值不影響內部狀態。

  • 參數:無。
  • 回傳:{calls, promptTokens, completionTokens, totalTokens}。
import { getRunUsage } from './src/usage.js';

const usage = getRunUsage();
// => { calls: 5, promptTokens: 1200, completionTokens: 600, totalTokens: 1800 }

resetRunUsage

重置模組級 token 累計(測試用),將 calls/promptTokens/completionTokens/totalTokens 全部歸零。

  • 參數:無。
  • 回傳:void。
import { resetRunUsage } from './src/usage.js';

resetRunUsage();

recordRateLimit

從回應 header 擷取速率配額剩餘量/上限:支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropic(anthropic-ratelimit-tokens-*),兩者皆缺時退而採用 requests 維度;記錄「最近一次」的數值。

  • 參數:headers(object | null | undefined)。
  • 回傳:void。
import { recordRateLimit } from './src/usage.js';

recordRateLimit({ 'x-ratelimit-remaining-tokens': '9000', 'x-ratelimit-limit-tokens': '10000' });

getRateLimit

取得最近一次的速率配額快照(複本)。

  • 參數:無。
  • 回傳:{hasData, remaining, limit, kind}。
import { getRateLimit } from './src/usage.js';

const rate = getRateLimit();
// => { hasData: true, remaining: 9000, limit: 10000, kind: 'tokens' }

resetRateLimit

重置速率配額快照(測試用),將 hasData/remaining/limit/kind 回復為初始狀態。

  • 參數:無。
  • 回傳:void。
import { resetRateLimit } from './src/usage.js';

resetRateLimit();

fetchAccountQuota

取得指定平台的帳號額度;任何失敗都降級為 { available: false, reason },不丟例外。多數平台(如 CLIProxyAPI)因權限限制誠實回報「無法取得」,僅 OpenRouter 這類平台可實際查詢。

  • 參數:provider(string)、config({apiKey?, apiKeys?, baseURL?},預設 {})、deps.get(可注入 HTTP GET,預設 axios.get)。
  • 回傳:Promise<{available, reason?, used?, limit?, remaining?, currency?, source?}>。
// 範例為示意,實際呼叫需搭配有效的 API Key 環境變數。
import { fetchAccountQuota } from './src/usage.js';

const quota = await fetchAccountQuota('cliproxyapi', { apiKeys: [], baseURL: 'https://proxy.example' });
// => { available: false, reason: 'CLIProxyAPI 不提供帳號額度資訊' }

resolveRemainingPercent

計算「剩餘可用百分比」,依優先序擇一:① 帳號額度(有有效上限)→ 剩餘 credits / 上限;② 速率配額(有有效上限)→ 當前視窗剩餘 / 上限。上限或剩餘為無效值時跳過計算,落到 { percent: null, reason }。

  • 參數:quota(fetchAccountQuota 的回傳結果)、rate(getRateLimit 的回傳結果)。
  • 回傳:{percent, basis, remaining, limit, unit} | {percent:null, reason}。
import { resolveRemainingPercent } from './src/usage.js';

resolveRemainingPercent(
  { available: false, reason: '不提供額度' },
  { hasData: true, remaining: 9000, limit: 10000, kind: 'tokens' },
);
// => { percent: 90, basis: '速率配額(當前視窗,token)', remaining: 9000, limit: 10000, unit: '' }

formatUsageStats

產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊,內含本次呼叫次數、token 表格與剩餘可用百分比說明。

  • 參數:provider(string)、model(string)、usage(getRunUsage 結果)、quota(fetchAccountQuota 結果)、rate(getRateLimit 結果)。
  • 回傳:string(多行 Markdown)。
import { formatUsageStats } from './src/usage.js';

const section = formatUsageStats('cliproxyapi', 'gpt-4o', { calls: 5, promptTokens: 1200, completionTokens: 600, totalTokens: 1800 }, quota, rate);

formatUsageStatsLine

產生單行 log 用的使用量摘要文字,內容與 formatUsageStats 一致但為單行純文字,token 數字未套用千分位格式化。

  • 參數:provider(string)、model(string)、usage(getRunUsage 結果)、quota(fetchAccountQuota 結果)、rate(getRateLimit 結果)。
  • 回傳:string。
import { formatUsageStatsLine } from './src/usage.js';

const summary = formatUsageStatsLine('cliproxyapi', 'gpt-4o', { calls: 5, promptTokens: 1200, completionTokens: 600, totalTokens: 1800 }, quota, rate);
// => '本次 cliproxyapi/gpt-4o: 提示1200 + 回應600 = 1800 token(5 次呼叫);剩餘可用: 90%(...)'