4 Commits
Author SHA1 Message Date
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
jiantw83 0eb30cf9d4 refresh review pipeline
CI / 1. BUILD (pull_request) Successful in 2s
CI / 2. TEST (pull_request) Failing after 19s
CI / 3. RESULT (pull_request) Skipped
2026-08-07 06:05:58 +00:00
admin e1d7f59a38 Merge pull request 'refactor: switch ai-code-review to CLIProxyAPI' (#1) from chore/cliproxyapi-proxy into develop
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Skipped
CI / 3. RESULT (pull_request) Skipped
Reviewed-on: https://gitea.jsc.idv.tw/actions/ai-code-review/pulls/1
2026-08-07 03:46:09 +00:00
jiantw83 21687fd2bd refactor: switch ai-code-review to CLIProxyAPI
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Skipped
CI / 3. RESULT (pull_request) Skipped
2026-08-06 17:42:49 +00:00
26 changed files with 2682 additions and 2138 deletions
+4 -16
View File
@@ -1,5 +1,7 @@
# 用途:CI workflow 的 command-file 草稿,保留原始流程並補上逐行說明。 # ============================================================================
# 更新時間:2026/07/11 19:00:45 # 用途:這是 pull request 時計算版本、視情況發佈 release、並在目標分支為 develop 的 beta 情境下執行 AI Code Review 的 CI workflow。
# 更新時間:2026/08/07 13:51:53
# ============================================================================
# workflow 名稱,對應 Gitea UI 中的顯示標題。 # workflow 名稱,對應 Gitea UI 中的顯示標題。
name: CI name: CI
# 定義此 workflow 的觸發事件。 # 定義此 workflow 的觸發事件。
@@ -72,26 +74,12 @@ jobs:
VERSION: ${{ needs.build.outputs.version }} VERSION: ${{ needs.build.outputs.version }}
# test job 的步驟。 # test job 的步驟。
steps: steps:
# 安裝或設定 LLM CLI。
- name: Setup LLM CLI
# 使用對應的 setup action。
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
# 傳入設定。
with:
# LLM CLI 的 OAuth 憑證。
oauth: ${{ secrets.LLM_OAUTH }}
# 執行 AI Code Review action。 # 執行 AI Code Review action。
- name: Run AI Code Review - name: Run AI Code Review
# step id,方便追蹤。 # step id,方便追蹤。
id: ai-code-review id: ai-code-review
# 使用本 repo 發佈的 action。 # 使用本 repo 發佈的 action。
uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }} uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }}
# action 參數。
with:
# 存取 Gitea API 的 token。
token: ${{ secrets.TOKEN }}
# 指定 LLM 模型名稱。
model: ${{ vars.LLM_NAME }}
# 第三個 job:輸出最終版本資訊。 # 第三個 job:輸出最終版本資訊。
result: result:
# job 顯示名稱。 # job 顯示名稱。
+4 -2
View File
@@ -1,5 +1,7 @@
# 用途:master workflow 的 command-file 草稿,保留原始流程並補上逐行說明。 # ============================================================================
# 更新時間:2026/07/11 19:00:45 # 用途:推送到 master 分支後,輸出 Gitea context、查詢 commit 對應 tag,作為部署前資訊檢查的 CD workflow
# 更新時間:2026/08/07 13:51:53
# ============================================================================
# workflow 名稱,對應 Gitea UI 中的顯示標題。 # workflow 名稱,對應 Gitea UI 中的顯示標題。
name: CD name: CD
# 定義此 workflow 的觸發事件。 # 定義此 workflow 的觸發事件。
+44 -35
View File
@@ -1,49 +1,58 @@
# GITEA NODE ACTION 工作流說明草稿 # Gitea Workflow 說明文件
更新時間:2026/07/11 18:54:51 更新時間:2026/08/07 13:51:53
## 總覽 ## 總覽
此專案目前包含兩個 workflow: 此專案(`ai-code-review`)目前包含兩個 workflow:
- `CI`:處理 pull request 期間的版本計算、釋出與 AI 程式碼審查。 | Workflow 名稱 | 檔案位置 | 觸發條件 | 大致用途 |
- `CD`:處理推送到 `master` 分支後的部署相關檢查與資訊輸出。 | --- | --- | --- | --- |
| `CI` | `.gitea/workflows/ci.yaml` | `pull_request`(`opened`、`synchronize`) | 計算版本號、必要時建立 release,並在目標分支為 `develop`(beta 情境)時執行 AI 程式碼審查(呼叫本專案自身發佈的 `ai-code-review` action) |
| `CD` | `.gitea/workflows/master.yaml` | `push` 到 `master` 分支 | 輸出完整 Gitea event context,並查詢指定 commit 對應的 tag,作為後續部署流程的基礎資訊 |
以下依 workflow 檔案逐一整理細節。
## Workflow 明細 ## Workflow 明細
### CI ### CI(`.gitea/workflows/ci.yaml`)
- 檔案位置:`.gitea/workflows/ci.yaml` - **workflow 名稱**:`CI`(Gitea UI 顯示標題)
- 用途:在 pull request 事件中計算版本,必要時建立 release,並在 beta 情境下執行 AI 程式碼審查。 - **檔案位置**:`.gitea/workflows/ci.yaml`
- 觸發條件:`pull_request`,事件類型為 `opened` 與 `synchronize`。 - **用途說明**:
- 主要輸入 / 環境參數: - `build` job:計算版本號(呼叫 `calculate-version` action),並用計算出的版本建立 Gitea release/tag。
- `gitea.base_ref`:用來判斷是否為 `develop`,進而決定 `IS_BETA`。 - `test` job:僅在 `build` job 判定為 beta 情境時執行,呼叫本專案自身發佈的 `ai-code-review` action 對 PR 進行 AI 程式碼審查。
- `vars.ACTION_CALCULATE_VERSION`:提供 `calculate-version` action 的版本。 - `result` job:等待前兩個 job 完成後,將版本號輸出到 log,作為流程結尾的確認步驟。
- `vars.ACTION_GITEA_RELEASE_VERSION`:提供 release action 的版本。 - **觸發條件**:`pull_request` 事件,且事件類型限定為 `opened` 與 `synchronize`(PR 建立與後續推送同步時觸發)。
- `secrets.LLM_OAUTH`:設定 LLM CLI 的 OAuth。 - **主要輸入 / 環境參數**:
- `secrets.TOKEN`:提供 AI 程式碼審查 action 存取 Gitea API。 - `gitea.base_ref`:用來判斷 PR 目標分支是否為 `develop`,據以設定 `IS_BETA` 環境變數。
- `vars.LLM_NAME`:指定審查使用的模型名稱。 - `vars.ACTION_CALCULATE_VERSION`:`calculate-version` action 的版本(`build` job 使用)。
- 重要注意事項: - `vars.ACTION_GITEA_RELEASE_VERSION`:`akkuman/gitea-release-action` 的版本(`build` job 使用)。
- `test` job 只會在 `IS_BETA == true` 時執行,也就是 pull request 目標分支為 `develop` 時。 - `gitea.event.repository.name`、`gitea.sha`:組成 release 名稱與 `target_commitish`。
- `Publishing Release` 會使用 `VERSION` 與 `gitea.sha` 建立 release 與 tag。 - `needs.build.outputs.version`、`needs.build.outputs.is_beta`:`test`、`result` job 透過 job 輸出取得版本號與 beta 判定結果。
- 若變數或 secret 未設定,對應步驟會失敗,需人工確認部署前置條件。 - **重要注意事項**:
- `test` job 的執行條件為 `needs.build.outputs.is_beta == 'true'`,即 PR 目標分支(`gitea.base_ref`)為 `develop` 時才會執行 AI 程式碼審查。
- `Publishing Release` 步驟會用 `VERSION` 與 `gitea.sha` 建立對應的 release 與 tag(`v${{ env.VERSION }}`),beta 情境下標記為 prerelease。
- `test` job 的「Run AI Code Review」步驟在 `ci.yaml` 中本身**未**透過 `with` 或 `env` 傳入任何額外參數;該步驟呼叫的是本專案自身發佈的 `ai-code-review@v{VERSION}` action,實際會用到哪些 `secrets.*`/`vars.*`(例如 action 內部的 token、CLI Proxy 相關設定)屬於該 action 自身的定義範圍,不在 `ci.yaml` 這個 workflow 檔案內顯式宣告,**需人工確認**該 action 版本實際所需的機密與變數是否已在目標環境設定妥當。
- 若 `vars.ACTION_CALCULATE_VERSION`、`vars.ACTION_GITEA_RELEASE_VERSION` 等變數未設定,對應步驟會失敗,需人工確認部署前置條件是否齊備。
### CD ### CD(`.gitea/workflows/master.yaml`)
- 檔案位置:`.gitea/workflows/master.yaml` - **workflow 名稱**:`CD`(Gitea UI 顯示標題)
- 用途:在 `master` 分支推送後輸出 Gitea context、檢查提交標籤,作為後續部署流程的基礎。 - **檔案位置**:`.gitea/workflows/master.yaml`
- 觸發條件:`push` 到 `master` 分支。 - **用途說明**:單一 `deploy` job,在推送到 `master` 分支後,輸出完整的 Gitea event context,並取回完整原始碼與 tag 歷史後,查詢指定 commit 對應的 tag,將結果印出。整個 workflow 目前僅做資訊輸出與查詢,未包含實際部署動作。
- 主要輸入 / 環境參數: - **觸發條件**:`push` 事件,且限定分支為 `master`。
- `gitea` 事件內容:轉成 `GITEA_CONTEXT` 後交給 `jq` 顯示。 - **主要輸入 / 環境參數**:
- `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢 commit tag。 - `gitea`(整個 context,透過 `toJSON(gitea)` 轉字串):以 `GITEA_CONTEXT` 環境變數輸出並用 `jq` 顯示。
- `vars.ACTION_CHECKOUT_VERSION`:提供 `actions/checkout` 的版本。 - `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢該 commit 對應的 tag。
- `GITEA_OUTPUT`:寫入 `git describe --contains` 的結果。 - `vars.ACTION_CHECKOUT_VERSION`:`actions/checkout` action 的版本。
- 重要注意事項: - `GITEA_OUTPUT`:`Get Commit Tag` 步驟將 `git describe --contains` 的查詢結果寫入此檔案,供 `steps.commit.outputs.tag` 讀取。
- `COMMIT_SHA` 取用 commits 陣列的第 2 筆資料,若 push 事件實際只有 1 筆 commit,需人工確認是否會發生索引風險。 - **重要注意事項**:
- `Get Commit Tag` 依賴完整的 git 歷史與 tags,因此 checkout 已設定 `fetch-depth: 0` 與 `fetch-tags: true`。 - **需人工確認**:`COMMIT_SHA` 目前固定取用 `gitea.event.commits` 陣列的第 2 筆(索引 1,即 `commits[1]`)。若一次 `push` 事件只包含 1 筆 commit,該索引將不存在,`COMMIT_SHA` 可能為空值,導致後續 `git describe --contains` 查詢失敗或行為不符預期;是否需改為取最後一筆(例如 `commits[-1]` 或依陣列長度動態取值)需人工確認並評估是否調整(本文件僅整理現況,未變更任何 workflow 實際邏輯)。
- `Show Gitea Context` 會輸出完整事件內容,若包含敏感資訊,需注意執行環境的日誌保存策略。 - `Get Commit Tag` 步驟依賴完整的 git 歷史與 tag 資訊,因此 `Source Code Checkout` 已設定 `fetch-depth: 0` 與 `fetch-tags: true`,若移除這兩個設定會導致 `git describe --contains` 查不到結果。
- `Show Gitea Context` 會將完整事件內容輸出到 log,若事件內容包含敏感資訊,需注意執行環境的日誌保存與存取權限策略。
## 備註 ## 備註
- 本檔為草稿版本,僅整理 workflow 行為與設定重點,不修改任何 workflow 實際邏輯。 - 本文件僅整理 `ci.yaml`、`master.yaml` 兩個 workflow 檔案目前的行為與參數重點,內容依實際檔案內容彙整,未新增或臆測未在檔案中出現的流程與參數;標註「需人工確認」之處為既有設計中需要人工再次確認的風險點,非文件本身待補內容。
- 若後續要覆蓋正式檔,請先確認 `ci.yaml` 與 `master.yaml` 的變數與 secret 已在目標環境中正確配置。 - 若後續要以本文件覆蓋既有說明文件,請先確認 `ci.yaml` 與 `master.yaml` 所引用的 `vars.*`、`secrets.*` 已在目標環境(Gitea repo/organization 設定)中正確配置。
-10
View File
@@ -1,10 +0,0 @@
FROM node:24-alpine
RUN apk add --no-cache git ca-certificates
COPY src/ /action/src/
COPY entrypoint.sh /action/entrypoint.sh
RUN chmod +x /action/entrypoint.sh
ENTRYPOINT ["/action/entrypoint.sh"]
-1467
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -8,11 +8,15 @@ inputs:
comment_token: comment_token:
description: '操作 Gitea Commit API 的 Token' description: '操作 Gitea Commit API 的 Token'
required: false required: false
model:
description: '使用的 AI 模型'
required: false
runs: runs:
using: 'docker' using: 'docker'
image: 'Dockerfile' image: 'dockerfile'
env: env:
GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }} GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }}
GITEA_COMMENT_TOKEN: ${{ inputs.comment_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: ${{ 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 }}
+23
View File
@@ -0,0 +1,23 @@
# ============================================================================
# 用途:Docker 容器 action 的建置檔,以 Node 24 Alpine 為基底安裝 git 與
# ca-certificates,並將 action 原始碼與進入點腳本複製進容器、設定執行進入點。
# 更新時間:2026/08/07 13:51:53
# ============================================================================
# 使用 Node 24 的 Alpine 精簡映像檔作為基底,提供 node 執行環境並縮小最終映像檔體積
FROM node:24-alpine
# 安裝 git 與 ca-certificates:action 執行期需要 clone/操作 git 倉庫,且透過 HTTPS 呼叫外部 API 時需要憑證驗證;--no-cache 可避免留下 apk 索引快取、進一步縮小映像檔
RUN apk add --no-cache git ca-certificates
# 複製 action 主程式原始碼到容器內的 /action/src/,供 entrypoint 執行時呼叫
COPY src/ /action/src/
# 複製容器啟動時要執行的進入點腳本到 /action/entrypoint.sh
COPY entrypoint.sh /action/entrypoint.sh
# 賦予 entrypoint.sh 執行權限,確保容器啟動時能直接執行該腳本
RUN chmod +x /action/entrypoint.sh
# 設定容器的進入點為 entrypoint.sh,容器啟動時會執行此腳本作為 action 的入口
ENTRYPOINT ["/action/entrypoint.sh"]
+7
View File
@@ -1,4 +1,11 @@
#!/bin/sh #!/bin/sh
# ============================================================================
# 用途:Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
# 更新時間:2026/08/07 13:51:53
# ============================================================================
# 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行
set -e set -e
# 以 exec 取代目前 shell 程序執行 Node 主程式,並將容器收到的所有參數("$@")原樣轉傳給它
exec node /action/src/main.js "$@" exec node /action/src/main.js "$@"
+1703
View File
File diff suppressed because it is too large Load Diff
+98 -15
View File
@@ -28,8 +28,11 @@ function findingRow(f) {
* 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。 * 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。
* *
* @param {Array<object>} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。 * @param {Array<object>} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。
* 注意:本參數必須是陣列,傳入 null/undefined 會在 `.map` 呼叫時拋出 TypeError(未防呆,需人工確認是否要補強)。
* @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。 * @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。
* @remarks 內部輔助函式,供發布舊問題、新問題(非嚴重)、單筆嚴重問題等 comment 內文使用。 * @remarks 內部輔助函式,供 {@link postOldFindingsComment}、{@link postNewNonCriticalComment}、
* {@link postNewCriticalComments} 組裝 comment 內文使用。
* 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。
*/ */
function buildTable(findings) { function buildTable(findings) {
const rows = findings.map(findingRow).join('\n'); const rows = findings.map(findingRow).join('\n');
@@ -60,8 +63,16 @@ const bySeverity = (a, b) => {
}; };
/** /**
* 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。 * 解析 finding 的 `location` 欄位,取出檔案路徑與(起始)行號,供行內 comment 標註使用。
* 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。 *
* @param {string} location finding 的位置字串。支援 `"file:19"`(單行)與 `"file:70-82"`(範圍,僅取起始行 19/70);
* 若包含逗號(代表對應多個檔案)、非字串、或無法比對出行號,一律視為無法定位。
* @returns {{ file: string, line: number } | null}
* 可解析時回傳 `{ file, line }`(line 為正整數起始行);`location` 非字串、含逗號、格式不符、
* 或行號非正整數時回傳 `null`。
* @remarks 供 {@link toReviewComment} 與 {@link postNewCriticalComments} 判斷 finding 是否能標註到
* diff 中的具體檔案行號;回傳 `null` 時呼叫端會降級為一般(非行內)comment。
* 使用情境:任何要把 finding 轉成 Gitea 行內 review comment 前,都應先呼叫本函式確認可定位。
*/ */
export function parseLocation(location) { export function parseLocation(location) {
if (typeof location !== 'string') return null; if (typeof location !== 'string') return null;
@@ -73,7 +84,18 @@ export function parseLocation(location) {
return line > 0 ? { file: match[1], line } : null; return line > 0 ? { file: match[1], line } : null;
} }
/** 行內 comment 內容:等級/審查員/建議 */ /**
* 產生單一 finding 的行內(inline)review comment 內文:等級/審查員/建議三行。
*
* @param {{ level?: string, role?: string, suggestion?: string }} f 單筆審查問題物件。
* `role`、`suggestion` 未定義時會直接輸出 `undefined` 字樣(未做防呆轉換)。
* @returns {string} 三行 Markdown 字串,以 `\n` 連接。
* @remarks 內部輔助函式,僅供 {@link postNewCriticalComments} 在成功解析出行號({@link parseLocation}
* 回傳非 null)時,組裝要標註到具體檔案行號的行內 comment 使用;相較 {@link reviewCommentBody}
* 少了「問題」一行,因為行內位置本身已能定位問題所在。
* 使用情境:僅用於新(`is_new` 為 truthy)且等級為 `critical` 的 finding,且該 finding 的
* `location` 能被解析出具體行號時。
*/
function inlineCommentBody(f) { function inlineCommentBody(f) {
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`; return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
} }
@@ -213,10 +235,28 @@ function toReviewComment(f) {
} }
/** /**
* 發布單一 Gitea review: * 發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」,並提供多層降級機制。
* - summaryFindings 只用來統計本文數字(含新舊問題) *
* - commentFindings 用來產生 review comments,並依嚴重等級排序; * @param {Array<object>} findings 本次審查的完整 findings 陣列;當 `deps.summaryFindings` 或
* 只為新問題加上行內標註,舊問題(is_new === false)僅計入統計、不再重複標註檔案與行數 * `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 統計區塊;空字串時不附加。
* @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 = {}) { export async function postFindingsReview(findings, deps = {}) {
const { const {
@@ -254,8 +294,19 @@ export async function postFindingsReview(findings, deps = {}) {
} }
/** /**
* 寫入 findings.json。 * 將 findings 寫入 `findings.json`(同步阻塞 I/O)。
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。 *
* @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。
* @param {Array<object>} findings 要寫入的 findings 陣列;會以 `JSON.stringify(findings, null, 2)` 序列化,
* 並在檔尾補一個換行字元。
* @param {?string} [mirrorDir=null] 額外鏡射輸出目錄(例如供後續 repo commit 使用);
* 為 `null`/`undefined`,或與 `workspace` 相同時,只會寫入一份(不重複寫入同一路徑)。
* @returns {void} 無回傳值;成功時每個目標各記錄一行 log。
* @remarks 每個目標寫入前皆會以 `fs.mkdirSync(..., { recursive: true })` 建立必要的父目錄。
* 本函式為同步阻塞呼叫,且**未做例外防護**——`fs.mkdirSync`/`fs.writeFileSync` 拋出的例外
* (例如權限不足、磁碟已滿)會直接向呼叫端傳播,需人工確認呼叫端是否需要額外 try/catch。
* 使用情境:每輪 AI Code Review 完成、findings 已定案後呼叫一次,將結果落地成 JSON 檔;
* 若同時需要寫回 workspace 與 repo 兩個位置,傳入 `mirrorDir` 即可一次呼叫完成兩份寫入。
*/ */
export function saveFindings(workspace, findings, mirrorDir = null) { export function saveFindings(workspace, findings, mirrorDir = null) {
const targets = [workspace]; const targets = [workspace];
@@ -270,7 +321,16 @@ export function saveFindings(workspace, findings, mirrorDir = null) {
} }
/** /**
* 發布所有舊問題 comment(一次發布,依等級排序) * 發布所有舊問題的彙總 comment(一次性發布一則一般 comment,不含行內標註)。
*
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
* 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題
* (注意:此判定與 {@link newFindingsOnly} 的 `is_new !== false` 不同,`undefined` 在此處被視為
* 「舊」而非「新」,是否為預期設計需人工確認)。
* @returns {Promise<void>} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。
* @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出(與 {@link postFindingsReview}
* 內部先用 `bySeverity` 排序的行為不同,請勿假設本函式輸出已排序)。
* 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。
*/ */
export async function postOldFindingsComment(findings) { export async function postOldFindingsComment(findings) {
const old = findings.filter(f => !f.is_new); const old = findings.filter(f => !f.is_new);
@@ -284,7 +344,17 @@ export async function postOldFindingsComment(findings) {
} }
/** /**
* 發布新問題中非 critical 的 comment(一次發布) * 發布新問題中非 critical 等級者的彙總 comment(一次性發布一則一般 comment)。
*
* @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` 的處理方向相反,是否為預期設計需人工確認)。
* `level !== 'critical'` 涵蓋 `warning`、`info` 及任何非 `'critical'` 的其他值(含未知等級字串)。
* @returns {Promise<void>} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。
* @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。
* 使用情境:每輪 AI Code Review 中,把「明確標記為新(`is_new === true`)且非嚴重」的問題
* 統一彙總成一則 comment 通知,嚴重問題另由 {@link postNewCriticalComments} 逐筆單獨發布。
*/ */
export async function postNewNonCriticalComment(findings) { export async function postNewNonCriticalComment(findings) {
const items = findings.filter(f => f.is_new && f.level !== 'critical'); const items = findings.filter(f => f.is_new && f.level !== 'critical');
@@ -298,9 +368,22 @@ export async function postNewNonCriticalComment(findings) {
} }
/** /**
* 每個新 critical 問題各發一個 comment。 * 針對每個新的 critical 問題各發一個 comment;優先用 Gitea 行內 review comment 標註問題檔案與行數
* 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議); * (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 comment。
* 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 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` 會被排除,需人工確認是否為預期設計)。
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換)。
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
* @returns {Promise<void>} 無回傳值;`criticals.length === 0` 時直接 return。
* @remarks 每筆 critical finding 依序處理:`location` 能解析出行號且 `postInline` 成功時只發行內
* comment;否則(無法解析,或 `postInline` 失敗且已被捕捉記錄 warn)改用 `postIssue` 發一般 comment。
* **注意**:`postIssue` 呼叫未包在 try/catch 中,若其拋出例外會中斷整個迴圈,導致排在後面的
* critical findings 不會被處理,此為需人工確認的行為,是否要補強視情況而定。
* 使用情境:每輪 AI Code Review 中,把「明確標記為新且等級為 critical」的問題逐筆單獨標註到
* PR 對應的檔案行號,讓審查者能直接在 diff 上看到問題。
*/ */
export async function postNewCriticalComments(findings, deps = {}) { export async function postNewCriticalComments(findings, deps = {}) {
const { postInline = postPullReviewComment, postIssue = postComment } = deps; const { postInline = postPullReviewComment, postIssue = postComment } = deps;
+30 -83
View File
@@ -1,6 +1,5 @@
import https from 'https'; import https from 'https';
import fs from 'fs'; import fs from 'fs';
import { execFileSync } from 'child_process';
// 本 action 會連接自架 Gitea / OpenCode,部署環境可能使用內部 CA 或自簽憑證。 // 本 action 會連接自架 Gitea / OpenCode,部署環境可能使用內部 CA 或自簽憑證。
// 對外部服務請優先使用預設 TLS 驗證;需要內部服務相容時才使用 getInsecureHttpsAgent()。 // 對外部服務請優先使用預設 TLS 驗證;需要內部服務相容時才使用 getInsecureHttpsAgent()。
@@ -37,26 +36,24 @@ export const PR_NUMBER = process.env.PR_NUMBER || (PR.number != null ? String(PR
export const PR_HEAD_SHA = process.env.PR_HEAD_SHA || PR.head?.sha || process.env.GITHUB_SHA || ''; export const PR_HEAD_SHA = process.env.PR_HEAD_SHA || PR.head?.sha || process.env.GITHUB_SHA || '';
export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || PR.head?.ref || process.env.GITHUB_HEAD_REF || ''; export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || PR.head?.ref || process.env.GITHUB_HEAD_REF || '';
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || PR.base?.ref || process.env.GITHUB_BASE_REF || ''; export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || PR.base?.ref || process.env.GITHUB_BASE_REF || '';
export const CLI_PROXY_API = process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '';
export const CLI_PROXY_API_KEY = process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '';
export const LLM_PROVIDER = 'cliproxyapi';
export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
let _insecureHttpsAgent = null;
/** /**
* 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent, * 取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供連接使用自簽或無效憑證的內部服務時使用
* 供連接使用自簽或無效憑證的內部服務時使用。 * (例如自架 Gitea、CLIProxyAPI)。
* *
* @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用, * @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用,
* 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。 * 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。
* 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。 * 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用;
* 若需要完整 TLS 安全性,應改用預設 `https.Agent`,不要調用這個函式。
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。 * @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
*/ */
let _insecureHttpsAgent = null;
/**
* 取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。
*
* @remarks 只應在信任的內網或測試環境使用;若需要完整 TLS 安全性,應改用預設
* `https.Agent`,不要調用這個函式。
*/
export function getInsecureHttpsAgent() { export function getInsecureHttpsAgent() {
return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false })); return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
} }
@@ -64,82 +61,32 @@ export function getInsecureHttpsAgent() {
// 過渡別名:既有呼叫端仍可用 OpenCode 語意名稱;新程式碼請直接使用 getInsecureHttpsAgent。 // 過渡別名:既有呼叫端仍可用 OpenCode 語意名稱;新程式碼請直接使用 getInsecureHttpsAgent。
export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
const CLI_CANDIDATES = [
{
provider: 'codex',
command: 'codex',
defaultModel: 'gpt-5.4-mini',
},
{
provider: 'claude',
command: 'claude',
defaultModel: 'sonnet',
},
{
provider: 'antigravity',
command: 'agy',
defaultModel: 'gemini-2.5-flash',
},
{
provider: 'antigravity',
command: 'antigravity',
defaultModel: 'gemini-2.5-flash',
},
{
provider: 'opencode',
command: 'opencode',
defaultModel: 'google/gemini-2.5-flash',
},
];
/** /**
* 取得目前支援的 LLM CLI 指令名稱清單。 * 依環境變數解析並回傳 CLIProxyAPI 設定。
* *
* @remarks 內容直接取自 `CLI_CANDIDATES`,若日後候選清單增減,輸出會同步變動。 * 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線),
* `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,
* 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供,
* 則交由 CLIProxyAPI 自動選擇模型,`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 }}
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
*/ */
export function getLLMCLICommands() { export function getLLMConfig() {
return CLI_CANDIDATES.map(c => c.command); const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
} const model = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
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 };
* 檢查指定 CLI 指令是否可在目前環境中執行。
*
* @param {string} command - 要檢查的指令名稱。
* @returns {boolean} 找得到指令時回傳 `true`,否則回傳 `false`。
* @remarks 透過 `/bin/sh -lc "command -v <command>"` 檢查,屬於同步存在性檢查。
*/
function commandExists(command) {
try {
execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* 依環境變數解析並回傳 LLM 提供者設定。
*
* 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、antigravity、opencode。
* model 依序取 `with: model`(`INPUT_MODEL`)、`MODEL`、相容舊的 `OPENCODE_MODEL`,最後用各 CLI 預設值。
*
* @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 CLI 偵測函式,供測試使用。
* @returns {{ provider: ('codex'|'claude'|'antigravity'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }}
* LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。
*/
export function getLLMConfig({ commandExistsFn = commandExists } = {}) {
const requested = process.env.AI_ASSISTANT_CLI;
const candidates = requested
? CLI_CANDIDATES.filter(c => c.provider === requested || c.command === requested)
: CLI_CANDIDATES;
const cli = candidates.find(c => commandExistsFn(c.command));
if (!cli) return { provider: null, apiKeys: [], baseURL: null, model: null, command: null };
return { return {
provider: cli.provider, provider: LLM_PROVIDER,
apiKeys: [cli.provider], apiKeys: apiKey ? [apiKey] : [],
baseURL: null, baseURL,
model: process.env.INPUT_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || cli.defaultModel, model: model || null,
command: cli.command, command: null,
}; };
} }
+125 -31
View File
@@ -8,8 +8,14 @@ import { line, ok, warn } from './log.js';
const LEVELS = ['critical', 'warning', 'info']; const LEVELS = ['critical', 'warning', 'info'];
/** /**
* 用單一角色分析 diff,回傳 findings 陣列。 * 用單一角色分析 diff,呼叫 LLM 取得該角色視角下的 code review 問題並回傳 findings 陣列。
* role 欄位一律以角色定義的 name 為準,避免 LLM 自行填入不一致的名稱。 * role 欄位一律以角色定義的 name 為準(覆寫 LLM 回傳值),避免 LLM 自行填入不一致的角色名稱。
*
* @param {{name: string}} role - 審查角色定義物件,至少需含 name。
* @param {string} diff - 欲分析的 unified diff 文字內容。
* @returns {Promise<Array<object>>} 有效 findings 陣列(僅保留同時具備 level/location/suggestion 者),
* 每筆皆補上 role(角色名稱)與 is_new: true。
* @throws 當 chatJSON 呼叫失敗(LLM 錯誤、額度限制等)時直接拋出例外,本函式不做降級處理。
*/ */
export async function analyzeWithRole(role, diff) { export async function analyzeWithRole(role, diff) {
line(`[${role.name}] 開始分析`); line(`[${role.name}] 開始分析`);
@@ -21,7 +27,11 @@ export async function analyzeWithRole(role, diff) {
} }
/** /**
* 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列 * 讀取 JSON 陣列檔案;檔案不存在、讀取失敗或內容非陣列時,皆視為空並回傳 []。
*
* @param {string} fullPath - 欲讀取的 JSON 檔案完整路徑。
* @param {string} label - 用於警告訊息中識別此次讀取對象的標籤文字(例如「舊 findings 」)。
* @returns {Array<object>} 解析出的陣列;任何失敗情況皆回傳空陣列 []。
*/ */
function readJSONArray(fullPath, label) { function readJSONArray(fullPath, label) {
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
@@ -101,21 +111,18 @@ function cleanText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
/**
* 將文字正規化為比對用形式:NFKC、小寫、標點/符號/空白統一為單一空白後壓縮。
*
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
* 因為比對常對同一段文字重複呼叫(findings × exclusions 笛卡爾積),
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複跑 NFKC/正則替換。
*/
const _normalizeTextCache = new Map(); const _normalizeTextCache = new Map();
/** /**
* 將文字正規化成比對用形式。 * 將文字正規化為比對用形式:先以 cleanText 轉為安全字串,NFKC 正規化、轉小寫,
* 並把所有標點/符號/空白字元壓縮成單一空白(再壓縮連續空白、去頭尾空白)。
* 因常對同一段文字重複呼叫(findings × exclusions 笛卡爾積比對),
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複執行 NFKC/正則運算。
* *
* @param {*} value - 任意值。 * @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串(不會寫入快取)。
* @remarks 適合用於誤報過濾與排除條目比對。 * @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
* 快取為模組層級、程序生命週期內不會清除,需人工確認長期執行(如常駐服務)情境下是否有記憶體成長風險;
* 在本專案作為一次性 CI 腳本執行的用法下應無實際影響。
*/ */
export function normalizeText(value) { export function normalizeText(value) {
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value); if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
@@ -297,7 +304,12 @@ function buildExclusionContext(exclusions) {
} }
/** /**
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH) * 讀取舊 findings(來源分支 cloned repoDir 下 FINDINGS_PATH 指向的檔案),
* 每筆項目一律標記 is_new: false(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。
* 檔案不存在或讀取失敗時視為空陣列,不拋例外。
*
* @param {string} workspace - 來源分支 clone 出的工作目錄根路徑,FINDINGS_PATH 會相對此路徑解析。
* @returns {Array<object>} 舊 findings 陣列,每筆皆含 is_new: false;讀取失敗或檔案不存在時回傳空陣列。
*/ */
export function loadOldFindings(workspace) { export function loadOldFindings(workspace) {
const fullPath = path.join(workspace, FINDINGS_PATH); const fullPath = path.join(workspace, FINDINGS_PATH);
@@ -314,7 +326,13 @@ export function loadOldFindings(workspace) {
} }
/** /**
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複 * 合併新舊 findings:以 (role + location + suggestion 前 50 字) 組成的字串為 key,
* 過濾掉 newFindings 中與 oldFindings(或 newFindings 自身先出現的項目)key 相同的重複項。
* oldFindings 本身不會互相去重(視為既有基準),回傳陣列為 [...oldFindings, ...去重後的 newFindings]。
*
* @param {Array<object>} oldFindings - 既有(上一輪)findings 陣列,作為去重比對基準,原樣保留於結果前段。
* @param {Array<object>} newFindings - 本輪新產生的 findings 陣列,將依 key 去除與 oldFindings 重複者。
* @returns {Array<object>} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。
*/ */
export function mergeFindings(oldFindings, newFindings) { 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.suggestion).slice(0, 50)}`;
@@ -330,14 +348,25 @@ export function mergeFindings(oldFindings, newFindings) {
} }
/** /**
* 依等級排序(critical > warning > info) * 依等級排序(critical > warning > info),回傳新陣列,不修改傳入的 findings。
*
* @param {Array<object>} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。
* @returns {Array<object>} 依 critical/warning/info 順序排序後的新陣列。
* @remarks level 不在 ['critical','warning','info'] 中的項目,因 indexOf 回傳 -1,
* 會被排到 critical 之前(最前面)而非最後面;此邊界行為是否為預期設計,需人工確認。
*/ */
export function sortByLevel(findings) { export function sortByLevel(findings) {
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level)); return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
} }
/** /**
* AI 呼叫失敗時的統一降級處理 * AI 呼叫失敗時的統一降級處理:記錄警告訊息後原樣回傳 findings(不做任何篩選),
* 確保 AI(去重/誤報過濾等)暫時性失敗時不會誤刪合法問題。
*
* @param {string} label - 用於警告訊息中識別此次失敗的處理名稱(例如「AI 去重」)。
* @param {Array<object>} findings - 發生失敗前的 findings 陣列,將原樣回傳。
* @param {Error} e - 捕捉到的錯誤物件;若 e.response.status 為 402 或 429,訊息會顯示為「額度/限流」,否則顯示 e.message。
* @returns {Array<object>} 原樣回傳的 findings(與傳入的參照相同,未複製)。
*/ */
function fallback(label, findings, e) { function fallback(label, findings, e) {
const status = e.response?.status; const status = e.response?.status;
@@ -348,7 +377,13 @@ function fallback(label, findings, e) {
const MAX_LOCATE_ATTEMPTS = 3; const MAX_LOCATE_ATTEMPTS = 3;
/** 從 location 取出行號;無 `檔案:行號`(或多檔逗號)時回 null。 */ /**
* 從 location(格式如「檔案:行號」或「檔案:起始行-結束行」)取出行號。
*
* @param {string|null|undefined} location - finding 的 location 欄位。
* @returns {number|null} 解析出的(起始)行號;若 location 為空、包含逗號(代表多檔案)
* 或不符合「檔案:數字」格式,回傳 null。範圍格式僅回傳起始行號,不回傳結束行號。
*/
function findingLine(location) { function findingLine(location) {
const s = String(location || '').trim(); const s = String(location || '').trim();
if (!s || s.includes(',')) return null; if (!s || s.includes(',')) return null;
@@ -356,7 +391,14 @@ function findingLine(location) {
return m ? Number(m[2]) : null; return m ? Number(m[2]) : null;
} }
/** 從整份 unified diff 擷取指定檔案的區段,找不到時回退整份 diff。 */ /**
* 從整份 unified diff 擷取指定檔案的區段(依 `diff --git a/... b/...` 標頭切分);找不到對應區段時回退回傳整份 diff。
*
* @param {string} diff - 完整的 unified diff 文字。
* @param {string} file - 欲擷取的檔案路徑(會以 includes 比對是否出現在 diff --git 標頭的 a/、b/ 路徑中)。
* @returns {string} 該檔案對應的 diff 區段文字;若無法定位,回退回傳原始 diff 字串。
* @remarks 檔名比對採子字串 includes,若 file 恰為另一檔案路徑的子字串,可能誤判擷取到錯誤區段,此為已知限制,需人工確認是否需要更嚴謹的邊界比對。
*/
function extractFileDiff(diff, file) { function extractFileDiff(diff, file) {
const lines = String(diff || '').split('\n'); const lines = String(diff || '').split('\n');
const out = []; const out = [];
@@ -371,7 +413,14 @@ function extractFileDiff(diff, file) {
/** /**
* 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號, * 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號,
* 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈); * 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈);
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。 * 成功則直接修改(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 已被就地修改)。
*/ */
export async function resolveMissingLineNumbers(findings, diff, deps = {}) { export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps; const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
@@ -418,7 +467,13 @@ function toAIPayload(findings) {
} }
/** /**
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings * 呼叫 LLM(Paladin 角色)進行語意去重:合併「同位置+同問題本質」的重複 findings,重複者保留等級較高者。
* 為避免 LLM 幻覺出不存在的內容,回傳結果會逐筆以 (location + suggestion 前 50 字) 對應回原始 findings,
* 對應不到、結果為空、非陣列或數量超過輸入筆數者,皆視為異常並整批降級為保留所有原始 findings(不篩選)。
*
* @param {Array<object>} findings - 欲去重的 findings 陣列;為空陣列時直接原樣回傳。
* @returns {Promise<Array<object>>} 去重後的原始 finding 物件陣列(非 LLM 回傳的精簡版);
* AI 呼叫失敗或結果驗證異常時,降級回傳原始 findings(未經任何篩選)。
*/ */
export async function deduplicateWithAI(findings) { export async function deduplicateWithAI(findings) {
if (findings.length === 0) return findings; if (findings.length === 0) return findings;
@@ -445,7 +500,15 @@ export async function deduplicateWithAI(findings) {
} }
/** /**
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH) * 讀取排除問題檔案(來源分支 cloned repoDir 下 EXCLUSIONS_PATH),正規化並去重後回傳。
* 若偵測到檔案為舊格式(非頂層陣列,如 { exclusions: [...] } 或 { excluded_findings: [...] }),
* 會就地把該檔案覆寫為標準頂層陣列格式(若提供 mirrorWorkspace 且路徑不同,也會同步寫入 mirror 目錄)。
* 檔案不存在或讀取/解析失敗時,皆視為空陣列,不拋出例外。
*
* @param {string} workspace - 來源分支工作目錄根路徑,EXCLUSIONS_PATH 會相對此路徑解析。
* @param {object|null} [repoState] - 可選的來源分支狀態(branch/shortSha 或 headSha/commitTime),僅用於診斷日誌。
* @param {string|null} [mirrorWorkspace] - 可選的鏡像工作目錄;當原始格式非頂層陣列時,會同步覆寫此目錄下的 exclusions.json。
* @returns {Array<object>} 正規化並去重後的排除條目陣列;讀取失敗或檔案不存在時回傳空陣列。
*/ */
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) { export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
const fullPath = path.join(workspace, EXCLUSIONS_PATH); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
@@ -494,8 +557,15 @@ export function loadExclusions(workspace, repoState = null, mirrorWorkspace = nu
} }
/** /**
* 把新的排除條目(raw 形式)append 到 exclusions.json,去重後以頂層陣列寫回 workspace 與 mirror。 * 把新的排除條目(raw 形式,未經 normalizeExclusionEntry 加工)append 到 exclusions.json,
* 去重以「檔案路徑 + 正規化原文」為準。回傳合併後的 raw 陣列(無新增時回傳既有陣列)。 * 以「檔案路徑(location 冒號前段)+ normalizeText 後的原文」為簽名去重後,
* 以頂層陣列格式寫回 workspace(及提供且路徑不同的 mirrorWorkspace)。
*
* @param {string} workspace - 目標工作目錄,EXCLUSIONS_PATH 相對此路徑解析並寫入。
* @param {Array<object>} newEntries - 欲新增的排除條目(raw 形式);為空或未提供時直接回傳 null(無操作)。
* @param {string|null} [mirrorWorkspace] - 可選鏡像目錄;提供且與 workspace 路徑不同時,會同步寫入相同內容。
* @returns {Array<object>|null} 合併後的 raw 排除條目陣列;newEntries 為空時回傳 null;
* 若 newEntries 皆與既有條目重複(無實際新增)則回傳既有陣列(未寫檔)。
*/ */
export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) { export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) {
if (!newEntries || newEntries.length === 0) return null; if (!newEntries || newEntries.length === 0) return null;
@@ -538,8 +608,19 @@ export function appendExclusions(workspace, newEntries, mirrorWorkspace = null)
} }
/** /**
* 套用排除規則,過濾掉符合排除條件的 findings * 套用排除規則,過濾掉符合任一排除條件的 findings。
* location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用 * exclusions 為空時原樣回傳 findings(新陣列,不修改原輸入)。
*
* 比對規則(對每個 exclusion,locationMatches && roleMatches && (有指定 path 或 role ? 一律視為符合 : textMatches)):
* - location 只比對檔案路徑(忽略行號),exclusion 未指定 filePath 時視為萬用;
* - role 未指定時視為萬用,否則需與 finding.role 完全相等;
* - 僅當 exclusion 同時未指定 filePath 與 role 時,才會實際比對正規化後文字(suggestion/title 等)是否互相包含。
*
* @param {Array<object>} findings - 欲過濾的 findings 陣列。
* @param {Array<object>} exclusions - 排除條目陣列(建議為已正規化含 filePath 的條目)。
* @returns {Array<object>} 過濾後的新陣列。
* @remarks 「只要 exclusion 指定了 filePath 或 role,文字比對即完全略過」是否為刻意設計,需人工確認;
* 若非刻意,可能造成排除範圍比預期寬(例如同檔案下所有問題都被排除,而非僅特定描述的問題)。
*/ */
export function applyExclusions(findings, exclusions) { export function applyExclusions(findings, exclusions) {
if (exclusions.length === 0) return findings; if (exclusions.length === 0) return findings;
@@ -558,7 +639,15 @@ export function applyExclusions(findings, exclusions) {
return filtered; return filtered;
} }
/** 派一個「防守方」sub-agent 裁決單一 finding 是否為誤報;任何失敗都保守視為成立(保留)。 */ /**
* 派一個「防守方」角色裁決單一 finding 是否為誤報;任何失敗都保守視為「成立」(即保留該問題)。
*
* @param {object} finding - 欲裁決的單一 finding。
* @param {object} defender - 防守方角色定義(通常為 Paladin),供 buildVerdictPrompt 組系統提示。
* @param {string} exclusionHint - 已知誤報清單的提示文字(可為空字串),供 AI 判斷是否與已知誤報類似。
* @param {Function} chatFn - 實際呼叫 LLM 的函式(簽名同 chatJSON),供測試時注入替換。
* @returns {Promise<boolean>} true 表示裁決為誤報(應剔除);false 表示成立或裁決失敗(保守保留)。
*/
async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) { async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) {
const systemPrompt = buildVerdictPrompt(defender, exclusionHint); const systemPrompt = buildVerdictPrompt(defender, exclusionHint);
try { try {
@@ -571,8 +660,13 @@ async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, cha
} }
/** /**
* 由「防守方」角色(Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。 * 由「防守方」角色(固定為 Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。
* 多個問題時各派一個 sub-agent 平行裁決;任一裁決失敗保守保留該問題,不中斷流程。 * 多個問題時各派一個裁決任務平行處理(併發上限 LLM_CONCURRENCY);任一裁決失敗保守保留該問題,不中斷流程。
*
* @param {Array<object>} findings - 欲裁決的 findings 陣列;為空陣列時直接原樣回傳。
* @param {Array<object>} [exclusions=[]] - 已知誤報排除條目,用於組裝提示,引導 AI 對相似的誤報更寬鬆判定。
* @param {Function} [chatFn=chatJSON] - 實際呼叫 LLM 的函式,供測試時注入替換。
* @returns {Promise<Array<object>>} 裁決為「非誤報」而保留下來的原始 finding 物件陣列。
*/ */
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) { export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
if (findings.length === 0) return findings; if (findings.length === 0) return findings;
+22 -1
View File
@@ -159,6 +159,14 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。 * 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用, * 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。 * 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
*
* @param {string} workspace 寫入暫存 askpass 腳本的目錄。
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
* 測試用依賴注入:覆寫底層同步 spawn 實作。
* @returns {{ok: boolean, error?: string}}
* `ok: true` 表示 ls-remote 成功;`ok: false` 並附上 `error`(例外訊息)表示失敗。
* @remarks 本函式內部已捕捉所有例外,不會向外拋出;預設使用 `GITEA_TOKEN`(唯讀用途)。
* @remarks 查詢的分支為 `PR_HEAD_BRANCH || 'HEAD'`;未帶入 PR 上下文(`PR_HEAD_BRANCH` 為空)時會改驗證 `HEAD`。
*/ */
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) { export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
@@ -173,7 +181,18 @@ export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
} }
/** /**
* Clone PR head branch to workspace/repo (idempotent) * 將 PR head branch clone 到 `workspace/repo`(idempotent):
* 若目標目錄不存在則以 `--depth=1 --branch <PR_HEAD_BRANCH>` clone;
* 若已存在則改為 `fetch` 最新後 `checkout` 到該分支,避免重複完整 clone。
*
* @param {string} workspace 工作區根目錄,clone 目的地為 `workspace/repo`,
* 亦是暫存 askpass 腳本的寫入位置。
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
* 測試用依賴注入:覆寫底層同步 spawn 實作。
* @returns {string} repo 的本機路徑(即 `path.join(workspace, 'repo')`)。
* @throws 透傳 clone / fetch / checkout 任一步驟失敗時的例外(不吞例外)。
* @remarks 使用 `withAskpass` 搭配預設 `GITEA_TOKEN` 進行 git HTTP 認證,
* clone/fetch 會帶認證環境,checkout 為本機操作不需認證。
*/ */
export function cloneRepo(workspace, _spawnSync = spawnSync) { export function cloneRepo(workspace, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
@@ -207,6 +226,8 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
* 測試用依賴注入:覆寫底層同步 spawn。 * 測試用依賴注入:覆寫底層同步 spawn。
* @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數; * @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數;
* 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。 * 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。
* @remarks `_sourceRoot` 實際用途需人工確認:目前函式主體未引用此參數,
* 且測試檔會傳入實際值,無法從程式碼可靠判斷其設計意圖或是否可安全移除。
* @param {'success'|'failure'} [reviewOutcome='success'] * @param {'success'|'failure'} [reviewOutcome='success']
* 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。 * 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。
* @returns {Promise<void>} 無回傳值;所有失敗皆以 log 記錄後吞掉。 * @returns {Promise<void>} 無回傳值;所有失敗皆以 log 記錄後吞掉。
+2
View File
@@ -69,6 +69,7 @@ export function parseReviewIgnore(text) {
/** /**
* 從被審 PR 的 head ref 取得 `.reviewignore` 並解析為排除清單。 * 從被審 PR 的 head ref 取得 `.reviewignore` 並解析為排除清單。
* 檔案不存在或為空時退回 {@link DEFAULT_REVIEW_IGNORE}。 * 檔案不存在或為空時退回 {@link DEFAULT_REVIEW_IGNORE}。
* @remarks 成功套用 .reviewignore 時會透過 line() 輸出套用規則數的日誌行(副作用,不影響回傳值)。
* @returns {Promise<string[]>} 套用於 diff 過濾的排除前綴清單。 * @returns {Promise<string[]>} 套用於 diff 過濾的排除前綴清單。
*/ */
export async function getReviewIgnore() { export async function getReviewIgnore() {
@@ -159,6 +160,7 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT
* 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/<prefix>` 做 startsWith 比對。 * 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/<prefix>` 做 startsWith 比對。
* @param {string} diff - 完整的 unified diff 文字。 * @param {string} diff - 完整的 unified diff 文字。
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。 * @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
* @remarks `diff` 必須為字串,非字串輸入會拋出 TypeError。
* @returns {string} 過濾後重新接合的 diff 文字。 * @returns {string} 過濾後重新接合的 diff 文字。
*/ */
export function filterDiff(diff, excludePrefixes = []) { export function filterDiff(diff, excludePrefixes = []) {
+148 -117
View File
@@ -1,12 +1,9 @@
import * as childProcess from 'child_process'; import axios from 'axios';
import { mkdtemp, writeFile, rm } from 'fs/promises'; import { getLLMConfig, getInsecureHttpsAgent } from './config.js';
import { tmpdir } from 'os'; import { recordUsage, recordRateLimit } from './usage.js';
import { join } from 'path';
import { getLLMConfig } from './config.js';
import { recordUsage } from './usage.js';
import { line } from './log.js'; import { line } from './log.js';
// 每個 LLM CLI 呼叫(角色分析、補行號等)都是一個獨立子行程。預設「不限制」併發(全部同時跑); // 每個 LLM proxy 呼叫(角色分析、補行號等)都是一個獨立 HTTP 請求。預設「不限制」併發(全部同時跑);
// 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。 // 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。
// 0 / 未設定 / 非正整數 → 不限制。 // 0 / 未設定 / 非正整數 → 不限制。
export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0; export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0;
@@ -15,12 +12,16 @@ export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0
* 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。 * 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。
* *
* limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。 * limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。
* fn 需自行處理例外(內部 try/catch);本函式不會因單一項目 reject 而中斷其餘工作。 * fn 需自行處理例外(內部 try/catch);若 fn 未處理而 reject,本函式會立即向外
* 拋出該錯誤(Promise.all fail-fast),但其他已啟動、尚在執行中的併發工作並不會
* 被取消,仍會在背景繼續處理剩餘項目,只是其結果會被捨棄。
*
* @template T, R * @template T, R
* @param {T[]} items - 要處理的項目。 * @param {T[]} items - 要處理的項目;非陣列(含 null/undefined)會被視為空陣列,不拋錯。
* @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。 * @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。
* @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。 * @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
* @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。 * @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。
* @throws 若任一次 fn 呼叫 reject 且未在內部處理,該錯誤會直接向外傳播。
*/ */
export async function mapWithConcurrency(items, limit, fn) { export async function mapWithConcurrency(items, limit, fn) {
const list = Array.isArray(items) ? items : []; const list = Array.isArray(items) ? items : [];
@@ -29,6 +30,26 @@ export async function mapWithConcurrency(items, limit, fn) {
const n = Number(limit); const n = Number(limit);
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length); const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
let cursor = 0; let cursor = 0;
/**
* mapWithConcurrency 的工作者(worker)迴圈:從共用游標 `cursor` 依序搶下一個尚未
* 處理的索引,呼叫外層傳入的 `fn`,並把結果寫入外層 `results` 陣列對應位置;直到
* `cursor` 到達 `list.length` 為止。
*
* 多個 `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`,
* 只是其結果最終會被捨棄。
*/
async function run() { async function run() {
while (cursor < list.length) { while (cursor < list.length) {
const i = cursor++; const i = cursor++;
@@ -40,7 +61,19 @@ export async function mapWithConcurrency(items, limit, fn) {
} }
/** /**
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。 * 將 system prompt 與 user content 合併成單一文字,作為送往 CLIProxyAPI 的
* 「使用者訊息」內容。
*
* 注意:此函式回傳的合併文字,會被 chat() 整段放入 HTTP request 的 user role
* 內容;實際送出的 HTTP system role 訊息是固定的通用指示(見 runProxyAPI),
* 並非這裡傳入的 systemPrompt——systemPrompt 是以 `<system>` 標籤形式內嵌在
* user 內容中,而非透過 API 的 system role 傳遞。
*
* @param {string} systemPrompt - 系統提示詞內容,會被包在 `<system>...</system>`
* 標籤內;`null`/`undefined` 會被視為空字串。
* @param {string} userContent - 使用者輸入內容,會被包在 `<user>...</user>`
* 標籤內;`null`/`undefined` 會被視為空字串。
* @returns {string} 合併後、以換行分隔的完整 prompt 文字。
*/ */
function buildPrompt(systemPrompt, userContent) { function buildPrompt(systemPrompt, userContent) {
return [ return [
@@ -57,39 +90,12 @@ function buildPrompt(systemPrompt, userContent) {
} }
/** /**
* 依不同 AI provider 產生 CLI 參數。 * 從 proxy API 錯誤輸出中抽出「真正有意義的錯誤」。
* *
* @param {*} provider - AI provider 名稱。 * 直接取前段很容易被 HTML / JSON 包裝或回顯雜訊洗掉,因此改為:先抽出看起來像
* @param {*} model - 模型名稱。 * 錯誤的行;抽不到再退取尾段。
* @param {*} promptFile - prompt 檔路徑,供 `opencode` 使用。
* @param {*} prompt - 直接傳給 CLI 的 prompt 文字,供部分 provider 使用。
* @remarks 適合把不同 CLI 的參數差異集中管理。
* @remarks 目前支援的 provider 名稱是硬編碼的,新增 provider 時需人工確認是否同步更新所有呼叫端。
*/
function cliArgs({ provider, model, promptFile = null, prompt = null }) {
if (provider === 'codex') {
return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
}
if (provider === 'claude') {
return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
}
if (provider === 'antigravity') {
return ['-p', prompt, '--model', model];
}
if (provider === 'opencode') {
return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。'];
}
throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
}
/**
* 從 CLI 輸出中抽出「真正有意義的錯誤」。
* *
* 像 codex 這類 CLI 會先印出一大段 banner(workdir/model/...)與回顯的 prompt, * @param {string} raw - HTTP 錯誤原始內容(response body、stderr 或 stdout)。
* 真正的失敗原因(例如 401、token 失效、額度不足)通常落在**尾端**。直接取前段
* 會被 banner/prompt 洗掉,因此改為:先抽出看起來像錯誤的行;抽不到再退取尾段。
*
* @param {string} raw - CLI 的原始輸出(stderr 或 stdout)。
* @param {number} [limit=1000] - 回傳字串長度上限。 * @param {number} [limit=1000] - 回傳字串長度上限。
* @returns {string} 最能說明失敗原因的片段。 * @returns {string} 最能說明失敗原因的片段。
*/ */
@@ -97,123 +103,148 @@ export function extractMeaningfulError(raw, limit = 1000) {
const text = String(raw || '').trim(); const text = String(raw || '').trim();
const errorLines = text const errorLines = text
.split('\n') .split('\n')
.filter(l => /\bERROR\b|error:|unauthorized|invalidated|revoked|forbidden|\b40[13]\b|rate.?limit|quota|insufficient/i.test(l)); .filter(l => /\bERROR\b|error:|unauthorized|invalidated|revoked|forbidden|\b40[13]\b|\b429\b|rate.?limit|quota|insufficient|temporarily unavailable/i.test(l));
const picked = (errorLines.length ? errorLines.join('\n') : text).trim(); const picked = (errorLines.length ? errorLines.join('\n') : text).trim();
return picked.length > limit ? picked.slice(-limit) : picked; return picked.length > limit ? picked.slice(-limit) : picked;
} }
/** /**
* 將 CLI 例外整理成較精簡的錯誤摘要。 * 將 HTTP 例外整理成較精簡的錯誤摘要,格式為 `"HTTP <status> <訊息>"`
* (無 status 時只有訊息)。
* *
* @param {*} e - 被拋出的錯誤物件,可能含 `stderr`、`stdout`、`message`。 * 依序嘗試:`response.data`(字串或物件的 error.message/message/error 欄位)→
* `stderr` → `stdout` → `e.message` → `String(e)`,取第一個非空來源後交給
* extractMeaningfulError 濃縮成精簡訊息,再與 HTTP 狀態碼(若有)合併。
*
* @param {*} e - 被拋出的錯誤物件,預期含 `response.data`/`response.status`/
* `stderr`/`stdout`/`message` 其中之一或多個。
* @returns {string} 精簡後的錯誤訊息;兩者皆空則回傳空字串。
* @remarks 適合在 log 與錯誤重新拋出前先整理訊息。 * @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
* @remarks 若錯誤物件結構和預期不同,仍會退回字串化處理,屬保守容錯。 * @remarks 容錯僅涵蓋「e 是物件但欄位缺失或型態不符」的情況;若 e 本身為
* `null`/`undefined`,存取 `e.stderr`/`e.stdout`/`e.message` 會直接拋出
* TypeError,並非完全的保守容錯(需人工確認是否要補上 optional chaining 修正)。
*/ */
function summarizeCliError(e) { function summarizeApiError(e) {
const responseData = e?.response?.data;
const responseText = typeof responseData === 'string'
? responseData
: responseData?.error?.message
|| responseData?.message
|| responseData?.error
|| '';
const stderr = String(e.stderr || '').trim(); const stderr = String(e.stderr || '').trim();
const stdout = String(e.stdout || '').trim(); const stdout = String(e.stdout || '').trim();
return extractMeaningfulError(stderr || stdout || e.message || String(e)); const status = e?.response?.status ? `HTTP ${e.response.status}` : '';
const message = extractMeaningfulError(responseText || stderr || stdout || e.message || String(e));
return [status, message].filter(Boolean).join(' ').trim();
} }
/** /**
* 執行 AI 助理 CLI 並回傳純文字結果。 * 透過 CLIProxyAPI 執行一次對話 HTTP 請求,回傳 API 的原始回應資料(物件),
* 並記錄回應 header 中的速率配額資訊。
* *
* @param {*} provider - CLI provider 名稱。 * 僅送出一次請求,不含任何重試邏輯——失敗(逾時、網路錯誤、非 2xx 狀態碼)時
* @param {*} command - 實際可執行指令。 * 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用
* @param {*} model - 要使用的模型名稱。 * `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。
* @param {*} prompt - 送給 CLI 的完整 prompt 內容。 *
* @remarks 適合用在需呼叫外部 AI CLI 的情境。 * @param {{provider: string, baseURL: string, apiKeys: string[], model?: string|null}} cfg - 連線設定;
* @remarks 逾時與輸出上限由環境變數控制,預設值是保守設定。 * 僅使用 `apiKeys[0]`;`model` 可省略,省略時交由 CLIProxyAPI 自動選擇。
* @remarks 若子行程回傳非 0,錯誤訊息會由上層摘要處理。 * @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容;
* HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `<system>` 內容無關。
* @returns {Promise<any>} API 回應的原始資料物件(`resp.data`),並非純文字;
* 純文字需由呼叫端自行從 `data.choices[0].message.content` 等欄位擷取。
* @throws 當 HTTP 請求失敗(逾時、網路錯誤、非 2xx 狀態碼)時,`axios` 拋出的
* 例外會原樣向外傳播,本函式不攔截、不重試。
* @remarks 逾時與輸出上限由環境變數 `AI_ASSISTANT_TIMEOUT_MS`/`AI_ASSISTANT_MAX_BUFFER`
* 控制,預設值為 15 分鐘/20 MB。
* @remarks 使用 `getInsecureHttpsAgent()`(停用 TLS 憑證驗證),適用內部自簽憑證環境。
*/ */
async function runAssistantCLI({ provider, command, model }, prompt) { async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
let tempDir = null;
let promptFile = null;
if (provider === 'opencode') {
tempDir = await mkdtemp(join(tmpdir(), 'ai-review-prompt-'));
promptFile = join(tempDir, 'prompt.md');
await writeFile(promptFile, prompt);
}
const args = cliArgs({ provider, model, promptFile, prompt });
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000); const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
try { const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
return await new Promise((resolve, reject) => { const root = String(baseURL || '').trim().replace(/\/$/, '');
const child = childProcess.spawn(command, args, { env: process.env, stdio: ['pipe', 'pipe', 'pipe'] }); const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : '';
let stdout = ''; const body = {
let stderr = ''; messages: [
let settled = false; { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
const timer = setTimeout(() => { { role: 'user', content: prompt },
settled = true; ],
child.kill('SIGTERM'); temperature: 0,
reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`)); stream: false,
}, timeout); };
const append = (kind, chunk) => { if (model) body.model = model;
if (kind === 'stdout') stdout += chunk; const resp = await axios.post(
else stderr += chunk; `${root}/v1/chat/completions`,
if (stdout.length + stderr.length > maxBuffer) { body,
settled = true; {
child.kill('SIGTERM'); timeout,
reject(new Error(`${provider} CLI 輸出超過 ${maxBuffer} bytes`)); maxBodyLength: maxBuffer,
} maxContentLength: maxBuffer,
}; headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
httpsAgent: getInsecureHttpsAgent(),
},
);
child.stdout.setEncoding('utf8'); recordRateLimit(resp.headers || {});
child.stderr.setEncoding('utf8'); return resp.data;
child.stdout.on('data', chunk => append('stdout', chunk));
child.stderr.on('data', chunk => append('stderr', chunk));
child.on('error', reject);
child.on('close', (code, signal) => {
clearTimeout(timer);
if (settled) return;
if (code === 0) resolve(stdout.trim());
else reject(Object.assign(new Error(`${provider} CLI exited with ${code ?? signal}`), { stdout, stderr }));
});
child.stdin.end(provider === 'opencode' || provider === 'antigravity' ? '' : prompt);
});
} finally {
if (tempDir) await rm(tempDir, { recursive: true, force: true });
}
} }
/** /**
* 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應。 * 對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應。
* *
* 從設定取得 provider/command/model;未偵測到 CLI 時拋錯。成功時記錄一次 * 從設定取得 provider/baseURL/model;未偵測到 proxy 時拋錯。成功時記錄一次
* usage 呼叫(CLI 通常不回傳 token 明細,因此 token 可能為 0)並回傳內容。 * usage 呼叫並回傳內容。**不含任何重試邏輯**——無論是設定缺失、底層 HTTP 請求
* 失敗,或回應內容為空,都是失敗一次即向外拋出(重新包裝為新的 Error,只保留
* 摘要後訊息),不會自動重試或切換金鑰/provider。呼叫前後皆會透過 line() 記錄
* 一行 log(成功記啟動資訊,失敗記錯誤摘要)。
* *
* @param {string} systemPrompt - 系統提示詞。 * @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。 * @param {string} userContent - 使用者輸入內容。
* @returns {Promise<string>} 模型回應的純文字內容。 * @returns {Promise<string>} 模型回應的純文字內容。
* @throws {Error} 當未偵測到可用 AI 助理 CLI,或 CLI 呼叫失敗時。 * @throws {Error} 當未偵測到可用 CLIProxyAPI 設定、底層 API 呼叫失敗,或回應
* 缺少可用文字內容時。
*/ */
export async function chat(systemPrompt, userContent) { export async function chat(systemPrompt, userContent) {
const cfg = getLLMConfig(); const cfg = getLLMConfig();
const { provider, command, model } = cfg; const { provider, baseURL, model } = cfg;
if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode'); if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
line(`[LLM] provider=${provider} command=${command} model=${model}`); line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
try { try {
const content = await runAssistantCLI(cfg, buildPrompt(systemPrompt, userContent)); const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent));
recordUsage(null); recordUsage(data);
return content; const content = data?.choices?.[0]?.message?.content
?? data?.choices?.[0]?.text
?? data?.output_text
?? data?.content
?? '';
const text = String(content).trim();
if (!text) throw new Error('CLIProxyAPI 回應缺少文字內容');
return text;
} catch (e) { } catch (e) {
const message = summarizeCliError(e); const message = summarizeApiError(e);
line(`[LLM] ${provider} CLI 呼叫失敗: ${message}`); line(`[LLM] ${provider} API 呼叫失敗: ${message}`);
throw new Error(message); throw new Error(message);
} }
} }
/** /**
* 對 AI 助理 CLI 送出對話並將回應解析為 JSON 物件/陣列。 * 對 CLIProxyAPI 送出對話並將回應解析為 JSON 物件/陣列。
* *
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。 * 先呼叫 {@link chat} 取得文字回應,再經 {@link extractJSONText} 抽出 JSON 片段後
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。 * 以 JSON.parse 解析。**僅 JSON 解析失敗時容錯**(記錄錯誤並回傳空陣列 `[]`,不
* 向外拋錯);若 `chat()` 本身失敗(例如未偵測到可用 CLIProxyAPI 設定、API 呼叫
* 失敗,或回應缺少文字內容),該例外不會被本函式攔截,會直接向外拋出。
* *
* @param {string} systemPrompt - 系統提示詞。 * @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。 * @param {string} userContent - 使用者輸入內容。
* @returns {Promise<any>} 解析後的 JSON 值;解析失敗時回傳空陣列 `[]`。 * @returns {Promise<any>} 解析後的 JSON 值;僅當 JSON 解析失敗時回傳空陣列 `[]`。
* @throws {Error} 當底層 {@link chat} 呼叫失敗時(設定缺失、API 錯誤、回應無文字
* 內容等),例外會原樣向外傳播。
*/ */
export async function chatJSON(systemPrompt, userContent) { export async function chatJSON(systemPrompt, userContent) {
const text = await chat(systemPrompt, userContent); const text = await chat(systemPrompt, userContent);
+41 -10
View File
@@ -1,13 +1,36 @@
/** /**
* 輸出最上層的「區塊/章節」分隔標題(前綴空行 + `=== 標題 ===`)。 * 依 `spec-time-log` 規範產生台灣時區(Asia/Taipei)的固定格式時間戳 `yyyy/MM/dd HH:mm:ss`。
* 供本模組所有輸出函式在訊息前加上 `[時間]` 前綴使用。
*
* @param {Date} [date] - 要格式化的時間點;省略時使用呼叫當下的系統時間。
* @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]));
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
}
/**
* 輸出最上層的「區塊/章節」分隔標題(前綴空行 + `[時間][INF]: === 標題 ===`)。
* 用於切分整個執行流程中彼此獨立的大段落(例如「環境檢查」「執行審查」「發布結果」), * 用於切分整個執行流程中彼此獨立的大段落(例如「環境檢查」「執行審查」「發布結果」),
* 讓 CI log 在視覺上分群;屬於最高層級的分隔,內部再以 step / line 等細分。 * 讓 CI log 在視覺上分群;屬於最高層級的分隔,內部再以 step / line 等細分。
* *
* @param {string} title - 區塊標題文字。 * @param {string} title - 區塊標題文字。
* @returns {void} 無回傳值,僅將標題寫入 stdout。 * @returns {void} 無回傳值,僅將標題寫入 stdout。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function section(title) { export function section(title) {
console.log(`\n=== ${title} ===`); console.log(`\n[${formatTimestamp()}][INF]: === ${title} ===`);
} }
/** /**
@@ -18,9 +41,10 @@ export function section(title) {
* @param {string} stepName - 步驟代號或編號,會以中括號包覆顯示。 * @param {string} stepName - 步驟代號或編號,會以中括號包覆顯示。
* @param {string} title - 步驟標題文字。 * @param {string} title - 步驟標題文字。
* @returns {void} 無回傳值,僅將步驟標題寫入 stdout。 * @returns {void} 無回傳值,僅將步驟標題寫入 stdout。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function step(stepName, title) { export function step(stepName, title) {
console.log(`\n[${stepName}] ${title}`); console.log(`\n[${formatTimestamp()}][INF]: [${stepName}] ${title}`);
} }
/** /**
@@ -30,9 +54,10 @@ export function step(stepName, title) {
* *
* @param {string} message - 要顯示的明細訊息。 * @param {string} message - 要顯示的明細訊息。
* @returns {void} 無回傳值,僅將明細寫入 stdout。 * @returns {void} 無回傳值,僅將明細寫入 stdout。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function line(message) { export function line(message) {
console.log(` - ${message}`); console.log(`[${formatTimestamp()}][INF]: - ${message}`);
} }
/** /**
@@ -41,9 +66,10 @@ export function line(message) {
* *
* @param {string} message - 描述輸入內容的訊息。 * @param {string} message - 描述輸入內容的訊息。
* @returns {void} 無回傳值,僅將輸入描述寫入 stdout。 * @returns {void} 無回傳值,僅將輸入描述寫入 stdout。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function input(message) { export function input(message) {
console.log(` ← 輸入:${message}`); console.log(`[${formatTimestamp()}][INF]: ← 輸入:${message}`);
} }
/** /**
@@ -52,9 +78,10 @@ export function input(message) {
* *
* @param {string} message - 描述輸出內容的訊息。 * @param {string} message - 描述輸出內容的訊息。
* @returns {void} 無回傳值,僅將輸出描述寫入 stdout。 * @returns {void} 無回傳值,僅將輸出描述寫入 stdout。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function output(message) { export function output(message) {
console.log(` → 輸出:${message}`); console.log(`[${formatTimestamp()}][INF]: → 輸出:${message}`);
} }
/** /**
@@ -65,9 +92,10 @@ export function output(message) {
* @param {boolean} passed - 結果是否通過;`true` 顯示成功、`false` 顯示失敗。 * @param {boolean} passed - 結果是否通過;`true` 顯示成功、`false` 顯示失敗。
* @param {string} message - 描述該結果的訊息。 * @param {string} message - 描述該結果的訊息。
* @returns {void} 無回傳值,僅將結果寫入 stdout。 * @returns {void} 無回傳值,僅將結果寫入 stdout。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴(成功為 `INF`、失敗為 `ERR`);此為單純呈現方式調整,仍維持一律寫入 stdout(不因失敗改寫 stderr),未改變輸出的實際語意或呼叫時機。
*/ */
export function result(passed, message) { export function result(passed, message) {
console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`); console.log(`[${formatTimestamp()}][${passed ? 'INF' : 'ERR'}]: ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`);
} }
/** /**
@@ -77,9 +105,10 @@ export function result(passed, message) {
* *
* @param {string} message - 描述成功內容的訊息。 * @param {string} message - 描述成功內容的訊息。
* @returns {void} 無回傳值,僅將成功訊息寫入 stdout。 * @returns {void} 無回傳值,僅將成功訊息寫入 stdout。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function ok(message) { export function ok(message) {
console.log(` ✓ ${message}`); console.log(`[${formatTimestamp()}][INF]: ✓ ${message}`);
} }
/** /**
@@ -89,9 +118,10 @@ export function ok(message) {
* *
* @param {string} message - 要顯示的警告訊息。 * @param {string} message - 要顯示的警告訊息。
* @returns {void} 無回傳值,僅將警告訊息寫入 stderr。 * @returns {void} 無回傳值,僅將警告訊息寫入 stderr。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]`(`WRN`)前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function warn(message) { export function warn(message) {
console.warn(` ! ${message}`); console.warn(`[${formatTimestamp()}][WRN]: ! ${message}`);
} }
/** /**
@@ -101,7 +131,8 @@ export function warn(message) {
* *
* @param {string} message - 要顯示的錯誤訊息。 * @param {string} message - 要顯示的錯誤訊息。
* @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。 * @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]`(`ERR`)前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
*/ */
export function error(message) { export function error(message) {
console.error(` x ${message}`); console.error(`[${formatTimestamp()}][ERR]: x ${message}`);
} }
+13 -7
View File
@@ -37,7 +37,7 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
* - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。 * - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。
* - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。 * - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。
* - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號; * - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號;
* 未設定 API Key 或取 diff 失敗 exit 1,diff 為空 exit 0。 * 未設定 CLIProxyAPI 或取 diff 失敗 exit 1,diff 為空 exit 0。
* - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。 * - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。
* - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。 * - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。
* - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。 * - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。
@@ -46,11 +46,16 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
* - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。 * - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。
* *
* 退出行為: * 退出行為:
* - exit 1:前置驗證未過、上輪 bot failure、未設定 LLM Key、取 diff 失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。 * - exit 1:前置驗證未過、上輪 bot failure、未設定 CLIProxyAPI(base URL)、取 diff 失敗、
* 所有角色分析皆失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。
* - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。 * - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。
* *
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、 * 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。 * Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
*
* 目前程式碼中有 3 個 exit 1 呼叫點(未設定 CLIProxyAPI、取 diff 失敗、所有角色分析皆失敗)
* 退出前未呼叫 `section('Pipeline 結束')`,與其餘 exit 點不一致,會少一行收尾分隔線,
* 是否為刻意設計尚需人工確認。
*/ */
export async function main() { export async function main() {
section('AI Code Review Pipeline'); section('AI Code Review Pipeline');
@@ -98,7 +103,7 @@ export async function main() {
step('Step5', '角色分析產生 findings'); step('Step5', '角色分析產生 findings');
const { provider, apiKeys, baseURL, model } = getLLMConfig(); const { provider, apiKeys, baseURL, model } = getLLMConfig();
if (!provider) { if (!provider) {
result(false, '未設定任何 LLM API Key,請檢查 action inputs'); result(false, '未設定 CLIProxyAPI,請檢查 action env');
process.exit(1); process.exit(1);
} }
const roles = loadRoles(); const roles = loadRoles();
@@ -114,9 +119,10 @@ export async function main() {
section('Pipeline 結束'); section('Pipeline 結束');
process.exit(0); 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 { try {
await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`); await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${modelLabel}`);
line('角色介紹 comment 已發布'); line('角色介紹 comment 已發布');
} catch (e) { } catch (e) {
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
@@ -186,9 +192,9 @@ export async function main() {
const runUsage = getRunUsage(); const runUsage = getRunUsage();
const quota = await fetchAccountQuota(provider, { apiKeys, baseURL }); const quota = await fetchAccountQuota(provider, { apiKeys, baseURL });
const rate = getRateLimit(); 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)})`); input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`);
line(`使用量: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`); line(`使用量: ${formatUsageStatsLine(provider, modelLabel, runUsage, quota, rate)}`);
try { try {
await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection }); await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection });
output('Gitea Review 已發布'); output('Gitea Review 已發布');
+63 -56
View File
@@ -1,7 +1,4 @@
import axios from 'axios'; import axios from 'axios';
import fs from 'fs';
import os from 'os';
import { join } from 'path';
import { import {
GITEA_TOKEN, GITEA_TOKEN,
GITEA_COMMENT_TOKEN, GITEA_COMMENT_TOKEN,
@@ -14,9 +11,6 @@ import {
import { verifyRemoteAccess } from './git.js'; import { verifyRemoteAccess } from './git.js';
import { step, line, ok, error, result } from './log.js'; import { step, line, ok, error, result } from './log.js';
// codex 內部用來取得帳號可用模型清單的端點;auth 失效時會回 HTTP 401。
const CODEX_MODELS_ENDPOINT = 'https://chatgpt.com/backend-api/codex/models';
const httpsAgent = getInsecureHttpsAgent(); const httpsAgent = getInsecureHttpsAgent();
/** /**
* 組出 Gitea REST API v1 的完整網址。 * 組出 Gitea REST API v1 的完整網址。
@@ -56,12 +50,16 @@ function giteaErr(e) {
* @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。 * @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。
* @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。 * @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。
* @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。 * @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。
* @remarks CLI Proxy API 的檢查(`missing` 中的 `'CLI_PROXY_API'`)恆直接讀取
* `process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API`,不受 `opts` 參數覆寫,
* 與 token/repo/pr 三項可測試注入的行為不同,測試時需留意此差異。
*/ */
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) { export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
const missing = []; const missing = [];
if (!token) missing.push('GITEA_TOKEN'); if (!token) missing.push('GITEA_TOKEN');
if (!repo) missing.push('GITEA_REPOSITORY'); if (!repo) missing.push('GITEA_REPOSITORY');
if (!pr) missing.push('PR_NUMBER'); if (!pr) missing.push('PR_NUMBER');
if (!(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API)) missing.push('CLI_PROXY_API');
return { ok: missing.length === 0, missing }; return { ok: missing.length === 0, missing };
} }
@@ -102,82 +100,91 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
} }
/** /**
* 讀取本機 codex 認證檔,向模型清單端點確認帳號目前可用的模型 slug。 * 從 CLIProxyAPI 回應中解析出模型 ID/slug 清單。
* *
* 用途:preflight 期即時分辨「auth 失效(HTTP 401)」與「模型無權限(不在清單)」, * 依序嘗試以 `data.data`(OpenAI 相容格式常見欄位)或 `data.models` 陣列作為來源;
* 不必等到 Step5 每個角色送 prompt 才神秘失敗。只讀清單、不送 prompt,不消耗生成額度。 * 陣列中的字串元素直接視為 ID,物件元素則依序取 `id`/`slug`/`name`;
* 所有錯誤都被攔截並轉為回傳值,不會 throw。 * 其他型態或無法取得有效值的元素會被過濾掉,結果不會包含空字串。
* * @param {unknown} data - CLIProxyAPI `/v1/models` 回應解析後的 JSON;可能為 null/undefined
* @param {object} [deps] - 可注入相依,供測試避免真的讀檔/打網路。 * 或不符預期的結構,函式對此類輸入具容錯性。
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。 * @returns {string[]} 解析出的模型 ID/slug 陣列;輸入非物件、或找不到可用陣列時回傳空陣列 `[]`。
* @param {string} [deps.authPath=~/.codex/auth.json] - codex 認證檔路徑。
* @param {string} [deps.clientVersion] - 帶給端點的 client_version 查詢參數。
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
* 成功回傳可用模型 slug 陣列;失敗回傳格式化錯誤訊息。
*/ */
export async function fetchCodexModels({ function extractModelIds(data) {
fetchImpl = fetch, if (!data || typeof data !== 'object') return [];
authPath = join(os.homedir(), '.codex', 'auth.json'), const source = Array.isArray(data.data) ? data.data : (Array.isArray(data.models) ? data.models : []);
clientVersion = '0.142.5', return source
} = {}) { .map(model => {
let auth; if (typeof model === 'string') return model;
try { if (model && typeof model === 'object') return model.id || model.slug || model.name || '';
auth = JSON.parse(fs.readFileSync(authPath, 'utf8')); return '';
} catch (e) { })
return { ok: false, error: `無法讀取 codex 認證檔(${authPath}): ${e.message}` }; .filter(Boolean);
} }
const tokens = auth.tokens || {};
if (!tokens.access_token) return { ok: false, error: 'codex 認證檔缺少 tokens.access_token' };
const headers = { Authorization: `Bearer ${tokens.access_token}` }; /**
if (tokens.account_id) headers['chatgpt-account-id'] = tokens.account_id; * 呼叫 CLIProxyAPI 的 /v1/models,確認 proxy 可用與模型清單可讀。
*
* @param {object} [deps] - 可注入相依,供測試避免真的打網路。
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
* @param {string} [deps.baseURL=CLI_PROXY_API] - CLIProxyAPI 基底網址。
* @param {string} [deps.apiKey=CLI_PROXY_API_KEY] - CLIProxyAPI API key(可空白)。
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
*/
export async function fetchLLMModels({
fetchImpl = fetch,
baseURL = process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API,
apiKey = process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY,
} = {}) {
const root = String(baseURL || '').trim().replace(/\/$/, '');
if (!root) return { ok: false, error: '未設定 CLI_PROXY_API' };
const headers = { 'Content-Type': 'application/json' };
if (String(apiKey || '').trim()) headers.Authorization = `Bearer ${apiKey.trim()}`;
let resp; let resp;
try { try {
resp = await fetchImpl(`${CODEX_MODELS_ENDPOINT}?client_version=${clientVersion}`, { headers }); resp = await fetchImpl(`${root}/v1/models`, { headers });
} catch (e) { } catch (e) {
return { ok: false, error: `codex 模型清單查詢連線錯誤: ${e.message}` }; return { ok: false, error: `CLIProxyAPI 模型清單查詢連線錯誤: ${e.message}` };
} }
if (resp.status === 401) { if (resp.status === 401) {
return { ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }; return { ok: false, error: 'CLIProxyAPI 認證失效(HTTP 401)——API key 已被撤銷或過期,請更新 CLI_PROXY_API_KEY secret' };
} }
if (!resp.ok) { if (!resp.ok) {
return { ok: false, error: `codex 模型清單查詢失敗(HTTP ${resp.status})` }; return { ok: false, error: `CLIProxyAPI 模型清單查詢失敗(HTTP ${resp.status})` };
} }
let data; let data;
try { try {
data = await resp.json(); data = await resp.json();
} catch (e) { } catch (e) {
return { ok: false, error: `codex 模型清單回應解析失敗: ${e.message}` }; return { ok: false, error: `CLIProxyAPI 模型清單回應解析失敗: ${e.message}` };
} }
const slugs = Array.isArray(data.models) ? data.models.map(m => m.slug).filter(Boolean) : []; return { ok: true, slugs: extractModelIds(data) };
return { ok: true, slugs };
} }
/** /**
* 驗證 LLM(AI 助理 CLI)設定可用。 * 驗證 LLM proxy 設定可用。
* *
* 確認目前環境可偵測到支援的 CLI 且已解析出 model;provider 為 codex 時, * 確認目前環境可偵測到 CLIProxyAPI;若有明確指定 model,則額外向模型清單端點確認
* 額外向模型清單端點確認 auth 有效且設定的 model 在可用清單內(不送 prompt)。 * 該 model 在可用清單內(不送 prompt)。當 model 未指定時,只要求 proxy 與模型清單端點可連線。
* @param {object} [deps] - 可注入相依,供測試。 * @param {object} [deps] - 可注入相依,供測試。
* @param {Function} [deps.fetchCodexModelsFn=fetchCodexModels] - codex 模型清單取得函式。 * @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。
* @returns {Promise< * @returns {Promise<
* {ok: true, provider: string, command: string, model: string, models?: string[]} | * {ok: true, provider: string, command: null, model: string|null, models?: string[]} |
* {ok: false, provider?: string, command?: string, model?: string, error: string} * {ok: false, provider?: string, command?: null, model?: string|null, error: string}
* >} * >}
* 通過時含 provider、command、model(codex 另含 models 清單);未設定 provider 的失敗分支不含 provider。 * 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。
* @remarks 設定來源為 config.js 的 getLLMConfig()。 * @remarks 設定來源為 config.js 的 getLLMConfig()。
*/ */
export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {}) { export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
const { provider, command, model } = getLLMConfig(); const { provider, command, model } = getLLMConfig();
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' }; if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
if (provider === 'codex') { if (provider === 'cliproxyapi') {
const models = await fetchCodexModelsFn(); const models = await fetchLLMModelsFn();
if (!models.ok) return { ok: false, provider, command, model, error: models.error }; 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} 不在 codex 可用清單: [${models.slugs.join(', ')}]` }; return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` };
} }
return { ok: true, provider, command, model, models: models.slugs }; return { ok: true, provider, command, model, models: models.slugs };
} }
@@ -186,7 +193,7 @@ export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {})
} }
/** /**
* 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM CLI。 * 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM proxy。
* *
* 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。 * 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。
* 各檢查可經 deps 注入覆寫,方便單元測試。 * 各檢查可經 deps 注入覆寫,方便單元測試。
@@ -196,7 +203,7 @@ export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {})
* @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。 * @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。
* @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。 * @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。
* @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。 * @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。
* @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM(AI 助理 CLI)驗證。 * @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM proxy 驗證。
* @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。 * @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。
* @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。 * @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。
*/ */
@@ -244,7 +251,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
error(`LLM 驗證失敗: ${llm.error}`); error(`LLM 驗證失敗: ${llm.error}`);
return false; return false;
} }
ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model})`); ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model || 'auto'})`);
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`); if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
result(true, '前置驗證通過'); result(true, '前置驗證通過');
+41 -7
View File
@@ -46,7 +46,11 @@ function levelToKey(raw) {
/** /**
* 嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。 * 嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。
* 同時支援 review comment(嚴重等級/審查員/問題/建議)與行內 critical comment(等級/審查員/建議)格式。 * 同時支援 review comment(嚴重等級/審查員/問題/建議)與行內 critical comment(等級/審查員/建議)格式。
* 不符合格式(例如人工自由留言)時回傳 null。 * 不符合格式(例如人工自由留言、缺少必要欄位)時回傳 null。
* @param {string} body - 留言原始內文(可能含 \r\n,函式內會自行正規化)。
* @returns {{level: ('critical'|'warning'|'info'), role: string, problem: string, suggestion: string} | null}
* 解析結果;level 缺省時補 'warning'、role 缺省時補 'AI Review'、suggestion 缺省時退回 problem 再退回 ''。
* 非 bot 格式(level 與 role 皆缺,或 problem 與 suggestion 皆缺)時回傳 null。
*/ */
export function parseBotReviewComment(body) { export function parseBotReviewComment(body) {
if (typeof body !== 'string' || !body.includes('**')) return null; if (typeof body !== 'string' || !body.includes('**')) return null;
@@ -68,7 +72,14 @@ export function parseBotReviewComment(body) {
/** /**
* 把 PR 上的行內 review comment 依「檔案路徑 + 行號」收斂成對話(同一處的留言與回覆視為一段對話)。 * 把 PR 上的行內 review comment 依「檔案路徑 + 行號」收斂成對話(同一處的留言與回覆視為一段對話)。
* 對話只要任一則 comment 帶有 resolver 即視為已解決;同時嘗試解析出該對話對應的 bot finding。 * 對話只要任一則 comment 帶有 resolver 即視為已解決;同時嘗試解析出該對話對應的每一則 bot finding。
* 缺少 path 的留言(無法定位)會整筆跳過,不併入任何群組。
* @param {Array<{id?: any, path?: string, position?: number, new_position?: number,
* original_position?: number, body?: string, resolver?: any}>} comments - Gitea PR review comments 原始陣列(容許 null/undefined)。
* @returns {Array<{key: string, path: string, line: number, commentIds: Array, bodies: string[],
* resolved: boolean, botFinding: object|null, botFindings: object[], thread: string}>}
* 依 path|line 收斂後的對話陣列;botFinding 為 botFindings 的第一筆(相容舊邏輯),
* thread 為該對話所有留言內容以 '\n---\n' 串接的結果。
*/ */
export function groupConversations(comments) { export function groupConversations(comments) {
const groups = new Map(); const groups = new Map();
@@ -98,7 +109,13 @@ export function groupConversations(comments) {
/** codeWindow 預設的上下文行數(目標行上下各取幾行)。 */ /** codeWindow 預設的上下文行數(目標行上下各取幾行)。 */
export const CODE_WINDOW_RADIUS = 20; export const CODE_WINDOW_RADIUS = 20;
/** 取目標行附近的程式碼片段(含行號),讓 AI 對照判斷問題是否已解決。 */ /**
* 取目標行附近的程式碼片段(含 1-based 行號前綴),讓 AI 對照判斷問題是否已解決。
* @param {string} content - 檔案完整內容(falsy 時直接回傳空字串)。
* @param {number} lineNum - 目標行號(1-based);非正數或非有限數時退回以檔案第一行為中心。
* @param {number} [radius=CODE_WINDOW_RADIUS] - 目標行上下各擷取的行數。
* @returns {string} 擷取範圍內每行以 `"<行號>: <內容>"` 格式、以 \n 串接的字串;content 為空時回傳空字串。
*/
export function codeWindow(content, lineNum, radius = CODE_WINDOW_RADIUS) { export function codeWindow(content, lineNum, radius = CODE_WINDOW_RADIUS) {
if (!content) return ''; if (!content) return '';
const lines = content.split('\n'); const lines = content.split('\n');
@@ -123,7 +140,11 @@ const JUDGE_SYSTEM_PROMPT = [
/** /**
* 批次請 AI 將每個對話判為 resolved / false_positive / open。 * 批次請 AI 將每個對話判為 resolved / false_positive / open。
* 回傳與輸入等長、依 idx 對齊的 [{ idx, verdict }];無法辨識者一律視為 'open'(寧可保留)。 * @param {Array<{idx: number, path: string, line: number, thread: string, code: string}>} items - 待判斷的對話清單。
* @param {(system: string, user: string) => Promise<any>} [chatFn=chatJSON] - 呼叫 AI 並回傳已解析 JSON 的函式,
* 可由呼叫端注入替換(例如測試時 mock),預設使用 `./llm.js` 的 `chatJSON`。
* @returns {Promise<Array<{idx: number, verdict: ('resolved'|'false_positive'|'open')}>>}
* 與 items 等長、依 idx 對齊的判斷結果;AI 回傳非陣列、缺漏或不合法的 idx,其 verdict 一律降級為 'open'(寧可保留)。
*/ */
export async function judgeConversations(items, chatFn = chatJSON) { export async function judgeConversations(items, chatFn = chatJSON) {
if (!items || items.length === 0) return []; if (!items || items.length === 0) return [];
@@ -141,10 +162,11 @@ export async function judgeConversations(items, chatFn = chatJSON) {
} }
/** /**
* 將一段仍成立(open)對話對應的 bot finding 加入結轉清單,標記 is_new=false 表示為延續的舊問題。 * 將一段仍成立(open)對話對應的 bot findings 加入結轉清單,各自標記 is_new=false 表示為延續的舊問題。
* 若該對話無 botFinding 則不做任何事。 * 優先使用 conversation.botFindings(多筆);若無則退回 conversation.botFinding(單筆,相容舊資料)。
* 若該對話完全沒有可用的 bot finding 則不做任何事。
* @param {Array<object>} target - 接收結轉 finding 的陣列(會被就地 push)。 * @param {Array<object>} target - 接收結轉 finding 的陣列(會被就地 push)。
* @param {{botFinding: object|null}} conversation - 對話群組(取其 botFinding)。 * @param {{botFinding: object|null, botFindings?: object[]}} conversation - 對話群組。
* @returns {void} * @returns {void}
*/ */
function pushCarried(target, conversation) { function pushCarried(target, conversation) {
@@ -192,6 +214,11 @@ export function isSafeRepoPath(p) {
* - 'false_positive'(誤報)→ 寫入 exclusions 並從舊問題移除(excludedFindings); * - 'false_positive'(誤報)→ 寫入 exclusions 並從舊問題移除(excludedFindings);
* - 'open'(仍成立)→ 加入舊問題集合(carriedFindings)。 * - 'open'(仍成立)→ 加入舊問題集合(carriedFindings)。
* 任一外部呼叫失敗都降級處理(保守視為 open),不中斷整體 pipeline。 * 任一外部呼叫失敗都降級處理(保守視為 open),不中斷整體 pipeline。
* @param {{listComments?: () => Promise<Array>, resolveComment?: (id: any) => Promise<any>,
* getFileContent?: (path: string) => Promise<string>, judge?: Function}} [deps] - 可覆寫的外部相依(供測試注入)。
* @returns {Promise<{resolvedFindings: object[], excludedFindings: object[], carriedFindings: object[],
* resolvedCount: number, falsePositiveCount: number, openCount: number, closedCount: number, unresolvedCount: number}>}
* 收斂結果統計與三類 findings 清單。
*/ */
export async function reconcileConversations(deps = {}) { export async function reconcileConversations(deps = {}) {
const { const {
@@ -337,6 +364,9 @@ function findingSig(f) {
/** /**
* 從 findings 中移除「已解決對話」對應的問題(以檔案路徑+建議內容比對,避免行號漂移誤判)。 * 從 findings 中移除「已解決對話」對應的問題(以檔案路徑+建議內容比對,避免行號漂移誤判)。
* @param {Array<object>} findings - 目前的 findings 清單。
* @param {Array<{location?: string, suggestion?: string}>} [resolvedFindings=[]] - 本輪判定為已修復的 findings。
* @returns {Array<object>} 移除已解決項目後的新陣列;resolvedFindings 為空時回傳 findings 原引用(未複製)。
*/ */
export function dropResolvedFindings(findings, resolvedFindings = []) { export function dropResolvedFindings(findings, resolvedFindings = []) {
if (!resolvedFindings || resolvedFindings.length === 0) return findings; if (!resolvedFindings || resolvedFindings.length === 0) return findings;
@@ -346,6 +376,10 @@ export function dropResolvedFindings(findings, resolvedFindings = []) {
/** /**
* 把「未解決對話」對應、但目前 findings 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準)。 * 把「未解決對話」對應、但目前 findings 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準)。
* 有實際新增項目時會透過 ok() 輸出一行提示訊息。
* @param {Array<object>} findings - 目前的 findings 清單。
* @param {Array<{location?: string, suggestion?: string}>} [carriedFindings=[]] - 本輪判定仍成立(open)的 findings。
* @returns {Array<object>} 加回缺漏項目後的新陣列;carriedFindings 為空時回傳 findings 原引用(未複製)。
*/ */
export function addCarriedFindings(findings, carriedFindings = []) { export function addCarriedFindings(findings, carriedFindings = []) {
if (!carriedFindings || carriedFindings.length === 0) return findings; if (!carriedFindings || carriedFindings.length === 0) return findings;
+2 -2
View File
@@ -61,8 +61,8 @@ function readRoleFiles() {
/** /**
* 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。 * 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。
* *
* 供 Step3 產生 findings 階段使用。防守方角色(如 Paladin)不在回傳之列, * 供 Step5(角色分析產生 findings)階段使用。防守方角色(如 Paladin)不在回傳之列,
* 其裁決邏輯由去重 / 誤報過濾流程處理。 * 其裁決邏輯(誤報判定)由 `buildVerdictPrompt` 搭配去重 / 誤報過濾流程處理。
* *
* @returns {Array<ReturnType<typeof parseRoleFile>>} 攻擊方角色物件陣列。 * @returns {Array<ReturnType<typeof parseRoleFile>>} 攻擊方角色物件陣列。
* *
+38 -32
View File
@@ -1,9 +1,10 @@
import { describe, it, beforeEach, afterEach } from 'node:test'; import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
const ENV_KEYS = [ const ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_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 = {}; let saved = {};
@@ -19,48 +20,53 @@ afterEach(() => {
}); });
describe('getLLMConfig', () => { describe('getLLMConfig', () => {
it('exports the supported assistant CLI commands', () => {
assert.deepEqual(getLLMCLICommands(), ['codex', 'claude', 'agy', 'antigravity', 'opencode']);
});
it('returns null provider when no env vars set', () => { it('returns null provider when no env vars set', () => {
const cfg = getLLMConfig({ commandExistsFn: () => false }); const cfg = getLLMConfig();
assert.equal(cfg.provider, null); assert.equal(cfg.provider, null);
assert.deepEqual(cfg.apiKeys, []); assert.deepEqual(cfg.apiKeys, []);
assert.equal(cfg.baseURL, null);
assert.equal(cfg.model, null);
});
it('reads CLIProxyAPI settings from env', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig();
assert.equal(cfg.provider, 'cliproxyapi');
assert.deepEqual(cfg.apiKeys, ['secret']);
assert.equal(cfg.baseURL, 'https://proxy.example');
assert.equal(cfg.model, 'gpt-5.5');
assert.equal(cfg.command, null); assert.equal(cfg.command, null);
}); });
it('detects the first installed assistant CLI with defaults', () => { it('uses INPUT_MODEL over MODEL when both exist', () => {
const cfg = getLLMConfig({ commandExistsFn: command => command === 'claude' }); process.env.CLI_PROXY_API = 'https://proxy.example';
assert.equal(cfg.provider, 'claude'); process.env.INPUT_MODEL = 'gpt-5-mini';
assert.deepEqual(cfg.apiKeys, ['claude']); process.env.MODEL = 'gpt-5.5';
assert.equal(cfg.baseURL, null);
assert.equal(cfg.command, 'claude'); const cfg = getLLMConfig();
assert.equal(cfg.model, 'sonnet');
});
it('uses MODEL for the selected assistant CLI', () => {
process.env.MODEL = 'gpt-5-mini';
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' });
assert.equal(cfg.provider, 'codex');
assert.equal(cfg.command, 'codex');
assert.equal(cfg.model, 'gpt-5-mini'); assert.equal(cfg.model, 'gpt-5-mini');
}); });
it('detects Antigravity through the agy command', () => { it('uses CLI_PROXY_API_MODEL when INPUT_MODEL is missing', () => {
const cfg = getLLMConfig({ commandExistsFn: command => command === 'agy' }); process.env.CLI_PROXY_API = 'https://proxy.example';
assert.equal(cfg.provider, 'antigravity'); process.env.CLI_PROXY_API_MODEL = 'gpt-5.4-mini';
assert.equal(cfg.command, 'agy'); process.env.MODEL = 'gpt-5.5';
assert.equal(cfg.model, 'gemini-2.5-flash');
const cfg = getLLMConfig();
assert.equal(cfg.model, 'gpt-5.4-mini');
}); });
it('can force a CLI with AI_ASSISTANT_CLI', () => { it('returns null provider when CLI_PROXY_API is missing', () => {
process.env.AI_ASSISTANT_CLI = 'opencode'; process.env.MODEL = 'gpt-5.5';
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro'; const cfg = getLLMConfig();
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' || command === 'opencode' }); assert.equal(cfg.provider, null);
assert.equal(cfg.provider, 'opencode'); assert.equal(cfg.baseURL, null);
assert.equal(cfg.command, 'opencode');
assert.equal(cfg.model, 'google/gemini-2.5-pro');
}); });
it('uses an insecure HTTPS agent for OpenCode', () => { it('uses an insecure HTTPS agent for OpenCode', () => {
+77 -91
View File
@@ -1,104 +1,92 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises'; import axios from 'axios';
import { tmpdir } from 'os';
import { join } from 'path';
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js'; import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
const ENV_KEYS = [ const ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER', 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
'FAKE_AI_STDOUT', 'FAKE_AI_STDERR', 'FAKE_AI_EXIT', 'FAKE_AI_STDIN_PATH', 'FAKE_AI_ARGS_PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
]; ];
let saved = {}; let saved = {};
let tempDir;
beforeEach(() => { beforeEach(() => {
saved = {}; saved = {};
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
tempDir = null;
}); });
afterEach(async () => { afterEach(() => {
for (const k of ENV_KEYS) { for (const k of ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k]; if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k]; else process.env[k] = saved[k];
} }
if (tempDir) await rm(tempDir, { recursive: true, force: true });
mock.restoreAll(); mock.restoreAll();
}); });
async function installFakeCLI(command = 'codex') { describe('chat - CLIProxyAPI', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ai-cli-test-'));
const script = join(tempDir, command);
await writeFile(script, `#!/bin/sh
if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi
if [ -n "$FAKE_AI_STDIN_PATH" ]; then /bin/cat > "$FAKE_AI_STDIN_PATH"; else /bin/cat >/dev/null; fi
if [ -n "$FAKE_AI_STDERR" ]; then printf '%s' "$FAKE_AI_STDERR" >&2; fi
if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi
exit "\${FAKE_AI_EXIT:-0}"
`);
await chmod(script, 0o755);
process.env.PATH = tempDir;
return { stdinPath: join(tempDir, 'stdin.txt'), argsPath: join(tempDir, 'args.txt') };
}
describe('chat - assistant CLI', async () => {
const { chat } = await import('../llm.js'); const { chat } = await import('../llm.js');
it('runs the detected CLI with MODEL and sends the prompts through stdin', async () => { it('posts the prompts to /v1/chat/completions and returns the response text', async () => {
const { stdinPath, argsPath } = await installFakeCLI('codex'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
process.env.MODEL = 'gpt-5-mini'; process.env.MODEL = 'gpt-5-mini';
process.env.FAKE_AI_STDOUT = 'cli response';
process.env.FAKE_AI_STDIN_PATH = stdinPath; let capturedUrl, capturedBody, capturedOpts;
process.env.FAKE_AI_ARGS_PATH = argsPath; mock.method(axios, 'post', async (url, body, opts) => {
capturedUrl = url;
capturedBody = body;
capturedOpts = opts;
return {
data: {
choices: [{ message: { content: 'cli response' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
},
headers: { 'x-ratelimit-remaining-tokens': '80', 'x-ratelimit-limit-tokens': '100' },
};
});
const result = await chat('sys', 'user'); const result = await chat('sys', 'user');
assert.equal(result, 'cli response'); assert.equal(result, 'cli response');
assert.match(await readFile(argsPath, 'utf8'), /exec --model gpt-5-mini/); assert.equal(capturedUrl, 'https://proxy.example/v1/chat/completions');
const prompt = await readFile(stdinPath, 'utf8'); assert.equal(capturedBody.model, 'gpt-5-mini');
assert.match(prompt, /<system>\nsys\n<\/system>/); assert.deepEqual(capturedBody.messages, [
assert.match(prompt, /<user>\nuser\n<\/user>/); { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
{ role: 'user', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。\n\n<system>\nsys\n</system>\n\n<user>\nuser\n</user>' },
]);
assert.equal(capturedBody.temperature, 0);
assert.equal(capturedBody.stream, false);
assert.equal(capturedOpts.headers.Authorization, 'Bearer secret');
}); });
it('can force opencode with AI_ASSISTANT_CLI', async () => { it('omits model from the request body when auto selection is allowed', async () => {
const { argsPath } = await installFakeCLI('opencode'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.AI_ASSISTANT_CLI = 'opencode'; process.env.CLI_PROXY_API_KEY = 'secret';
process.env.MODEL = 'google/gemini-2.5-pro';
process.env.FAKE_AI_STDOUT = 'ok'; let capturedBody;
process.env.FAKE_AI_ARGS_PATH = argsPath; mock.method(axios, 'post', async (url, body) => {
capturedBody = body;
return {
data: { choices: [{ message: { content: 'cli response' } }] },
headers: {},
};
});
const result = await chat('sys', 'user'); const result = await chat('sys', 'user');
assert.equal(result, 'ok'); assert.equal(result, 'cli response');
assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/); assert.equal(Object.hasOwn(capturedBody, 'model'), false);
}); });
it('runs Antigravity through agy with MODEL and prompt argument', async () => { it('throws an error when the API fails', async () => {
const { stdinPath, argsPath } = await installFakeCLI('agy'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.AI_ASSISTANT_CLI = 'agy'; process.env.MODEL = 'gpt-5-mini';
process.env.MODEL = 'gemini-2.5-pro'; mock.method(axios, 'post', async () => {
process.env.FAKE_AI_STDOUT = 'antigravity response'; const e = new Error('Request failed');
process.env.FAKE_AI_STDIN_PATH = stdinPath; e.response = { status: 401, data: { error: { message: 'access token revoked' } } };
process.env.FAKE_AI_ARGS_PATH = argsPath; throw e;
});
const result = await chat('sys', 'user'); await assert.rejects(() => chat('sys', 'user'), /401/);
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
assert.equal(result, 'antigravity response');
const args = await readFile(argsPath, 'utf8');
assert.match(args, /-p .*--model gemini-2.5-pro/s);
assert.match(args, /<system>\nsys\n<\/system>/);
assert.equal(await readFile(stdinPath, 'utf8'), '');
});
it('throws an error when the CLI fails instead of exiting the process', async () => {
await installFakeCLI('codex');
process.env.FAKE_AI_EXIT = '2';
process.env.FAKE_AI_STDERR = 'provider overloaded';
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit should not be called'); });
await assert.rejects(() => chat('sys', 'user'), /provider overloaded/);
assert.equal(exitMock.mock.calls.length, 0);
}); });
}); });
@@ -106,8 +94,9 @@ describe('chatJSON', async () => {
const { chatJSON } = await import('../llm.js'); const { chatJSON } = await import('../llm.js');
it('parses plain JSON response', async () => { it('parses plain JSON response', async () => {
await installFakeCLI('codex'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.FAKE_AI_STDOUT = '[{"level":"critical"}]'; process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '[{"level":"critical"}]' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -115,8 +104,9 @@ describe('chatJSON', async () => {
}); });
it('strips markdown code block before parsing', async () => { it('strips markdown code block before parsing', async () => {
await installFakeCLI('codex'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.FAKE_AI_STDOUT = '```json\n[{"level":"info"}]\n```'; process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '```json\n[{"level":"info"}]\n```' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -124,8 +114,9 @@ describe('chatJSON', async () => {
}); });
it('extracts JSON array from surrounding prose', async () => { it('extracts JSON array from surrounding prose', async () => {
await installFakeCLI('codex'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.FAKE_AI_STDOUT = '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.'; process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -133,8 +124,9 @@ describe('chatJSON', async () => {
}); });
it('extracts JSON object from surrounding prose', async () => { it('extracts JSON object from surrounding prose', async () => {
await installFakeCLI('codex'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.FAKE_AI_STDOUT = '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}'; process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -142,8 +134,9 @@ describe('chatJSON', async () => {
}); });
it('returns [] when JSON is invalid', async () => { it('returns [] when JSON is invalid', async () => {
await installFakeCLI('codex'); process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.FAKE_AI_STDOUT = 'not json'; process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: 'not json' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -239,26 +232,19 @@ describe('extractJSONText', () => {
}); });
describe('extractMeaningfulError', () => { describe('extractMeaningfulError', () => {
it('抽出尾端真正的錯誤,而非開頭的 codex banner/回顯 prompt', () => { it('抽出尾端真正的錯誤,而非前段雜訊', () => {
const raw = [ const raw = [
'OpenAI Codex v0.142.5', 'HTTP/1.1 401 Unauthorized',
'--------', '{"error":{"message":"access token revoked"}}',
'workdir: /workspace/actions/ai-code-review', 'trace: proxy request failed',
'model: gpt-5.4-mini', 'ERROR: access token revoked',
'reasoning effort: none',
'--------',
'user',
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
'ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 401 Unauthorized',
'ERROR: Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
].join('\n'); ].join('\n');
const result = extractMeaningfulError(raw); const result = extractMeaningfulError(raw);
assert.match(result, /401 Unauthorized/); assert.match(result, /401 Unauthorized/);
assert.match(result, /refresh token was revoked/); assert.match(result, /access token revoked/);
assert.doesNotMatch(result, /workdir:/); assert.doesNotMatch(result, /trace:/);
assert.doesNotMatch(result, /請依照以下系統指示/);
}); });
it('抽不到錯誤行時退取尾段(不取開頭)', () => { it('抽不到錯誤行時退取尾段(不取開頭)', () => {
+12 -16
View File
@@ -4,6 +4,8 @@ import { section, step, line, input, output, result, ok, warn, error } from '../
afterEach(() => mock.restoreAll()); afterEach(() => mock.restoreAll());
const TS = '\\[\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2}\\]';
describe('log helpers', () => { describe('log helpers', () => {
it('formats section and step messages', () => { it('formats section and step messages', () => {
const calls = []; const calls = [];
@@ -14,10 +16,8 @@ describe('log helpers', () => {
section('Pipeline'); section('Pipeline');
step('Step1', 'Start'); step('Step1', 'Start');
assert.deepEqual(calls, [ assert.match(calls[0], new RegExp(`^\\n${TS}\\[INF\\]: === Pipeline ===$`));
'\n=== Pipeline ===', assert.match(calls[1], new RegExp(`^\\n${TS}\\[INF\\]: \\[Step1\\] Start$`));
'\n[Step1] Start',
]);
}); });
it('formats line and ok messages with console.log', () => { it('formats line and ok messages with console.log', () => {
@@ -29,10 +29,8 @@ describe('log helpers', () => {
line('hello'); line('hello');
ok('done'); ok('done');
assert.deepEqual(calls, [ assert.match(calls[0], new RegExp(`^${TS}\\[INF\\]: - hello$`));
' - hello', assert.match(calls[1], new RegExp(`^${TS}\\[INF\\]: ✓ done$`));
' ✓ done',
]);
}); });
it('formats input/output and pass/fail result messages', () => { it('formats input/output and pass/fail result messages', () => {
@@ -46,12 +44,10 @@ describe('log helpers', () => {
result(true, '通過'); result(true, '通過');
result(false, '未通過'); result(false, '未通過');
assert.deepEqual(calls, [ assert.match(calls[0], new RegExp(`^${TS}\\[INF\\]: ← 輸入:5 筆$`));
' ← 輸入:5 筆', assert.match(calls[1], new RegExp(`^${TS}\\[INF\\]: → 輸出:3 筆$`));
' → 輸出:3 筆', assert.match(calls[2], new RegExp(`^${TS}\\[INF\\]: ✅ 成功:通過$`));
' ✅ 成功:通過', assert.match(calls[3], new RegExp(`^${TS}\\[ERR\\]: ❌ 失敗:未通過$`));
' ❌ 失敗:未通過',
]);
}); });
it('formats warn messages with console.warn', () => { it('formats warn messages with console.warn', () => {
@@ -62,7 +58,7 @@ describe('log helpers', () => {
warn('careful'); warn('careful');
assert.deepEqual(calls, [' ! careful']); assert.match(calls[0], new RegExp(`^${TS}\\[WRN\\]: ! careful$`));
}); });
it('formats error messages with console.error', () => { it('formats error messages with console.error', () => {
@@ -73,6 +69,6 @@ describe('log helpers', () => {
error('boom'); error('boom');
assert.deepEqual(calls, [' x boom']); assert.match(calls[0], new RegExp(`^${TS}\\[ERR\\]: x boom$`));
}); });
}); });
+7 -1
View File
@@ -17,7 +17,7 @@ function baseStubs() {
config: { config: {
GITEA_REPOSITORY: 'owner/repo', PR_NUMBER: '1', PR_HEAD_BRANCH: 'feat', PR_BASE_BRANCH: 'develop', GITEA_REPOSITORY: 'owner/repo', PR_NUMBER: '1', PR_HEAD_BRANCH: 'feat', PR_BASE_BRANCH: 'develop',
FINDINGS_PATH: '.gitea/ai-review/findings.json', EXCLUSIONS_PATH: '.gitea/ai-review/exclusions.json', FINDINGS_PATH: '.gitea/ai-review/findings.json', EXCLUSIONS_PATH: '.gitea/ai-review/exclusions.json',
getLLMConfig: () => ({ provider: 'codex', apiKeys: ['codex'], baseURL: null, model: 'gpt-5.5', command: 'codex' }), getLLMConfig: () => ({ provider: 'cliproxyapi', apiKeys: ['secret'], baseURL: 'https://proxy.example', model: 'gpt-5.5', command: null }),
}, },
roles: { loadRoles: () => [{ name: 'Mage' }], getRoleIntro: () => 'intro' }, roles: { loadRoles: () => [{ name: 'Mage' }], getRoleIntro: () => 'intro' },
gitea: { gitea: {
@@ -131,6 +131,12 @@ describe('main pipeline', () => {
assert.equal(await runMain(), 0); 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 () => { it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => {
assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0); assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0);
}); });
+103 -130
View File
@@ -1,52 +1,38 @@
import { describe, it, afterEach, mock } from 'node:test'; import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import axios from 'axios'; import axios from 'axios';
import { mkdtemp, writeFile, chmod, rm } from 'fs/promises'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js';
import { tmpdir } from 'os';
import { join } from 'path';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchCodexModels, runPreflight } from '../preflight.js';
const LLM_ENV_KEYS = [ const LLM_ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', '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',
]; ];
const ORIGINAL_PATH = process.env.PATH;
function clearLLMEnv() { function clearLLMEnv() {
for (const k of LLM_ENV_KEYS) delete process.env[k]; for (const k of LLM_ENV_KEYS) delete process.env[k];
} }
let tempDir; afterEach(() => {
afterEach(async () => {
mock.restoreAll(); mock.restoreAll();
clearLLMEnv(); clearLLMEnv();
process.env.PATH = ORIGINAL_PATH;
if (tempDir) await rm(tempDir, { recursive: true, force: true });
tempDir = null;
}); });
async function installFakeCLI(command = 'codex') {
tempDir = await mkdtemp(join(tmpdir(), 'preflight-cli-test-'));
const script = join(tempDir, command);
await writeFile(script, '#!/bin/sh\nexit 0\n');
await chmod(script, 0o755);
process.env.PATH = tempDir;
}
describe('checkRequiredEnv', () => { describe('checkRequiredEnv', () => {
it('reports all three missing when nothing provided', () => { it('reports all missing values when nothing is provided', () => {
const result = checkRequiredEnv({ token: '', repo: '', pr: '' }); const result = checkRequiredEnv({ token: '', repo: '', pr: '' });
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER']); assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER', 'CLI_PROXY_API']);
}); });
it('reports only the missing ones', () => { it('reports only the missing ones', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = checkRequiredEnv({ token: 't', repo: '', pr: '5' }); const result = checkRequiredEnv({ token: 't', repo: '', pr: '5' });
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.deepEqual(result.missing, ['GITEA_REPOSITORY']); assert.deepEqual(result.missing, ['GITEA_REPOSITORY']);
}); });
it('ok when all provided', () => { it('ok when all required values are provided', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = checkRequiredEnv({ token: 't', repo: 'owner/repo', pr: '5' }); const result = checkRequiredEnv({ token: 't', repo: 'owner/repo', pr: '5' });
assert.equal(result.ok, true); assert.equal(result.ok, true);
assert.deepEqual(result.missing, []); assert.deepEqual(result.missing, []);
@@ -84,7 +70,7 @@ describe('verifyGiteaToken', () => {
}); });
describe('verifyCommentToken', () => { describe('verifyCommentToken', () => {
it('skips when no comment token provided', async () => { it('skips when no comment token is provided', async () => {
const result = await verifyCommentToken(''); const result = await verifyCommentToken('');
assert.deepEqual(result, { ok: true, skipped: true }); assert.deepEqual(result, { ok: true, skipped: true });
}); });
@@ -118,129 +104,109 @@ describe('verifyCommentToken', () => {
}); });
}); });
describe('verifyLLM', () => { describe('fetchLLMModels', () => {
it('fails when no supported assistant CLI is detected', async () => { it('returns the model ids on HTTP 200', async () => {
clearLLMEnv();
process.env.AI_ASSISTANT_CLI = 'no-such-ai-cli';
process.env.PATH = '';
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /AI 助理 CLI/);
});
it('passes when a supported assistant CLI is detected and the model is in the codex list', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, true);
assert.equal(result.provider, 'codex');
assert.equal(result.command, 'codex');
assert.equal(result.model, 'gpt-5.4-mini');
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
});
it('fails when codex auth is invalid (model list check reports 401)', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'codex');
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/);
});
it('fails when the configured model is not in the codex available list', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-9-imaginary';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, false);
assert.match(result.error, /不在 codex 可用清單/);
assert.match(result.error, /gpt-5\.4-mini/);
});
it('fails when a requested CLI is not installed', async () => {
clearLLMEnv();
process.env.AI_ASSISTANT_CLI = 'missing-cli';
process.env.PATH = '';
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /AI 助理 CLI/);
});
});
describe('fetchCodexModels', () => {
async function writeAuth(json) {
tempDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-'));
const authPath = join(tempDir, 'auth.json');
await writeFile(authPath, JSON.stringify(json));
return authPath;
}
it('returns the model slugs on HTTP 200', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'tok', account_id: 'acc' } });
let capturedUrl, capturedHeaders; let capturedUrl, capturedHeaders;
const result = await fetchCodexModels({ const result = await fetchLLMModels({
authPath, baseURL: 'https://proxy.example/',
apiKey: 'tok',
fetchImpl: async (url, opts) => { fetchImpl: async (url, opts) => {
capturedUrl = url; capturedUrl = url;
capturedHeaders = opts.headers; capturedHeaders = opts.headers;
return { status: 200, ok: true, json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }] }) }; return { status: 200, ok: true, json: async () => ({ data: [{ id: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }, 'custom-model'] }) };
}, },
}); });
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }); assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini', 'custom-model'] });
assert.match(capturedUrl, /client_version=/); assert.equal(capturedUrl, 'https://proxy.example/v1/models');
assert.equal(capturedHeaders['Authorization'], 'Bearer tok'); assert.equal(capturedHeaders.Authorization, 'Bearer tok');
assert.equal(capturedHeaders['chatgpt-account-id'], 'acc');
}); });
it('reports an auth failure on HTTP 401', async () => { it('reports an auth failure on HTTP 401', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'revoked' } }); const result = await fetchLLMModels({
const result = await fetchCodexModels({ baseURL: 'https://proxy.example',
authPath, apiKey: 'revoked',
fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }), fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }),
}); });
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.match(result.error, /HTTP 401/); assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/); assert.match(result.error, /CLIProxyAPI/);
}); });
it('fails when the auth file cannot be read', async () => { it('fails when the base URL is missing', async () => {
const result = await fetchCodexModels({ const result = await fetchLLMModels({ baseURL: '', apiKey: 'tok', fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
authPath: join(tmpdir(), 'definitely-missing-codex-auth-xyz.json'), assert.equal(result.ok, false);
fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({ models: [] }) }), assert.match(result.error, /CLI_PROXY_API/);
});
});
describe('verifyLLM', () => {
it('fails when no CLIProxyAPI is configured', async () => {
clearLLMEnv();
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /CLIProxyAPI/);
});
it('passes when the configured model is in the proxy model list', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
}); });
assert.equal(result.ok, false);
assert.match(result.error, /無法讀取 codex 認證檔/); assert.equal(result.ok, true);
assert.equal(result.provider, 'cliproxyapi');
assert.equal(result.command, null);
assert.equal(result.model, 'gpt-5.4-mini');
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
}); });
it('fails when the auth file lacks an access_token', async () => { it('passes when no model is specified and the proxy is reachable', async () => {
const authPath = await writeAuth({ tokens: {} }); clearLLMEnv();
const result = await fetchCodexModels({ authPath, fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) }); 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';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: false, error: 'CLIProxyAPI 認證失效(HTTP 401)——API key 已被撤銷或過期,請更新 CLI_PROXY_API_KEY secret' }),
});
assert.equal(result.ok, false); assert.equal(result.ok, false);
assert.match(result.error, /缺少 tokens\.access_token/); assert.equal(result.provider, 'cliproxyapi');
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /CLI_PROXY_API_KEY/);
});
it('fails when the configured model is not in the proxy available list', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-9-imaginary';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, false);
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
assert.match(result.error, /gpt-9-imaginary/);
}); });
}); });
@@ -251,7 +217,7 @@ describe('runPreflight', () => {
verifyToken: async () => ({ ok: true }), verifyToken: async () => ({ ok: true }),
verifyComment: async () => ({ ok: true }), verifyComment: async () => ({ ok: true }),
verifyRemote: () => ({ ok: true }), verifyRemote: () => ({ ok: true }),
verifyLLMFn: async () => ({ ok: true, provider: 'codex' }), verifyLLMFn: async () => ({ ok: true, provider: 'cliproxyapi', command: null, model: 'gpt-5.5', models: ['gpt-5.5'] }),
...overrides, ...overrides,
}; };
} }
@@ -262,11 +228,13 @@ describe('runPreflight', () => {
}); });
it('returns true when every verification step succeeds', async () => { it('returns true when every verification step succeeds', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps()); const result = await runPreflight('/ws', makeDeps());
assert.equal(result, true); assert.equal(result, true);
}); });
it('returns true when the comment token check is skipped', async () => { it('returns true when the comment token check is skipped', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps({ const result = await runPreflight('/ws', makeDeps({
verifyComment: async () => ({ ok: true, skipped: true }), verifyComment: async () => ({ ok: true, skipped: true }),
})); }));
@@ -274,6 +242,7 @@ describe('runPreflight', () => {
}); });
it('returns false when the Gitea token check fails', async () => { it('returns false when the Gitea token check fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
let remoteCalled = false; let remoteCalled = false;
const result = await runPreflight('/ws', makeDeps({ const result = await runPreflight('/ws', makeDeps({
verifyToken: async () => ({ ok: false, error: 'HTTP 401' }), verifyToken: async () => ({ ok: false, error: 'HTTP 401' }),
@@ -284,6 +253,7 @@ describe('runPreflight', () => {
}); });
it('returns false when the comment token check fails', async () => { it('returns false when the comment token check fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps({ const result = await runPreflight('/ws', makeDeps({
verifyComment: async () => ({ ok: false, error: 'HTTP 401' }), verifyComment: async () => ({ ok: false, error: 'HTTP 401' }),
})); }));
@@ -291,6 +261,7 @@ describe('runPreflight', () => {
}); });
it('returns false when git remote access fails', async () => { it('returns false when git remote access fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
let llmCalled = false; let llmCalled = false;
const result = await runPreflight('/ws', makeDeps({ const result = await runPreflight('/ws', makeDeps({
verifyRemote: () => ({ ok: false, error: 'auth failed' }), verifyRemote: () => ({ ok: false, error: 'auth failed' }),
@@ -301,13 +272,15 @@ describe('runPreflight', () => {
}); });
it('returns false when LLM verification fails', async () => { it('returns false when LLM verification fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps({ const result = await runPreflight('/ws', makeDeps({
verifyLLMFn: async () => ({ ok: false, error: 'AI 助理 CLI 驗證失敗' }), verifyLLMFn: async () => ({ ok: false, error: 'CLIProxyAPI 驗證失敗' }),
})); }));
assert.equal(result, false); assert.equal(result, false);
}); });
it('passes the workspace through to the remote-access check', async () => { it('passes the workspace through to the remote-access check', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
let captured; let captured;
await runPreflight('/custom/ws', makeDeps({ await runPreflight('/custom/ws', makeDeps({
verifyRemote: (ws) => { captured = ws; return { ok: true }; }, verifyRemote: (ws) => { captured = ws; return { ok: true }; },
+69 -7
View File
@@ -20,6 +20,11 @@ function num(x) {
* 支援:OpenAI 相容 usage、OpenAI Responses(input/output_tokens)、 * 支援:OpenAI 相容 usage、OpenAI Responses(input/output_tokens)、
* Gemini usageMetadata、Ollama 原生 eval_count、OpenCode tokens。 * Gemini usageMetadata、Ollama 原生 eval_count、OpenCode tokens。
* 回應中沒有任何可辨識的 usage 時回傳 null。 * 回應中沒有任何可辨識的 usage 時回傳 null。
* @param {*} data 平台回應本體(通常為 HTTP response 的 JSON 內容)。
* @returns {{promptTokens:number, completionTokens:number, totalTokens:number}|null}
* 正規化後的 usage;資料非物件或無任何可辨識欄位時為 null。
* @remarks 每個分支在 prompt、completion、total 三者皆為 0 時視為「未辨識」而繼續往下嘗試其他平台格式;
* 若某平台的合法回應恰好三者皆為 0,會被誤判為未辨識並回傳 null。是否為預期行為需人工確認。
*/ */
export function extractUsage(data) { export function extractUsage(data) {
if (!data || typeof data !== 'object') return null; if (!data || typeof data !== 'object') return null;
@@ -61,7 +66,12 @@ export function extractUsage(data) {
return null; return null;
} }
/** 記錄一次 LLM 呼叫的 usage(無法解析時仍計一次呼叫,但 token 計 0)。 */ /**
* 記錄一次 LLM 呼叫的 usage(無法解析時仍計一次呼叫,但 token 計 0),並累加進模組級 runUsage。
* @param {*} data 平台回應本體,會轉交 extractUsage 解析。
* @returns {{promptTokens:number, completionTokens:number, totalTokens:number}|null}
* 本次解析出的 usage;無法解析時為 null(但呼叫次數仍已累加)。
*/
export function recordUsage(data) { export function recordUsage(data) {
runUsage.calls += 1; runUsage.calls += 1;
const u = extractUsage(data); const u = extractUsage(data);
@@ -73,12 +83,19 @@ export function recordUsage(data) {
return u; return u;
} }
/** 取得本次執行至今的 token 累計(複本)。 */ /**
* 取得本次執行至今的 token 累計(複本)。
* @returns {{calls:number, promptTokens:number, completionTokens:number, totalTokens:number}}
* 目前累計的淺拷貝,修改回傳值不影響內部狀態。
*/
export function getRunUsage() { export function getRunUsage() {
return { ...runUsage }; return { ...runUsage };
} }
/** 重置累計(測試用)。 */ /**
* 重置累計(測試用)。
* @returns {void}
*/
export function resetRunUsage() { export function resetRunUsage() {
runUsage.calls = 0; runUsage.calls = 0;
runUsage.promptTokens = 0; runUsage.promptTokens = 0;
@@ -105,6 +122,8 @@ function lowerCaseKeys(obj) {
* 從回應 header 擷取速率配額剩餘量/上限。 * 從回應 header 擷取速率配額剩餘量/上限。
* 支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropic(anthropic-ratelimit-tokens-*), * 支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropic(anthropic-ratelimit-tokens-*),
* 兩者皆缺時退而採用 requests 維度。記錄「最近一次」的數值(即最新的視窗狀態)。 * 兩者皆缺時退而採用 requests 維度。記錄「最近一次」的數值(即最新的視窗狀態)。
* @param {Object<string, *>|null|undefined} headers HTTP 回應 headers(大小寫不拘)。
* @returns {void}
*/ */
export function recordRateLimit(headers) { export function recordRateLimit(headers) {
if (!headers || typeof headers !== 'object') return; if (!headers || typeof headers !== 'object') return;
@@ -126,12 +145,19 @@ export function recordRateLimit(headers) {
rateLimit.kind = kind; rateLimit.kind = kind;
} }
/** 取得最近一次的速率配額快照(複本)。 */ /**
* 取得最近一次的速率配額快照(複本)。
* @returns {{hasData:boolean, remaining:number|null, limit:number|null, kind:('tokens'|'requests'|null)}}
* 目前快照的淺拷貝。
*/
export function getRateLimit() { export function getRateLimit() {
return { ...rateLimit }; return { ...rateLimit };
} }
/** 重置速率配額快照(測試用)。 */ /**
* 重置速率配額快照(測試用)。
* @returns {void}
*/
export function resetRateLimit() { export function resetRateLimit() {
rateLimit.hasData = false; rateLimit.hasData = false;
rateLimit.remaining = null; rateLimit.remaining = null;
@@ -190,6 +216,7 @@ async function fetchOpenRouterQuota({ apiKey, baseURL }, get) {
* 本地/自架服務(ollama/opencode)則回報「不適用」。 * 本地/自架服務(ollama/opencode)則回報「不適用」。
*/ */
const QUOTA_STRATEGIES = { const QUOTA_STRATEGIES = {
cliproxyapi: async () => ({ available: false, reason: 'CLIProxyAPI 不提供帳號額度資訊' }),
openai: async (cfg, get) => { openai: async (cfg, get) => {
if (isOpenRouterBaseURL(cfg.baseURL)) return fetchOpenRouterQuota(cfg, get); if (isOpenRouterBaseURL(cfg.baseURL)) return fetchOpenRouterQuota(cfg, get);
return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' }; return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' };
@@ -205,6 +232,15 @@ const QUOTA_STRATEGIES = {
/** /**
* 取得指定平台的帳號額度。任何失敗都降級為 { available: false, reason },不丟例外。 * 取得指定平台的帳號額度。任何失敗都降級為 { available: false, reason },不丟例外。
* deps.get 可注入以利測試(預設 axios.get)。 * deps.get 可注入以利測試(預設 axios.get)。
* @param {string} provider 平台識別字串(如 'openai'、'claude'、'cliproxyapi' 等,須存在於 QUOTA_STRATEGIES)。
* @param {{apiKey?:string, apiKeys?:string[], baseURL?:string}} [config={}] 該平台連線設定。
* @param {{get?: function(string, object): Promise<{data:*}>}} [deps={}]
* 可注入依賴,deps.get 為 HTTP GET 函式,預設 axios.get(供測試替換)。
* @returns {Promise<{available:boolean, reason?:string, used?:number, limit?:number|null,
* remaining?:number|null, currency?:string, source?:string}>}
* 額度資訊;不支援或查詢失敗時 available 為 false 並附 reason。
* @remarks apiKeys 為陣列時目前固定取第一個元素(而非合併或輪詢多組 key),
* 此為既有設計決策,程式碼未說明理由,需人工確認是否為預期行為。
*/ */
export async function fetchAccountQuota(provider, config = {}, deps = {}) { export async function fetchAccountQuota(provider, config = {}, deps = {}) {
const get = deps.get || axios.get; const get = deps.get || axios.get;
@@ -274,6 +310,12 @@ function calculatePercent(remaining, limit) {
* 1. 帳號額度(quota 有有效上限)→ 剩餘 credits / 上限; * 1. 帳號額度(quota 有有效上限)→ 剩餘 credits / 上限;
* 2. 速率配額(rate limit header,有有效上限)→ 當前視窗剩餘 / 上限; * 2. 速率配額(rate limit header,有有效上限)→ 當前視窗剩餘 / 上限;
* 上限或剩餘為無效值(null/0/負數/NaN/Infinity)時跳過計算,落到 { percent: null, reason }。 * 上限或剩餘為無效值(null/0/負數/NaN/Infinity)時跳過計算,落到 { percent: null, reason }。
* @param {{available:boolean, limit?:number|null, remaining?:number|null, used?:number, reason?:string, currency?:string}|null|undefined} quota
* fetchAccountQuota 的回傳結果。
* @param {{hasData:boolean, remaining?:number|null, limit?:number|null, kind?:string}|null|undefined} rate
* getRateLimit 的回傳結果。
* @returns {{percent:number, basis:string, remaining:number, limit:number, unit:string}|{percent:null, reason:string}}
* 可計算時附百分比與明細;否則附無法計算的原因。
*/ */
export function resolveRemainingPercent(quota, rate) { export function resolveRemainingPercent(quota, rate) {
if (quota?.available && quota.limit != null) { if (quota?.available && quota.limit != null) {
@@ -311,7 +353,16 @@ function remainingLine(pct) {
return `剩餘可用 **${pct.percent}%**(${detail})`; return `剩餘可用 **${pct.percent}%**(${detail})`;
} }
/** 產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊。 */ /**
* 產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊。
* @param {string} provider 平台識別字串(如 'openai'、'claude')。
* @param {string} model 模型名稱。
* @param {{calls:number, promptTokens:number, completionTokens:number, totalTokens:number}} usage
* getRunUsage 的回傳結果。
* @param {*} quota fetchAccountQuota 的回傳結果,轉交 resolveRemainingPercent。
* @param {*} rate getRateLimit 的回傳結果,轉交 resolveRemainingPercent。
* @returns {string} 多行 Markdown 字串(含標題、表格、剩餘可用說明)。
*/
export function formatUsageStats(provider, model, usage, quota, rate) { export function formatUsageStats(provider, model, usage, quota, rate) {
const pct = resolveRemainingPercent(quota, rate); const pct = resolveRemainingPercent(quota, rate);
const lines = [ const lines = [
@@ -330,7 +381,18 @@ export function formatUsageStats(provider, model, usage, quota, rate) {
return lines.join('\n'); return lines.join('\n');
} }
/** 產生單行 log 用的使用量摘要。 */ /**
* 產生單行 log 用的使用量摘要。
* @param {string} provider 平台識別字串(如 'openai'、'claude')。
* @param {string} model 模型名稱。
* @param {{calls:number, promptTokens:number, completionTokens:number, totalTokens:number}} usage
* getRunUsage 的回傳結果。
* @param {*} quota fetchAccountQuota 的回傳結果,轉交 resolveRemainingPercent。
* @param {*} rate getRateLimit 的回傳結果,轉交 resolveRemainingPercent。
* @returns {string} 單行純文字摘要(token 用量 + 剩餘可用百分比或原因)。
* @remarks tokenPart 中的 token 數字未套用 fmt 千分位格式化,與 formatUsageStats 的表格欄位處理方式不同,
* 是否為刻意設計(單行 log 保持精簡)或屬遺漏,需人工確認。
*/
export function formatUsageStatsLine(provider, model, usage, quota, rate) { export function formatUsageStatsLine(provider, model, usage, quota, rate) {
const pct = resolveRemainingPercent(quota, rate); const pct = resolveRemainingPercent(quota, rate);
const tokenPart = `本次 ${provider}/${model}: 提示${usage.promptTokens} + 回應${usage.completionTokens} = ${usage.totalTokens} token(${usage.calls} 次呼叫)`; const tokenPart = `本次 ${provider}/${model}: 提示${usage.promptTokens} + 回應${usage.completionTokens} = ${usage.totalTokens} token(${usage.calls} 次呼叫)`;