6 Commits
Author SHA1 Message Date
AI Review Bot 605d557455 chore: update ai-review findings [ai-review-bot][success]
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Successful in 2s
CI / 3. RESULT (pull_request) Successful in 1s
2026-08-07 08:55:58 +00:00
jiantw83 d21e2f0e12 chore(ai-review 狀態): 轉換 findings wrapper 並加入排除
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Successful in 6m16s
CI / 3. RESULT (pull_request) Successful in 1s
2026-08-07 08:48:50 +00:00
jiantw83 409536b341 docs(ai-review 註解): 收斂註解與腳本雜訊 2026-08-07 08:48:50 +00:00
jiantw83 dcd80750ba fix(ai-review model validation): 驗證 model 並支援自動選模 2026-08-07 08:48:50 +00:00
AI Review Bot 5e9bd86bbc chore: update ai-review findings [ai-review-bot][success]
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Successful in 2s
CI / 3. RESULT (pull_request) Successful in 1s
2026-08-07 07:09:39 +00:00
jiantw83 651e221e90 allow proxy auto model selection
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Successful in 2s
CI / 3. RESULT (pull_request) Successful in 0s
2026-08-07 06:16:45 +00:00
16 changed files with 334 additions and 85 deletions
+25
View File
@@ -0,0 +1,25 @@
[
{
"addedAt": "2026/08/07 16:47:53",
"prNumber": 4,
"reviewer": "Bard",
"severity": "警告",
"file": "readme.md",
"startLine": 3,
"endLine": 3,
"problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。",
"reason": "此專案的 README 本身就是生成式 API 參考文件,維持完整索引與連結有助於內部使用,屬於文件取捨而非功能性缺陷。"
},
{
"location": "src/main.js:59",
"role": "Bard",
"original_finding": "刪掉這種會隨流程變動而失真的數量型描述;若真要提醒收尾差異,改成更穩定的概念性說明即可。",
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
},
{
"location": "entrypoint.sh:2",
"role": "Bard",
"original_finding": "移除這種會過期的時間戳註解,只保留真正需要提醒讀者的簡短說明即可。",
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
}
]
+146
View File
@@ -0,0 +1,146 @@
[
{
"level": "warning",
"role": "Assassin",
"location": "action.yml:14",
"problem": "action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符(如 `gpt-4; rm -rf /`),這些惡意輸入會先被寫入環境變數 `CLI_PROXY_API_MODEL`,才在 Node.js 代碼中被驗證。違反了「最小信任原則」。",
"suggestion": "在 action.yml 中的 inputs.model 新增驗證限制(GitHub Actions 層面無原生驗證機制,但可在文檔中強調風險,並確保 Node.js 驗證實作完備)。或改為使用 `choices` 列表限制可選值。目前的 Node.js 驗證雖然有效,但應在 GitHub Actions 文檔中明確說明:只有英數字、點、底線、連字號的 model 值才會被接受,其他值會被拒絕並導致工作流失敗。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"problem": "`postFindingsReview` 這段 JSDoc 太像流程筆記,不像 API 說明。`@param`、`@remarks`、`使用情境` 與多層降級敘事一路堆疊,重點被枝節埋掉,閱讀節奏很不乾淨。",
"suggestion": "把註解壓縮回最必要的契約說明:用途、參數、回傳與例外即可;降級順序和測試注入細節留給實作內的短註解。",
"location": "src/comments.js:235",
"is_new": false
},
{
"level": "warning",
"role": "Bard",
"location": "action.yml:15",
"problem": "把 Docker Action 的 image 參照改成小寫 `dockerfile`,讓原本業界慣用的 `Dockerfile` 檔名失去辨識度;這種大小寫改動會讓人讀配置時多停一下,也讓專案風格顯得不一致。",
"suggestion": "把檔名與 `action.yml` 的 `runs.image` 都改回慣用的 `Dockerfile`,維持 Docker 生態的標準寫法。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "readme.md:1",
"problem": "新文件採用小寫 `readme.md`,和倉庫中常見的 `README.md` 命名慣例不合。這種只差大小寫的命名,最容易在查找與瀏覽時破壞一致感。",
"suggestion": "改名為 `README.md`,讓入口文件維持一眼可辨的標準名稱。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "src/comments.js:314",
"problem": "這段 JSDoc 連到不存在的 `newFindingsOnly`,斷鏈的 `{@link}` 會讓文件閱讀時突然失聲;同時還把判定差異寫得過於旁白化,讓主註解變得冗長。",
"suggestion": "把 cross-reference 換成實際存在的符號,或直接刪掉;差異說明則濃縮成一句話,保留重點即可。",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/comments.js:37",
"problem": "buildTable 函式在呼叫 findings.map() 前無防呆檢查,若 findings 為 null/undefined 會拋出 TypeError。文件已提及此問題但函式本體未修正",
"suggestion": "在 .map() 呼叫前加入 `if (!Array.isArray(findings)) findings = [];` 或改用可選鏈語法,確保即使傳入無效值也能優雅降級",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/comments.js:90",
"problem": "inlineCommentBody 函式若 f.role 或 f.suggestion 為 undefined,會直接內嵌 undefined 字樣到輸出字串,產生 '**等級**:xxx\\n**審查員**:undefined\\n**建議**:undefined' 的破損註解",
"suggestion": "在組字前加檢查:`const role = f.role || 'AI Review'; const suggestion = f.suggestion || '';` 確保回傳值不含 undefined 字面值",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/findings.js:658",
"problem": "applyExclusions 的比對邏輯在 (locationMatches && roleMatches && (textMatches || ...)) 中,若排除規則只指定 filePath 不指定 role,會產生「該檔案內所有角色的問題都被排除」的非預期行為;若只指定 role 不指定 filePath,則「該角色所有檔案的問題都被排除」。此為對稱性缺陷",
"suggestion": "重新檢視比對邏輯意圖:若欲實現「指定 filePath 時自動不檢查 role」的設計,需在文件中明確說明此為刻意設計;若非刻意,應改為 (locationMatches || !exclusion.filePath) && (roleMatches || !exclusion.role) && (textMatches || ...),確保每個維度皆能獨立篩選",
"is_new": true
},
{
"level": "warning",
"role": "Mage",
"location": "src/llm.js:161",
"problem": "summarizeApiError 函式內存取 e.stderr 與 e.stdout 時未使用可選鏈,若 e 為 null 或 undefined,會拋出 TypeError 而非優雅容錯",
"suggestion": "改用可選鏈:`const stderr = e?.stderr || ''` 與 `const stdout = e?.stdout || ''`,或在函式開頭加入 `if (!e) return String(e);` 早期退出",
"is_new": true
},
{
"level": "warning",
"role": "Rogue",
"location": "src/log.js:7",
"problem": "formatTimestamp() 每次調用都新建 Intl.DateTimeFormat 實例,加上 formatToParts() 與 Object.fromEntries() 轉換,高頻日誌場景下重複成本大;而日誌函式會在 section/step/line/input/output/result/ok/warn/error 等多處調用,累積開銷明顯",
"suggestion": "將 Intl.DateTimeFormat 快取為模組層級單例(const formatter = new Intl.DateTimeFormat(...)),或改用更輕量的時間格式化方式(例如直接用 Date 方法),避免每條日誌都重複實例化",
"is_new": true
},
{
"level": "info",
"role": "Assassin",
"location": "src/config.js:46",
"problem": "正則表達式 `MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/` 不允許 `/` 字符。某些合法的模型名稱格式(如 `openrouter/openai/gpt-4o` 或 `providers/openai/models/gpt-4o`)會被拒絕,導致功能受限。雖然不是直接的安全漏洞,但可能造成合法請求被誤判為異常。",
"suggestion": "評估是否需要在正則表達式中允許 `/` 字符。若允許,應同時確保不會引入新的安全風險(例如路徑穿越攻擊)。改為 `/^[A-Za-z0-9._/-]+$/` 並增加單元測試確認邊界情況。",
"is_new": true
},
{
"level": "info",
"role": "Assassin",
"location": "src/log.js:19",
"problem": "formatTimestamp 函數依賴 Intl.DateTimeFormat.formatToParts 的實現細節。若回應結構不符預期,`map.year`、`map.month` 等會是 `undefined`,導致日誌中顯示 `undefined` 字樣。雖然不影響安全性,但可能造成日誌混亂及除錯困難。",
"suggestion": "加強容錯處理。在存取 `map.year` 等屬性前先驗證其存在性;或改用更穩定的日期格式化方式(如 `new Date().toISOString()`)。同時增加單元測試,確保在異常情況下(例如不同的語言環境或舊版本瀏覽器)仍能產生正確的日誌格式。",
"is_new": true
},
{
"level": "info",
"role": "Bard",
"location": "src/llm.js:31",
"problem": "`mapWithConcurrency` 內部工作者 `run()` 的註解太像設計文件,對 `cursor`、`Promise.all` 行為、背景工作都展開長篇解釋,視覺重量遠超過程式本身。",
"suggestion": "把這段縮成一兩句重點註解,保留「限制併發、保序寫入」即可,其餘執行細節交回外層函式說明。",
"is_new": true
},
{
"level": "info",
"role": "Mage",
"location": "src/findings.js:349",
"problem": "mergeFindings 用 suggestion 前 50 字作為 key 的一部分進行去重。若兩個 findings 的 role 與 location 相同但 suggestion 在第 50 字之後才出現差異,會被誤判為重複而遭移除",
"suggestion": "考慮是否改用完整 suggestion 或增加其他識別字段(如 problem)來組成 key,確保去重不會誤刪本質不同的問題",
"is_new": true
},
{
"level": "info",
"role": "Mage",
"location": "src/findings.js:363",
"problem": "sortByLevel 使用 LEVELS.indexOf() 排序,級別不在 ['critical','warning','info'] 中的項目因 indexOf 回傳 -1 而被排到 critical 之前(最前面),此邊界行為是否為預期設計不明確",
"suggestion": "在文件或代碼中明確說明未知級別項目的預期排序位置,或改用顯式的條件判斷以提升代碼可讀性",
"is_new": true
},
{
"level": "info",
"role": "Mage",
"location": "src/resolve.js:98",
"problem": "groupConversations 在設置 botFinding 時用 `botFindings[0]`,若該對話的 botFindings 陣列為空,botFinding 會為 undefined。此設計雖有文件說明是為相容舊邏輯,但下游代碼仍需確保可安全處理 undefined 值",
"suggestion": "在文件中明確註記 botFinding 可為 undefined,並在此函式或其呼叫端加入明確的 null 檢查,或改用 `botFinding: botFindings.length > 0 ? botFindings[0] : null` 以更清晰地表達意圖",
"is_new": true
},
{
"level": "info",
"role": "Rogue",
"location": "src/llm.js:154",
"problem": "runProxyAPI() 中 body 物件先建立後再條件性添加 model 屬性;若此函式在併發量大的場景反覆呼叫,每次都會新建完整物件結構",
"suggestion": "改用 Object.assign() 或 const body = { messages: [...], temperature: 0, stream: false, ...(model && { model }) },減少不必要的中間物件建立步驟",
"is_new": true
},
{
"level": "info",
"role": "Rogue",
"location": "src/log.js:18",
"problem": "formatToParts() 後用 Object.fromEntries(parts.map(...)) 進行雙次陣列與物件轉換,再拼字串;格式化操作偏複雜,對日誌輸出這種高頻操作成本偏高",
"suggestion": "改用 reduce() 直接在一次遍歷內組出 map 物件,或改寫為單一模板字符串拼接,避免中間陣列轉換",
"is_new": true
}
]
+5 -1
View File
@@ -8,6 +8,9 @@ inputs:
comment_token:
description: '操作 Gitea Commit API 的 Token'
required: false
model:
description: '使用的 AI 模型,僅允許英數字、點、底線與連字號'
required: false
runs:
using: 'docker'
image: 'dockerfile'
@@ -15,4 +18,5 @@ runs:
GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }}
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }}
CLI_PROXY_API: ${{ vars.CLI_PROXY_API }}
CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }}
CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }}
CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }}
+1 -4
View File
@@ -1,8 +1,5 @@
#!/bin/sh
# ============================================================================
# 用途:Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
# 更新時間:2026/08/07 13:51:53
# ============================================================================
# Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
# 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行
set -e
+5 -5
View File
@@ -293,7 +293,7 @@ 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`(若有)仍會回傳。
依環境變數解析並回傳 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 }`。
@@ -301,9 +301,9 @@ await axios.get('https://internal-gitea.example/api/v1/user', { httpsAgent });
```javascript
import { getLLMConfig } from './src/config.js';
// 環境變數:CLI_PROXY_API=https://proxy.example, MODEL=gpt-4o, CLI_PROXY_API_KEY=sk-xxx
// 環境變數: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: 'gpt-4o', command: null }
// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: null, command: null }
```
<a id="analyzewithrole"></a>
@@ -972,7 +972,7 @@ extractMeaningfulError('some noise\nERROR: rate limit exceeded\nmore noise');
- 例外:設定缺失、API 呼叫失敗或回應無文字內容時拋出。
```javascript
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API / MODEL)。
// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API;MODEL 可省略)。
import { chat } from './src/llm.js';
const reply = await chat('你是程式碼審查員', '請審查以下 diff:...');
@@ -1251,7 +1251,7 @@ const result = await fetchLLMModels();
### verifyLLM
驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI 且已解析出 model,額外向模型清單端點確認 proxy 可連線且設定的 model 在可用清單內(不送 prompt)。
驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI;若有指定 model,額外向模型清單端點確認該 model 在可用清單內(不送 prompt)。未指定 model 時,只要求 proxy 與模型清單端點可連線。
- 參數:`deps.fetchLLMModelsFn`(預設 `fetchLLMModels`)。
- 回傳:`Promise<{ok:true, provider, command, model, models?} | {ok:false, provider?, command?, model?, error}>`。
+9 -21
View File
@@ -235,28 +235,16 @@ function toReviewComment(f) {
}
/**
* 發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」,並提供多層降級機制。
*
* @param {Array<object>} findings 本次審查的完整 findings 陣列;當 `deps.summaryFindings` 或
* `deps.commentFindings` 未提供時,兩者皆預設使用此參數。
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換,正常情境可省略)。
* @param {Function} [deps.postReview=postPullReview] 發布整批 review(含 body 與 comments)的函式。
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。
* @param {Function} [deps.postIssue=postComment] 發布一般(非 review)comment 的函式,作為最終降級手段。
* @param {Array<object>} [deps.summaryFindings=findings] 用於統計本文數字(含新舊問題)的 findings 子集合。
* @param {Array<object>} [deps.commentFindings=findings] 用於產生 review comments 的 findings 子集合;
* 會先依 {@link bySeverity} 排序,僅新問題(`is_new !== false`)會被轉成行內 comment,
* 舊問題只計入統計、不再重複標註檔案與行數。
* @param {string} [deps.usageSection=''] 附加在統計表之後的用量/token 統計區塊;空字串時不附加。
* 發布單一 Gitea review,必要時會先降級成 summary review,再降級成一般 comment。
* @param {Array<object>} findings 審查 findings。
* @param {object} [deps={}] 可注入的相依物件。
* @param {Function} [deps.postReview=postPullReview] 發布整批 review 的函式。
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 comment 的函式。
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
* @param {Array<object>} [deps.summaryFindings=findings] 用於統計的 findings 子集合。
* @param {Array<object>} [deps.commentFindings=findings] 用於建立 review comments 的 findings 子集合。
* @param {string} [deps.usageSection=''] 附加的使用量區塊。
* @returns {Promise<void>} 無回傳值。
* @remarks
* 降級順序:① 整批 `postReview`(含 comments)→ 失敗則 ② 僅 body 的 `postReview`
* (comments 為空陣列)→ 失敗則 ③ `postIssue(body)`。**注意:③ 未包在 try/catch 中**,
* 若 `postIssue` 本身失敗,例外會直接從本函式往外拋出(reject),呼叫端須自行 catch。
* 無論走到哪一步,只要走完 ①~③ 中任一步不再往下失敗,後續都會逐筆嘗試 `postInline` 補發
* 行內 comment,每筆各自失敗僅記錄 warn 並略過,不影響其他筆。
* 使用情境:CI 流程完成一輪 AI Code Review 後,呼叫一次本函式即可把整批結果發布到 Gitea PR;
* 單元測試時可透過 `deps` 注入假的 `postReview`/`postInline`/`postIssue` 以驗證各降級分支。
*/
export async function postFindingsReview(findings, deps = {}) {
const {
+20 -6
View File
@@ -42,6 +42,16 @@ export const LLM_PROVIDER = 'cliproxyapi';
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
const MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/;
function normalizeModelName(raw) {
const model = String(raw || '').trim();
if (!model) return { model: null, modelError: null };
if (!MODEL_NAME_RE.test(model)) {
return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線與連字號' };
}
return { model, modelError: null };
}
let _insecureHttpsAgent = null;
/**
@@ -65,26 +75,30 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
* 依環境變數解析並回傳 CLIProxyAPI 設定。
*
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線),
* `INPUT_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,相容舊 OpenCode 設定)作為模型
* 名稱,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。
* `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,
* 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供,
* 則交由 CLIProxyAPI 自動選擇模型。若提供的名稱含非法字元,會被視為無效並於
* `modelError` 回報,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。
*
* 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、
* `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。
*
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), command: null }}
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), modelError: (string|null), command: null }}
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
*/
export function getLLMConfig() {
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
const model = process.env.INPUT_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
const rawModel = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
const { model, modelError } = normalizeModelName(rawModel);
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model: model || null, command: null };
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model, modelError, command: null };
return {
provider: LLM_PROVIDER,
apiKeys: apiKey ? [apiKey] : [],
baseURL,
model: model || null,
model,
modelError,
command: null,
};
}
+6 -10
View File
@@ -411,16 +411,12 @@ function extractFileDiff(diff, file) {
}
/**
* 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號,
* 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈);
* 成功則直接修改(mutate)該 finding 的 location 為 `檔案:行號`,否則保留原檔名不變。
* 各條 finding 以獨立 LLM 呼叫並行定位,併發上限見 concurrency。
*
* @param {Array<object>} findings - findings 陣列;缺行號且有檔名者會被就地修改 location(mutate),其餘不受影響。
* @param {string} diff - 完整 unified diff,用於擷取各檔案對應區段作為定位依據。
* @param {{chatFn?: Function, getRole?: Function, maxAttempts?: number, concurrency?: number}} [deps] - 依賴注入(利於測試):
* chatFn 預設 chatJSON;getRole 預設 loadRole;maxAttempts 預設 3(MAX_LOCATE_ATTEMPTS);concurrency 預設 LLM_CONCURRENCY。
* @returns {Promise<Array<object>>} 與傳入 findings 相同參照的陣列(部分項目的 location 已被就地修改)。
* 對缺行號的 findings 重新詢問原角色補上行號,成功時會就地更新 `location`。
* @param {Array<object>} findings findings 陣列。
* @param {string} diff 完整 unified diff。
* @param {{chatFn?: Function, getRole?: Function, maxAttempts?: number, concurrency?: number}} [deps]
* 測試用依賴注入。
* @returns {Promise<Array<object>>} 與傳入相同參照的 findings 陣列。
*/
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
+16 -14
View File
@@ -147,8 +147,8 @@ function summarizeApiError(e) {
* 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用
* `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。
*
* @param {{provider: string, baseURL: string, apiKeys: string[], model: string}} cfg - 連線設定;
* 僅使用 `apiKeys[0]`。
* @param {{provider: string, baseURL: string, apiKeys: string[], model?: string|null}} cfg - 連線設定;
* 僅使用 `apiKeys[0]`;`model` 可省略,省略時交由 CLIProxyAPI 自動選擇。
* @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容;
* HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `<system>` 內容無關。
* @returns {Promise<any>} API 回應的原始資料物件(`resp.data`),並非純文字;
@@ -164,17 +164,18 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
const root = String(baseURL || '').trim().replace(/\/$/, '');
const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : '';
const body = {
messages: [
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
{ role: 'user', content: prompt },
],
temperature: 0,
stream: false,
};
if (model) body.model = model;
const resp = await axios.post(
`${root}/v1/chat/completions`,
{
model,
messages: [
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
{ role: 'user', content: prompt },
],
temperature: 0,
stream: false,
},
body,
{
timeout,
maxBodyLength: maxBuffer,
@@ -208,10 +209,11 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
*/
export async function chat(systemPrompt, userContent) {
const cfg = getLLMConfig();
const { provider, baseURL, model } = cfg;
if (!provider || !baseURL || !model) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API 與 MODEL');
const { provider, baseURL, model, modelError } = cfg;
if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
if (modelError) throw new Error(modelError);
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model}`);
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
try {
const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent));
+1 -1
View File
@@ -6,7 +6,7 @@
* @returns {string} 例如 `2026/08/07 12:39:43`。
*/
function formatTimestamp(date = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
const parts = new Intl.DateTimeFormat('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
month: '2-digit',
+6 -8
View File
@@ -37,7 +37,7 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
* - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。
* - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。
* - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號;
* 未設定 API Key 或取 diff 失敗 exit 1,diff 為空 exit 0。
* 未設定 CLIProxyAPI 或取 diff 失敗 exit 1,diff 為空 exit 0。
* - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。
* - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。
* - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。
@@ -53,9 +53,6 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
*
* 目前程式碼中有 3 個 exit 1 呼叫點(未設定 CLIProxyAPI、取 diff 失敗、所有角色分析皆失敗)
* 退出前未呼叫 `section('Pipeline 結束')`,與其餘 exit 點不一致,會少一行收尾分隔線,
* 是否為刻意設計尚需人工確認。
*/
export async function main() {
section('AI Code Review Pipeline');
@@ -119,9 +116,10 @@ export async function main() {
section('Pipeline 結束');
process.exit(0);
}
input(`LLM=${provider}/${model};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`);
const modelLabel = model || 'auto';
input(`LLM=${provider}/${modelLabel};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`);
try {
await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`);
await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${modelLabel}`);
line('角色介紹 comment 已發布');
} catch (e) {
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
@@ -191,9 +189,9 @@ export async function main() {
const runUsage = getRunUsage();
const quota = await fetchAccountQuota(provider, { apiKeys, baseURL });
const rate = getRateLimit();
const usageSection = formatUsageStats(provider, model, runUsage, quota, rate);
const usageSection = formatUsageStats(provider, modelLabel, runUsage, quota, rate);
input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`);
line(`使用量: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`);
line(`使用量: ${formatUsageStatsLine(provider, modelLabel, runUsage, quota, rate)}`);
try {
await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection });
output('Gitea Review 已發布');
+8 -11
View File
@@ -165,29 +165,26 @@ export async function fetchLLMModels({
/**
* 驗證 LLM proxy 設定可用。
*
* 確認目前環境可偵測到 CLIProxyAPI 且已解析出 model;額外向模型清單端點確認
* proxy 可連線且設定的 model 在可用清單內(不送 prompt)。
* 確認目前環境可偵測到 CLIProxyAPI;若有明確指定 model,則額外向模型清單端點確認
* 該 model 在可用清單內(不送 prompt)。當 model 未指定時,只要求 proxy 與模型清單端點可連線。
* @param {object} [deps] - 可注入相依,供測試。
* @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。
* @returns {Promise<
* {ok: true, provider: string, command: null, model: string, models?: string[]} |
* {ok: false, provider?: string, command?: null, model?: string, error: string}
* {ok: true, provider: string, command: null, model: string|null, models?: string[]} |
* {ok: false, provider?: string, command?: null, model?: string|null, error: string}
* >}
* 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。
* @remarks 設定來源為 config.js 的 getLLMConfig()。
* @remarks 【需人工確認】依目前 getLLMConfig() 的型別標註(`provider: ('cliproxyapi'|null)`),
* `provider` 存在但不是 `'cliproxyapi'` 的分支在目前設定來源下應為不會被觸發的保留分支,
* 但無法從本檔案確認這是刻意保留的向前相容設計、還是尚未清理的死碼,建議與維護者確認。
*/
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
const { provider, command, model } = getLLMConfig();
const { provider, command, model, modelError } = getLLMConfig();
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
if (modelError) return { ok: false, provider, command, model, error: modelError };
if (provider === 'cliproxyapi') {
const models = await fetchLLMModelsFn();
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
if (!models.slugs.includes(model)) {
if (model && !models.slugs.includes(model)) {
return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` };
}
return { ok: true, provider, command, model, models: models.slugs };
@@ -255,7 +252,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
error(`LLM 驗證失敗: ${llm.error}`);
return false;
}
ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model})`);
ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model || 'auto'})`);
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
result(true, '前置驗證通過');
+22 -2
View File
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
const ENV_KEYS = [
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', 'INPUT_MODEL',
'MODEL', 'OPENCODE_MODEL',
];
let saved = {};
@@ -52,6 +52,26 @@ describe('getLLMConfig', () => {
assert.equal(cfg.model, 'gpt-5-mini');
});
it('uses CLI_PROXY_API_MODEL when INPUT_MODEL is missing', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_MODEL = 'gpt-5.4-mini';
process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig();
assert.equal(cfg.model, 'gpt-5.4-mini');
});
it('rejects invalid model names', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
const cfg = getLLMConfig();
assert.equal(cfg.model, null);
assert.match(cfg.modelError, /無效的 model 參數/);
});
it('returns null provider when CLI_PROXY_API is missing', () => {
process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig();
+27 -1
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
const ENV_KEYS = [
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
];
@@ -57,6 +57,25 @@ describe('chat - CLIProxyAPI', async () => {
assert.equal(capturedOpts.headers.Authorization, 'Bearer secret');
});
it('omits model from the request body when auto selection is allowed', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
let capturedBody;
mock.method(axios, 'post', async (url, body) => {
capturedBody = body;
return {
data: { choices: [{ message: { content: 'cli response' } }] },
headers: {},
};
});
const result = await chat('sys', 'user');
assert.equal(result, 'cli response');
assert.equal(Object.hasOwn(capturedBody, 'model'), false);
});
it('throws an error when the API fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5-mini';
@@ -69,6 +88,13 @@ describe('chat - CLIProxyAPI', async () => {
await assert.rejects(() => chat('sys', 'user'), /401/);
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
});
it('throws when the configured model name is invalid', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
await assert.rejects(() => chat('sys', 'user'), /無效的 model 參數/);
});
});
describe('chatJSON', async () => {
+6
View File
@@ -131,6 +131,12 @@ describe('main pipeline', () => {
assert.equal(await runMain(), 0);
});
it('無 MODEL 仍可由 Proxy 自動選模並正常走完(exit 0)', async () => {
assert.equal(await runMain({
config: { getLLMConfig: () => ({ provider: 'cliproxyapi', apiKeys: ['secret'], baseURL: 'https://proxy.example', model: null, command: null }) },
}), 0);
});
it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => {
assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0);
});
+31 -1
View File
@@ -4,7 +4,7 @@ import axios from 'axios';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js';
const LLM_ENV_KEYS = [
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
];
@@ -164,6 +164,22 @@ describe('verifyLLM', () => {
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
});
it('passes when no model is specified and the proxy is reachable', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, true);
assert.equal(result.provider, 'cliproxyapi');
assert.equal(result.command, null);
assert.equal(result.model, null);
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
});
it('fails when proxy auth is invalid', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
@@ -192,6 +208,20 @@ describe('verifyLLM', () => {
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
assert.match(result.error, /gpt-9-imaginary/);
});
it('fails when the configured model name is invalid', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.5; rm -rf /';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5'] }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'cliproxyapi');
assert.match(result.error, /無效的 model 參數/);
});
});
describe('runPreflight', () => {