Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e46031cb69 | ||
|
|
ab384fe108 | ||
|
|
938db793a7 | ||
|
|
a8d3fb60ed | ||
|
|
65dcb52777 | ||
|
|
605d557455 | ||
|
|
d21e2f0e12 | ||
|
|
409536b341 | ||
|
|
dcd80750ba | ||
|
|
5e9bd86bbc | ||
|
|
651e221e90 |
@@ -0,0 +1,69 @@
|
||||
[
|
||||
{
|
||||
"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 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
|
||||
},
|
||||
{
|
||||
"addedAt": "2026/08/07 16:39:03",
|
||||
"prNumber": null,
|
||||
"reviewer": "Assassin",
|
||||
"severity": "警告",
|
||||
"file": "action.yml",
|
||||
"startLine": 14,
|
||||
"endLine": 14,
|
||||
"problem": "action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符,這些值會先進入環境變數,再由程式端驗證。",
|
||||
"reason": "GitHub / Gitea 的 action schema 不提供字串輸入的正則驗證;本專案已在程式端做完整驗證,action.yml 無法再向前移到平台層。"
|
||||
},
|
||||
{
|
||||
"addedAt": "2026/08/07 16:39:03",
|
||||
"prNumber": null,
|
||||
"reviewer": "Bard",
|
||||
"severity": "警告",
|
||||
"file": "readme.md",
|
||||
"startLine": 1,
|
||||
"endLine": 1,
|
||||
"problem": "新文件採用小寫 readme.md,和倉庫中常見的 README.md 命名慣例不合。",
|
||||
"reason": "這個檔名是現有專案慣例的一部分,直接改名會牽動大量內部連結與生成內容,屬於文件命名取捨。"
|
||||
},
|
||||
{
|
||||
"addedAt": "2026/08/07 16:39:03",
|
||||
"prNumber": null,
|
||||
"reviewer": "Mage",
|
||||
"severity": "警告",
|
||||
"file": "src/findings.js",
|
||||
"startLine": 658,
|
||||
"endLine": 658,
|
||||
"problem": "applyExclusions 的比對邏輯在 (locationMatches && roleMatches && (textMatches || ...)) 中,若排除規則只指定 filePath 不指定 role,會產生「該檔案內所有角色的問題都被排除」的非預期行為;若只指定 role 不指定 filePath,則「該角色所有檔案的問題都被排除」。此為對稱性缺陷",
|
||||
"reason": "此處的排除規則刻意把 filePath / role 當成可獨立放寬的過濾條件,讓已知誤報可以用較粗粒度收斂;行為與設計一致。"
|
||||
},
|
||||
{
|
||||
"addedAt": "2026/08/07 16:39:03",
|
||||
"prNumber": null,
|
||||
"reviewer": "Mage",
|
||||
"severity": "建議",
|
||||
"file": "src/resolve.js",
|
||||
"startLine": 84,
|
||||
"endLine": 84,
|
||||
"problem": "groupConversations 在設置 botFinding 時用 botFindings[0],若該對話的 botFindings 陣列為空,botFinding 會為 undefined。",
|
||||
"reason": "程式已將 botFinding 以 null 初始化,且下游邏輯以 botFindings 陣列為主要資料來源;此為相容舊邏輯的保守設計。"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"generatedAt": "2026/08/08 00:46:03",
|
||||
"commitSha": "ab384fe1080e5265724747a8faa960e4e960bf68",
|
||||
"prNumber": 4,
|
||||
"tool": {
|
||||
"name": "ai-code-review",
|
||||
"version": "1.0.0",
|
||||
"model": "auto"
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"level": "warning",
|
||||
"role": "Mage",
|
||||
"problem": "summarizeApiError 函式內存取 e.stderr 與 e.stdout 時未使用可選鏈,若 e 為 null 或 undefined,會拋出 TypeError 而非優雅容錯",
|
||||
"suggestion": "改用可選鏈:`const stderr = e?.stderr || ''` 與 `const stdout = e?.stdout || ''`,或在函式開頭加入 `if (!e) return String(e);` 早期退出",
|
||||
"location": "src/llm.js:161",
|
||||
"is_new": false
|
||||
},
|
||||
{
|
||||
"level": "warning",
|
||||
"role": "Assassin",
|
||||
"location": "src/config.js:44",
|
||||
"problem": "這裡只限制字元種類,卻還放行 `.` 與 `/`,因此像 `../foo`、`foo/../../bar` 這類路徑式字串仍可通過。攻擊者只要能控制 `inputs.model` 或 `CLI_PROXY_API_MODEL`,就能把惡意 model 值送進 CLIProxyAPI;若後端拿 model 名稱去拼路徑、呼叫指令或做檔名查找,這個輸入就可能被拿來做路徑穿越或指令注入。",
|
||||
"suggestion": "不要只做字元白名單,應改成明確白名單比對可用模型 slug,並額外拒絕 `..`、前導/結尾 `/`、連續 `/`、反斜線與控制字元;如果可行,直接用 `/v1/models` 回傳清單做嚴格選擇,而不是接受任意形狀的字串。",
|
||||
"is_new": true
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"role": "Mage",
|
||||
"problem": "mergeFindings 用 suggestion 前 50 字作為 key 的一部分進行去重。若兩個 findings 的 role 與 location 相同但 suggestion 在第 50 字之後才出現差異,會被誤判為重複而遭移除",
|
||||
"suggestion": "考慮是否改用完整 suggestion 或增加其他識別字段(如 problem)來組成 key,確保去重不會誤刪本質不同的問題",
|
||||
"location": "src/findings.js:349",
|
||||
"is_new": false
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"role": "Assassin",
|
||||
"problem": "formatTimestamp 函數依賴 Intl.DateTimeFormat.formatToParts 的實現細節。若回應結構不符預期,`map.year`、`map.month` 等會是 `undefined`,導致日誌中顯示 `undefined` 字樣。雖然不影響安全性,但可能造成日誌混亂及除錯困難。",
|
||||
"suggestion": "加強容錯處理。在存取 `map.year` 等屬性前先驗證其存在性;或改用更穩定的日期格式化方式(如 `new Date().toISOString()`)。同時增加單元測試,確保在異常情況下(例如不同的語言環境或舊版本瀏覽器)仍能產生正確的日誌格式。",
|
||||
"location": "src/log.js:19",
|
||||
"is_new": false
|
||||
},
|
||||
{
|
||||
"level": "info",
|
||||
"role": "Assassin",
|
||||
"location": "readme.md:9",
|
||||
"problem": "這份新增文件把內部 Gitea 網域與完整倉庫路徑直接寫進專案內容。只要文件被外部看見,攻擊者就能先掌握內部服務命名、URL 模式與專案結構,降低枚舉、釣魚與後續橫向移動的成本。",
|
||||
"suggestion": "如果這份文件有外部可見的可能,請把內網主機名與完整路徑改成相對路徑或 placeholder,並把只限內部使用的操作細節移到不對外公開的位置。",
|
||||
"is_new": true
|
||||
}
|
||||
],
|
||||
"excluded": []
|
||||
}
|
||||
+5
-1
@@ -8,11 +8,15 @@ inputs:
|
||||
comment_token:
|
||||
description: '操作 Gitea Commit API 的 Token'
|
||||
required: false
|
||||
model:
|
||||
description: '使用的 AI 模型,僅允許英數字、點、底線、連字號與斜線'
|
||||
required: false
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'dockerfile'
|
||||
image: 'Dockerfile'
|
||||
env:
|
||||
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_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }}
|
||||
|
||||
+1
-4
@@ -1,8 +1,5 @@
|
||||
#!/bin/sh
|
||||
# ============================================================================
|
||||
# 用途:Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
|
||||
# 更新時間:2026/08/07 13:51:53
|
||||
# ============================================================================
|
||||
# Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
|
||||
|
||||
# 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行
|
||||
set -e
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| [formatFindingsStats](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L173) | [產生新舊問題依嚴重等級(嚴重/警告/建議/無法標示)分類統計的 Markdown 表格。](#formatfindingsstats) |
|
||||
| [formatFindingsStatsLine](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L194) | [產生與統計表相同內容的單行文字摘要,供 log 輸出使用。](#formatfindingsstatsline) |
|
||||
| [postFindingsReview](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L261) | [發布整批 findings 的 Gitea review(摘要+行內 comment),並提供多層降級機制。](#postfindingsreview) |
|
||||
| [saveFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L311) | [將 findings 陣列以 JSON 格式寫入 workspace(及可選的鏡像目錄)。](#savefindings) |
|
||||
| [saveFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L311) | [將 findings 包成新版 wrapper 後寫入 workspace(及可選的鏡像目錄)。](#savefindings) |
|
||||
| [postOldFindingsComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L335) | [發布所有舊有未解決問題的彙總 comment。](#postoldfindingscomment) |
|
||||
| [postNewNonCriticalComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L359) | [發布新問題中非 critical 等級者的彙總 comment。](#postnewnoncriticalcomment) |
|
||||
| [postNewCriticalComments](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L388) | [針對每個新的 critical 問題逐筆發布行內 comment,無法定位或失敗時降級為一般 comment。](#postnewcriticalcomments) |
|
||||
@@ -68,7 +68,7 @@
|
||||
| [stripCodeFence](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L17) | [移除文字外層的 markdown code fence 並清理前後空白。](#stripcodefence) |
|
||||
| [repairJSONArrayWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L43) | [透過 LLM 將原始內容修復成可直接 JSON.parse 的 JSON 陣列字串。](#repairjsonarraywithai) |
|
||||
| [validateJSONArrayFile](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L93) | [驗證 JSON 檔案是否合法,格式錯誤時嘗試以 AI 修復一次。](#validatejsonarrayfile) |
|
||||
| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L137) | [確保指定路徑存在 JSON 檔案,不存在時建立空陣列檔。](#ensurejsonarrayfileexists) |
|
||||
| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L137) | [確保指定路徑存在 JSON 檔案,不存在時建立空陣列或 findings wrapper。](#ensurejsonarrayfileexists) |
|
||||
| [mapWithConcurrency](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L26) | [以可控併發數並行處理陣列項目並保序回傳結果。](#mapwithconcurrency) |
|
||||
| [extractMeaningfulError](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L102) | [從 CLI/HTTP 原始輸出中擷取最有用的錯誤訊息片段。](#extractmeaningfulerror) |
|
||||
| [chat](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L209) | [呼叫 CLIProxyAPI 送出對話請求並回傳純文字回應。](#chat) |
|
||||
@@ -207,7 +207,7 @@ await postFindingsReview(findings, { usageSection: '## 使用量\n...' });
|
||||
|
||||
### saveFindings
|
||||
|
||||
將 findings 陣列以 `JSON.stringify(findings, null, 2)` 序列化並補結尾換行後,寫入 `workspace/.gitea/ai-review/findings.json`;若提供且不同於 `workspace` 的 `mirrorDir`,會同時寫入該鏡像目錄的相同路徑。寫入前會建立必要的父目錄;本函式為同步阻塞呼叫且未做例外防護,`fs` 錯誤會直接向外拋出。
|
||||
將 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`。
|
||||
@@ -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>
|
||||
@@ -345,7 +345,7 @@ normalizeText(' 這裡有 SQL Injection!! ');
|
||||
|
||||
### loadOldFindings
|
||||
|
||||
讀取來源分支 clone 出的工作目錄下 `FINDINGS_PATH`(`.gitea/ai-review/findings.json`),每筆標記 `is_new: false`;並記錄檔案大小/修改時間等診斷日誌。檔案不存在或讀取失敗時視為空陣列,不拋例外。
|
||||
讀取來源分支 clone 出的工作目錄下 `FINDINGS_PATH`(`.gitea/ai-review/findings.json`),相容舊版頂層陣列與新版 wrapper 物件;每筆標記 `is_new: false`,並記錄檔案大小/修改時間等診斷日誌。檔案不存在或讀取失敗時視為空陣列,不拋例外。
|
||||
|
||||
- 參數:`workspace`(`string`)。
|
||||
- 回傳:`Array<object>`。
|
||||
@@ -900,7 +900,7 @@ const repaired = await repairJSONArrayWithAI('/workspace/findings.json', 'findin
|
||||
|
||||
### validateJSONArrayFile
|
||||
|
||||
驗證指定路徑是否為合法的 JSON 檔案:檔案不存在回傳 `{ exists:false }`(交由呼叫端補檔);解析成功回傳 `{ exists:true, valid:true, repaired:false }`;解析失敗則呼叫 `repairer` 修復、覆寫檔案(確保以換行結尾)並再驗證一次,通過則回傳 `repaired:true`,仍失敗則拋出例外。僅嘗試修復一次。
|
||||
驗證指定路徑是否為合法的 JSON 檔案:檔案不存在回傳 `{ exists:false }`(交由呼叫端補檔);`exclusions.json` 仍以頂層陣列為準,而 `findings.json` 則接受新版 wrapper 物件,若讀到舊版 findings 陣列會自動正規化成 wrapper。解析失敗則呼叫 `repairer` 修復、覆寫檔案(確保以換行結尾)並再驗證一次,通過則回傳 `repaired:true`,仍失敗則拋出例外。僅嘗試修復一次。
|
||||
|
||||
- 參數:`fullPath`(`string`)、`label`(`string`)、`repairer`(`Function`,預設 `repairJSONArrayWithAI`)。
|
||||
- 回傳:`Promise<{exists, valid, repaired}>`。
|
||||
@@ -917,7 +917,7 @@ const result = await validateJSONArrayFile('/workspace/.gitea/ai-review/findings
|
||||
|
||||
### ensureJSONArrayFileExists
|
||||
|
||||
確保指定路徑存在一個 JSON 檔案;不存在則建立內容為 `"[]\n"` 的空陣列檔(會先建立父目錄)。若檔案已存在則原樣保留、不檢查內容是否合法。為同步函式。
|
||||
確保指定路徑存在一個 JSON 檔案;`exclusions.json` 不存在時建立內容為 `"[]\n"` 的空陣列檔,而 `findings.json` 不存在時建立空的新版 wrapper 物件(會先建立父目錄)。若檔案已存在則原樣保留、不檢查內容是否合法。為同步函式。
|
||||
|
||||
- 參數:`fullPath`(`string`)、`label`(`string`)。
|
||||
- 回傳:`boolean`(是否為本次新建)。
|
||||
@@ -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}>`。
|
||||
|
||||
+19
-38
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { postComment, postPullReviewComment, postPullReview } from './gitea.js';
|
||||
import { FINDINGS_PATH } from './config.js';
|
||||
import { buildFindingsWrapper } from './json.js';
|
||||
import { ok, line, warn } from './log.js';
|
||||
|
||||
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
||||
@@ -35,7 +36,8 @@ function findingRow(f) {
|
||||
* 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。
|
||||
*/
|
||||
function buildTable(findings) {
|
||||
const rows = findings.map(findingRow).join('\n');
|
||||
const list = Array.isArray(findings) ? findings : [];
|
||||
const rows = list.map(findingRow).join('\n');
|
||||
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ export function parseLocation(location) {
|
||||
* `location` 能被解析出具體行號時。
|
||||
*/
|
||||
function inlineCommentBody(f) {
|
||||
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
||||
return `**等級**:${levelText(f)}\n**審查員**:${f?.role || 'AI Review'}\n**建議**:${f?.suggestion || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,9 +125,9 @@ function problemText(f) {
|
||||
function reviewCommentBody(f) {
|
||||
return [
|
||||
`**嚴重等級**:${levelText(f)}`,
|
||||
`**審查員**:${f.role}`,
|
||||
`**審查員**:${f?.role || 'AI Review'}`,
|
||||
`**問題**:${problemText(f)}`,
|
||||
`**建議**:${f.suggestion}`,
|
||||
`**建議**:${f?.suggestion || ''}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -235,28 +237,11 @@ function toReviewComment(f) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」,並提供多層降級機制。
|
||||
* 發布單一 Gitea review,必要時降級成 summary 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 統計區塊;空字串時不附加。
|
||||
* @param {Array<object>} findings 審查 findings。
|
||||
* @param {object} [deps={}] 可注入的相依物件。
|
||||
* @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 {
|
||||
@@ -294,11 +279,11 @@ export async function postFindingsReview(findings, deps = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 findings 寫入 `findings.json`(同步阻塞 I/O)。
|
||||
* 將 findings 寫入新版 wrapper 格式的 `findings.json`(同步阻塞 I/O)。
|
||||
*
|
||||
* @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。
|
||||
* @param {Array<object>} findings 要寫入的 findings 陣列;會以 `JSON.stringify(findings, null, 2)` 序列化,
|
||||
* 並在檔尾補一個換行字元。
|
||||
* @param {Array<object>} findings 要寫入的 findings 陣列;會包成包含 `generatedAt`/`commitSha`/
|
||||
* `prNumber`/`tool`/`findings`/`excluded` 的 wrapper,再以 2 空白縮排 JSON 序列化並補換行。
|
||||
* @param {?string} [mirrorDir=null] 額外鏡射輸出目錄(例如供後續 repo commit 使用);
|
||||
* 為 `null`/`undefined`,或與 `workspace` 相同時,只會寫入一份(不重複寫入同一路徑)。
|
||||
* @returns {void} 無回傳值;成功時每個目標各記錄一行 log。
|
||||
@@ -309,14 +294,15 @@ export async function postFindingsReview(findings, deps = {}) {
|
||||
* 若同時需要寫回 workspace 與 repo 兩個位置,傳入 `mirrorDir` 即可一次呼叫完成兩份寫入。
|
||||
*/
|
||||
export function saveFindings(workspace, findings, mirrorDir = null) {
|
||||
const wrapper = buildFindingsWrapper(findings, []);
|
||||
const targets = [workspace];
|
||||
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
|
||||
|
||||
for (const targetDir of targets) {
|
||||
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
|
||||
ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`);
|
||||
fs.writeFileSync(fullPath, JSON.stringify(wrapper, null, 2) + '\n', 'utf8');
|
||||
ok(`findings 寫入: ${fullPath} (${wrapper.findings.length} 筆)`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,12 +310,9 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
|
||||
* 發布所有舊問題的彙總 comment(一次性發布一則一般 comment,不含行內標註)。
|
||||
*
|
||||
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||
* 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題
|
||||
* (注意:此判定與 {@link newFindingsOnly} 的 `is_new !== false` 不同,`undefined` 在此處被視為
|
||||
* 「舊」而非「新」,是否為預期設計需人工確認)。
|
||||
* 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題。
|
||||
* @returns {Promise<void>} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||||
* @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出(與 {@link postFindingsReview}
|
||||
* 內部先用 `bySeverity` 排序的行為不同,請勿假設本函式輸出已排序)。
|
||||
* @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出。
|
||||
* 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。
|
||||
*/
|
||||
export async function postOldFindingsComment(findings) {
|
||||
@@ -348,8 +331,7 @@ export async function postOldFindingsComment(findings) {
|
||||
*
|
||||
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||
* 以 `f.is_new && f.level !== 'critical'` 篩選——`is_new` 須為 truthy(例如 `true`)才算新問題,
|
||||
* `undefined`/`false` 皆會被排除(注意:此判定比 {@link newFindingsOnly} 的
|
||||
* `is_new !== false` 更嚴格,兩者對 `undefined` 的處理方向相反,是否為預期設計需人工確認)。
|
||||
* `undefined`/`false` 皆會被排除。
|
||||
* `level !== 'critical'` 涵蓋 `warning`、`info` 及任何非 `'critical'` 的其他值(含未知等級字串)。
|
||||
* @returns {Promise<void>} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||||
* @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。
|
||||
@@ -372,8 +354,7 @@ export async function postNewNonCriticalComment(findings) {
|
||||
* (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 comment。
|
||||
*
|
||||
* @param {Array<{ is_new?: boolean, level?: string, location?: string, role?: string, suggestion?: string }>} findings
|
||||
* 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題
|
||||
* (與 {@link newFindingsOnly} 的寬鬆判定不同,`undefined` 會被排除,需人工確認是否為預期設計)。
|
||||
* 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題。
|
||||
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換)。
|
||||
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。
|
||||
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
|
||||
|
||||
+20
-6
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+30
-43
@@ -26,27 +26,6 @@ export async function analyzeWithRole(role, diff) {
|
||||
return valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* 讀取 JSON 陣列檔案;檔案不存在、讀取失敗或內容非陣列時,皆視為空並回傳 []。
|
||||
*
|
||||
* @param {string} fullPath - 欲讀取的 JSON 檔案完整路徑。
|
||||
* @param {string} label - 用於警告訊息中識別此次讀取對象的標籤文字(例如「舊 findings 」)。
|
||||
* @returns {Array<object>} 解析出的陣列;任何失敗情況皆回傳空陣列 []。
|
||||
*/
|
||||
function readJSONArray(fullPath, label) {
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
warn(`${label}檔案不存在,視為空`);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch (e) {
|
||||
warn(`讀取${label}失敗: ${e.message},視為空`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
|
||||
*
|
||||
@@ -305,7 +284,8 @@ function buildExclusionContext(exclusions) {
|
||||
|
||||
/**
|
||||
* 讀取舊 findings(來源分支 cloned repoDir 下 FINDINGS_PATH 指向的檔案),
|
||||
* 每筆項目一律標記 is_new: false(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。
|
||||
* 同時相容舊版頂層陣列與新版 wrapper 物件;每筆項目一律標記 is_new: false
|
||||
*(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。
|
||||
* 檔案不存在或讀取失敗時視為空陣列,不拋例外。
|
||||
*
|
||||
* @param {string} workspace - 來源分支 clone 出的工作目錄根路徑,FINDINGS_PATH 會相對此路徑解析。
|
||||
@@ -313,11 +293,20 @@ function buildExclusionContext(exclusions) {
|
||||
*/
|
||||
export function loadOldFindings(workspace) {
|
||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
|
||||
let old = [];
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const stat = fs.statSync(fullPath);
|
||||
line(`讀取舊 findings 檔案: ${fullPath}`);
|
||||
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
const sourceFormat = Array.isArray(data) ? 'array' : (data && Array.isArray(data.findings) ? 'wrapper' : 'unknown');
|
||||
const rawFindings = Array.isArray(data) ? data : (data && Array.isArray(data.findings) ? data.findings : []);
|
||||
old = rawFindings.map(f => ({ ...f, is_new: false }));
|
||||
line(`讀取舊 findings 檔案: ${fullPath}`);
|
||||
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} source=${sourceFormat} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||
} catch (e) {
|
||||
warn(`讀取舊 findings 失敗: ${e.message},視為空: ${fullPath}`);
|
||||
old = [];
|
||||
}
|
||||
} else {
|
||||
warn(`舊 findings 檔案不存在: ${fullPath}`);
|
||||
}
|
||||
@@ -326,7 +315,7 @@ export function loadOldFindings(workspace) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 合併新舊 findings:以 (role + location + suggestion 前 50 字) 組成的字串為 key,
|
||||
* 合併新舊 findings:以 (role + location + problem + suggestion 前 50 字) 組成的字串為 key,
|
||||
* 過濾掉 newFindings 中與 oldFindings(或 newFindings 自身先出現的項目)key 相同的重複項。
|
||||
* oldFindings 本身不會互相去重(視為既有基準),回傳陣列為 [...oldFindings, ...去重後的 newFindings]。
|
||||
*
|
||||
@@ -335,7 +324,7 @@ export function loadOldFindings(workspace) {
|
||||
* @returns {Array<object>} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。
|
||||
*/
|
||||
export function mergeFindings(oldFindings, newFindings) {
|
||||
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
|
||||
const key = f => `${f.role}|${f.location}|${String(f.problem || '')}|${String(f.suggestion || '').slice(0, 50)}`;
|
||||
const seen = new Set(oldFindings.map(key));
|
||||
const deduped = newFindings.filter(f => {
|
||||
if (seen.has(key(f))) return false;
|
||||
@@ -348,15 +337,17 @@ export function mergeFindings(oldFindings, newFindings) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 依等級排序(critical > warning > info),回傳新陣列,不修改傳入的 findings。
|
||||
* 依等級排序(critical > warning > info,未知等級排最後),回傳新陣列,不修改傳入的 findings。
|
||||
*
|
||||
* @param {Array<object>} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。
|
||||
* @returns {Array<object>} 依 critical/warning/info 順序排序後的新陣列。
|
||||
* @remarks level 不在 ['critical','warning','info'] 中的項目,因 indexOf 回傳 -1,
|
||||
* 會被排到 critical 之前(最前面)而非最後面;此邊界行為是否為預期設計,需人工確認。
|
||||
* @returns {Array<object>} 依 critical/warning/info 順序排序後的新陣列;未知等級會排在最後。
|
||||
*/
|
||||
export function sortByLevel(findings) {
|
||||
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
|
||||
const rank = (level) => {
|
||||
const index = LEVELS.indexOf(level);
|
||||
return index === -1 ? LEVELS.length : index;
|
||||
};
|
||||
return [...findings].sort((a, b) => rank(a.level) - rank(b.level));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,16 +402,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;
|
||||
|
||||
+108
-6
@@ -1,9 +1,85 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { chat } from './llm.js';
|
||||
import { FINDINGS_PATH, PR_HEAD_SHA, PR_NUMBER, getLLMConfig } from './config.js';
|
||||
import { ok, warn, error } from './log.js';
|
||||
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const PACKAGE_VERSION = (() => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
function formatTaipeiTimestamp(date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const map = Object.fromEntries(parts.filter(p => p.type !== 'literal').map(p => [p.type, p.value]));
|
||||
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
|
||||
}
|
||||
|
||||
function parsePrNumber(raw) {
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isFindingsWrapperLabel(label) {
|
||||
return String(label || '') === FINDINGS_PATH || String(label || '').endsWith('/findings.json') || String(label || '').endsWith('findings.json');
|
||||
}
|
||||
|
||||
function defaultToolInfo() {
|
||||
const { model } = getLLMConfig();
|
||||
return {
|
||||
name: 'ai-code-review',
|
||||
version: PACKAGE_VERSION,
|
||||
model: model || 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFindingsWrapper(findings, excluded = [], overrides = {}) {
|
||||
return {
|
||||
generatedAt: overrides.generatedAt || formatTaipeiTimestamp(),
|
||||
commitSha: overrides.commitSha || PR_HEAD_SHA || '',
|
||||
prNumber: overrides.prNumber !== undefined ? overrides.prNumber : parsePrNumber(PR_NUMBER),
|
||||
tool: overrides.tool || defaultToolInfo(),
|
||||
findings: Array.isArray(findings) ? findings : [],
|
||||
excluded: Array.isArray(excluded) ? excluded : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFindingsWrapper(data) {
|
||||
if (Array.isArray(data)) return buildFindingsWrapper(data, []);
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
if (!Array.isArray(data.findings)) return null;
|
||||
return {
|
||||
generatedAt: typeof data.generatedAt === 'string' && data.generatedAt.trim() ? data.generatedAt : formatTaipeiTimestamp(),
|
||||
commitSha: typeof data.commitSha === 'string' ? data.commitSha : (PR_HEAD_SHA || ''),
|
||||
prNumber: data.prNumber !== undefined ? parsePrNumber(data.prNumber) : parsePrNumber(PR_NUMBER),
|
||||
tool: data.tool && typeof data.tool === 'object'
|
||||
? {
|
||||
name: typeof data.tool.name === 'string' && data.tool.name.trim() ? data.tool.name : 'ai-code-review',
|
||||
version: typeof data.tool.version === 'string' && data.tool.version.trim() ? data.tool.version : PACKAGE_VERSION,
|
||||
model: typeof data.tool.model === 'string' && data.tool.model.trim() ? data.tool.model : 'auto',
|
||||
}
|
||||
: defaultToolInfo(),
|
||||
findings: data.findings,
|
||||
excluded: Array.isArray(data.excluded) ? data.excluded : [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeJSON(fullPath, data) {
|
||||
fs.writeFileSync(fullPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),
|
||||
@@ -92,6 +168,7 @@ function readJSONText(fullPath, label) {
|
||||
*/
|
||||
export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
const expectsFindingsWrapper = isFindingsWrapperLabel(label);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
warn(`${label} 不存在,將於驗證後補建`);
|
||||
@@ -99,7 +176,23 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(readJSONText(fullPath, label));
|
||||
const parsed = JSON.parse(readJSONText(fullPath, label));
|
||||
if (expectsFindingsWrapper) {
|
||||
const normalized = normalizeFindingsWrapper(parsed);
|
||||
if (!normalized) {
|
||||
throw new Error(`${label} 不是 findings wrapper`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
ok(`${label} JSON 格式正確`);
|
||||
return { exists: true, valid: true, repaired: false };
|
||||
}
|
||||
writeJSON(fullPath, normalized);
|
||||
ok(`${label} 已正規化為 findings wrapper`);
|
||||
return { exists: true, valid: true, repaired: true };
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${label} 不是 JSON 陣列`);
|
||||
}
|
||||
ok(`${label} JSON 格式正確`);
|
||||
return { exists: true, valid: true, repaired: false };
|
||||
} catch (e) {
|
||||
@@ -110,10 +203,18 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
||||
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
|
||||
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
|
||||
const parsed = JSON.parse(normalized);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
|
||||
if (expectsFindingsWrapper) {
|
||||
const wrapper = normalizeFindingsWrapper(parsed);
|
||||
if (!wrapper) {
|
||||
throw new Error(`${label} 修復後內容不是 findings wrapper`);
|
||||
}
|
||||
writeJSON(fullPath, wrapper);
|
||||
} else {
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
|
||||
}
|
||||
fs.writeFileSync(fullPath, normalized, 'utf8');
|
||||
}
|
||||
fs.writeFileSync(fullPath, normalized, 'utf8');
|
||||
ok(`${label} 已由 AI 修正並通過再次驗證`);
|
||||
return { exists: true, valid: true, repaired: true };
|
||||
} catch (repairErr) {
|
||||
@@ -138,7 +239,8 @@ export function ensureJSONArrayFileExists(fullPath, label) {
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
if (fs.existsSync(fullPath)) return false;
|
||||
|
||||
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
||||
warn(`${label} 不存在,已建立空陣列`);
|
||||
const content = isFindingsWrapperLabel(label) ? buildFindingsWrapper([], []) : [];
|
||||
writeJSON(fullPath, content);
|
||||
warn(`${label} 不存在,已建立空${isFindingsWrapperLabel(label) ? ' findings wrapper' : '陣列'}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
+12
-26
@@ -31,24 +31,9 @@ export async function mapWithConcurrency(items, limit, fn) {
|
||||
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
|
||||
let cursor = 0;
|
||||
/**
|
||||
* mapWithConcurrency 的工作者(worker)迴圈:從共用游標 `cursor` 依序搶下一個尚未
|
||||
* 處理的索引,呼叫外層傳入的 `fn`,並把結果寫入外層 `results` 陣列對應位置;直到
|
||||
* `cursor` 到達 `list.length` 為止。
|
||||
* 內部 worker:從共用游標依序搶下一個索引,呼叫 `fn` 後把結果寫回對應位置。
|
||||
*
|
||||
* 多個 `run()` 會被同時啟動(依 `workers` 數量),透過共用的 `cursor` 變數達到
|
||||
* 「限制併發數、動態搶下一筆」的效果——先完成者會先搶到下一個索引,因此各次 `fn`
|
||||
* 呼叫的完成順序不保證,但因寫入位置以原始索引 `i` 為準,`results` 仍能保持與
|
||||
* `items` 相同順序。
|
||||
*
|
||||
* 本函式為 `mapWithConcurrency` 內部使用的閉包(closure),依賴外層作用域的
|
||||
* `list`、`results`、`fn`、`cursor` 變數運作;不接受參數,也不可、不應在外部
|
||||
* 單獨呼叫或匯出。
|
||||
*
|
||||
* @returns {Promise<void>} 無回傳值;副作用為寫入外層 `results` 陣列與推進 `cursor`。
|
||||
* @throws 若某次 `fn(list[i], i)` reject,本函式會原樣向外拋出該錯誤(不吞例外),
|
||||
* 使 `mapWithConcurrency` 的 `Promise.all` 立即 reject;但其他已啟動、尚在執行
|
||||
* 中的 `run()` 實例不會被取消,仍會在背景繼續搬移 `cursor` 並寫入 `results`,
|
||||
* 只是其結果最終會被捨棄。
|
||||
* @returns {Promise<void>} 無回傳值。
|
||||
*/
|
||||
async function run() {
|
||||
while (cursor < list.length) {
|
||||
@@ -132,10 +117,10 @@ function summarizeApiError(e) {
|
||||
|| responseData?.message
|
||||
|| responseData?.error
|
||||
|| '';
|
||||
const stderr = String(e.stderr || '').trim();
|
||||
const stdout = String(e.stdout || '').trim();
|
||||
const stderr = String(e?.stderr || '').trim();
|
||||
const stdout = String(e?.stdout || '').trim();
|
||||
const status = e?.response?.status ? `HTTP ${e.response.status}` : '';
|
||||
const message = extractMeaningfulError(responseText || stderr || stdout || e.message || String(e));
|
||||
const message = extractMeaningfulError(responseText || stderr || stdout || e?.message || String(e));
|
||||
return [status, message].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
@@ -147,8 +132,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`),並非純文字;
|
||||
@@ -167,13 +152,13 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
||||
const resp = await axios.post(
|
||||
`${root}/v1/chat/completions`,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
@@ -208,10 +193,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));
|
||||
|
||||
+16
-11
@@ -1,3 +1,14 @@
|
||||
const timestampFormatter = new Intl.DateTimeFormat('zh-TW', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
/**
|
||||
* 依 `spec-time-log` 規範產生台灣時區(Asia/Taipei)的固定格式時間戳 `yyyy/MM/dd HH:mm:ss`。
|
||||
* 供本模組所有輸出函式在訊息前加上 `[時間]` 前綴使用。
|
||||
@@ -6,17 +17,11 @@
|
||||
* @returns {string} 例如 `2026/08/07 12:39:43`。
|
||||
*/
|
||||
function formatTimestamp(date = new Date()) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Taipei',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
||||
const parts = timestampFormatter.formatToParts(date);
|
||||
const map = parts.reduce((acc, part) => {
|
||||
acc[part.type] = part.value;
|
||||
return acc;
|
||||
}, {});
|
||||
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
|
||||
}
|
||||
|
||||
|
||||
+6
-8
@@ -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
@@ -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, '前置驗證通過');
|
||||
|
||||
@@ -21,10 +21,14 @@ describe('saveFindings', () => {
|
||||
|
||||
saveFindings(workspace, findings, mirrorDir);
|
||||
|
||||
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
||||
const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8');
|
||||
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
|
||||
assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n');
|
||||
const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'));
|
||||
const mirrorData = JSON.parse(fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8'));
|
||||
assert.equal(typeof workspaceData.generatedAt, 'string');
|
||||
assert.equal(typeof workspaceData.commitSha, 'string');
|
||||
assert.ok(Array.isArray(workspaceData.findings));
|
||||
assert.deepEqual(workspaceData.findings, findings);
|
||||
assert.deepEqual(workspaceData.excluded, []);
|
||||
assert.deepEqual(mirrorData, workspaceData);
|
||||
});
|
||||
|
||||
it('writes only to workspace when mirrorDir is omitted', () => {
|
||||
@@ -33,8 +37,9 @@ describe('saveFindings', () => {
|
||||
|
||||
saveFindings(workspace, findings);
|
||||
|
||||
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
||||
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
|
||||
const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'));
|
||||
assert.deepEqual(workspaceData.findings, findings);
|
||||
assert.deepEqual(workspaceData.excluded, []);
|
||||
});
|
||||
|
||||
it('does not duplicate writes when mirrorDir matches workspace', () => {
|
||||
@@ -58,13 +63,14 @@ describe('saveFindings', () => {
|
||||
assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH));
|
||||
});
|
||||
|
||||
it('writes an empty JSON array when findings is empty', () => {
|
||||
it('writes an empty findings wrapper when findings is empty', () => {
|
||||
const workspace = makeTempDir('findings-empty-');
|
||||
|
||||
saveFindings(workspace, []);
|
||||
|
||||
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
||||
assert.equal(workspaceText, '[]\n');
|
||||
const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'));
|
||||
assert.deepEqual(workspaceData.findings, []);
|
||||
assert.deepEqual(workspaceData.excluded, []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
+32
-2
@@ -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,36 @@ 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('accepts slash-delimited model names', () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'provider/gpt-5.5';
|
||||
|
||||
const cfg = getLLMConfig();
|
||||
|
||||
assert.equal(cfg.model, 'provider/gpt-5.5');
|
||||
assert.equal(cfg.modelError, null);
|
||||
});
|
||||
|
||||
it('returns null provider when CLI_PROXY_API is missing', () => {
|
||||
process.env.MODEL = 'gpt-5.5';
|
||||
const cfg = getLLMConfig();
|
||||
|
||||
@@ -348,12 +348,19 @@ describe('findings exclusions', () => {
|
||||
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
||||
});
|
||||
|
||||
it('logs findings file metadata when loading old findings', () => {
|
||||
it('loads wrapper findings and logs findings file metadata', () => {
|
||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, JSON.stringify([
|
||||
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
|
||||
], null, 2));
|
||||
fs.writeFileSync(fullPath, JSON.stringify({
|
||||
generatedAt: '2026/08/07 16:47:53',
|
||||
commitSha: 'deadbeef',
|
||||
prNumber: 7,
|
||||
tool: { name: 'ai-code-review', version: '1.0.0', model: 'auto' },
|
||||
findings: [
|
||||
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
|
||||
],
|
||||
excluded: [],
|
||||
}, null, 2));
|
||||
|
||||
const findings = loadOldFindings(workspace);
|
||||
|
||||
|
||||
+32
-21
@@ -37,6 +37,19 @@ describe('json helpers', () => {
|
||||
assert.ok(capturedUserContent.includes('"{broken"'));
|
||||
});
|
||||
|
||||
it('creates an empty findings wrapper when asked to ensure existence', () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
|
||||
const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json');
|
||||
|
||||
assert.equal(created, true);
|
||||
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
assert.equal(typeof written.generatedAt, 'string');
|
||||
assert.ok(Array.isArray(written.findings));
|
||||
assert.deepEqual(written.findings, []);
|
||||
assert.deepEqual(written.excluded, []);
|
||||
});
|
||||
|
||||
it('reports missing file without creating it', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
|
||||
@@ -46,15 +59,6 @@ describe('json helpers', () => {
|
||||
assert.equal(fs.existsSync(fullPath), false);
|
||||
});
|
||||
|
||||
it('creates an empty array file when asked to ensure existence', () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
|
||||
const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json');
|
||||
|
||||
assert.equal(created, true);
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
||||
});
|
||||
|
||||
it('returns false when ensuring an existing file', () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/exclusions.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
@@ -77,29 +81,32 @@ describe('json helpers', () => {
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
||||
});
|
||||
|
||||
it('rejects repaired JSON that is not an array', async () => {
|
||||
it('rejects repaired JSON that is not a findings wrapper', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
||||
|
||||
await assert.rejects(
|
||||
() => validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async () => '{"ok":true}'),
|
||||
/不是 JSON 陣列/,
|
||||
/不是 findings wrapper/,
|
||||
);
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '{broken');
|
||||
});
|
||||
|
||||
it('reads a valid JSON file whose size equals the maximum limit', async () => {
|
||||
it('normalizes a valid legacy findings array whose size equals the maximum limit', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, `[]${' '.repeat(MAX_JSON_BYTES - 2)}`, 'utf8');
|
||||
|
||||
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json');
|
||||
|
||||
assert.deepEqual(result, { exists: true, valid: true, repaired: false });
|
||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
assert.deepEqual(written.findings, []);
|
||||
assert.deepEqual(written.excluded, []);
|
||||
});
|
||||
|
||||
it('repairs invalid JSON using AI output and rewrites the file', async () => {
|
||||
it('repairs invalid findings JSON using AI output and rewrites the file as a wrapper', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
||||
@@ -110,7 +117,9 @@ describe('json helpers', () => {
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
||||
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
assert.deepEqual(written.findings, [{ fixed: true }]);
|
||||
assert.deepEqual(written.excluded, []);
|
||||
});
|
||||
|
||||
it('preserves a trailing newline returned by AI repair', async () => {
|
||||
@@ -124,7 +133,9 @@ describe('json helpers', () => {
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
||||
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
assert.deepEqual(written.findings, [{ fixed: true }]);
|
||||
assert.deepEqual(written.excluded, []);
|
||||
});
|
||||
|
||||
it('throws when AI repair fails', async () => {
|
||||
@@ -163,7 +174,7 @@ describe('validateJSONArrayFile repair failure paths', () => {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('overwrites the invalid file with the valid array returned by the repairer', async () => {
|
||||
it('overwrites the invalid findings file with a wrapper built from the repaired array', async () => {
|
||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, '{ this is not json', 'utf8');
|
||||
@@ -183,10 +194,10 @@ describe('validateJSONArrayFile repair failure paths', () => {
|
||||
|
||||
assert.equal(receivedOriginal, '{ this is not json');
|
||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||
// file is overwritten with the repaired content, trailing newline appended (line 110)
|
||||
const written = fs.readFileSync(fullPath, 'utf8');
|
||||
assert.equal(written, '[{"id":1},{"id":2}]\n');
|
||||
assert.deepEqual(JSON.parse(written), [{ id: 1 }, { id: 2 }]);
|
||||
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||
assert.equal(typeof written.generatedAt, 'string');
|
||||
assert.deepEqual(written.findings, [{ id: 1 }, { id: 2 }]);
|
||||
assert.deepEqual(written.excluded, []);
|
||||
});
|
||||
|
||||
it('throws when the repaired text is still invalid JSON and does NOT overwrite the original file', async () => {
|
||||
|
||||
+27
-1
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user