Reviewed-on: #5
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 標註使用。支援 "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 包成新版 wrapper 物件後,以 JSON.stringify(wrapper, null, 2) 序列化並補結尾換行,寫入 workspace/.gitea/ai-review/findings.json;wrapper 內含 generatedAt/commitSha/prNumber/tool/findings/excluded。若提供且不同於 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/CLI_PROXY_API_MODEL/MODEL/OPENCODE_MODEL 依序 fallback 作可選模型名稱;若未指定模型,會交由 CLIProxyAPI 自動選擇,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, CLI_PROXY_API_KEY=sk-xxx
const cfg = getLLMConfig();
// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: null, 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),相容舊版頂層陣列與新版 wrapper 物件;每筆標記 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 }(交由呼叫端補檔);exclusions.json 仍以頂層陣列為準,而 findings.json 則接受新版 wrapper 物件,若讀到舊版 findings 陣列會自動正規化成 wrapper。解析失敗則呼叫 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 檔案;exclusions.json 不存在時建立內容為 "[]\n" 的空陣列檔,而 findings.json 不存在時建立空的新版 wrapper 物件(會先建立父目錄)。若檔案已存在則原樣保留、不檢查內容是否合法。為同步函式。
- 參數:
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,額外向模型清單端點確認該 model 在可用清單內(不送 prompt)。未指定 model 時,只要求 proxy 與模型清單端點可連線。
- 參數:
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%(...)'