From 0eb30cf9d4a31afa9b9ed6e2f7054e46b1814148 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 06:05:58 +0000 Subject: [PATCH 01/12] refresh review pipeline --- .gitea/workflows/ci.yaml | 6 +- .gitea/workflows/master.yaml | 6 +- .gitea/workflows/readme.md | 79 +- Dockerfile | 10 - README.md | 1451 ----------------------------- action.yml | 2 +- dockerfile | 23 + entrypoint.sh | 7 + readme.md | 1703 ++++++++++++++++++++++++++++++++++ src/comments.js | 113 ++- src/config.js | 23 +- src/findings.js | 156 +++- src/git.js | 23 +- src/gitea.js | 2 + src/llm.js | 98 +- src/log.js | 51 +- src/main.js | 7 +- src/preflight.js | 16 + src/resolve.js | 48 +- src/roles.js | 4 +- src/test/log.test.js | 28 +- src/usage.js | 75 +- 22 files changed, 2311 insertions(+), 1620 deletions(-) delete mode 100644 Dockerfile delete mode 100644 README.md create mode 100644 dockerfile create mode 100644 readme.md diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0125fd1..28e79fe 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 中的顯示標題。 name: CI # 定義此 workflow 的觸發事件。 diff --git a/.gitea/workflows/master.yaml b/.gitea/workflows/master.yaml index 95fa62c..b61089d 100644 --- a/.gitea/workflows/master.yaml +++ b/.gitea/workflows/master.yaml @@ -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 中的顯示標題。 name: CD # 定義此 workflow 的觸發事件。 diff --git a/.gitea/workflows/readme.md b/.gitea/workflows/readme.md index d8c10be..c13f723 100644 --- a/.gitea/workflows/readme.md +++ b/.gitea/workflows/readme.md @@ -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 程式碼審查。 -- `CD`:處理推送到 `master` 分支後的部署相關檢查與資訊輸出。 +| Workflow 名稱 | 檔案位置 | 觸發條件 | 大致用途 | +| --- | --- | --- | --- | +| `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 明細 -### CI +### CI(`.gitea/workflows/ci.yaml`) -- 檔案位置:`.gitea/workflows/ci.yaml` -- 用途:在 pull request 事件中計算版本,必要時建立 release,並在 beta 情境下執行 AI 程式碼審查。 -- 觸發條件:`pull_request`,事件類型為 `opened` 與 `synchronize`。 -- 主要輸入 / 環境參數: - - `gitea.base_ref`:用來判斷是否為 `develop`,進而決定 `IS_BETA`。 - - `vars.ACTION_CALCULATE_VERSION`:提供 `calculate-version` action 的版本。 - - `vars.ACTION_GITEA_RELEASE_VERSION`:提供 release action 的版本。 - - `secrets.LLM_OAUTH`:設定 LLM CLI 的 OAuth。 - - `secrets.TOKEN`:提供 AI 程式碼審查 action 存取 Gitea API。 - - `vars.LLM_NAME`:指定審查使用的模型名稱。 -- 重要注意事項: - - `test` job 只會在 `IS_BETA == true` 時執行,也就是 pull request 目標分支為 `develop` 時。 - - `Publishing Release` 會使用 `VERSION` 與 `gitea.sha` 建立 release 與 tag。 - - 若變數或 secret 未設定,對應步驟會失敗,需人工確認部署前置條件。 +- **workflow 名稱**:`CI`(Gitea UI 顯示標題) +- **檔案位置**:`.gitea/workflows/ci.yaml` +- **用途說明**: + - `build` job:計算版本號(呼叫 `calculate-version` action),並用計算出的版本建立 Gitea release/tag。 + - `test` job:僅在 `build` job 判定為 beta 情境時執行,呼叫本專案自身發佈的 `ai-code-review` action 對 PR 進行 AI 程式碼審查。 + - `result` job:等待前兩個 job 完成後,將版本號輸出到 log,作為流程結尾的確認步驟。 +- **觸發條件**:`pull_request` 事件,且事件類型限定為 `opened` 與 `synchronize`(PR 建立與後續推送同步時觸發)。 +- **主要輸入 / 環境參數**: + - `gitea.base_ref`:用來判斷 PR 目標分支是否為 `develop`,據以設定 `IS_BETA` 環境變數。 + - `vars.ACTION_CALCULATE_VERSION`:`calculate-version` action 的版本(`build` job 使用)。 + - `vars.ACTION_GITEA_RELEASE_VERSION`:`akkuman/gitea-release-action` 的版本(`build` job 使用)。 + - `gitea.event.repository.name`、`gitea.sha`:組成 release 名稱與 `target_commitish`。 + - `needs.build.outputs.version`、`needs.build.outputs.is_beta`:`test`、`result` job 透過 job 輸出取得版本號與 beta 判定結果。 +- **重要注意事項**: + - `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` -- 用途:在 `master` 分支推送後輸出 Gitea context、檢查提交標籤,作為後續部署流程的基礎。 -- 觸發條件:`push` 到 `master` 分支。 -- 主要輸入 / 環境參數: - - `gitea` 事件內容:轉成 `GITEA_CONTEXT` 後交給 `jq` 顯示。 - - `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢 commit tag。 - - `vars.ACTION_CHECKOUT_VERSION`:提供 `actions/checkout` 的版本。 - - `GITEA_OUTPUT`:寫入 `git describe --contains` 的結果。 -- 重要注意事項: - - `COMMIT_SHA` 取用 commits 陣列的第 2 筆資料,若 push 事件實際只有 1 筆 commit,需人工確認是否會發生索引風險。 - - `Get Commit Tag` 依賴完整的 git 歷史與 tags,因此 checkout 已設定 `fetch-depth: 0` 與 `fetch-tags: true`。 - - `Show Gitea Context` 會輸出完整事件內容,若包含敏感資訊,需注意執行環境的日誌保存策略。 +- **workflow 名稱**:`CD`(Gitea UI 顯示標題) +- **檔案位置**:`.gitea/workflows/master.yaml` +- **用途說明**:單一 `deploy` job,在推送到 `master` 分支後,輸出完整的 Gitea event context,並取回完整原始碼與 tag 歷史後,查詢指定 commit 對應的 tag,將結果印出。整個 workflow 目前僅做資訊輸出與查詢,未包含實際部署動作。 +- **觸發條件**:`push` 事件,且限定分支為 `master`。 +- **主要輸入 / 環境參數**: + - `gitea`(整個 context,透過 `toJSON(gitea)` 轉字串):以 `GITEA_CONTEXT` 環境變數輸出並用 `jq` 顯示。 + - `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢該 commit 對應的 tag。 + - `vars.ACTION_CHECKOUT_VERSION`:`actions/checkout` action 的版本。 + - `GITEA_OUTPUT`:`Get Commit Tag` 步驟將 `git describe --contains` 的查詢結果寫入此檔案,供 `steps.commit.outputs.tag` 讀取。 +- **重要注意事項**: + - **需人工確認**:`COMMIT_SHA` 目前固定取用 `gitea.event.commits` 陣列的第 2 筆(索引 1,即 `commits[1]`)。若一次 `push` 事件只包含 1 筆 commit,該索引將不存在,`COMMIT_SHA` 可能為空值,導致後續 `git describe --contains` 查詢失敗或行為不符預期;是否需改為取最後一筆(例如 `commits[-1]` 或依陣列長度動態取值)需人工確認並評估是否調整(本文件僅整理現況,未變更任何 workflow 實際邏輯)。 + - `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` 的變數與 secret 已在目標環境中正確配置。 +- 本文件僅整理 `ci.yaml`、`master.yaml` 兩個 workflow 檔案目前的行為與參數重點,內容依實際檔案內容彙整,未新增或臆測未在檔案中出現的流程與參數;標註「需人工確認」之處為既有設計中需要人工再次確認的風險點,非文件本身待補內容。 +- 若後續要以本文件覆蓋既有說明文件,請先確認 `ci.yaml` 與 `master.yaml` 所引用的 `vars.*`、`secrets.*` 已在目標環境(Gitea repo/organization 設定)中正確配置。 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index bb4b53d..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/README.md b/README.md deleted file mode 100644 index 7ea73e4..0000000 --- a/README.md +++ /dev/null @@ -1,1451 +0,0 @@ -# AI Code Review - -更新時間:2026/07/11 19:18:23 - -## 專案列表 - -| 專案名稱 | 專案描述 | -| --- | --- | -| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/) | 此專案提供 Gitea 工作流程中的 AI 程式碼審查、findings / exclusions 管理、CLIProxyAPI 橋接與 git / Gitea 前置驗證工具。 | - -| 專案名稱 | 參考專案列表 | -| --- | --- | -| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/) | 無 | - -| 專案名稱 | NuGet 套件列表 | -| --- | --- | -| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/) | 無 | - -## 功能列表 - -### AI Code Review - -| 功能名稱 | 功能描述 | -| --- | --- | -| [parseLocation](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L66) | [解析 finding location 為檔案與行號。](#parselocation) | -| [formatFindingsStats](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L150) | [產生 findings 統計表格。](#formatfindingsstats) | -| [formatFindingsStatsLine](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L171) | [產生 findings 單行統計摘要。](#formatfindingsstatsline) | -| [postFindingsReview](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L220) | [發布 findings 的 Gitea review。](#postfindingsreview) | -| [saveFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L259) | [將 findings 寫入工作區與鏡像目錄。](#savefindings) | -| [postOldFindingsComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L274) | [發布舊問題的 comment。](#postoldfindingscomment) | -| [postNewNonCriticalComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L288) | [發布新問題中的非嚴重 comment。](#postnewnoncriticalcomment) | -| [postNewCriticalComments](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L304) | [發布新嚴重問題的 comment。](#postnewcriticalcomments) | -| [getInsecureHttpsAgent](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/config.js#L60) | [取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。](#getinsecurehttpsagent) | -| [getLLMConfig](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/config.js#L130) | [依環境變數解析目前可用的 CLIProxyAPI 設定。](#getllmconfig) | -| [analyzeWithRole](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L14) | [用指定角色分析 diff 並產生 findings。](#analyzewithrole) | -| [normalizeText](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L120) | [將文字正規化成比對用形式。](#normalizetext) | -| [loadOldFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L302) | [讀取舊 findings 並標記為舊問題。](#loadoldfindings) | -| [mergeFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L319) | [合併新舊 findings 並去重。](#mergefindings) | -| [sortByLevel](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L335) | [依嚴重度排序 findings。](#sortbylevel) | -| [resolveMissingLineNumbers](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L376) | [為缺少行號的 findings 補上行號。](#resolvemissinglinenumbers) | -| [deduplicateWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L423) | [用 AI 進行 findings 語意去重。](#deduplicatewithai) | -| [loadExclusions](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L450) | [讀取並正規化 exclusions。](#loadexclusions) | -| [appendExclusions](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L500) | [將新的排除條目追加到 exclusions 檔。](#appendexclusions) | -| [applyExclusions](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L544) | [依 exclusions 過濾 findings。](#applyexclusions) | -| [filterFalsePositivesWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L577) | [用 AI 過濾誤報 findings。](#filterfalsepositiveswithai) | -| [getRepoState](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/git.js#L120) | [讀取指定 git repo 的基本狀態快照,包含 HEAD、分支與 commit 時間。](#getrepostate) | -| [getHeadCommitMessage](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/git.js#L138) | [讀取指定 repo 的 HEAD commit 完整 commit message,失敗時保守回傳空字串。](#getheadcommitmessage) | -| [isBotAutoCommit](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/git.js#L153) | [判斷 HEAD commit 是否由 AI Review bot 自動產生,避免重複觸發後續流程。](#isbotautocommit) | -| [verifyRemoteAccess](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/git.js#L163) | [先用 `git ls-remote` 驗證 remote 認證與連線是否可用,失敗時回傳結構化錯誤。](#verifyremoteaccess) | -| [cloneRepo](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/git.js#L178) | [以可重入方式抓取 PR head branch 到工作目錄內的 `repo` 資料夾。](#clonerepo) | -| [commitAndPush](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/git.js#L220) | [將 AI 審查產出的檔案結轉、提交並推回 PR head branch,失敗時保守記錄 log 而不中斷流程。](#commitandpush) | -| [getBotReviewOutcome](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L39) | [解析 AI Review bot 的結果標記,回傳 success、failure 或 unknown。](#getbotreviewoutcome) | -| [parseReviewIgnore](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L62) | [把 `.reviewignore` 文字解析成可用的排除前綴陣列。](#parsereviewignore) | -| [getReviewIgnore](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L74) | [讀取並解析 PR 的 `.reviewignore`,沒有自訂規則時回退預設排除清單。](#getreviewignore) | -| [getPRDiff](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L89) | [取得目前 PR 的 unified diff,並套用 `.reviewignore` 與內建過濾規則。](#getprdiff) | -| [getCommitMessageBySha](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L101) | [依 commit SHA 讀取 Gitea 上的 commit message,失敗時保守回空字串。](#getcommitmessagebysha) | -| [getBranchHeadCommitMessage](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L122) | [讀取指定分支 head commit 的訊息,失敗時保守回空字串。](#getbranchheadcommitmessage) | -| [shouldSkipBotCommit](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L147) | [判斷目前 PR head 是否為 bot 自動提交,若是就跳過後續審查流程。](#shouldskipbotcommit) | -| [filterDiff](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L164) | [過濾 unified diff 中不需要審查的路徑區塊,保留其餘內容。](#filterdiff) | -| [postComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L184) | [在 PR 底下發布一則 Markdown 留言,適合非行內評論用途。](#postcomment) | -| [postPullReviewComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L203) | [對 PR 指定檔案與行號發送單筆行內 review comment。](#postpullreviewcomment) | -| [postPullReview](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L226) | [建立一則包含摘要與多筆行內 comment 的 PR review。](#postpullreview) | -| [listPullReviews](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L246) | [列出目前 PR 的所有 review,回應格式不正確時保守回空陣列。](#listpullreviews) | -| [getPullReviewComments](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L260) | [依 review ID 取得該 review 底下的所有行內 comment。](#getpullreviewcomments) | -| [listAllReviewComments](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L274) | [彙整目前 PR 的所有 review comments,單筆失敗時略過並持續處理。](#listallreviewcomments) | -| [resolvePullReviewComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L296) | [解決指定 review comment 對話,對應 Gitea 的 resolve API。](#resolvepullreviewcomment) | -| [getFileContentAtRef](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/gitea.js#L313) | [讀取指定 ref 下的檔案文字內容,支援 base64 解碼並在失敗時保守回空字串。](#getfilecontentatref) | -| [stripCodeFence](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/json.js#L17) | [移除文字外層的 markdown code fence,並清理前後空白。](#stripcodefence) | -| [repairJSONArrayWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/json.js#L43) | [透過 LLM 將原始內容修復成可直接 JSON.parse 的 JSON 陣列字串。](#repairjsonarraywithai) | -| [validateJSONArrayFile](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/json.js#L93) | [驗證 JSON 檔案是否合法,必要時嘗試透過 AI 修復一次。](#validatejsonarrayfile) | -| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/json.js#L134) | [確保指定路徑存在一個 JSON 檔案,不存在時建立空陣列檔。](#ensurejsonarrayfileexists) | -| [mapWithConcurrency](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L25) | [以可控制併發數的方式並行處理陣列項目。](#mapwithconcurrency) | -| [extractMeaningfulError](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L96) | [從 CLI 原始輸出中擷取最有用的錯誤訊息。](#extractmeaningfulerror) | -| [chat](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L190) | [呼叫 CLIProxyAPI,並回傳文字回應。](#chat) | -| [chatJSON](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L218) | [呼叫 AI 助理並把回應解析成 JSON。](#chatjson) | -| [extractBalancedJSON](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L255) | [從指定索引開始擷取完整平衡的 JSON 片段。](#extractbalancedjson) | -| [extractJSONText](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L298) | [從雜訊文字中抽出最可能的 JSON 內容。](#extractjsontext) | -| [section](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L9) | [輸出最上層區塊標題,用來切分整體執行流程。](#section) | -| [step](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L22) | [輸出流程中的步驟標題,標示某一小段工作內容。](#step) | -| [line](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L34) | [輸出一行中性的明細資訊。](#line) | -| [input](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L45) | [輸出某一步驟的輸入描述,方便追蹤資料來源。](#input) | -| [output](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L56) | [輸出某一步驟的產出描述,方便追蹤結果。](#output) | -| [result](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L69) | [依布林值輸出成功或失敗的檢查結果。](#result) | -| [ok](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L81) | [輸出一筆成功或完成訊息。](#ok) | -| [warn](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L93) | [輸出一筆警告訊息到 stderr。](#warn) | -| [error](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/log.js#L105) | [輸出一筆錯誤訊息到 stderr。](#error) | -| [main](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/main.js#L55) | [執行 AI Code Review Pipeline 的完整流程。](#main) | -| [checkRequiredEnv](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L60) | [檢查前置驗證所需的必要環境值是否齊全。](#checkrequiredenv) | -| [verifyGiteaToken](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L77) | [驗證 Gitea token 是否可讀取指定 repository。](#verifygiteatoken) | -| [verifyCommentToken](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L94) | [驗證 comment token 是否可用;未提供時回傳 skipped。](#verifycommenttoken) | -| [fetchLLMModels](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L118) | [呼叫 CLIProxyAPI 的模型清單端點並取得目前可用的模型 id 清單。](#fetchllmmodels) | -| [verifyLLM](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L171) | [驗證目前環境是否有可用的 CLIProxyAPI 設定與對應模型。](#verifyllm) | -| [runPreflight](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L203) | [執行所有前置驗證流程,任一失敗即回傳 false。](#runpreflight) | -| [parseBotReviewComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L51) | [解析 bot 產生的 review comment,還原成 finding 欄位物件。](#parsebotreviewcomment) | -| [groupConversations](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L73) | [依檔案路徑與行號把 review comments 收斂成對話群組。](#groupconversations) | -| [codeWindow](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L100) | [擷取目標行附近的程式碼片段,供 AI 判讀。](#codewindow) | -| [judgeConversations](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L126) | [處理 judgeConversations 相關邏輯。](#judgeconversations) | -| [isSafeRepoPath](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L174) | [檢查路徑是否安全,避免讀取 repo 外檔案。](#issaferepopath) | -| [reconcileConversations](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L190) | [收斂 PR review 對話,並依 AI 裁定回填 findings 的去向。](#reconcileconversations) | -| [dropResolvedFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L329) | [移除已解決對話對應的 findings。](#dropresolvedfindings) | -| [addCarriedFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L338) | [將仍成立但缺漏的 findings 補回清單。](#addcarriedfindings) | -| [parseRoleFile](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/roles.js#L25) | [解析角色 Markdown 檔,取出 frontmatter 與本文。](#parserolefile) | -| [loadRoles](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/roles.js#L71) | [只載入攻擊方角色。](#loadroles) | -| [loadRole](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/roles.js#L85) | [依名稱查找單一角色。](#loadrole) | -| [buildAnalysisPrompt](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/roles.js#L104) | [產生攻擊方角色的程式碼審查 system prompt。](#buildanalysisprompt) | -| [buildLocateLinePrompt](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/roles.js#L149) | [產生用來補 finding 行號的 prompt。](#buildlocatelineprompt) | -| [buildVerdictPrompt](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/roles.js#L173) | [產生單條 finding 的誤報裁決 prompt。](#buildverdictprompt) | -| [getRoleIntro](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/roles.js#L206) | [產生 AI Code Review 團隊的 Markdown 介紹表。](#getroleintro) | -| [extractUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L24) | [從 LLM 回應抽出 token usage。](#extractusage) | -| [recordUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L65) | [記錄一次 LLM 呼叫使用量。](#recordusage) | -| [getRunUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L77) | [取得目前累積的使用量。](#getrunusage) | -| [resetRunUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L82) | [重置執行中的 usage 累計。](#resetrunusage) | -| [recordRateLimit](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L109) | [記錄最近一次速率配額資訊。](#recordratelimit) | -| [getRateLimit](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L130) | [取得最近一次 rate limit 快照。](#getratelimit) | -| [resetRateLimit](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L135) | [重置 rate limit 快照。](#resetratelimit) | -| [fetchAccountQuota](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L209) | [查詢指定平台的帳號額度。](#fetchaccountquota) | -| [resolveRemainingPercent](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L278) | [計算可用額度剩餘百分比。](#resolveremainingpercent) | -| [formatUsageStats](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L315) | [產生 AI 助理使用量 Markdown 區塊。](#formatusagestats) | -| [formatUsageStatsLine](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/usage.js#L334) | [產生使用量單行摘要。](#formatusagestatsline) | - -## 使用範例 - -### parseLocation - -解析 finding location 為檔案與行號。 - -檔案位置:`src/comments.js` 第 66 行。 - -```js -const result = parseLocation(location); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### formatFindingsStats - -產生 findings 統計表格。 - -檔案位置:`src/comments.js` 第 150 行。 - -```js -const result = formatFindingsStats(findings); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### formatFindingsStatsLine - -產生 findings 單行統計摘要。 - -檔案位置:`src/comments.js` 第 171 行。 - -```js -const result = formatFindingsStatsLine(findings); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### postFindingsReview - -發布 findings 的 Gitea review。 - -檔案位置:`src/comments.js` 第 220 行。 - -```js -const result = await postFindingsReview(findings, deps); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### saveFindings - -將 findings 寫入工作區與鏡像目錄。 - -檔案位置:`src/comments.js` 第 259 行。 - -```js -const result = saveFindings(workspace, findings, mirrorDir); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### postOldFindingsComment - -發布舊問題的 comment。 - -檔案位置:`src/comments.js` 第 274 行。 - -```js -const result = await postOldFindingsComment(findings); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### postNewNonCriticalComment - -發布新問題中的非嚴重 comment。 - -檔案位置:`src/comments.js` 第 288 行。 - -```js -const result = await postNewNonCriticalComment(findings); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### postNewCriticalComments - -發布新嚴重問題的 comment。 - -檔案位置:`src/comments.js` 第 304 行。 - -```js -const result = await postNewCriticalComments(findings, deps); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### getInsecureHttpsAgent - -取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。 - -檔案位置:`src/config.js` 第 60 行。 - -```js -const result = getInsecureHttpsAgent(); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getLLMConfig - -依環境變數解析目前可用的 CLIProxyAPI 設定。 - -檔案位置:`src/config.js` 第 130 行。 - -```js -const result = getLLMConfig(commandExistsFn); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### analyzeWithRole - -用指定角色分析 diff 並產生 findings。 - -檔案位置:`src/findings.js` 第 14 行。 - -```js -const result = await analyzeWithRole(role, diff); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### normalizeText - -將文字正規化成比對用形式。 - -檔案位置:`src/findings.js` 第 120 行。 - -```js -const result = normalizeText(value); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### loadOldFindings - -讀取舊 findings 並標記為舊問題。 - -檔案位置:`src/findings.js` 第 302 行。 - -```js -const result = loadOldFindings(workspace); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### mergeFindings - -合併新舊 findings 並去重。 - -檔案位置:`src/findings.js` 第 319 行。 - -```js -const result = mergeFindings(oldFindings, newFindings); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### sortByLevel - -依嚴重度排序 findings。 - -檔案位置:`src/findings.js` 第 335 行。 - -```js -const result = sortByLevel(findings); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### resolveMissingLineNumbers - -為缺少行號的 findings 補上行號。 - -檔案位置:`src/findings.js` 第 376 行。 - -```js -const result = await resolveMissingLineNumbers(findings, diff, deps); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### deduplicateWithAI - -用 AI 進行 findings 語意去重。 - -檔案位置:`src/findings.js` 第 423 行。 - -```js -const result = await deduplicateWithAI(findings); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### loadExclusions - -讀取並正規化 exclusions。 - -檔案位置:`src/findings.js` 第 450 行。 - -```js -const result = loadExclusions(workspace, repoState, mirrorWorkspace); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### appendExclusions - -將新的排除條目追加到 exclusions 檔。 - -檔案位置:`src/findings.js` 第 500 行。 - -```js -const result = appendExclusions(workspace, newEntries, mirrorWorkspace); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### applyExclusions - -依 exclusions 過濾 findings。 - -檔案位置:`src/findings.js` 第 544 行。 - -```js -const result = applyExclusions(findings, exclusions); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### filterFalsePositivesWithAI - -用 AI 過濾誤報 findings。 - -檔案位置:`src/findings.js` 第 577 行。 - -```js -const result = await filterFalsePositivesWithAI(findings, exclusions, chatFn); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### getRepoState - -讀取指定 git repo 的基本狀態快照,包含 HEAD、分支與 commit 時間。 - -檔案位置:`src/git.js` 第 120 行。 - -```js -const result = getRepoState(repoDir, _spawnSync); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getHeadCommitMessage - -讀取指定 repo 的 HEAD commit 完整 commit message,失敗時保守回傳空字串。 - -檔案位置:`src/git.js` 第 138 行。 - -```js -const result = getHeadCommitMessage(repoDir, _spawnSync); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### isBotAutoCommit - -判斷 HEAD commit 是否由 AI Review bot 自動產生,避免重複觸發後續流程。 - -檔案位置:`src/git.js` 第 153 行。 - -```js -const result = isBotAutoCommit(repoDir, _spawnSync); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### verifyRemoteAccess - -先用 `git ls-remote` 驗證 remote 認證與連線是否可用,失敗時回傳結構化錯誤。 - -檔案位置:`src/git.js` 第 163 行。 - -```js -const result = verifyRemoteAccess(workspace, _spawnSync); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳驗證成功/失敗的結構化結果。 - -### cloneRepo - -以可重入方式抓取 PR head branch 到工作目錄內的 `repo` 資料夾。 - -檔案位置:`src/git.js` 第 178 行。 - -```js -const result = cloneRepo(workspace, _spawnSync); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### commitAndPush - -將 AI 審查產出的檔案結轉、提交並推回 PR head branch,失敗時保守記錄 log 而不中斷流程。 - -檔案位置:`src/git.js` 第 220 行。 - -```js -const result = await commitAndPush(workspace, repoDir, _spawnSync, _sourceRoot, reviewOutcome); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### getBotReviewOutcome - -解析 AI Review bot 的結果標記,回傳 success、failure 或 unknown。 - -檔案位置:`src/gitea.js` 第 39 行。 - -```js -const result = getBotReviewOutcome(message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### parseReviewIgnore - -把 `.reviewignore` 文字解析成可用的排除前綴陣列。 - -檔案位置:`src/gitea.js` 第 62 行。 - -```js -const result = parseReviewIgnore(text); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getReviewIgnore - -讀取並解析 PR 的 `.reviewignore`,沒有自訂規則時回退預設排除清單。 - -檔案位置:`src/gitea.js` 第 74 行。 - -```js -const result = await getReviewIgnore(); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getPRDiff - -取得目前 PR 的 unified diff,並套用 `.reviewignore` 與內建過濾規則。 - -檔案位置:`src/gitea.js` 第 89 行。 - -```js -const result = await getPRDiff(); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getCommitMessageBySha - -依 commit SHA 讀取 Gitea 上的 commit message,失敗時保守回空字串。 - -檔案位置:`src/gitea.js` 第 101 行。 - -```js -const result = await getCommitMessageBySha(sha); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getBranchHeadCommitMessage - -讀取指定分支 head commit 的訊息,失敗時保守回空字串。 - -檔案位置:`src/gitea.js` 第 122 行。 - -```js -const result = await getBranchHeadCommitMessage(branch); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### shouldSkipBotCommit - -判斷目前 PR head 是否為 bot 自動提交,若是就跳過後續審查流程。 - -檔案位置:`src/gitea.js` 第 147 行。 - -```js -const result = await shouldSkipBotCommit(sha, branch); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### filterDiff - -過濾 unified diff 中不需要審查的路徑區塊,保留其餘內容。 - -檔案位置:`src/gitea.js` 第 164 行。 - -```js -const result = filterDiff(diff, excludePrefixes); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### postComment - -在 PR 底下發布一則 Markdown 留言,適合非行內評論用途。 - -檔案位置:`src/gitea.js` 第 184 行。 - -```js -const result = await postComment(body); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### postPullReviewComment - -對 PR 指定檔案與行號發送單筆行內 review comment。 - -檔案位置:`src/gitea.js` 第 203 行。 - -```js -const result = await postPullReviewComment(pathfilePath, line, body); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### postPullReview - -建立一則包含摘要與多筆行內 comment 的 PR review。 - -檔案位置:`src/gitea.js` 第 226 行。 - -```js -const result = await postPullReview(body, comments); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### listPullReviews - -列出目前 PR 的所有 review,回應格式不正確時保守回空陣列。 - -檔案位置:`src/gitea.js` 第 246 行。 - -```js -const result = await listPullReviews(); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### getPullReviewComments - -依 review ID 取得該 review 底下的所有行內 comment。 - -檔案位置:`src/gitea.js` 第 260 行。 - -```js -const result = await getPullReviewComments(reviewId); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### listAllReviewComments - -彙整目前 PR 的所有 review comments,單筆失敗時略過並持續處理。 - -檔案位置:`src/gitea.js` 第 274 行。 - -```js -const result = await listAllReviewComments(); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### resolvePullReviewComment - -解決指定 review comment 對話,對應 Gitea 的 resolve API。 - -檔案位置:`src/gitea.js` 第 296 行。 - -```js -const result = await resolvePullReviewComment(commentId); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getFileContentAtRef - -讀取指定 ref 下的檔案文字內容,支援 base64 解碼並在失敗時保守回空字串。 - -檔案位置:`src/gitea.js` 第 313 行。 - -```js -const result = await getFileContentAtRef(filePath, ref); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### stripCodeFence - -移除文字外層的 markdown code fence,並清理前後空白。 - -檔案位置:`src/json.js` 第 17 行。 - -```js -const result = stripCodeFence(text); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### repairJSONArrayWithAI - -透過 LLM 將原始內容修復成可直接 JSON.parse 的 JSON 陣列字串。 - -檔案位置:`src/json.js` 第 43 行。 - -```js -const result = await repairJSONArrayWithAI(fullPath, label, rawText, chatFn); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### validateJSONArrayFile - -驗證 JSON 檔案是否合法,必要時嘗試透過 AI 修復一次。 - -檔案位置:`src/json.js` 第 93 行。 - -```js -const result = await validateJSONArrayFile(fullPath, label, repairer); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳驗證成功/失敗的結構化結果。 - -### ensureJSONArrayFileExists - -確保指定路徑存在一個 JSON 檔案,不存在時建立空陣列檔。 - -檔案位置:`src/json.js` 第 134 行。 - -```js -const result = ensureJSONArrayFileExists(fullPath, label); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### mapWithConcurrency - -以可控制併發數的方式並行處理陣列項目。 - -檔案位置:`src/llm.js` 第 25 行。 - -```js -const result = await mapWithConcurrency(items, limit, fn); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### extractMeaningfulError - -從 CLI 原始輸出中擷取最有用的錯誤訊息。 - -檔案位置:`src/llm.js` 第 96 行。 - -```js -const result = extractMeaningfulError(raw, limit); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### chat - -呼叫 CLIProxyAPI,並回傳文字回應。 - -檔案位置:`src/llm.js` 第 190 行。 - -```js -const result = await chat(systemPrompt, userContent); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### chatJSON - -呼叫 AI 助理並把回應解析成 JSON。 - -檔案位置:`src/llm.js` 第 218 行。 - -```js -const result = await chatJSON(systemPrompt, userContent); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### extractBalancedJSON - -從指定索引開始擷取完整平衡的 JSON 片段。 - -檔案位置:`src/llm.js` 第 255 行。 - -```js -const result = extractBalancedJSON(text, startIndex); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### extractJSONText - -從雜訊文字中抽出最可能的 JSON 內容。 - -檔案位置:`src/llm.js` 第 298 行。 - -```js -const result = extractJSONText(text); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### section - -輸出最上層區塊標題,用來切分整體執行流程。 - -檔案位置:`src/log.js` 第 9 行。 - -```js -const result = section(title); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### step - -輸出流程中的步驟標題,標示某一小段工作內容。 - -檔案位置:`src/log.js` 第 22 行。 - -```js -const result = step(stepName, title); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### line - -輸出一行中性的明細資訊。 - -檔案位置:`src/log.js` 第 34 行。 - -```js -const result = line(message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### input - -輸出某一步驟的輸入描述,方便追蹤資料來源。 - -檔案位置:`src/log.js` 第 45 行。 - -```js -const result = input(message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### output - -輸出某一步驟的產出描述,方便追蹤結果。 - -檔案位置:`src/log.js` 第 56 行。 - -```js -const result = output(message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### result - -依布林值輸出成功或失敗的檢查結果。 - -檔案位置:`src/log.js` 第 69 行。 - -```js -const result = result(passed, message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### ok - -輸出一筆成功或完成訊息。 - -檔案位置:`src/log.js` 第 81 行。 - -```js -const result = ok(message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### warn - -輸出一筆警告訊息到 stderr。 - -檔案位置:`src/log.js` 第 93 行。 - -```js -const result = warn(message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### error - -輸出一筆錯誤訊息到 stderr。 - -檔案位置:`src/log.js` 第 105 行。 - -```js -const result = error(message); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### main - -執行 AI Code Review Pipeline 的完整流程。 - -檔案位置:`src/main.js` 第 55 行。 - -```js -const result = await main(); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### checkRequiredEnv - -檢查前置驗證所需的必要環境值是否齊全。 - -檔案位置:`src/preflight.js` 第 60 行。 - -```js -const result = checkRequiredEnv(token, repo, pr); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### verifyGiteaToken - -驗證 Gitea token 是否可讀取指定 repository。 - -檔案位置:`src/preflight.js` 第 77 行。 - -```js -const result = await verifyGiteaToken(token, repo); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳驗證成功/失敗的結構化結果。 - -### verifyCommentToken - -驗證 comment token 是否可用;未提供時回傳 skipped。 - -檔案位置:`src/preflight.js` 第 94 行。 - -```js -const result = await verifyCommentToken(token); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳驗證成功/失敗的結構化結果。 - -### fetchLLMModels - -呼叫 CLIProxyAPI 的模型清單端點並取得目前可用的模型 id 清單。 - -檔案位置:`src/preflight.js` 第 118 行。 - -```js -const result = await fetchLLMModels(); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### verifyLLM - -驗證目前環境是否有可用的 CLIProxyAPI 設定與對應模型。 - -檔案位置:`src/preflight.js` 第 171 行。 - -```js -const result = await verifyLLM(fetchLLMModelsFn); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:回傳驗證成功/失敗的結構化結果。 - -### runPreflight - -執行所有前置驗證流程,任一失敗即回傳 false。 - -檔案位置:`src/preflight.js` 第 203 行。 - -```js -const result = await runPreflight(workspace, deps); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### parseBotReviewComment - -解析 bot 產生的 review comment,還原成 finding 欄位物件。 - -檔案位置:`src/resolve.js` 第 51 行。 - -```js -const result = parseBotReviewComment(body); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### groupConversations - -依檔案路徑與行號把 review comments 收斂成對話群組。 - -檔案位置:`src/resolve.js` 第 73 行。 - -```js -const result = groupConversations(comments); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### codeWindow - -擷取目標行附近的程式碼片段,供 AI 判讀。 - -檔案位置:`src/resolve.js` 第 100 行。 - -```js -const result = codeWindow(content, lineNum, radius); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### judgeConversations - -處理 judgeConversations 相關邏輯。 - -檔案位置:`src/resolve.js` 第 126 行。 - -```js -const result = await judgeConversations(items, chatFn); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### isSafeRepoPath - -檢查路徑是否安全,避免讀取 repo 外檔案。 - -檔案位置:`src/resolve.js` 第 174 行。 - -```js -const result = isSafeRepoPath(p); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### reconcileConversations - -收斂 PR review 對話,並依 AI 裁定回填 findings 的去向。 - -檔案位置:`src/resolve.js` 第 190 行。 - -```js -const result = await reconcileConversations(deps); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### dropResolvedFindings - -移除已解決對話對應的 findings。 - -檔案位置:`src/resolve.js` 第 329 行。 - -```js -const result = dropResolvedFindings(findings, resolvedFindings); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### addCarriedFindings - -將仍成立但缺漏的 findings 補回清單。 - -檔案位置:`src/resolve.js` 第 338 行。 - -```js -const result = addCarriedFindings(findings, carriedFindings); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### parseRoleFile - -解析角色 Markdown 檔,取出 frontmatter 與本文。 - -檔案位置:`src/roles.js` 第 25 行。 - -```js -const result = parseRoleFile(content); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### loadRoles - -只載入攻擊方角色。 - -檔案位置:`src/roles.js` 第 71 行。 - -```js -const result = loadRoles(); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### loadRole - -依名稱查找單一角色。 - -檔案位置:`src/roles.js` 第 85 行。 - -```js -const result = loadRole(name); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### buildAnalysisPrompt - -產生攻擊方角色的程式碼審查 system prompt。 - -檔案位置:`src/roles.js` 第 104 行。 - -```js -const result = buildAnalysisPrompt(role); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### buildLocateLinePrompt - -產生用來補 finding 行號的 prompt。 - -檔案位置:`src/roles.js` 第 149 行。 - -```js -const result = buildLocateLinePrompt(role); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### buildVerdictPrompt - -產生單條 finding 的誤報裁決 prompt。 - -檔案位置:`src/roles.js` 第 173 行。 - -```js -const result = buildVerdictPrompt(role, exclusionHint); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### getRoleIntro - -產生 AI Code Review 團隊的 Markdown 介紹表。 - -檔案位置:`src/roles.js` 第 206 行。 - -```js -const result = getRoleIntro(roles); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### extractUsage - -從 LLM 回應抽出 token usage。 - -檔案位置:`src/usage.js` 第 24 行。 - -```js -const result = extractUsage(data); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### recordUsage - -記錄一次 LLM 呼叫使用量。 - -檔案位置:`src/usage.js` 第 65 行。 - -```js -const result = recordUsage(data); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### getRunUsage - -取得目前累積的使用量。 - -檔案位置:`src/usage.js` 第 77 行。 - -```js -const result = getRunUsage(); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### resetRunUsage - -重置執行中的 usage 累計。 - -檔案位置:`src/usage.js` 第 82 行。 - -```js -const result = resetRunUsage(); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### recordRateLimit - -記錄最近一次速率配額資訊。 - -檔案位置:`src/usage.js` 第 109 行。 - -```js -const result = recordRateLimit(headers); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:完成對外部系統的寫入、發佈或執行動作。 - -### getRateLimit - -取得最近一次 rate limit 快照。 - -檔案位置:`src/usage.js` 第 130 行。 - -```js -const result = getRateLimit(); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### resetRateLimit - -重置 rate limit 快照。 - -檔案位置:`src/usage.js` 第 135 行。 - -```js -const result = resetRateLimit(); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### fetchAccountQuota - -查詢指定平台的帳號額度。 - -檔案位置:`src/usage.js` 第 209 行。 - -```js -const result = await fetchAccountQuota(provider, config, deps); -``` - -使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。 - -預期結果:依函式用途回傳對應結果。 - -### resolveRemainingPercent - -計算可用額度剩餘百分比。 - -檔案位置:`src/usage.js` 第 278 行。 - -```js -const result = resolveRemainingPercent(quota, rate); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### formatUsageStats - -產生 AI 助理使用量 Markdown 區塊。 - -檔案位置:`src/usage.js` 第 315 行。 - -```js -const result = formatUsageStats(provider, model, usage, quota, rate); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - -### formatUsageStatsLine - -產生使用量單行摘要。 - -檔案位置:`src/usage.js` 第 334 行。 - -```js -const result = formatUsageStatsLine(provider, model, usage, quota, rate); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 diff --git a/action.yml b/action.yml index ad77435..7d8b769 100644 --- a/action.yml +++ b/action.yml @@ -10,7 +10,7 @@ inputs: required: false runs: using: 'docker' - image: 'Dockerfile' + image: 'dockerfile' env: GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }} GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }} diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..53e3cb5 --- /dev/null +++ b/dockerfile @@ -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"] diff --git a/entrypoint.sh b/entrypoint.sh index 6ce4408..33e36a7 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,4 +1,11 @@ #!/bin/sh +# ============================================================================ +# 用途:Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。 +# 更新時間:2026/08/07 13:51:53 +# ============================================================================ + +# 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行 set -e +# 以 exec 取代目前 shell 程序執行 Node 主程式,並將容器收到的所有參數("$@")原樣轉傳給它 exec node /action/src/main.js "$@" diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..af47163 --- /dev/null +++ b/readme.md @@ -0,0 +1,1703 @@ +# AI Code Review + +更新時間:2026/08/07 13:51:53 + +## 專案列表 + +| 專案名稱 | 專案描述 | +| --- | --- | +| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src) | Gitea Docker 容器 action:對 PR 的 diff 派多個角色進行 AI 程式碼審查,產生 findings 並依對話收斂、排除規則與 AI 誤報裁決收斂結果;負責 Gitea PR API(diff/comment/review/resolve)串接、CLIProxyAPI 對話與 usage/額度統計、git clone/commit/push 持久化 findings,以及執行前的 token/LLM/git 遠端前置驗證。 | + +| 專案名稱 | 參考專案列表 | +| --- | --- | +| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src) | 無 | + +| 專案名稱 | npm 套件列表 | +| --- | --- | +| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src) | axios ^1.6.7
js-yaml ^4.1.0 | + +## 功能列表 + +### AI Code Review + +| 功能名稱 | 功能描述 | +| --- | --- | +| [parseLocation](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L77) | [解析 finding 的 location 字串,取出檔案路徑與起始行號,供行內 comment 定位使用。](#parselocation) | +| [formatFindingsStats](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L173) | [產生新舊問題依嚴重等級(嚴重/警告/建議/無法標示)分類統計的 Markdown 表格。](#formatfindingsstats) | +| [formatFindingsStatsLine](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L194) | [產生與統計表相同內容的單行文字摘要,供 log 輸出使用。](#formatfindingsstatsline) | +| [postFindingsReview](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L261) | [發布整批 findings 的 Gitea review(摘要+行內 comment),並提供多層降級機制。](#postfindingsreview) | +| [saveFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L311) | [將 findings 陣列以 JSON 格式寫入 workspace(及可選的鏡像目錄)。](#savefindings) | +| [postOldFindingsComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L335) | [發布所有舊有未解決問題的彙總 comment。](#postoldfindingscomment) | +| [postNewNonCriticalComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L359) | [發布新問題中非 critical 等級者的彙總 comment。](#postnewnoncriticalcomment) | +| [postNewCriticalComments](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L388) | [針對每個新的 critical 問題逐筆發布行內 comment,無法定位或失敗時降級為一般 comment。](#postnewcriticalcomments) | +| [getInsecureHttpsAgent](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/config.js#L57) | [取得關閉 TLS 憑證驗證的 HTTPS Agent 單例,供連接自簽憑證的內部服務使用。](#getinsecurehttpsagent) | +| [getLLMConfig](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/config.js#L77) | [依環境變數解析目前可用的 CLIProxyAPI 設定(base URL、model、API key)。](#getllmconfig) | +| [analyzeWithRole](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L20) | [用指定角色分析 diff,呼叫 LLM 產生該角色視角下的 findings 陣列。](#analyzewithrole) | +| [normalizeText](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L127) | [將文字正規化(NFKC、轉小寫、壓縮空白)為比對用形式,並以快取加速重複呼叫。](#normalizetext) | +| [loadOldFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L314) | [讀取來源分支的舊 findings 檔案,標記為非新問題並記錄診斷日誌。](#loadoldfindings) | +| [mergeFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L337) | [依 role/location/suggestion 組成的 key 合併新舊 findings 並去重。](#mergefindings) | +| [sortByLevel](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L358) | [依 critical/warning/info 順序排序 findings。](#sortbylevel) | +| [resolveMissingLineNumbers](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L425) | [對只有檔名缺行號的 findings,反問原角色依 diff 補上行號。](#resolvemissinglinenumbers) | +| [deduplicateWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L478) | [呼叫 LLM 對 findings 做語意去重,合併同位置同問題本質的重複項。](#deduplicatewithai) | +| [loadExclusions](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L513) | [讀取並正規化 exclusions 檔案,相容多種舊格式並就地修正為標準陣列。](#loadexclusions) | +| [appendExclusions](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L570) | [將新的排除條目去重後追加寫入 exclusions.json。](#appendexclusions) | +| [applyExclusions](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L625) | [依 exclusions 規則過濾 findings,移除符合排除條件的問題。](#applyexclusions) | +| [filterFalsePositivesWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/findings.js#L671) | [由防守方角色逐條裁決 findings 是否為誤報並剔除。](#filterfalsepositiveswithai) | +| [getBotReviewOutcome](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L39) | [解析文字中的 `[ai-review-bot]` 標記,回傳 success/failure/unknown。](#getbotreviewoutcome) | +| [parseReviewIgnore](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L62) | [把 `.reviewignore` 文字解析成排除前綴陣列。](#parsereviewignore) | +| [getReviewIgnore](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L75) | [讀取並解析 PR 的 `.reviewignore`,沒有規則時退回內建預設清單。](#getreviewignore) | +| [getPRDiff](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L90) | [取得目前 PR 的 diff,並套用 `.reviewignore` 與內建過濾規則。](#getprdiff) | +| [getCommitMessageBySha](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L102) | [依 commit SHA 向 Gitea 查詢該 commit 的訊息。](#getcommitmessagebysha) | +| [getBranchHeadCommitMessage](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L123) | [讀取指定分支 head commit 的訊息。](#getbranchheadcommitmessage) | +| [shouldSkipBotCommit](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L148) | [判斷目前 PR head 是否為 bot 自動提交,決定是否跳過審查。](#shouldskipbotcommit) | +| [filterDiff](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L166) | [過濾 unified diff 中不需要審查的路徑區塊。](#filterdiff) | +| [postComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L186) | [在 PR 下發布一則一般 Markdown 留言。](#postcomment) | +| [postPullReviewComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L205) | [對 PR 指定檔案行號發送單筆行內 review comment。](#postpullreviewcomment) | +| [postPullReview](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L228) | [建立包含摘要與多筆行內 comment 的 PR review。](#postpullreview) | +| [listPullReviews](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L248) | [列出目前 PR 的所有 review。](#listpullreviews) | +| [getPullReviewComments](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L262) | [依 review ID 取得該 review 底下的所有行內 comment。](#getpullreviewcomments) | +| [listAllReviewComments](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L276) | [彙整目前 PR 所有 review 的行內 comments 成單一陣列。](#listallreviewcomments) | +| [resolvePullReviewComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L298) | [解決指定 review comment 所屬的對話。](#resolvepullreviewcomment) | +| [getFileContentAtRef](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/gitea.js#L315) | [讀取指定 ref 下檔案的文字內容(自動 base64 解碼)。](#getfilecontentatref) | +| [getRepoState](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/git.js#L120) | [讀取指定 git repo 目錄的 HEAD SHA、分支與 commit 時間等狀態快照。](#getrepostate) | +| [getHeadCommitMessage](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/git.js#L138) | [讀取 HEAD commit 的完整 commit message。](#getheadcommitmessage) | +| [isBotAutoCommit](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/git.js#L153) | [判斷 HEAD commit 是否為 AI Review bot 自動產生的 commit。](#isbotautocommit) | +| [verifyRemoteAccess](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/git.js#L171) | [用 `git ls-remote` 驗證 remote 認證與連線是否可用。](#verifyremoteaccess) | +| [cloneRepo](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/git.js#L197) | [以可重入方式將 PR head branch clone/fetch 到工作目錄。](#clonerepo) | +| [commitAndPush](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/git.js#L241) | [將 findings/exclusions 結轉到 repo 並 commit、push 回 PR head branch。](#commitandpush) | +| [stripCodeFence](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L17) | [移除文字外層的 markdown code fence 並清理前後空白。](#stripcodefence) | +| [repairJSONArrayWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L43) | [透過 LLM 將原始內容修復成可直接 JSON.parse 的 JSON 陣列字串。](#repairjsonarraywithai) | +| [validateJSONArrayFile](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L93) | [驗證 JSON 檔案是否合法,格式錯誤時嘗試以 AI 修復一次。](#validatejsonarrayfile) | +| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L137) | [確保指定路徑存在 JSON 檔案,不存在時建立空陣列檔。](#ensurejsonarrayfileexists) | +| [mapWithConcurrency](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L26) | [以可控併發數並行處理陣列項目並保序回傳結果。](#mapwithconcurrency) | +| [extractMeaningfulError](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L102) | [從 CLI/HTTP 原始輸出中擷取最有用的錯誤訊息片段。](#extractmeaningfulerror) | +| [chat](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L209) | [呼叫 CLIProxyAPI 送出對話請求並回傳純文字回應。](#chat) | +| [chatJSON](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L248) | [呼叫 chat 取得回應後,將文字解析為 JSON。](#chatjson) | +| [extractBalancedJSON](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L285) | [從指定索引以括號平衡方式擷取完整的 JSON 子字串。](#extractbalancedjson) | +| [extractJSONText](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L328) | [從雜訊文字中盡力抽出可被 JSON.parse 解析的片段。](#extractjsontext) | +| [section](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L32) | [輸出最上層的區塊分隔標題,切分整體執行流程。](#section) | +| [step](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L46) | [輸出流程中某個步驟的標題。](#step) | +| [line](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L59) | [輸出一行縮排的中性明細資訊。](#line) | +| [input](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L71) | [輸出「階段輸入」描述,標示目前步驟吃進了什麼資料。](#input) | +| [output](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L83) | [輸出「階段輸出」描述,標示目前步驟產出了什麼結果。](#output) | +| [result](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L97) | [依布林結果輸出成功或失敗的檢查/把關結果列。](#result) | +| [ok](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L110) | [輸出一筆成功/完成訊息。](#ok) | +| [warn](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L123) | [輸出一筆警告訊息(寫入 stderr)。](#warn) | +| [error](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/log.js#L136) | [輸出一筆錯誤訊息(寫入 stderr)。](#error) | +| [main](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/main.js#L60) | [AI Code Review Pipeline 的總指揮,依序執行 Step1~Step11 並依結果決定 exit code。](#main) | +| [checkRequiredEnv](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/preflight.js#L57) | [檢查 code review 所需的必要環境變數是否齊全。](#checkrequiredenv) | +| [verifyGiteaToken](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/preflight.js#L75) | [驗證 Gitea token 有效且對指定 repo 有讀取權限。](#verifygiteatoken) | +| [verifyCommentToken](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/preflight.js#L92) | [驗證選用的 comment token(GITEA_COMMENT_TOKEN)是否可用。](#verifycommenttoken) | +| [fetchLLMModels](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/preflight.js#L133) | [呼叫 CLIProxyAPI 的 `/v1/models`,確認 proxy 可用與模型清單可讀。](#fetchllmmodels) | +| [verifyLLM](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/preflight.js#L182) | [驗證 LLM proxy 設定可用,且設定的模型在可用清單內。](#verifyllm) | +| [runPreflight](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/preflight.js#L214) | [執行所有前置驗證(環境變數、Gitea token、comment token、git 遠端、LLM proxy)。](#runpreflight) | +| [parseBotReviewComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L55) | [嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。](#parsebotreviewcomment) | +| [groupConversations](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L84) | [把 PR 上的行內 review comment 依檔案路徑+行號收斂成對話。](#groupconversations) | +| [codeWindow](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L119) | [取目標行附近的程式碼片段,供 AI 對照判斷問題是否已解決。](#codewindow) | +| [judgeConversations](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L149) | [批次請 AI 將每個對話判為 resolved / false_positive / open。](#judgeconversations) | +| [isSafeRepoPath](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L202) | [安全守衛:判定路徑是否為 repo 內的相對路徑,拒絕路徑穿越。](#issaferepopath) | +| [reconcileConversations](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L223) | [對話收斂主流程:關閉未解決 comment,並依 AI 判斷把 findings 分流為已修復/誤報/仍成立。](#reconcileconversations) | +| [dropResolvedFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L371) | [從 findings 中移除已判定為「已解決對話」對應的問題。](#dropresolvedfindings) | +| [addCarriedFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/resolve.js#L384) | [把仍成立但目前 findings 清單中遺漏的問題加回。](#addcarriedfindings) | +| [parseRoleFile](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/roles.js#L25) | [解析角色 Markdown 檔內容,拆出 frontmatter 與本文。](#parserolefile) | +| [loadRoles](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/roles.js#L71) | [載入所有「攻擊方」角色定義(`side === 'attack'`)。](#loadroles) | +| [loadRole](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/roles.js#L85) | [依名稱(不分大小寫)取得單一角色定義。](#loadrole) | +| [buildAnalysisPrompt](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/roles.js#L104) | [由攻擊方角色定義組出分析 diff 用的 system prompt。](#buildanalysisprompt) | +| [buildLocateLinePrompt](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/roles.js#L149) | [組出「補行號」用的 system prompt。](#buildlocatelineprompt) | +| [buildVerdictPrompt](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/roles.js#L173) | [由防守方角色定義組出單條 finding 誤報裁決用的 system prompt。](#buildverdictprompt) | +| [getRoleIntro](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/roles.js#L206) | [由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格。](#getroleintro) | +| [extractUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L29) | [把各平台回應中的 token usage 正規化成統一格式。](#extractusage) | +| [recordUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L75) | [記錄一次 LLM 呼叫的 usage 並累加進模組層級統計。](#recordusage) | +| [getRunUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L91) | [取得本次執行至今的 token 累計(複本)。](#getrunusage) | +| [resetRunUsage](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L99) | [重置本次執行的 token 累計(測試用)。](#resetrunusage) | +| [recordRateLimit](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L128) | [從回應 header 擷取速率配額剩餘量/上限並記錄。](#recordratelimit) | +| [getRateLimit](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L153) | [取得最近一次的速率配額快照(複本)。](#getratelimit) | +| [resetRateLimit](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L161) | [重置速率配額快照(測試用)。](#resetratelimit) | +| [fetchAccountQuota](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L245) | [取得指定平台的帳號額度資訊,任何失敗都降級回報無法取得。](#fetchaccountquota) | +| [resolveRemainingPercent](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L320) | [依優先序(帳號額度→速率配額)計算「剩餘可用百分比」。](#resolveremainingpercent) | +| [formatUsageStats](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L366) | [產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊。](#formatusagestats) | +| [formatUsageStatsLine](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/usage.js#L396) | [產生單行 log 用的使用量摘要文字。](#formatusagestatsline) | + +## 使用範例 + + + +### parseLocation + +解析 finding 的 `location` 欄位,取出檔案路徑與(起始)行號,供行內 comment 標註使用。支援 `"file:19"`(單行)與 `"file:70-82"`(範圍,僅取起始行);若 `location` 非字串、包含逗號(代表對應多個檔案),或無法比對出行號格式,一律回傳 `null`,呼叫端應據此降級為一般(非行內)comment。 + +- 參數:`location`(`string`)- finding 的位置字串。 +- 回傳:`{ file: string, line: number } | null`。 + +```javascript +import { parseLocation } from './src/comments.js'; + +parseLocation('src/config.js:57'); +// => { file: 'src/config.js', line: 57 } + +parseLocation('src/config.js:70-82'); +// => { file: 'src/config.js', line: 70 }(範圍格式僅取起始行) + +parseLocation('a.js:1,b.js:2'); +// => null(多檔案不支援) +``` + + + +### formatFindingsStats + +產生 findings 統計的 Markdown 表格:以 `is_new === false` 判定為舊問題、其餘為新問題,分別統計嚴重(critical)/警告(warning)/建議(info)/無法標示(level 不在三者之內)四欄的筆數,輸出含表頭、分隔列與兩筆資料列的表格字串。空陣列時仍會輸出表格(各欄為 0 筆)。 + +- 參數:`findings`(`Array`)- 審查問題陣列。 +- 回傳:`string`(Markdown 表格)。 + +```javascript +import { formatFindingsStats } from './src/comments.js'; + +const findings = [ + { is_new: true, level: 'critical' }, + { is_new: true, level: 'warning' }, + { is_new: false, level: 'info' }, +]; +console.log(formatFindingsStats(findings)); +// | 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 | +// | --- | --- | --- | --- | --- | +// | 新問題 | 1 筆 | 1 筆 | 0 筆 | 0 筆 | +// | 舊問題 | 0 筆 | 0 筆 | 1 筆 | 0 筆 | +``` + + + +### formatFindingsStatsLine + +產生與 `formatFindingsStats` 相同統計邏輯(新/舊問題 × 嚴重/警告/建議/無法標示)的單行純文字摘要,供 log 輸出使用,格式如 `新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...`。 + +- 參數:`findings`(`Array`)- 審查問題陣列。 +- 回傳:`string`(單行摘要)。 + +```javascript +import { formatFindingsStatsLine } from './src/comments.js'; + +const line = formatFindingsStatsLine([{ is_new: true, level: 'critical' }]); +console.log(line); +// => 新: 嚴重1 / 警告0 / 建議0 / 無法標示0;舊: 嚴重0 / 警告0 / 建議0 / 無法標示0 +``` + + + +### postFindingsReview + +發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」。降級順序:① 整批 `postReview`(含 comments)失敗 → ② 僅 body 的 `postReview`(comments 為空)失敗 → ③ `postIssue(body)`(此步未包 try/catch,失敗會直接向外拋出);走完任一步不再失敗後,會逐筆嘗試 `postInline` 補發行內 comment,單筆失敗只記錄警告並略過。 + +- 參數:`findings`(`Array`)- 本次審查的完整 findings;`deps`(`object`,可選)- 可覆寫 `postReview`/`postInline`/`postIssue`/`summaryFindings`/`commentFindings`/`usageSection`,供測試注入。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { postFindingsReview } from './src/comments.js'; + +const findings = [ + { level: 'critical', role: 'Paladin', location: 'src/a.js:10', suggestion: '修正邊界檢查', is_new: true }, +]; +await postFindingsReview(findings, { usageSection: '## 使用量\n...' }); +// 依序嘗試發布整批 review → summary review → 一般 comment,並逐筆補發行內 comment +``` + + + +### saveFindings + +將 findings 陣列以 `JSON.stringify(findings, null, 2)` 序列化並補結尾換行後,寫入 `workspace/.gitea/ai-review/findings.json`;若提供且不同於 `workspace` 的 `mirrorDir`,會同時寫入該鏡像目錄的相同路徑。寫入前會建立必要的父目錄;本函式為同步阻塞呼叫且未做例外防護,`fs` 錯誤會直接向外拋出。 + +- 參數:`workspace`(`string`)、`findings`(`Array`)、`mirrorDir`(`?string`,預設 `null`)。 +- 回傳:`void`。 + +```javascript +import { saveFindings } from './src/comments.js'; + +saveFindings('/workspace', [{ level: 'warning', location: 'a.js:1', suggestion: '...' }], '/workspace/repo'); +// 同時寫入 /workspace/.gitea/ai-review/findings.json 與 /workspace/repo/.gitea/ai-review/findings.json +``` + + + +### postOldFindingsComment + +以 `!f.is_new`(`is_new` 為 `false`/`undefined`/其他 falsy 值皆視為舊問題)篩選出舊問題,若有則發布一則彙總 comment(表格依 `findings` 原始順序,未依等級排序);若無舊問題則只記錄一行 log 並直接 return,不會呼叫 `postComment`。 + +- 參數:`findings`(`Array`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { postOldFindingsComment } from './src/comments.js'; + +await postOldFindingsComment([{ is_new: false, level: 'warning', role: 'Scout', location: 'a.js:5', suggestion: '...' }]); +// 發布「## 📋 舊有未解決問題(1 筆)」comment +``` + + + +### postNewNonCriticalComment + +以 `f.is_new && f.level !== 'critical'`(`is_new` 須為 truthy 才算新問題)篩選出新的非嚴重問題,若有則發布一則彙總 comment;無則只記錄 log 並直接 return。 + +- 參數:`findings`(`Array`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { postNewNonCriticalComment } from './src/comments.js'; + +await postNewNonCriticalComment([{ is_new: true, level: 'warning', role: 'Scout', location: 'a.js:5', suggestion: '...' }]); +// 發布「## 🔍 新發現問題(1 筆)」comment +``` + + + +### postNewCriticalComments + +以 `f.is_new && f.level === 'critical'` 篩選出新的嚴重問題,逐筆處理:`location` 可解析出行號且 `postInline` 成功時只發行內 comment;否則(無法解析,或 `postInline` 失敗)改用 `postIssue` 發一般 comment(此呼叫未包 try/catch,失敗會中斷迴圈)。 + +- 參數:`findings`(`Array`)、`deps`(`object`,可選,覆寫 `postInline`/`postIssue`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { postNewCriticalComments } from './src/comments.js'; + +await postNewCriticalComments([ + { is_new: true, level: 'critical', role: 'Paladin', location: 'src/a.js:12', suggestion: '修正 SQL Injection 風險' }, +]); +// 對 src/a.js:12 發一則行內 review comment;若無法定位則改發一般 comment +``` + + + +### getInsecureHttpsAgent + +取得一個關閉 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent 單例,供連接使用自簽或無效憑證的內部服務(如自架 Gitea、CLIProxyAPI)時使用;首次呼叫建立後於模組層級快取重複使用。僅限受信任的內部環境,需要完整 TLS 安全性時請改用預設 `https.Agent`。 + +- 參數:無。 +- 回傳:`import('https').Agent`。 + +```javascript +import { getInsecureHttpsAgent } from './src/config.js'; +import axios from 'axios'; + +const httpsAgent = getInsecureHttpsAgent(); +await axios.get('https://internal-gitea.example/api/v1/user', { httpsAgent }); +``` + + + +### getLLMConfig + +依環境變數解析並回傳 CLIProxyAPI 設定:`INPUT_CLI_PROXY_API`/`CLI_PROXY_API` 作 base URL(trim 並去尾斜線),`INPUT_MODEL`/`MODEL`/`OPENCODE_MODEL` 依序 fallback 作模型名稱,`INPUT_CLI_PROXY_API_KEY`/`CLI_PROXY_API_KEY` 作金鑰。base URL 無法解析時 `provider`/`baseURL` 回 `null`、`apiKeys` 回空陣列,但 `model`(若有)仍會回傳。 + +- 參數:無。 +- 回傳:`{ provider, apiKeys, baseURL, model, command }`。 + +```javascript +import { getLLMConfig } from './src/config.js'; + +// 環境變數:CLI_PROXY_API=https://proxy.example, MODEL=gpt-4o, CLI_PROXY_API_KEY=sk-xxx +const cfg = getLLMConfig(); +// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: 'gpt-4o', command: null } +``` + + + +### analyzeWithRole + +用單一角色分析 diff:呼叫 `chatJSON` 取得該角色視角下的 code review 問題,過濾出同時具備 `level`/`location`/`suggestion` 的有效 findings,並補上 `role`(一律覆寫為角色定義的 `name`,避免 LLM 自填不一致名稱)與 `is_new: true`。`chatJSON`(LLM)失敗時例外直接向外拋出,不做降級。 + +- 參數:`role`(`{name: string}`)、`diff`(`string`)。 +- 回傳:`Promise>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { analyzeWithRole } from './src/findings.js'; +import { loadRole } from './src/roles.js'; + +const role = loadRole('Scout'); +const findings = await analyzeWithRole(role, diffText); +// => [{ level: 'warning', role: 'Scout', location: 'a.js:12', problem: '...', suggestion: '...', is_new: true }, ...] +``` + + + +### normalizeText + +將任意值正規化為比對用形式:先安全轉字串(非字串轉空字串),再做 NFKC 正規化、轉小寫,把所有標點/符號/空白字元壓縮成單一空白、去頭尾空白。因常對相同字串重複呼叫(findings × exclusions 笛卡爾積比對),以模組層級 `Map` 對字串輸入做快取,程序生命週期內不會清除。 + +- 參數:`value`(`*`)。 +- 回傳:`string`。 + +```javascript +import { normalizeText } from './src/findings.js'; + +normalizeText(' 這裡有 SQL Injection!! '); +// => '這裡有 sql injection' +``` + + + +### loadOldFindings + +讀取來源分支 clone 出的工作目錄下 `FINDINGS_PATH`(`.gitea/ai-review/findings.json`),每筆標記 `is_new: false`;並記錄檔案大小/修改時間等診斷日誌。檔案不存在或讀取失敗時視為空陣列,不拋例外。 + +- 參數:`workspace`(`string`)。 +- 回傳:`Array`。 + +```javascript +import { loadOldFindings } from './src/findings.js'; + +const old = loadOldFindings('/workspace/repo'); +// => [{ level: 'warning', location: 'a.js:5', suggestion: '...', is_new: false }, ...] +``` + + + +### mergeFindings + +以 `role + location + suggestion 前 50 字` 組成的字串為 key,將 `newFindings` 中與 `oldFindings`(或先出現的 `newFindings` 自身)key 相同者去除;`oldFindings` 本身不互相去重,回傳 `[...oldFindings, ...去重後的 newFindings]`,不修改傳入的兩個陣列。 + +- 參數:`oldFindings`(`Array`)、`newFindings`(`Array`)。 +- 回傳:`Array`。 + +```javascript +import { mergeFindings } from './src/findings.js'; + +const merged = mergeFindings( + [{ role: 'Scout', location: 'a.js:5', suggestion: '既有問題' }], + [{ role: 'Scout', location: 'a.js:5', suggestion: '既有問題' }, { role: 'Paladin', location: 'b.js:1', suggestion: '新問題' }], +); +// => [{...既有問題}, {...新問題}](重複的新問題被濾除) +``` + + + +### sortByLevel + +依 `critical > warning > info` 順序排序 findings,回傳新陣列,不修改傳入陣列。等級不在三者之內的項目因 `indexOf` 回傳 `-1`,會被排到 critical 之前(最前面)。 + +- 參數:`findings`(`Array`)。 +- 回傳:`Array`。 + +```javascript +import { sortByLevel } from './src/findings.js'; + +sortByLevel([{ level: 'info' }, { level: 'critical' }, { level: 'warning' }]); +// => [{ level: 'critical' }, { level: 'warning' }, { level: 'info' }] +``` + + + +### resolveMissingLineNumbers + +對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號(每條最多 `maxAttempts` 次,預設 3 次),成功則就地修改(mutate)該 finding 的 `location` 為 `檔案:行號`,否則保留原檔名。各條 finding 以獨立 LLM 呼叫並行定位,併發上限見 `concurrency`(預設 `LLM_CONCURRENCY`)。 + +- 參數:`findings`(`Array`)、`diff`(`string`)、`deps`(`object`,可選:`chatFn`/`getRole`/`maxAttempts`/`concurrency`)。 +- 回傳:`Promise>`(與輸入相同參照)。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { resolveMissingLineNumbers } from './src/findings.js'; + +const findings = [{ role: 'Scout', location: 'src/a.js', problem: '...', suggestion: '...' }]; +await resolveMissingLineNumbers(findings, diffText); +// findings[0].location 可能被就地改為 'src/a.js:42' +``` + + + +### deduplicateWithAI + +呼叫 LLM(Paladin 角色)進行語意去重:合併「同位置+同問題本質」的重複 findings,重複者保留等級較高者。為避免幻覺,結果逐筆以 `(location + suggestion 前 50 字)` 對應回原始 findings;對應不到、空結果、非陣列或數量超過輸入者,皆整批降級為保留所有原始 findings。 + +- 參數:`findings`(`Array`)。 +- 回傳:`Promise>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { deduplicateWithAI } from './src/findings.js'; + +const deduped = await deduplicateWithAI(mergedFindings); +// => 去重後的原始 finding 物件陣列;AI 失敗時原樣回傳 mergedFindings +``` + + + +### loadExclusions + +讀取來源分支工作目錄下 `EXCLUSIONS_PATH`(`.gitea/ai-review/exclusions.json`),正規化並去重後回傳。若偵測到舊格式(`{ exclusions: [] }` 或 `{ excluded_findings: [] }`),會就地覆寫為標準頂層陣列(並同步寫入 `mirrorWorkspace`,若提供且路徑不同)。檔案不存在或讀取失敗時皆視為空陣列,不拋例外。 + +- 參數:`workspace`(`string`)、`repoState`(`?object`,僅供診斷日誌)、`mirrorWorkspace`(`?string`)。 +- 回傳:`Array`。 + +```javascript +import { loadExclusions } from './src/findings.js'; + +const exclusions = loadExclusions('/workspace/repo', { branch: 'feature/x', shortSha: 'abc1234' }, '/workspace'); +// => [{ location: 'a.js', role: 'Scout', text: '...', textKey: '...', fingerprint: '...' }, ...] +``` + + + +### appendExclusions + +把新的排除條目(raw 形式)append 到 `exclusions.json`,以「檔案路徑(location 冒號前段)+ `normalizeText` 後的原文」為簽名去重後,以頂層陣列格式寫回 `workspace`(及提供且路徑不同的 `mirrorWorkspace`)。`newEntries` 為空時直接回傳 `null`;若全部重複則回傳既有陣列且不寫檔。 + +- 參數:`workspace`(`string`)、`newEntries`(`Array`)、`mirrorWorkspace`(`?string`)。 +- 回傳:`Array | null`。 + +```javascript +import { appendExclusions } from './src/findings.js'; + +appendExclusions('/workspace/repo', [ + { location: 'a.js:10', role: 'Scout', original_finding: '此處誤報', reason: 'AI 對話收斂判定為誤報' }, +], '/workspace'); +// => 合併後的完整排除條目陣列,並寫入 /workspace/repo 與 /workspace 的 exclusions.json +``` + + + +### applyExclusions + +套用排除規則過濾 findings:對每個 exclusion,`locationMatches`(只比對檔案路徑,忽略行號)且 `roleMatches`(未指定則萬用)且(`exclusion` 同時未指定 `filePath` 與 `role` 時才比對正規化後文字是否互相包含,否則直接視為符合)即視為命中並剔除。`exclusions` 為空時原樣回傳新陣列(不修改原輸入)。 + +- 參數:`findings`(`Array`)、`exclusions`(`Array`)。 +- 回傳:`Array`。 + +```javascript +import { applyExclusions } from './src/findings.js'; + +const filtered = applyExclusions( + [{ location: 'a.js:5', role: 'Scout', suggestion: '此處為既知誤報' }], + [{ filePath: 'a.js', role: 'Scout' }], +); +// => [](同檔案同角色即視為命中,文字比對被略過) +``` + + + +### filterFalsePositivesWithAI + +由「防守方」角色(固定為 Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者;多筆時各派一個裁決任務平行處理(併發上限 `LLM_CONCURRENCY`)。任一筆裁決失敗時保守保留該問題,不中斷整體流程。 + +- 參數:`findings`(`Array`)、`exclusions`(`Array`,預設 `[]`,用於引導相似誤報更寬鬆判定)、`chatFn`(`Function`,預設 `chatJSON`)。 +- 回傳:`Promise>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { filterFalsePositivesWithAI } from './src/findings.js'; + +const kept = await filterFalsePositivesWithAI(ruleFiltered, exclusions); +// => 裁決為「非誤報」而保留下來的 finding 陣列 +``` + + + +### getBotReviewOutcome + +解析文字中的 `[ai-review-bot][success|failure]` 標記,判斷上一次自動審查結果;用於 commit 訊息或留言內容,無標記或無後綴時視為未知。 + +- 參數:`message`(`string`)。 +- 回傳:`'success' | 'failure' | 'unknown'`。 + +```javascript +import { getBotReviewOutcome } from './src/gitea.js'; + +getBotReviewOutcome('chore: update ai-review findings [ai-review-bot][failure]'); +// => 'failure' +getBotReviewOutcome('一般 commit 訊息'); +// => 'unknown' +``` + + + +### parseReviewIgnore + +解析 `.reviewignore` 文字為排除前綴陣列(gitignore 風格):每行一個路徑前綴,trim 後略過空行與 `#` 開頭的註解行。 + +- 參數:`text`(`string`)。 +- 回傳:`string[]`。 + +```javascript +import { parseReviewIgnore } from './src/gitea.js'; + +parseReviewIgnore('# 註解\n.gitea/\n\nREADME.md\n'); +// => ['.gitea/', 'README.md'] +``` + + + +### getReviewIgnore + +從被審 PR 的 head ref 取得 `.reviewignore` 並解析為排除清單;檔案不存在或為空時退回 `DEFAULT_REVIEW_IGNORE`。成功套用自訂規則時會輸出一行 log。 + +- 參數:無。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { getReviewIgnore } from './src/gitea.js'; + +const patterns = await getReviewIgnore(); +// => ['.gitea/', '.github/', 'README.md', ...](無自訂 .reviewignore 時為預設清單) +``` + + + +### getPRDiff + +取得目前 PR 的完整 unified diff(`GET /repos/{repo}/pulls/{index}.diff`),並依 `.reviewignore`(讀不到時用內建預設)排除不需審查的路徑。 + +- 參數:無。 +- 回傳:`Promise`。 +- 例外:Gitea API 請求失敗時拋出。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { getPRDiff } from './src/gitea.js'; + +const diff = await getPRDiff(); +// => 過濾後的 unified diff 文字,供各角色分析使用 +``` + + + +### getCommitMessageBySha + +依 commit SHA 向 Gitea 查詢該 commit 的訊息(`GET /repos/{repo}/git/commits/{sha}`);失敗或 `sha` 為空時不拋例外,記錄警告並回傳空字串。 + +- 參數:`sha`(`string`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。 +import { getCommitMessageBySha } from './src/gitea.js'; + +const message = await getCommitMessageBySha('abcdef1234567890'); +// => commit 訊息文字,查無或失敗時為 '' +``` + + + +### getBranchHeadCommitMessage + +取得指定分支 head commit 的訊息:先查 `GET /repos/{repo}/branches/{branch}` 取 SHA,再查該 commit;失敗或 `branch` 為空時不拋例外,回傳空字串。 + +- 參數:`branch`(`string`,預設 `PR_HEAD_BRANCH`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。 +import { getBranchHeadCommitMessage } from './src/gitea.js'; + +const message = await getBranchHeadCommitMessage('feature/x'); +``` + + + +### shouldSkipBotCommit + +判斷目前 PR head(commit 或分支 head)的訊息是否帶 `[ai-review-bot]` 標記;若是,代表本次變更為 bot 自動提交,呼叫端應跳過審查以避免自我審查迴圈。內部查詢失敗會降級為空字串(視為未命中),正常情況下不會拋例外。 + +- 參數:`options.sha`(`string`,預設 `PR_HEAD_SHA`)、`options.branch`(`string`,預設 `PR_HEAD_BRANCH`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { shouldSkipBotCommit } from './src/gitea.js'; + +if (await shouldSkipBotCommit()) { + // 本次為 bot 自動提交,跳過本輪審查 +} +``` + + + +### filterDiff + +過濾 unified diff,移除檔案路徑前綴命中 `excludePrefixes` 的區塊;以每個 `diff --git ` 行為界切割,並一律強制排除任何深度的 `node_modules/`。`diff` 必須為字串,否則拋出 TypeError。 + +- 參數:`diff`(`string`)、`excludePrefixes`(`string[]`,預設 `[]`)。 +- 回傳:`string`。 + +```javascript +import { filterDiff } from './src/gitea.js'; + +const filtered = filterDiff(rawDiff, ['.gitea/', 'README.md']); +// => 移除 .gitea/ 與 README.md 相關區塊、且一律排除 node_modules/ 後的 diff 文字 +``` + + + +### postComment + +在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言),透過 `POST /repos/{repo}/issues/{index}/comments`,優先使用 `GITEA_COMMENT_TOKEN` 授權。 + +- 參數:`body`(`string`)。 +- 回傳:`Promise`。 +- 例外:請求失敗時拋出。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { postComment } from './src/gitea.js'; + +await postComment('## 🔍 新發現問題(1 筆)\n\n...'); +``` + + + +### postPullReviewComment + +在 PR 指定檔案的指定新版行號發布一筆行內 review comment(建立只含單一 comment 的 `COMMENT` review);該行不在 diff 範圍時 Gitea 會回錯誤而拋例外,呼叫端可降級為一般留言。 + +- 參數:`{ path, line, body }`。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { postPullReviewComment } from './src/gitea.js'; + +await postPullReviewComment({ path: 'src/a.js', line: 42, body: '**建議**:補上邊界檢查' }); +``` + + + +### postPullReview + +建立一個 PR review:本文放摘要,`comments` 批次放多筆行內 review comments;透過 `POST /repos/{repo}/pulls/{index}/reviews`(`event=COMMENT`),優先使用 `GITEA_COMMENT_TOKEN`。 + +- 參數:`{ body, comments }`(`comments` 預設 `[]`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { postPullReview } from './src/gitea.js'; + +await postPullReview({ + body: '## AI Code Review 統計\n...', + comments: [{ path: 'src/a.js', body: '**建議**:...', new_position: 10 }], +}); +``` + + + +### listPullReviews + +取得目前 PR 上所有 review(`GET /repos/{repo}/pulls/{index}/reviews`);回應非陣列時回傳空陣列以保證型別一致。 + +- 參數:無。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { listPullReviews } from './src/gitea.js'; + +const reviews = await listPullReviews(); +``` + + + +### getPullReviewComments + +取得指定 review 底下的所有行內 comment(`GET /repos/{repo}/pulls/{index}/reviews/{id}/comments`)。 + +- 參數:`reviewId`(`number | string`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { getPullReviewComments } from './src/gitea.js'; + +const comments = await getPullReviewComments(123); +``` + + + +### listAllReviewComments + +取得目前 PR 上所有 review 的行內 comment 並展平為單一陣列;單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程,最後輸出統計日誌。 + +- 參數:無。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { listAllReviewComments } from './src/gitea.js'; + +const allComments = await listAllReviewComments(); +``` + + + +### resolvePullReviewComment + +解決(resolve)指定 review comment 所屬的對話,對應 Gitea API `POST /repos/{repo}/pulls/comments/{id}/resolve`,使用 `GITEA_COMMENT_TOKEN` 授權。 + +- 參數:`commentId`(`number | string`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。 +import { resolvePullReviewComment } from './src/gitea.js'; + +await resolvePullReviewComment(456); +``` + + + +### getFileContentAtRef + +取得指定 ref(預設 PR head)下某檔案的文字內容,透過 Gitea contents API,base64 內容會自動解碼為 UTF-8 字串;檔案不存在、非文字或請求失敗時不拋例外,記錄警告並回傳空字串。 + +- 參數:`filePath`(`string`)、`ref`(`string`,預設 `PR_HEAD_SHA||PR_HEAD_BRANCH`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { getFileContentAtRef } from './src/gitea.js'; + +const content = await getFileContentAtRef('.reviewignore'); +``` + + + +### getRepoState + +讀取指定 repo 目錄的目前狀態(HEAD SHA、短 SHA、目前分支、commit 時間 `%cI`);所有查詢皆採容錯讀取,任一失敗對應欄位即為空字串,不會丟出例外。 + +- 參數:`repoDir`(`string`)、`_spawnSync`(測試用依賴注入,預設 `spawnSync`)。 +- 回傳:`{ repoDir, branch, headSha, shortSha, commitTime }`。 + +```javascript +import { getRepoState } from './src/git.js'; + +const state = getRepoState('/workspace/repo'); +// => { repoDir: '/workspace/repo', branch: 'feature/x', headSha: 'abc123...', shortSha: 'abc123', commitTime: '2026-08-07T13:41:28+08:00' } +``` + + + +### getHeadCommitMessage + +取得 HEAD commit 的完整 commit message(`%B`,含 subject 與 body),容錯讀取,失敗時回傳空字串。 + +- 參數:`repoDir`(`string`)、`_spawnSync`(測試用依賴注入)。 +- 回傳:`string`。 + +```javascript +import { getHeadCommitMessage } from './src/git.js'; + +const message = getHeadCommitMessage('/workspace/repo'); +``` + + + +### isBotAutoCommit + +判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit(commit message 含 `BOT_COMMIT_MARKER`:`[ai-review-bot]`),常用於避免機器人 commit 反覆觸發新一輪審查。 + +- 參數:`repoDir`(`string`)、`_spawnSync`(測試用依賴注入)。 +- 回傳:`boolean`。 + +```javascript +import { isBotAutoCommit } from './src/git.js'; + +if (isBotAutoCommit('/workspace/repo')) { + // 跳過本輪審查 +} +``` + + + +### verifyRemoteAccess + +用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,驗證 git 對 remote 的認證與連線是否可用(不寫入任何東西);查詢分支為 `PR_HEAD_BRANCH || 'HEAD'`。所有例外皆在內部捕捉,不會向外拋出。 + +- 參數:`workspace`(`string`)、`_spawnSync`(測試用依賴注入)。 +- 回傳:`{ ok: boolean, error?: string }`。 + +```javascript +import { verifyRemoteAccess } from './src/git.js'; + +const result = verifyRemoteAccess('/workspace'); +// => { ok: true } 或 { ok: false, error: '...' } +``` + + + +### cloneRepo + +將 PR head branch clone 到 `workspace/repo`(idempotent):目標目錄不存在則以 `--depth=1 --branch ` clone;已存在則改為 `fetch` 最新後 `checkout` 到該分支。使用 `GITEA_TOKEN` 做 git HTTP 認證;clone/fetch/checkout 任一步驟失敗時例外會直接向外拋出。 + +- 參數:`workspace`(`string`)、`_spawnSync`(測試用依賴注入)。 +- 回傳:`string`(repo 本機路徑)。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { cloneRepo } from './src/git.js'; + +const repoDir = cloneRepo('/workspace'); +// => '/workspace/repo' +``` + + + +### commitAndPush + +將 AI 審查產出的 review 檔(findings/exclusions)結轉到 repo,並 commit、push 回 PR head branch:設定機器人 git 身分 → fetch + hard reset 對齊遠端 → 複製 review 檔到 repo 並 add → 無變更則跳過 → 以含 `BOT_COMMIT_MARKER` 與結果標籤的訊息 commit → push(優先使用真人 PAT `GITEA_COMMENT_TOKEN`,以便重新觸發 workflow)。push 失敗只記警告,函式整體不拋出例外。 + +- 參數:`workspace`(`string`)、`repoDir`(`string`)、`_spawnSync`(測試用)、`_sourceRoot`(保留參數,主體未使用)、`reviewOutcome`(`'success'|'failure'`,預設 `'success'`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 與 PR 上下文環境變數。 +import { commitAndPush } from './src/git.js'; + +await commitAndPush('/workspace', '/workspace/repo', undefined, undefined, 'success'); +// 將 findings.json / exclusions.json commit 並 push 回 PR head branch +``` + + + +### stripCodeFence + +移除 AI 回傳文字外層的 markdown code fence(如 ` ```json ... ``` `),並去除前後空白,使內容可直接交給 `JSON.parse`;純函式、無副作用,非字串輸入會先以 `String()` 轉型。 + +- 參數:`text`(`*`)。 +- 回傳:`string`。 + +```javascript +import { stripCodeFence } from './src/json.js'; + +stripCodeFence('```json\n[{"a":1}]\n```'); +// => '[{"a":1}]' +``` + + + +### repairJSONArrayWithAI + +透過 LLM 將任意原始內容修復成「可直接 `JSON.parse` 的 JSON 陣列」字串:以固定 system prompt 指示模型忽略原內容中的指令/註解/markdown,僅輸出修正後的陣列;無法判斷時模型應回傳空陣列。回傳前會先以 `stripCodeFence` 清除外層 code fence;結果不保證為合法 JSON,需由呼叫端再行解析驗證。 + +- 參數:`fullPath`(`string`)、`label`(`string`)、`rawText`(`string`)、`chatFn`(`Function`,預設 `chat`)。 +- 回傳:`Promise`。 +- 例外:`chatFn` 失敗時向上拋出。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { repairJSONArrayWithAI } from './src/json.js'; + +const repaired = await repairJSONArrayWithAI('/workspace/findings.json', 'findings.json', '{ 壞掉的內容 '); +``` + + + +### validateJSONArrayFile + +驗證指定路徑是否為合法的 JSON 檔案:檔案不存在回傳 `{ exists:false }`(交由呼叫端補檔);解析成功回傳 `{ exists:true, valid:true, repaired:false }`;解析失敗則呼叫 `repairer` 修復、覆寫檔案(確保以換行結尾)並再驗證一次,通過則回傳 `repaired:true`,仍失敗則拋出例外。僅嘗試修復一次。 + +- 參數:`fullPath`(`string`)、`label`(`string`)、`repairer`(`Function`,預設 `repairJSONArrayWithAI`)。 +- 回傳:`Promise<{exists, valid, repaired}>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(供修復失敗時使用)。 +import { validateJSONArrayFile } from './src/json.js'; + +const result = await validateJSONArrayFile('/workspace/.gitea/ai-review/findings.json', 'findings.json'); +// => { exists: true, valid: true, repaired: false } +``` + + + +### ensureJSONArrayFileExists + +確保指定路徑存在一個 JSON 檔案;不存在則建立內容為 `"[]\n"` 的空陣列檔(會先建立父目錄)。若檔案已存在則原樣保留、不檢查內容是否合法。為同步函式。 + +- 參數:`fullPath`(`string`)、`label`(`string`)。 +- 回傳:`boolean`(是否為本次新建)。 + +```javascript +import { ensureJSONArrayFileExists } from './src/json.js'; + +const created = ensureJSONArrayFileExists('/workspace/.gitea/ai-review/exclusions.json', 'exclusions.json'); +// => true(新建)或 false(原本即存在) +``` + + + +### mapWithConcurrency + +對 `items` 並行執行 async `fn`(保序回傳),加速多個獨立的 LLM 子行程呼叫;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。`fn` 需自行處理例外,否則 reject 會使本函式立即向外拋出(其他已啟動的併發工作不會被取消,只是結果被捨棄)。 + +- 參數:`items`(`T[]`)、`limit`(`number`)、`fn`(`(item, index) => Promise`)。 +- 回傳:`Promise`。 + +```javascript +import { mapWithConcurrency } from './src/llm.js'; + +const results = await mapWithConcurrency([1, 2, 3], 2, async (n) => n * 2); +// => [2, 4, 6](同時最多 2 個併發) +``` + + + +### extractMeaningfulError + +從 proxy API 錯誤輸出中抽出「真正有意義的錯誤」:先抽出看起來像錯誤的行(含 ERROR/unauthorized/rate limit 等關鍵字),抽不到再退取尾段;回傳長度受 `limit` 限制。 + +- 參數:`raw`(`string`)、`limit`(`number`,預設 1000)。 +- 回傳:`string`。 + +```javascript +import { extractMeaningfulError } from './src/llm.js'; + +extractMeaningfulError('some noise\nERROR: rate limit exceeded\nmore noise'); +// => 'ERROR: rate limit exceeded' +``` + + + +### chat + +對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應:未偵測到 proxy 設定時拋錯;不含任何重試邏輯,失敗一次即向外拋出(重新包裝為精簡訊息的 `Error`)。成功時記錄一次 usage 呼叫。 + +- 參數:`systemPrompt`(`string`)、`userContent`(`string`)。 +- 回傳:`Promise`。 +- 例外:設定缺失、API 呼叫失敗或回應無文字內容時拋出。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API / MODEL)。 +import { chat } from './src/llm.js'; + +const reply = await chat('你是程式碼審查員', '請審查以下 diff:...'); +``` + + + +### chatJSON + +對 CLIProxyAPI 送出對話並將回應解析為 JSON:先呼叫 `chat` 取得文字回應,再經 `extractJSONText` 抽出 JSON 片段後 `JSON.parse`。僅 JSON 解析失敗時容錯(回傳空陣列 `[]`);若 `chat()` 本身失敗,例外會直接向外傳播。 + +- 參數:`systemPrompt`(`string`)、`userContent`(`string`)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { chatJSON } from './src/llm.js'; + +const findings = await chatJSON('請以 JSON 陣列回覆審查結果', diffText); +// => [] 或解析後的 JSON 值 +``` + + + +### extractBalancedJSON + +從指定索引起,以括號平衡方式擷取一段完整配對的 JSON 子字串;依起始字元判定為物件或陣列,逐字元計數巢狀深度並正確略過字串字面值與跳脫字元,深度歸零時回傳完整片段,找不到配對時回傳 `null`。 + +- 參數:`text`(`*`)、`startIndex`(`number`,應指向 `{` 或 `[`)。 +- 回傳:`string | null`。 + +```javascript +import { extractBalancedJSON } from './src/llm.js'; + +extractBalancedJSON('前言 [1, 2, {"a": "]"}] 後言', 3); +// => '[1, 2, {"a": "]"}]' +``` + + + +### extractJSONText + +從可能夾雜雜訊或被 code fence 包裹的文字中,盡力抽出可被 `JSON.parse` 解析的片段:先去除外層 fence;若整段即為合法 JSON 直接回傳;否則由左至右尋找每個 `{`/`[` 起點,以括號平衡擷取候選片段並試解析,回傳第一個成功者;全數失敗則回傳去 fence 後的原文。 + +- 參數:`text`(`*`)。 +- 回傳:`string`。 + +```javascript +import { extractJSONText } from './src/llm.js'; + +extractJSONText('這是回應:```json\n[{"level":"info"}]\n```'); +// => '[{"level":"info"}]' +``` + + + +### section + +輸出最上層的「區塊/章節」分隔標題(前綴空行 + `[時間][INF]: === 標題 ===`),用於切分整個執行流程中彼此獨立的大段落,讓 CI log 在視覺上分群。 + +- 參數:`title`(`string`)。 +- 回傳:`void`。 + +```javascript +import { section } from './src/log.js'; + +section('AI Code Review Pipeline'); +// => 印出:\n[2026/08/07 13:51:53][INF]: === AI Code Review Pipeline === +``` + + + +### step + +輸出某個「步驟」的標題(前綴空行 + `[步驟代號] 標題`),適合在一個 section 之下標示流程中的各個有序步驟。 + +- 參數:`stepName`(`string`)、`title`(`string`)。 +- 回傳:`void`。 + +```javascript +import { step } from './src/log.js'; + +step('Step2', '前置驗證(驗證相關設定)'); +``` + + + +### line + +輸出一筆縮排的一般明細列(` - 訊息`),用於列出不帶語意成敗的中性資訊。 + +- 參數:`message`(`string`)。 +- 回傳:`void`。 + +```javascript +import { line } from './src/log.js'; + +line('已套用 .reviewignore:3 條排除規則'); +``` + + + +### input + +輸出「階段輸入」描述(` ← 輸入:訊息`),標示目前步驟吃進了什麼資料。 + +- 參數:`message`(`string`)。 +- 回傳:`void`。 + +```javascript +import { input } from './src/log.js'; + +input('repo=owner/name PR=#12 feature/x → develop'); +``` + + + +### output + +輸出「階段輸出」描述(` → 輸出:訊息`),標示目前步驟產出了什麼結果。 + +- 參數:`message`(`string`)。 +- 回傳:`void`。 + +```javascript +import { output } from './src/log.js'; + +output('新 findings 3 筆(嚴重1 / 警告1 / 建議1 / 無法標示0)'); +``` + + + +### result + +輸出一筆檢查/把關結果列,依 `passed` 以 `✅ 成功` 或 `❌ 失敗` 為前綴;成功寫 `INF` 等級、失敗寫 `ERR` 等級,但一律輸出至 stdout(不因失敗改寫 stderr)。 + +- 參數:`passed`(`boolean`)、`message`(`string`)。 +- 回傳:`void`。 + +```javascript +import { result } from './src/log.js'; + +result(true, '前置驗證通過'); +result(false, '發現 1 個嚴重問題,workflow 失敗(exit 1)'); +``` + + + +### ok + +輸出一筆成功/完成訊息(` ✓ 訊息`),用於確認某項動作已順利完成的正向回饋。 + +- 參數:`message`(`string`)。 +- 回傳:`void`。 + +```javascript +import { ok } from './src/log.js'; + +ok('GITEA_TOKEN 可讀取 repo owner/name'); +``` + + + +### warn + +輸出一筆警告訊息(` ! 訊息`),透過 `console.warn` 寫入 stderr;用於流程仍可繼續、但需要提醒注意的非致命狀況。 + +- 參數:`message`(`string`)。 +- 回傳:`void`。 + +```javascript +import { warn } from './src/log.js'; + +warn('對話收斂失敗(繼續執行): timeout'); +``` + + + +### error + +輸出一筆錯誤訊息(` x 訊息`),透過 `console.error` 寫入 stderr;用於明確的失敗或例外狀況,是日誌中最高的嚴重層級。 + +- 參數:`message`(`string`)。 +- 回傳:`void`。 + +```javascript +import { error } from './src/log.js'; + +error('缺少必要環境變數: GITEA_TOKEN, PR_NUMBER'); +``` + + + +### main + +AI Code Review Pipeline 的總指揮:依序串接 Step1~Step11(啟動、前置驗證、自動提交檢查、PR 對話收斂、角色平行分析、findings 合併去重、排除規則與誤報過濾、寫入 findings 並發布 Review、JSON 格式驗證、commit/push、嚴重問題把關)。結果主要透過 `process.exit()` 決定 workflow 成敗,而非以回傳值傳遞;未被攔截的未預期例外會由頂層 `main().catch(...)` 接住並以 `process.exit(1)` 結束。 + +- 參數:無。 +- 回傳:`Promise`(正常走完且無嚴重問題時 resolve;多數結束路徑直接 `process.exit()`)。 + +```javascript +// 範例為示意,實際執行需搭配完整的 Gitea / CLIProxyAPI 環境變數, +// 通常由 entrypoint.sh 以 `node src/main.js` 直接啟動整個 pipeline,不建議手動 import 呼叫。 +import { main } from './src/main.js'; + +await main(); +``` + + + +### checkRequiredEnv + +檢查 code review 所需的必要環境變數是否齊全(`GITEA_TOKEN`、`GITEA_REPOSITORY`、`PR_NUMBER`、`CLI_PROXY_API`),缺任何一項即列出缺少項目。`CLI_PROXY_API` 一律直接讀環境變數,不受 `opts` 覆寫。 + +- 參數:`opts.token`/`opts.repo`/`opts.pr`(可選,供測試注入)。 +- 回傳:`{ ok: boolean, missing: string[] }`。 + +```javascript +import { checkRequiredEnv } from './src/preflight.js'; + +checkRequiredEnv({ token: '', repo: 'owner/name', pr: '12' }); +// => { ok: false, missing: ['GITEA_TOKEN', ...(若 CLI_PROXY_API 環境變數也未設)] } +``` + + + +### verifyGiteaToken + +驗證 Gitea token 有效且對指定 repo 有讀取權限:透過唯讀的 `GET /repos/{repo}` 探測;任何錯誤都被攔截並轉為回傳值,不會 throw。 + +- 參數:`token`(`string`,預設 `GITEA_TOKEN`)、`repo`(`string`,預設 `GITEA_REPOSITORY`)。 +- 回傳:`Promise<{ok:true} | {ok:false, error:string}>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。 +import { verifyGiteaToken } from './src/preflight.js'; + +const result = await verifyGiteaToken(); +// => { ok: true } 或 { ok: false, error: 'HTTP 401 ...' } +``` + + + +### verifyCommentToken + +驗證選用的 comment token(`GITEA_COMMENT_TOKEN`)是否可用:未提供時直接視為通過並標記 `skipped:true`(之後 comment 會沿用主 token);有提供則以 `GET /user` 探測。 + +- 參數:`token`(`string`,預設 `GITEA_COMMENT_TOKEN`)。 +- 回傳:`Promise<{ok:true, skipped?:true} | {ok:false, error:string}>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 Gitea Token 環境變數。 +import { verifyCommentToken } from './src/preflight.js'; + +const result = await verifyCommentToken(); +``` + + + +### fetchLLMModels + +呼叫 CLIProxyAPI 的 `/v1/models`,確認 proxy 可用與模型清單可讀;`baseURL` 未設定、連線錯誤、401 認證失效或非 2xx 回應都會被轉為結構化的失敗結果。 + +- 參數:`deps.fetchImpl`(預設 `fetch`)、`deps.baseURL`(預設 `CLI_PROXY_API`)、`deps.apiKey`(預設 `CLI_PROXY_API_KEY`)。 +- 回傳:`Promise<{ok:true, slugs:string[]} | {ok:false, error:string}>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { fetchLLMModels } from './src/preflight.js'; + +const result = await fetchLLMModels(); +// => { ok: true, slugs: ['gpt-4o', 'claude-sonnet-5', ...] } +``` + + + +### verifyLLM + +驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI 且已解析出 model,額外向模型清單端點確認 proxy 可連線且設定的 model 在可用清單內(不送 prompt)。 + +- 參數:`deps.fetchLLMModelsFn`(預設 `fetchLLMModels`)。 +- 回傳:`Promise<{ok:true, provider, command, model, models?} | {ok:false, provider?, command?, model?, error}>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { verifyLLM } from './src/preflight.js'; + +const result = await verifyLLM(); +``` + + + +### runPreflight + +執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端(`ls-remote`)、LLM proxy;全程唯讀,不發布任何 comment,任一檢查失敗即記錄錯誤並回傳 `false`。各檢查可經 `deps` 注入覆寫,方便單元測試。 + +- 參數:`workspace`(`string`,預設 `GITHUB_WORKSPACE||'/workspace'`)、`deps`(可覆寫各檢查函式)。 +- 回傳:`Promise`。 + +```javascript +// 範例為示意,實際呼叫需搭配完整的 Gitea / CLIProxyAPI 環境變數。 +import { runPreflight } from './src/preflight.js'; + +const passed = await runPreflight('/workspace'); +if (!passed) process.exit(1); +``` + + + +### parseBotReviewComment + +嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位:同時支援 review comment(嚴重等級/審查員/問題/建議)與行內 critical comment(等級/審查員/建議)兩種格式。不符合格式(如人工自由留言)時回傳 `null`。 + +- 參數:`body`(`string`)。 +- 回傳:`{level, role, problem, suggestion} | null`。 + +```javascript +import { parseBotReviewComment } from './src/resolve.js'; + +parseBotReviewComment('**嚴重等級**:🔴 嚴重\n**審查員**:Paladin\n**問題**:缺少輸入驗證\n**建議**:補上檢查'); +// => { level: 'critical', role: 'Paladin', problem: '缺少輸入驗證', suggestion: '補上檢查' } +``` + + + +### groupConversations + +把 PR 上的行內 review comment 依「檔案路徑 + 行號」收斂成對話(同一處的留言與回覆視為一段對話);對話只要任一則 comment 帶有 `resolver` 即視為已解決,同時嘗試解析出對應的每一則 bot finding。缺少 `path` 的留言會整筆跳過。 + +- 參數:`comments`(Gitea PR review comments 原始陣列)。 +- 回傳:`Array<{key, path, line, commentIds, bodies, resolved, botFinding, botFindings, thread}>`。 + +```javascript +import { groupConversations } from './src/resolve.js'; + +const conversations = groupConversations(rawComments); +// => [{ key: 'a.js|10', path: 'a.js', line: 10, resolved: false, botFindings: [...], thread: '...' }, ...] +``` + + + +### codeWindow + +取目標行附近的程式碼片段(含 1-based 行號前綴),讓 AI 對照判斷問題是否已解決;`content` 為空時回傳空字串,`lineNum` 非正數或非有限數時退回以檔案第一行為中心。 + +- 參數:`content`(`string`)、`lineNum`(`number`)、`radius`(`number`,預設 `CODE_WINDOW_RADIUS`=20)。 +- 回傳:`string`。 + +```javascript +import { codeWindow } from './src/resolve.js'; + +const snippet = codeWindow(fileContent, 42, 5); +// => '38: ...\n39: ...\n...\n47: ...'(第 42 行上下各 5 行) +``` + + + +### judgeConversations + +批次請 AI 將每個對話判為 `resolved` / `false_positive` / `open`;AI 回傳非陣列、缺漏或不合法的 `idx`,對應結果一律降級為 `open`(寧可保留)。 + +- 參數:`items`(`Array<{idx, path, line, thread, code}>`)、`chatFn`(預設 `chatJSON`)。 +- 回傳:`Promise>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數。 +import { judgeConversations } from './src/resolve.js'; + +const verdicts = await judgeConversations([{ idx: 0, path: 'a.js', line: 10, thread: '...', code: '...' }]); +// => [{ idx: 0, verdict: 'resolved' }] +``` + + + +### isSafeRepoPath + +安全守衛:判定路徑是否為 repo 內的相對路徑(拒絕絕對路徑、Windows 磁碟機前綴與含 `..` 的路徑穿越),用於防止以外部 PR 檔名讀取 repo 外的檔案。 + +- 參數:`p`(`string`)。 +- 回傳:`boolean`。 + +```javascript +import { isSafeRepoPath } from './src/resolve.js'; + +isSafeRepoPath('src/a.js'); // => true +isSafeRepoPath('../../etc/passwd'); // => false +isSafeRepoPath('/etc/passwd'); // => false +``` + + + +### reconcileConversations + +對話收斂主流程:取得 PR 所有行內 review comment,把每個未解決 comment 一律呼叫 Gitea resolve API 關閉;再以「檔案路徑+行號」收斂成對話、取最新程式碼交 AI 判斷,依判斷結果把對應 findings 分流為 `resolvedFindings`(已修復)/`excludedFindings`(誤報,寫入 exclusions)/`carriedFindings`(仍成立)。任一外部呼叫失敗都降級保守處理(視為 open),不中斷整體 pipeline。 + +- 參數:`deps`(可覆寫 `listComments`/`resolveComment`/`getFileContent`/`judge`,供測試注入)。 +- 回傳:`Promise<{resolvedFindings, excludedFindings, carriedFindings, resolvedCount, falsePositiveCount, openCount, closedCount, unresolvedCount}>`。 + +```javascript +// 範例為示意,實際呼叫需搭配完整的 Gitea / CLIProxyAPI 環境變數。 +import { reconcileConversations } from './src/resolve.js'; + +const reconcile = await reconcileConversations(); +// => { resolvedFindings: [...], excludedFindings: [...], carriedFindings: [...], closedCount: 3, ... } +``` + + + +### dropResolvedFindings + +從 `findings` 中移除「已解決對話」對應的問題,以檔案路徑+建議內容比對(對行號漂移不敏感);`resolvedFindings` 為空時回傳 `findings` 原引用(未複製)。 + +- 參數:`findings`(`Array`)、`resolvedFindings`(`Array`,預設 `[]`)。 +- 回傳:`Array`。 + +```javascript +import { dropResolvedFindings } from './src/resolve.js'; + +const remaining = dropResolvedFindings(oldFindings, reconcile.resolvedFindings); +``` + + + +### addCarriedFindings + +把「未解決對話」對應、但目前 `findings` 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準);有實際新增項目時輸出一行提示 log。 + +- 參數:`findings`(`Array`)、`carriedFindings`(`Array`,預設 `[]`)。 +- 回傳:`Array`。 + +```javascript +import { addCarriedFindings } from './src/resolve.js'; + +const updated = addCarriedFindings(oldFindings, reconcile.carriedFindings); +``` + + + +### parseRoleFile + +解析單一角色 Markdown 檔內容,拆出前置 YAML frontmatter(徽章、代表色、面向、個性等欄位,會攤平到回傳物件)與本文(`body`,已去除頭尾空白);缺少合法 `---` frontmatter 區塊時拋出例外。 + +- 參數:`content`(`string`)。 +- 回傳:`{name?, side?, focus?, badge?, color?, personality?, body, ...}`。 +- 例外:缺 frontmatter 或 YAML 格式錯誤時拋出。 + +```javascript +import { parseRoleFile } from './src/roles.js'; + +const role = parseRoleFile('---\nname: Scout\nside: attack\nfocus: 安全性\n---\n審查重點...'); +// => { name: 'Scout', side: 'attack', focus: '安全性', body: '審查重點...' } +``` + + + +### loadRoles + +載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序;供 Step5(角色分析產生 findings)階段使用。防守方角色(如 Paladin)不在回傳之列。 + +- 參數:無。 +- 回傳:`Array`。 + +```javascript +import { loadRoles } from './src/roles.js'; + +const roles = loadRoles(); +// => [{ name: 'Scout', side: 'attack', ... }, { name: 'Sentinel', side: 'attack', ... }, ...] +``` + + + +### loadRole + +依 frontmatter `name` 取得單一角色(比對不分大小寫),不分攻擊方/防守方,找不到回傳 `null`。 + +- 參數:`name`(`string`)。 +- 回傳:`object | null`。 + +```javascript +import { loadRole } from './src/roles.js'; + +const paladin = loadRole('paladin'); +// => { name: 'Paladin', side: 'defense', ... } +``` + + + +### buildAnalysisPrompt + +由攻擊方角色定義組出其分析用 system prompt:套用角色的徽章、名稱、面向(缺省「綜合」)、個性與審查重點本文,並附上固定指示——分析 diff 僅針對新增/修改處找問題,以固定 JSON 陣列格式(`level`/`role`/`location`/`problem`/`suggestion`)回傳,強制每條問題帶 `檔案路徑:行號`。 + +- 參數:`role`(`object`,需含 `name`/`body`)。 +- 回傳:`string`。 +- 例外:`role` 為 `null`/`undefined` 時拋出 TypeError。 + +```javascript +import { buildAnalysisPrompt } from './src/roles.js'; +import { loadRole } from './src/roles.js'; + +const prompt = buildAnalysisPrompt(loadRole('Scout')); +``` + + + +### buildLocateLinePrompt + +組出「補行號」用的 system prompt:用於某角色先前提出的 finding 其 `location` 只有檔名、缺行號時,請 LLM 對照該檔 diff 找出實際行號,只回 `{"line": 數字}`(找不到回 `{"line": 0}`)。`role` 可省略或為 `null`,此時名稱退回 `'AI Review'`。 + +- 參數:`role`(`object | null | undefined`,可選)。 +- 回傳:`string`。 + +```javascript +import { buildLocateLinePrompt } from './src/roles.js'; + +const prompt = buildLocateLinePrompt({ name: 'Scout', focus: '安全性' }); +``` + + + +### buildVerdictPrompt + +由防守方角色定義組出「單條 finding 誤報裁決」用的 system prompt:要求對一條攻擊方 finding 判定「成立 / 誤報」,只回 `{"verdict", "reason"}`,無法確定時一律回 `"confirmed"`(寧可保留)。`role` 為空值時退回固定的通用裁判 persona(🛡️ Paladin)。 + +- 參數:`role`(`object | null | undefined`)、`exclusionHint`(`string`,預設 `''`)。 +- 回傳:`string`。 + +```javascript +import { buildVerdictPrompt } from './src/roles.js'; +import { loadRole } from './src/roles.js'; + +const prompt = buildVerdictPrompt(loadRole('Paladin'), '已知誤報清單:...'); +``` + + + +### getRoleIntro + +由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格(角色/面向/個性三欄),通常用於 PR 留言/審查報告開頭呈現參與審查的角色陣容。 + +- 參數:`roles`(`Array`)。 +- 回傳:`string`(Markdown 表格)。 +- 例外:`roles` 非可迭代值時拋出 TypeError。 + +```javascript +import { getRoleIntro } from './src/roles.js'; +import { loadRoles } from './src/roles.js'; + +console.log(getRoleIntro(loadRoles())); +// ## 🤖 AI Code Review 團隊 +// | 👤 角色 | 🎯 面向 | 🧠 個性 | +// |--------|--------|--------| +// | **🔍 Scout** | 安全性 | ... | +``` + + + +### extractUsage + +把各平台回應中的 token usage 正規化成 `{ promptTokens, completionTokens, totalTokens }`:支援 OpenAI 相容 usage、OpenAI Responses、Gemini `usageMetadata`、Ollama 原生欄位、OpenCode tokens。回應中沒有任何可辨識的 usage 時回傳 `null`。 + +- 參數:`data`(`*`)。 +- 回傳:`{promptTokens, completionTokens, totalTokens} | null`。 + +```javascript +import { extractUsage } from './src/usage.js'; + +extractUsage({ usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 } }); +// => { promptTokens: 100, completionTokens: 50, totalTokens: 150 } +``` + + + +### recordUsage + +記錄一次 LLM 呼叫的 usage(無法解析時仍計一次呼叫,但 token 計 0),並累加進模組級 `runUsage`。 + +- 參數:`data`(`*`)。 +- 回傳:`{promptTokens, completionTokens, totalTokens} | null`。 + +```javascript +import { recordUsage } from './src/usage.js'; + +recordUsage({ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }); +``` + + + +### getRunUsage + +取得本次執行至今的 token 累計(複本),修改回傳值不影響內部狀態。 + +- 參數:無。 +- 回傳:`{calls, promptTokens, completionTokens, totalTokens}`。 + +```javascript +import { getRunUsage } from './src/usage.js'; + +const usage = getRunUsage(); +// => { calls: 5, promptTokens: 1200, completionTokens: 600, totalTokens: 1800 } +``` + + + +### resetRunUsage + +重置模組級 token 累計(測試用),將 `calls`/`promptTokens`/`completionTokens`/`totalTokens` 全部歸零。 + +- 參數:無。 +- 回傳:`void`。 + +```javascript +import { resetRunUsage } from './src/usage.js'; + +resetRunUsage(); +``` + + + +### recordRateLimit + +從回應 header 擷取速率配額剩餘量/上限:支援 OpenAI 相容(`x-ratelimit-*-tokens`)與 Anthropic(`anthropic-ratelimit-tokens-*`),兩者皆缺時退而採用 requests 維度;記錄「最近一次」的數值。 + +- 參數:`headers`(`object | null | undefined`)。 +- 回傳:`void`。 + +```javascript +import { recordRateLimit } from './src/usage.js'; + +recordRateLimit({ 'x-ratelimit-remaining-tokens': '9000', 'x-ratelimit-limit-tokens': '10000' }); +``` + + + +### getRateLimit + +取得最近一次的速率配額快照(複本)。 + +- 參數:無。 +- 回傳:`{hasData, remaining, limit, kind}`。 + +```javascript +import { getRateLimit } from './src/usage.js'; + +const rate = getRateLimit(); +// => { hasData: true, remaining: 9000, limit: 10000, kind: 'tokens' } +``` + + + +### resetRateLimit + +重置速率配額快照(測試用),將 `hasData`/`remaining`/`limit`/`kind` 回復為初始狀態。 + +- 參數:無。 +- 回傳:`void`。 + +```javascript +import { resetRateLimit } from './src/usage.js'; + +resetRateLimit(); +``` + + + +### fetchAccountQuota + +取得指定平台的帳號額度;任何失敗都降級為 `{ available: false, reason }`,不丟例外。多數平台(如 CLIProxyAPI)因權限限制誠實回報「無法取得」,僅 OpenRouter 這類平台可實際查詢。 + +- 參數:`provider`(`string`)、`config`(`{apiKey?, apiKeys?, baseURL?}`,預設 `{}`)、`deps.get`(可注入 HTTP GET,預設 `axios.get`)。 +- 回傳:`Promise<{available, reason?, used?, limit?, remaining?, currency?, source?}>`。 + +```javascript +// 範例為示意,實際呼叫需搭配有效的 API Key 環境變數。 +import { fetchAccountQuota } from './src/usage.js'; + +const quota = await fetchAccountQuota('cliproxyapi', { apiKeys: [], baseURL: 'https://proxy.example' }); +// => { available: false, reason: 'CLIProxyAPI 不提供帳號額度資訊' } +``` + + + +### resolveRemainingPercent + +計算「剩餘可用百分比」,依優先序擇一:① 帳號額度(有有效上限)→ 剩餘 credits / 上限;② 速率配額(有有效上限)→ 當前視窗剩餘 / 上限。上限或剩餘為無效值時跳過計算,落到 `{ percent: null, reason }`。 + +- 參數:`quota`(`fetchAccountQuota` 的回傳結果)、`rate`(`getRateLimit` 的回傳結果)。 +- 回傳:`{percent, basis, remaining, limit, unit} | {percent:null, reason}`。 + +```javascript +import { resolveRemainingPercent } from './src/usage.js'; + +resolveRemainingPercent( + { available: false, reason: '不提供額度' }, + { hasData: true, remaining: 9000, limit: 10000, kind: 'tokens' }, +); +// => { percent: 90, basis: '速率配額(當前視窗,token)', remaining: 9000, limit: 10000, unit: '' } +``` + + + +### formatUsageStats + +產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊,內含本次呼叫次數、token 表格與剩餘可用百分比說明。 + +- 參數:`provider`(`string`)、`model`(`string`)、`usage`(`getRunUsage` 結果)、`quota`(`fetchAccountQuota` 結果)、`rate`(`getRateLimit` 結果)。 +- 回傳:`string`(多行 Markdown)。 + +```javascript +import { formatUsageStats } from './src/usage.js'; + +const section = formatUsageStats('cliproxyapi', 'gpt-4o', { calls: 5, promptTokens: 1200, completionTokens: 600, totalTokens: 1800 }, quota, rate); +``` + + + +### formatUsageStatsLine + +產生單行 log 用的使用量摘要文字,內容與 `formatUsageStats` 一致但為單行純文字,token 數字未套用千分位格式化。 + +- 參數:`provider`(`string`)、`model`(`string`)、`usage`(`getRunUsage` 結果)、`quota`(`fetchAccountQuota` 結果)、`rate`(`getRateLimit` 結果)。 +- 回傳:`string`。 + +```javascript +import { formatUsageStatsLine } from './src/usage.js'; + +const summary = formatUsageStatsLine('cliproxyapi', 'gpt-4o', { calls: 5, promptTokens: 1200, completionTokens: 600, totalTokens: 1800 }, quota, rate); +// => '本次 cliproxyapi/gpt-4o: 提示1200 + 回應600 = 1800 token(5 次呼叫);剩餘可用: 90%(...)' +``` diff --git a/src/comments.js b/src/comments.js index 3d298a5..a14a8db 100644 --- a/src/comments.js +++ b/src/comments.js @@ -28,8 +28,11 @@ function findingRow(f) { * 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。 * * @param {Array} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。 + * 注意:本參數必須是陣列,傳入 null/undefined 會在 `.map` 呼叫時拋出 TypeError(未防呆,需人工確認是否要補強)。 * @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。 - * @remarks 內部輔助函式,供發布舊問題、新問題(非嚴重)、單筆嚴重問題等 comment 內文使用。 + * @remarks 內部輔助函式,供 {@link postOldFindingsComment}、{@link postNewNonCriticalComment}、 + * {@link postNewCriticalComments} 組裝 comment 內文使用。 + * 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。 */ function buildTable(findings) { const rows = findings.map(findingRow).join('\n'); @@ -60,8 +63,16 @@ const bySeverity = (a, b) => { }; /** - * 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。 - * 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。 + * 解析 finding 的 `location` 欄位,取出檔案路徑與(起始)行號,供行內 comment 標註使用。 + * + * @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) { if (typeof location !== 'string') return null; @@ -73,7 +84,18 @@ export function parseLocation(location) { 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) { return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`; } @@ -213,10 +235,28 @@ function toReviewComment(f) { } /** - * 發布單一 Gitea review: - * - summaryFindings 只用來統計本文數字(含新舊問題) - * - commentFindings 用來產生 review comments,並依嚴重等級排序; - * 只為新問題加上行內標註,舊問題(is_new === false)僅計入統計、不再重複標註檔案與行數 + * 發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」,並提供多層降級機制。 + * + * @param {Array} findings 本次審查的完整 findings 陣列;當 `deps.summaryFindings` 或 + * `deps.commentFindings` 未提供時,兩者皆預設使用此參數。 + * @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換,正常情境可省略)。 + * @param {Function} [deps.postReview=postPullReview] 發布整批 review(含 body 與 comments)的函式。 + * @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。 + * @param {Function} [deps.postIssue=postComment] 發布一般(非 review)comment 的函式,作為最終降級手段。 + * @param {Array} [deps.summaryFindings=findings] 用於統計本文數字(含新舊問題)的 findings 子集合。 + * @param {Array} [deps.commentFindings=findings] 用於產生 review comments 的 findings 子集合; + * 會先依 {@link bySeverity} 排序,僅新問題(`is_new !== false`)會被轉成行內 comment, + * 舊問題只計入統計、不再重複標註檔案與行數。 + * @param {string} [deps.usageSection=''] 附加在統計表之後的用量/token 統計區塊;空字串時不附加。 + * @returns {Promise} 無回傳值。 + * @remarks + * 降級順序:① 整批 `postReview`(含 comments)→ 失敗則 ② 僅 body 的 `postReview` + * (comments 為空陣列)→ 失敗則 ③ `postIssue(body)`。**注意:③ 未包在 try/catch 中**, + * 若 `postIssue` 本身失敗,例外會直接從本函式往外拋出(reject),呼叫端須自行 catch。 + * 無論走到哪一步,只要走完 ①~③ 中任一步不再往下失敗,後續都會逐筆嘗試 `postInline` 補發 + * 行內 comment,每筆各自失敗僅記錄 warn 並略過,不影響其他筆。 + * 使用情境:CI 流程完成一輪 AI Code Review 後,呼叫一次本函式即可把整批結果發布到 Gitea PR; + * 單元測試時可透過 `deps` 注入假的 `postReview`/`postInline`/`postIssue` 以驗證各降級分支。 */ export async function postFindingsReview(findings, deps = {}) { const { @@ -254,8 +294,19 @@ export async function postFindingsReview(findings, deps = {}) { } /** - * 寫入 findings.json。 - * 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。 + * 將 findings 寫入 `findings.json`(同步阻塞 I/O)。 + * + * @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。 + * @param {Array} 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) { 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} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。 + * @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出(與 {@link postFindingsReview} + * 內部先用 `bySeverity` 排序的行為不同,請勿假設本函式輸出已排序)。 + * 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。 */ export async function postOldFindingsComment(findings) { 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} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。 + * @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。 + * 使用情境:每輪 AI Code Review 中,把「明確標記為新(`is_new === true`)且非嚴重」的問題 + * 統一彙總成一則 comment 通知,嚴重問題另由 {@link postNewCriticalComments} 逐筆單獨發布。 */ export async function postNewNonCriticalComment(findings) { const items = findings.filter(f => f.is_new && f.level !== 'critical'); @@ -298,9 +368,22 @@ export async function postNewNonCriticalComment(findings) { } /** - * 每個新 critical 問題各發一個 comment。 - * 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議); - * 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 comment。 + * 針對每個新的 critical 問題各發一個 comment;優先用 Gitea 行內 review comment 標註問題檔案與行數 + * (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 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} 無回傳值;`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 = {}) { const { postInline = postPullReviewComment, postIssue = postComment } = deps; diff --git a/src/config.js b/src/config.js index e308cb7..11e3677 100644 --- a/src/config.js +++ b/src/config.js @@ -43,22 +43,17 @@ export const LLM_PROVIDER = 'cliproxyapi'; export const FINDINGS_PATH = '.gitea/ai-review/findings.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)重複使用, * 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。 - * 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。 + * 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用; + * 若需要完整 TLS 安全性,應改用預設 `https.Agent`,不要調用這個函式。 * @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。 */ -let _insecureHttpsAgent = null; -/** - * 取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。 - * - * @remarks 只應在信任的內網或測試環境使用;若需要完整 TLS 安全性,應改用預設 - * `https.Agent`,不要調用這個函式。 - */ export function getInsecureHttpsAgent() { return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false })); } @@ -69,8 +64,12 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; /** * 依環境變數解析並回傳 CLIProxyAPI 設定。 * - * 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL,`INPUT_MODEL` / `MODEL` - * 作為模型名稱,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰。 + * 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線), + * `INPUT_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,相容舊 OpenCode 設定)作為模型 + * 名稱,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。 + * + * 若 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 設定。 diff --git a/src/findings.js b/src/findings.js index df740cf..6a2545e 100644 --- a/src/findings.js +++ b/src/findings.js @@ -8,8 +8,14 @@ import { line, ok, warn } from './log.js'; const LEVELS = ['critical', 'warning', 'info']; /** - * 用單一角色分析 diff,回傳 findings 陣列。 - * role 欄位一律以角色定義的 name 為準,避免 LLM 自行填入不一致的名稱。 + * 用單一角色分析 diff,呼叫 LLM 取得該角色視角下的 code review 問題並回傳 findings 陣列。 + * role 欄位一律以角色定義的 name 為準(覆寫 LLM 回傳值),避免 LLM 自行填入不一致的角色名稱。 + * + * @param {{name: string}} role - 審查角色定義物件,至少需含 name。 + * @param {string} diff - 欲分析的 unified diff 文字內容。 + * @returns {Promise>} 有效 findings 陣列(僅保留同時具備 level/location/suggestion 者), + * 每筆皆補上 role(角色名稱)與 is_new: true。 + * @throws 當 chatJSON 呼叫失敗(LLM 錯誤、額度限制等)時直接拋出例外,本函式不做降級處理。 */ export async function analyzeWithRole(role, diff) { 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} 解析出的陣列;任何失敗情況皆回傳空陣列 []。 */ function readJSONArray(fullPath, label) { if (!fs.existsSync(fullPath)) { @@ -101,21 +111,18 @@ function cleanText(value) { 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(); /** - * 將文字正規化成比對用形式。 + * 將文字正規化為比對用形式:先以 cleanText 轉為安全字串,NFKC 正規化、轉小寫, + * 並把所有標點/符號/空白字元壓縮成單一空白(再壓縮連續空白、去頭尾空白)。 + * 因常對同一段文字重複呼叫(findings × exclusions 笛卡爾積比對), + * 以模組層級 Map 對「字串輸入」做 memoization,避免重複執行 NFKC/正則運算。 * - * @param {*} value - 任意值。 - * @remarks 適合用於誤報過濾與排除條目比對。 + * @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串(不會寫入快取)。 + * @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。 + * @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。 + * 快取為模組層級、程序生命週期內不會清除,需人工確認長期執行(如常駐服務)情境下是否有記憶體成長風險; + * 在本專案作為一次性 CI 腳本執行的用法下應無實際影響。 */ export function normalizeText(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} 舊 findings 陣列,每筆皆含 is_new: false;讀取失敗或檔案不存在時回傳空陣列。 */ export function loadOldFindings(workspace) { 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} oldFindings - 既有(上一輪)findings 陣列,作為去重比對基準,原樣保留於結果前段。 + * @param {Array} newFindings - 本輪新產生的 findings 陣列,將依 key 去除與 oldFindings 重複者。 + * @returns {Array} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。 */ export function mergeFindings(oldFindings, newFindings) { 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} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。 + * @returns {Array} 依 critical/warning/info 順序排序後的新陣列。 + * @remarks level 不在 ['critical','warning','info'] 中的項目,因 indexOf 回傳 -1, + * 會被排到 critical 之前(最前面)而非最後面;此邊界行為是否為預期設計,需人工確認。 */ export function sortByLevel(findings) { return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level)); } /** - * AI 呼叫失敗時的統一降級處理 + * AI 呼叫失敗時的統一降級處理:記錄警告訊息後原樣回傳 findings(不做任何篩選), + * 確保 AI(去重/誤報過濾等)暫時性失敗時不會誤刪合法問題。 + * + * @param {string} label - 用於警告訊息中識別此次失敗的處理名稱(例如「AI 去重」)。 + * @param {Array} findings - 發生失敗前的 findings 陣列,將原樣回傳。 + * @param {Error} e - 捕捉到的錯誤物件;若 e.response.status 為 402 或 429,訊息會顯示為「額度/限流」,否則顯示 e.message。 + * @returns {Array} 原樣回傳的 findings(與傳入的參照相同,未複製)。 */ function fallback(label, findings, e) { const status = e.response?.status; @@ -348,7 +377,13 @@ function fallback(label, findings, e) { const MAX_LOCATE_ATTEMPTS = 3; -/** 從 location 取出行號;無 `檔案:行號`(或多檔逗號)時回 null。 */ +/** + * 從 location(格式如「檔案:行號」或「檔案:起始行-結束行」)取出行號。 + * + * @param {string|null|undefined} location - finding 的 location 欄位。 + * @returns {number|null} 解析出的(起始)行號;若 location 為空、包含逗號(代表多檔案) + * 或不符合「檔案:數字」格式,回傳 null。範圍格式僅回傳起始行號,不回傳結束行號。 + */ function findingLine(location) { const s = String(location || '').trim(); if (!s || s.includes(',')) return null; @@ -356,7 +391,14 @@ function findingLine(location) { 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) { const lines = String(diff || '').split('\n'); const out = []; @@ -371,7 +413,14 @@ function extractFileDiff(diff, file) { /** * 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號, * 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈); - * 成功則把 location 補成 `檔案:行號`,否則保留原檔名。 + * 成功則直接修改(mutate)該 finding 的 location 為 `檔案:行號`,否則保留原檔名不變。 + * 各條 finding 以獨立 LLM 呼叫並行定位,併發上限見 concurrency。 + * + * @param {Array} 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>} 與傳入 findings 相同參照的陣列(部分項目的 location 已被就地修改)。 */ export async function resolveMissingLineNumbers(findings, diff, 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} findings - 欲去重的 findings 陣列;為空陣列時直接原樣回傳。 + * @returns {Promise>} 去重後的原始 finding 物件陣列(非 LLM 回傳的精簡版); + * AI 呼叫失敗或結果驗證異常時,降級回傳原始 findings(未經任何篩選)。 */ export async function deduplicateWithAI(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} 正規化並去重後的排除條目陣列;讀取失敗或檔案不存在時回傳空陣列。 */ export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) { 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 陣列(無新增時回傳既有陣列)。 + * 把新的排除條目(raw 形式,未經 normalizeExclusionEntry 加工)append 到 exclusions.json, + * 以「檔案路徑(location 冒號前段)+ normalizeText 後的原文」為簽名去重後, + * 以頂層陣列格式寫回 workspace(及提供且路徑不同的 mirrorWorkspace)。 + * + * @param {string} workspace - 目標工作目錄,EXCLUSIONS_PATH 相對此路徑解析並寫入。 + * @param {Array} newEntries - 欲新增的排除條目(raw 形式);為空或未提供時直接回傳 null(無操作)。 + * @param {string|null} [mirrorWorkspace] - 可選鏡像目錄;提供且與 workspace 路徑不同時,會同步寫入相同內容。 + * @returns {Array|null} 合併後的 raw 排除條目陣列;newEntries 為空時回傳 null; + * 若 newEntries 皆與既有條目重複(無實際新增)則回傳既有陣列(未寫檔)。 */ export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) { if (!newEntries || newEntries.length === 0) return null; @@ -538,8 +608,19 @@ export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) } /** - * 套用排除規則,過濾掉符合排除條件的 findings - * location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用 + * 套用排除規則,過濾掉符合任一排除條件的 findings。 + * exclusions 為空時原樣回傳 findings(新陣列,不修改原輸入)。 + * + * 比對規則(對每個 exclusion,locationMatches && roleMatches && (有指定 path 或 role ? 一律視為符合 : textMatches)): + * - location 只比對檔案路徑(忽略行號),exclusion 未指定 filePath 時視為萬用; + * - role 未指定時視為萬用,否則需與 finding.role 完全相等; + * - 僅當 exclusion 同時未指定 filePath 與 role 時,才會實際比對正規化後文字(suggestion/title 等)是否互相包含。 + * + * @param {Array} findings - 欲過濾的 findings 陣列。 + * @param {Array} exclusions - 排除條目陣列(建議為已正規化含 filePath 的條目)。 + * @returns {Array} 過濾後的新陣列。 + * @remarks 「只要 exclusion 指定了 filePath 或 role,文字比對即完全略過」是否為刻意設計,需人工確認; + * 若非刻意,可能造成排除範圍比預期寬(例如同檔案下所有問題都被排除,而非僅特定描述的問題)。 */ export function applyExclusions(findings, exclusions) { if (exclusions.length === 0) return findings; @@ -558,7 +639,15 @@ export function applyExclusions(findings, exclusions) { 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} true 表示裁決為誤報(應剔除);false 表示成立或裁決失敗(保守保留)。 + */ async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) { const systemPrompt = buildVerdictPrompt(defender, exclusionHint); try { @@ -571,8 +660,13 @@ async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, cha } /** - * 由「防守方」角色(Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。 - * 多個問題時各派一個 sub-agent 平行裁決;任一裁決失敗保守保留該問題,不中斷流程。 + * 由「防守方」角色(固定為 Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。 + * 多個問題時各派一個裁決任務平行處理(併發上限 LLM_CONCURRENCY);任一裁決失敗保守保留該問題,不中斷流程。 + * + * @param {Array} findings - 欲裁決的 findings 陣列;為空陣列時直接原樣回傳。 + * @param {Array} [exclusions=[]] - 已知誤報排除條目,用於組裝提示,引導 AI 對相似的誤報更寬鬆判定。 + * @param {Function} [chatFn=chatJSON] - 實際呼叫 LLM 的函式,供測試時注入替換。 + * @returns {Promise>} 裁決為「非誤報」而保留下來的原始 finding 物件陣列。 */ export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) { if (findings.length === 0) return findings; diff --git a/src/git.js b/src/git.js index d099ade..a3f1a96 100644 --- a/src/git.js +++ b/src/git.js @@ -159,6 +159,14 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) { * 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。 * 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用, * 所以放在前置驗證可以提前抓出 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) { 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 ` 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) { const run = makeRunner(_spawnSync); @@ -207,6 +226,8 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) { * 測試用依賴注入:覆寫底層同步 spawn。 * @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數; * 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。 + * @remarks `_sourceRoot` 實際用途需人工確認:目前函式主體未引用此參數, + * 且測試檔會傳入實際值,無法從程式碼可靠判斷其設計意圖或是否可安全移除。 * @param {'success'|'failure'} [reviewOutcome='success'] * 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。 * @returns {Promise} 無回傳值;所有失敗皆以 log 記錄後吞掉。 diff --git a/src/gitea.js b/src/gitea.js index 3c379a4..45fbd89 100644 --- a/src/gitea.js +++ b/src/gitea.js @@ -69,6 +69,7 @@ export function parseReviewIgnore(text) { /** * 從被審 PR 的 head ref 取得 `.reviewignore` 並解析為排除清單。 * 檔案不存在或為空時退回 {@link DEFAULT_REVIEW_IGNORE}。 + * @remarks 成功套用 .reviewignore 時會透過 line() 輸出套用規則數的日誌行(副作用,不影響回傳值)。 * @returns {Promise} 套用於 diff 過濾的排除前綴清單。 */ export async function getReviewIgnore() { @@ -159,6 +160,7 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT * 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/` 做 startsWith 比對。 * @param {string} diff - 完整的 unified diff 文字。 * @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。 + * @remarks `diff` 必須為字串,非字串輸入會拋出 TypeError。 * @returns {string} 過濾後重新接合的 diff 文字。 */ export function filterDiff(diff, excludePrefixes = []) { diff --git a/src/llm.js b/src/llm.js index 2e55711..adb7d06 100644 --- a/src/llm.js +++ b/src/llm.js @@ -12,12 +12,16 @@ export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0 * 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。 * * limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。 - * fn 需自行處理例外(內部 try/catch);本函式不會因單一項目 reject 而中斷其餘工作。 + * fn 需自行處理例外(內部 try/catch);若 fn 未處理而 reject,本函式會立即向外 + * 拋出該錯誤(Promise.all fail-fast),但其他已啟動、尚在執行中的併發工作並不會 + * 被取消,仍會在背景繼續處理剩餘項目,只是其結果會被捨棄。 + * * @template T, R - * @param {T[]} items - 要處理的項目。 + * @param {T[]} items - 要處理的項目;非陣列(含 null/undefined)會被視為空陣列,不拋錯。 * @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。 * @param {(item: T, index: number) => Promise} fn - 對每個項目執行的 async 函式。 * @returns {Promise} 與 items 對應(同索引)的結果陣列。 + * @throws 若任一次 fn 呼叫 reject 且未在內部處理,該錯誤會直接向外傳播。 */ export async function mapWithConcurrency(items, limit, fn) { const list = Array.isArray(items) ? items : []; @@ -26,6 +30,26 @@ export async function mapWithConcurrency(items, limit, fn) { const n = Number(limit); const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length); let cursor = 0; + /** + * mapWithConcurrency 的工作者(worker)迴圈:從共用游標 `cursor` 依序搶下一個尚未 + * 處理的索引,呼叫外層傳入的 `fn`,並把結果寫入外層 `results` 陣列對應位置;直到 + * `cursor` 到達 `list.length` 為止。 + * + * 多個 `run()` 會被同時啟動(依 `workers` 數量),透過共用的 `cursor` 變數達到 + * 「限制併發數、動態搶下一筆」的效果——先完成者會先搶到下一個索引,因此各次 `fn` + * 呼叫的完成順序不保證,但因寫入位置以原始索引 `i` 為準,`results` 仍能保持與 + * `items` 相同順序。 + * + * 本函式為 `mapWithConcurrency` 內部使用的閉包(closure),依賴外層作用域的 + * `list`、`results`、`fn`、`cursor` 變數運作;不接受參數,也不可、不應在外部 + * 單獨呼叫或匯出。 + * + * @returns {Promise} 無回傳值;副作用為寫入外層 `results` 陣列與推進 `cursor`。 + * @throws 若某次 `fn(list[i], i)` reject,本函式會原樣向外拋出該錯誤(不吞例外), + * 使 `mapWithConcurrency` 的 `Promise.all` 立即 reject;但其他已啟動、尚在執行 + * 中的 `run()` 實例不會被取消,仍會在背景繼續搬移 `cursor` 並寫入 `results`, + * 只是其結果最終會被捨棄。 + */ async function run() { while (cursor < list.length) { const i = cursor++; @@ -37,7 +61,19 @@ export async function mapWithConcurrency(items, limit, fn) { } /** - * 將既有 system/user prompt 合併成一次 HTTP 呼叫用的輸入。 + * 將 system prompt 與 user content 合併成單一文字,作為送往 CLIProxyAPI 的 + * 「使用者訊息」內容。 + * + * 注意:此函式回傳的合併文字,會被 chat() 整段放入 HTTP request 的 user role + * 內容;實際送出的 HTTP system role 訊息是固定的通用指示(見 runProxyAPI), + * 並非這裡傳入的 systemPrompt——systemPrompt 是以 `` 標籤形式內嵌在 + * user 內容中,而非透過 API 的 system role 傳遞。 + * + * @param {string} systemPrompt - 系統提示詞內容,會被包在 `...` + * 標籤內;`null`/`undefined` 會被視為空字串。 + * @param {string} userContent - 使用者輸入內容,會被包在 `...` + * 標籤內;`null`/`undefined` 會被視為空字串。 + * @returns {string} 合併後、以換行分隔的完整 prompt 文字。 */ function buildPrompt(systemPrompt, userContent) { return [ @@ -73,11 +109,20 @@ export function extractMeaningfulError(raw, limit = 1000) { } /** - * 將 HTTP 例外整理成較精簡的錯誤摘要。 + * 將 HTTP 例外整理成較精簡的錯誤摘要,格式為 `"HTTP <訊息>"` + * (無 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 若錯誤物件結構和預期不同,仍會退回字串化處理,屬保守容錯。 + * @remarks 容錯僅涵蓋「e 是物件但欄位缺失或型態不符」的情況;若 e 本身為 + * `null`/`undefined`,存取 `e.stderr`/`e.stdout`/`e.message` 會直接拋出 + * TypeError,並非完全的保守容錯(需人工確認是否要補上 optional chaining 修正)。 */ function summarizeApiError(e) { const responseData = e?.response?.data; @@ -95,13 +140,24 @@ function summarizeApiError(e) { } /** - * 透過 CLIProxyAPI 執行一次對話並回傳純文字結果。 + * 透過 CLIProxyAPI 執行一次對話 HTTP 請求,回傳 API 的原始回應資料(物件), + * 並記錄回應 header 中的速率配額資訊。 * - * @param {{provider: string, baseURL: string, apiKeys: string[], model: string}} cfg - 連線設定。 - * @param {string} prompt - 送給 API 的完整 prompt 內容。 - * @remarks 適合用在需呼叫外部 AI API 的情境。 - * @remarks 逾時與輸出上限由環境變數控制,預設值是保守設定。 - * @remarks 若 HTTP 回傳非 2xx,錯誤訊息會由上層摘要處理。 + * 僅送出一次請求,不含任何重試邏輯——失敗(逾時、網路錯誤、非 2xx 狀態碼)時 + * 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用 + * `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。 + * + * @param {{provider: string, baseURL: string, apiKeys: string[], model: string}} cfg - 連線設定; + * 僅使用 `apiKeys[0]`。 + * @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容; + * HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `` 內容無關。 + * @returns {Promise} 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 runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000); @@ -139,12 +195,16 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { * 對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應。 * * 從設定取得 provider/baseURL/model;未偵測到 proxy 時拋錯。成功時記錄一次 - * usage 呼叫並回傳內容。 + * usage 呼叫並回傳內容。**不含任何重試邏輯**——無論是設定缺失、底層 HTTP 請求 + * 失敗,或回應內容為空,都是失敗一次即向外拋出(重新包裝為新的 Error,只保留 + * 摘要後訊息),不會自動重試或切換金鑰/provider。呼叫前後皆會透過 line() 記錄 + * 一行 log(成功記啟動資訊,失敗記錯誤摘要)。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 * @returns {Promise} 模型回應的純文字內容。 - * @throws {Error} 當未偵測到可用 CLIProxyAPI,或 API 呼叫失敗時。 + * @throws {Error} 當未偵測到可用 CLIProxyAPI 設定、底層 API 呼叫失敗,或回應 + * 缺少可用文字內容時。 */ export async function chat(systemPrompt, userContent) { const cfg = getLLMConfig(); @@ -174,12 +234,16 @@ export async function chat(systemPrompt, userContent) { /** * 對 CLIProxyAPI 送出對話並將回應解析為 JSON 物件/陣列。 * - * 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。 - * 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。 + * 先呼叫 {@link chat} 取得文字回應,再經 {@link extractJSONText} 抽出 JSON 片段後 + * 以 JSON.parse 解析。**僅 JSON 解析失敗時容錯**(記錄錯誤並回傳空陣列 `[]`,不 + * 向外拋錯);若 `chat()` 本身失敗(例如未偵測到可用 CLIProxyAPI 設定、API 呼叫 + * 失敗,或回應缺少文字內容),該例外不會被本函式攔截,會直接向外拋出。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 - * @returns {Promise} 解析後的 JSON 值;解析失敗時回傳空陣列 `[]`。 + * @returns {Promise} 解析後的 JSON 值;僅當 JSON 解析失敗時回傳空陣列 `[]`。 + * @throws {Error} 當底層 {@link chat} 呼叫失敗時(設定缺失、API 錯誤、回應無文字 + * 內容等),例外會原樣向外傳播。 */ export async function chatJSON(systemPrompt, userContent) { const text = await chat(systemPrompt, userContent); diff --git a/src/log.js b/src/log.js index ba59ea7..e02533a 100644 --- a/src/log.js +++ b/src/log.js @@ -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 等細分。 * * @param {string} title - 區塊標題文字。 * @returns {void} 無回傳值,僅將標題寫入 stdout。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ 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} title - 步驟標題文字。 * @returns {void} 無回傳值,僅將步驟標題寫入 stdout。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ 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 - 要顯示的明細訊息。 * @returns {void} 無回傳值,僅將明細寫入 stdout。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ export function line(message) { - console.log(` - ${message}`); + console.log(`[${formatTimestamp()}][INF]: - ${message}`); } /** @@ -41,9 +66,10 @@ export function line(message) { * * @param {string} message - 描述輸入內容的訊息。 * @returns {void} 無回傳值,僅將輸入描述寫入 stdout。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ export function input(message) { - console.log(` ← 輸入:${message}`); + console.log(`[${formatTimestamp()}][INF]: ← 輸入:${message}`); } /** @@ -52,9 +78,10 @@ export function input(message) { * * @param {string} message - 描述輸出內容的訊息。 * @returns {void} 無回傳值,僅將輸出描述寫入 stdout。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ 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 {string} message - 描述該結果的訊息。 * @returns {void} 無回傳值,僅將結果寫入 stdout。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴(成功為 `INF`、失敗為 `ERR`);此為單純呈現方式調整,仍維持一律寫入 stdout(不因失敗改寫 stderr),未改變輸出的實際語意或呼叫時機。 */ 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 - 描述成功內容的訊息。 * @returns {void} 無回傳值,僅將成功訊息寫入 stdout。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ export function ok(message) { - console.log(` ✓ ${message}`); + console.log(`[${formatTimestamp()}][INF]: ✓ ${message}`); } /** @@ -89,9 +118,10 @@ export function ok(message) { * * @param {string} message - 要顯示的警告訊息。 * @returns {void} 無回傳值,僅將警告訊息寫入 stderr。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]`(`WRN`)前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ export function warn(message) { - console.warn(` ! ${message}`); + console.warn(`[${formatTimestamp()}][WRN]: ! ${message}`); } /** @@ -101,7 +131,8 @@ export function warn(message) { * * @param {string} message - 要顯示的錯誤訊息。 * @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。 + * @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]`(`ERR`)前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。 */ export function error(message) { - console.error(` x ${message}`); + console.error(`[${formatTimestamp()}][ERR]: x ${message}`); } diff --git a/src/main.js b/src/main.js index 69216e8..7676d44 100644 --- a/src/main.js +++ b/src/main.js @@ -46,11 +46,16 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; * - 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 為空、正常走完無嚴重問題。 * * 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、 * Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。 + * + * 目前程式碼中有 3 個 exit 1 呼叫點(未設定 CLIProxyAPI、取 diff 失敗、所有角色分析皆失敗) + * 退出前未呼叫 `section('Pipeline 結束')`,與其餘 exit 點不一致,會少一行收尾分隔線, + * 是否為刻意設計尚需人工確認。 */ export async function main() { section('AI Code Review Pipeline'); diff --git a/src/preflight.js b/src/preflight.js index 729db59..8ac8cea 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -50,6 +50,9 @@ function giteaErr(e) { * @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。 * @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。 * @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 } = {}) { const missing = []; @@ -96,6 +99,16 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { } } +/** + * 從 CLIProxyAPI 回應中解析出模型 ID/slug 清單。 + * + * 依序嘗試以 `data.data`(OpenAI 相容格式常見欄位)或 `data.models` 陣列作為來源; + * 陣列中的字串元素直接視為 ID,物件元素則依序取 `id`/`slug`/`name`; + * 其他型態或無法取得有效值的元素會被過濾掉,結果不會包含空字串。 + * @param {unknown} data - CLIProxyAPI `/v1/models` 回應解析後的 JSON;可能為 null/undefined + * 或不符預期的結構,函式對此類輸入具容錯性。 + * @returns {string[]} 解析出的模型 ID/slug 陣列;輸入非物件、或找不到可用陣列時回傳空陣列 `[]`。 + */ function extractModelIds(data) { if (!data || typeof data !== 'object') return []; const source = Array.isArray(data.data) ? data.data : (Array.isArray(data.models) ? data.models : []); @@ -162,6 +175,9 @@ export async function fetchLLMModels({ * >} * 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。 * @remarks 設定來源為 config.js 的 getLLMConfig()。 + * @remarks 【需人工確認】依目前 getLLMConfig() 的型別標註(`provider: ('cliproxyapi'|null)`), + * `provider` 存在但不是 `'cliproxyapi'` 的分支在目前設定來源下應為不會被觸發的保留分支, + * 但無法從本檔案確認這是刻意保留的向前相容設計、還是尚未清理的死碼,建議與維護者確認。 */ export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) { const { provider, command, model } = getLLMConfig(); diff --git a/src/resolve.js b/src/resolve.js index 40e1118..50b720f 100644 --- a/src/resolve.js +++ b/src/resolve.js @@ -46,7 +46,11 @@ function levelToKey(raw) { /** * 嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。 * 同時支援 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) { if (typeof body !== 'string' || !body.includes('**')) return null; @@ -68,7 +72,14 @@ export function parseBotReviewComment(body) { /** * 把 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) { const groups = new Map(); @@ -98,7 +109,13 @@ export function groupConversations(comments) { /** codeWindow 預設的上下文行數(目標行上下各取幾行)。 */ 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) { if (!content) return ''; const lines = content.split('\n'); @@ -123,7 +140,11 @@ const JUDGE_SYSTEM_PROMPT = [ /** * 批次請 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} [chatFn=chatJSON] - 呼叫 AI 並回傳已解析 JSON 的函式, + * 可由呼叫端注入替換(例如測試時 mock),預設使用 `./llm.js` 的 `chatJSON`。 + * @returns {Promise>} + * 與 items 等長、依 idx 對齊的判斷結果;AI 回傳非陣列、缺漏或不合法的 idx,其 verdict 一律降級為 'open'(寧可保留)。 */ export async function judgeConversations(items, chatFn = chatJSON) { if (!items || items.length === 0) return []; @@ -141,10 +162,11 @@ export async function judgeConversations(items, chatFn = chatJSON) { } /** - * 將一段仍成立(open)對話對應的 bot finding 加入結轉清單,標記 is_new=false 表示為延續的舊問題。 - * 若該對話無 botFinding 則不做任何事。 + * 將一段仍成立(open)對話對應的 bot findings 加入結轉清單,各自標記 is_new=false 表示為延續的舊問題。 + * 優先使用 conversation.botFindings(多筆);若無則退回 conversation.botFinding(單筆,相容舊資料)。 + * 若該對話完全沒有可用的 bot finding 則不做任何事。 * @param {Array} target - 接收結轉 finding 的陣列(會被就地 push)。 - * @param {{botFinding: object|null}} conversation - 對話群組(取其 botFinding)。 + * @param {{botFinding: object|null, botFindings?: object[]}} conversation - 對話群組。 * @returns {void} */ function pushCarried(target, conversation) { @@ -192,6 +214,11 @@ export function isSafeRepoPath(p) { * - 'false_positive'(誤報)→ 寫入 exclusions 並從舊問題移除(excludedFindings); * - 'open'(仍成立)→ 加入舊問題集合(carriedFindings)。 * 任一外部呼叫失敗都降級處理(保守視為 open),不中斷整體 pipeline。 + * @param {{listComments?: () => Promise, resolveComment?: (id: any) => Promise, + * getFileContent?: (path: string) => Promise, 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 = {}) { const { @@ -337,6 +364,9 @@ function findingSig(f) { /** * 從 findings 中移除「已解決對話」對應的問題(以檔案路徑+建議內容比對,避免行號漂移誤判)。 + * @param {Array} findings - 目前的 findings 清單。 + * @param {Array<{location?: string, suggestion?: string}>} [resolvedFindings=[]] - 本輪判定為已修復的 findings。 + * @returns {Array} 移除已解決項目後的新陣列;resolvedFindings 為空時回傳 findings 原引用(未複製)。 */ export function dropResolvedFindings(findings, resolvedFindings = []) { if (!resolvedFindings || resolvedFindings.length === 0) return findings; @@ -346,6 +376,10 @@ export function dropResolvedFindings(findings, resolvedFindings = []) { /** * 把「未解決對話」對應、但目前 findings 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準)。 + * 有實際新增項目時會透過 ok() 輸出一行提示訊息。 + * @param {Array} findings - 目前的 findings 清單。 + * @param {Array<{location?: string, suggestion?: string}>} [carriedFindings=[]] - 本輪判定仍成立(open)的 findings。 + * @returns {Array} 加回缺漏項目後的新陣列;carriedFindings 為空時回傳 findings 原引用(未複製)。 */ export function addCarriedFindings(findings, carriedFindings = []) { if (!carriedFindings || carriedFindings.length === 0) return findings; diff --git a/src/roles.js b/src/roles.js index 4f2d34e..c97b825 100644 --- a/src/roles.js +++ b/src/roles.js @@ -61,8 +61,8 @@ function readRoleFiles() { /** * 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。 * - * 供 Step3 產生 findings 階段使用。防守方角色(如 Paladin)不在回傳之列, - * 其裁決邏輯由去重 / 誤報過濾流程處理。 + * 供 Step5(角色分析產生 findings)階段使用。防守方角色(如 Paladin)不在回傳之列, + * 其裁決邏輯(誤報判定)由 `buildVerdictPrompt` 搭配去重 / 誤報過濾流程處理。 * * @returns {Array>} 攻擊方角色物件陣列。 * diff --git a/src/test/log.test.js b/src/test/log.test.js index b7f6fac..2037b31 100644 --- a/src/test/log.test.js +++ b/src/test/log.test.js @@ -4,6 +4,8 @@ import { section, step, line, input, output, result, ok, warn, error } from '../ afterEach(() => mock.restoreAll()); +const TS = '\\[\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2}\\]'; + describe('log helpers', () => { it('formats section and step messages', () => { const calls = []; @@ -14,10 +16,8 @@ describe('log helpers', () => { section('Pipeline'); step('Step1', 'Start'); - assert.deepEqual(calls, [ - '\n=== Pipeline ===', - '\n[Step1] Start', - ]); + assert.match(calls[0], new RegExp(`^\\n${TS}\\[INF\\]: === Pipeline ===$`)); + assert.match(calls[1], new RegExp(`^\\n${TS}\\[INF\\]: \\[Step1\\] Start$`)); }); it('formats line and ok messages with console.log', () => { @@ -29,10 +29,8 @@ describe('log helpers', () => { line('hello'); ok('done'); - assert.deepEqual(calls, [ - ' - hello', - ' ✓ done', - ]); + assert.match(calls[0], new RegExp(`^${TS}\\[INF\\]: - hello$`)); + assert.match(calls[1], new RegExp(`^${TS}\\[INF\\]: ✓ done$`)); }); it('formats input/output and pass/fail result messages', () => { @@ -46,12 +44,10 @@ describe('log helpers', () => { result(true, '通過'); result(false, '未通過'); - assert.deepEqual(calls, [ - ' ← 輸入:5 筆', - ' → 輸出:3 筆', - ' ✅ 成功:通過', - ' ❌ 失敗:未通過', - ]); + assert.match(calls[0], new RegExp(`^${TS}\\[INF\\]: ← 輸入:5 筆$`)); + assert.match(calls[1], new RegExp(`^${TS}\\[INF\\]: → 輸出:3 筆$`)); + assert.match(calls[2], new RegExp(`^${TS}\\[INF\\]: ✅ 成功:通過$`)); + assert.match(calls[3], new RegExp(`^${TS}\\[ERR\\]: ❌ 失敗:未通過$`)); }); it('formats warn messages with console.warn', () => { @@ -62,7 +58,7 @@ describe('log helpers', () => { warn('careful'); - assert.deepEqual(calls, [' ! careful']); + assert.match(calls[0], new RegExp(`^${TS}\\[WRN\\]: ! careful$`)); }); it('formats error messages with console.error', () => { @@ -73,6 +69,6 @@ describe('log helpers', () => { error('boom'); - assert.deepEqual(calls, [' x boom']); + assert.match(calls[0], new RegExp(`^${TS}\\[ERR\\]: x boom$`)); }); }); diff --git a/src/usage.js b/src/usage.js index 1f58473..6d6588a 100644 --- a/src/usage.js +++ b/src/usage.js @@ -20,6 +20,11 @@ function num(x) { * 支援:OpenAI 相容 usage、OpenAI Responses(input/output_tokens)、 * Gemini usageMetadata、Ollama 原生 eval_count、OpenCode tokens。 * 回應中沒有任何可辨識的 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) { if (!data || typeof data !== 'object') return null; @@ -61,7 +66,12 @@ export function extractUsage(data) { 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) { runUsage.calls += 1; const u = extractUsage(data); @@ -73,12 +83,19 @@ export function recordUsage(data) { return u; } -/** 取得本次執行至今的 token 累計(複本)。 */ +/** + * 取得本次執行至今的 token 累計(複本)。 + * @returns {{calls:number, promptTokens:number, completionTokens:number, totalTokens:number}} + * 目前累計的淺拷貝,修改回傳值不影響內部狀態。 + */ export function getRunUsage() { return { ...runUsage }; } -/** 重置累計(測試用)。 */ +/** + * 重置累計(測試用)。 + * @returns {void} + */ export function resetRunUsage() { runUsage.calls = 0; runUsage.promptTokens = 0; @@ -105,6 +122,8 @@ function lowerCaseKeys(obj) { * 從回應 header 擷取速率配額剩餘量/上限。 * 支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropic(anthropic-ratelimit-tokens-*), * 兩者皆缺時退而採用 requests 維度。記錄「最近一次」的數值(即最新的視窗狀態)。 + * @param {Object|null|undefined} headers HTTP 回應 headers(大小寫不拘)。 + * @returns {void} */ export function recordRateLimit(headers) { if (!headers || typeof headers !== 'object') return; @@ -126,12 +145,19 @@ export function recordRateLimit(headers) { rateLimit.kind = kind; } -/** 取得最近一次的速率配額快照(複本)。 */ +/** + * 取得最近一次的速率配額快照(複本)。 + * @returns {{hasData:boolean, remaining:number|null, limit:number|null, kind:('tokens'|'requests'|null)}} + * 目前快照的淺拷貝。 + */ export function getRateLimit() { return { ...rateLimit }; } -/** 重置速率配額快照(測試用)。 */ +/** + * 重置速率配額快照(測試用)。 + * @returns {void} + */ export function resetRateLimit() { rateLimit.hasData = false; rateLimit.remaining = null; @@ -206,6 +232,15 @@ const QUOTA_STRATEGIES = { /** * 取得指定平台的帳號額度。任何失敗都降級為 { available: false, reason },不丟例外。 * 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 = {}) { const get = deps.get || axios.get; @@ -275,6 +310,12 @@ function calculatePercent(remaining, limit) { * 1. 帳號額度(quota 有有效上限)→ 剩餘 credits / 上限; * 2. 速率配額(rate limit header,有有效上限)→ 當前視窗剩餘 / 上限; * 上限或剩餘為無效值(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) { if (quota?.available && quota.limit != null) { @@ -312,7 +353,16 @@ function remainingLine(pct) { 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) { const pct = resolveRemainingPercent(quota, rate); const lines = [ @@ -331,7 +381,18 @@ export function formatUsageStats(provider, model, usage, quota, rate) { 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) { const pct = resolveRemainingPercent(quota, rate); const tokenPart = `本次 ${provider}/${model}: 提示${usage.promptTokens} + 回應${usage.completionTokens} = ${usage.totalTokens} token(${usage.calls} 次呼叫)`; From 651e221e900abc0505c7a0ce282daf246624d179 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 06:16:45 +0000 Subject: [PATCH 02/12] allow proxy auto model selection --- action.yml | 6 +++++- readme.md | 10 +++++----- src/config.js | 8 +++++--- src/llm.js | 27 ++++++++++++++------------- src/main.js | 11 ++++++----- src/preflight.js | 16 ++++++---------- src/test/config.test.js | 14 ++++++++++++-- src/test/llm.test.js | 21 ++++++++++++++++++++- src/test/main.test.js | 6 ++++++ src/test/preflight.test.js | 18 +++++++++++++++++- 10 files changed, 96 insertions(+), 41 deletions(-) diff --git a/action.yml b/action.yml index 7d8b769..f1badc1 100644 --- a/action.yml +++ b/action.yml @@ -8,6 +8,9 @@ inputs: comment_token: description: '操作 Gitea Commit API 的 Token' required: false + model: + description: '使用的 AI 模型' + required: false runs: using: 'docker' image: 'dockerfile' @@ -15,4 +18,5 @@ runs: GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }} GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }} CLI_PROXY_API: ${{ vars.CLI_PROXY_API }} - CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }} \ No newline at end of file + CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }} + CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }} \ No newline at end of file diff --git a/readme.md b/readme.md index af47163..42b01e6 100644 --- a/readme.md +++ b/readme.md @@ -293,7 +293,7 @@ await axios.get('https://internal-gitea.example/api/v1/user', { httpsAgent }); ### getLLMConfig -依環境變數解析並回傳 CLIProxyAPI 設定:`INPUT_CLI_PROXY_API`/`CLI_PROXY_API` 作 base URL(trim 並去尾斜線),`INPUT_MODEL`/`MODEL`/`OPENCODE_MODEL` 依序 fallback 作模型名稱,`INPUT_CLI_PROXY_API_KEY`/`CLI_PROXY_API_KEY` 作金鑰。base URL 無法解析時 `provider`/`baseURL` 回 `null`、`apiKeys` 回空陣列,但 `model`(若有)仍會回傳。 +依環境變數解析並回傳 CLIProxyAPI 設定:`INPUT_CLI_PROXY_API`/`CLI_PROXY_API` 作 base URL(trim 並去尾斜線),`INPUT_MODEL`/`CLI_PROXY_API_MODEL`/`MODEL`/`OPENCODE_MODEL` 依序 fallback 作可選模型名稱;若未指定模型,會交由 CLIProxyAPI 自動選擇,`INPUT_CLI_PROXY_API_KEY`/`CLI_PROXY_API_KEY` 作金鑰。base URL 無法解析時 `provider`/`baseURL` 回 `null`、`apiKeys` 回空陣列,但 `model`(若有)仍會回傳。 - 參數:無。 - 回傳:`{ provider, apiKeys, baseURL, model, command }`。 @@ -301,9 +301,9 @@ await axios.get('https://internal-gitea.example/api/v1/user', { httpsAgent }); ```javascript import { getLLMConfig } from './src/config.js'; -// 環境變數:CLI_PROXY_API=https://proxy.example, MODEL=gpt-4o, CLI_PROXY_API_KEY=sk-xxx +// 環境變數:CLI_PROXY_API=https://proxy.example, CLI_PROXY_API_KEY=sk-xxx const cfg = getLLMConfig(); -// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: 'gpt-4o', command: null } +// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: null, command: null } ``` @@ -972,7 +972,7 @@ extractMeaningfulError('some noise\nERROR: rate limit exceeded\nmore noise'); - 例外:設定缺失、API 呼叫失敗或回應無文字內容時拋出。 ```javascript -// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API / MODEL)。 +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API;MODEL 可省略)。 import { chat } from './src/llm.js'; const reply = await chat('你是程式碼審查員', '請審查以下 diff:...'); @@ -1251,7 +1251,7 @@ const result = await fetchLLMModels(); ### verifyLLM -驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI 且已解析出 model,額外向模型清單端點確認 proxy 可連線且設定的 model 在可用清單內(不送 prompt)。 +驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI;若有指定 model,額外向模型清單端點確認該 model 在可用清單內(不送 prompt)。未指定 model 時,只要求 proxy 與模型清單端點可連線。 - 參數:`deps.fetchLLMModelsFn`(預設 `fetchLLMModels`)。 - 回傳:`Promise<{ok:true, provider, command, model, models?} | {ok:false, provider?, command?, model?, error}>`。 diff --git a/src/config.js b/src/config.js index 11e3677..bcfde41 100644 --- a/src/config.js +++ b/src/config.js @@ -65,8 +65,10 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; * 依環境變數解析並回傳 CLIProxyAPI 設定。 * * 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線), - * `INPUT_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,相容舊 OpenCode 設定)作為模型 - * 名稱,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。 + * `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback, + * 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供, + * 則交由 CLIProxyAPI 自動選擇模型,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` + * 作為存取金鑰(會 trim)。 * * 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、 * `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。 @@ -76,7 +78,7 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; */ export function getLLMConfig() { const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, ''); - const model = process.env.INPUT_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || ''; + const 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 }; diff --git a/src/llm.js b/src/llm.js index adb7d06..677669a 100644 --- a/src/llm.js +++ b/src/llm.js @@ -147,8 +147,8 @@ function summarizeApiError(e) { * 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用 * `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。 * - * @param {{provider: string, baseURL: string, apiKeys: string[], model: string}} cfg - 連線設定; - * 僅使用 `apiKeys[0]`。 + * @param {{provider: string, baseURL: string, apiKeys: string[], model?: string|null}} cfg - 連線設定; + * 僅使用 `apiKeys[0]`;`model` 可省略,省略時交由 CLIProxyAPI 自動選擇。 * @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容; * HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `` 內容無關。 * @returns {Promise} API 回應的原始資料物件(`resp.data`),並非純文字; @@ -164,17 +164,18 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024); const root = String(baseURL || '').trim().replace(/\/$/, ''); const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : ''; + const body = { + messages: [ + { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' }, + { role: 'user', content: prompt }, + ], + temperature: 0, + stream: false, + }; + if (model) body.model = model; const resp = await axios.post( `${root}/v1/chat/completions`, - { - model, - messages: [ - { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' }, - { role: 'user', content: prompt }, - ], - temperature: 0, - stream: false, - }, + body, { timeout, maxBodyLength: maxBuffer, @@ -209,9 +210,9 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { export async function chat(systemPrompt, userContent) { const cfg = getLLMConfig(); const { provider, baseURL, model } = cfg; - if (!provider || !baseURL || !model) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API 與 MODEL'); + if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API'); - line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model}`); + line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`); try { const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent)); diff --git a/src/main.js b/src/main.js index 7676d44..1f3528e 100644 --- a/src/main.js +++ b/src/main.js @@ -37,7 +37,7 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; * - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。 * - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。 * - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號; - * 未設定 API Key 或取 diff 失敗 exit 1,diff 為空 exit 0。 + * 未設定 CLIProxyAPI 或取 diff 失敗 exit 1,diff 為空 exit 0。 * - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。 * - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。 * - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。 @@ -119,9 +119,10 @@ export async function main() { section('Pipeline 結束'); process.exit(0); } - input(`LLM=${provider}/${model};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`); + const modelLabel = model || 'auto'; + input(`LLM=${provider}/${modelLabel};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`); try { - await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`); + await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${modelLabel}`); line('角色介紹 comment 已發布'); } catch (e) { warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); @@ -191,9 +192,9 @@ export async function main() { const runUsage = getRunUsage(); const quota = await fetchAccountQuota(provider, { apiKeys, baseURL }); const rate = getRateLimit(); - const usageSection = formatUsageStats(provider, model, runUsage, quota, rate); + const usageSection = formatUsageStats(provider, modelLabel, runUsage, quota, rate); input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`); - line(`使用量: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`); + line(`使用量: ${formatUsageStatsLine(provider, modelLabel, runUsage, quota, rate)}`); try { await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection }); output('Gitea Review 已發布'); diff --git a/src/preflight.js b/src/preflight.js index 8ac8cea..3b2343c 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -165,29 +165,25 @@ export async function fetchLLMModels({ /** * 驗證 LLM proxy 設定可用。 * - * 確認目前環境可偵測到 CLIProxyAPI 且已解析出 model;額外向模型清單端點確認 - * proxy 可連線且設定的 model 在可用清單內(不送 prompt)。 + * 確認目前環境可偵測到 CLIProxyAPI;若有明確指定 model,則額外向模型清單端點確認 + * 該 model 在可用清單內(不送 prompt)。當 model 未指定時,只要求 proxy 與模型清單端點可連線。 * @param {object} [deps] - 可注入相依,供測試。 * @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。 * @returns {Promise< - * {ok: true, provider: string, command: null, model: string, models?: string[]} | - * {ok: false, provider?: string, command?: null, model?: string, error: string} + * {ok: true, provider: string, command: null, model: string|null, models?: string[]} | + * {ok: false, provider?: string, command?: null, model?: string|null, error: string} * >} * 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。 * @remarks 設定來源為 config.js 的 getLLMConfig()。 - * @remarks 【需人工確認】依目前 getLLMConfig() 的型別標註(`provider: ('cliproxyapi'|null)`), - * `provider` 存在但不是 `'cliproxyapi'` 的分支在目前設定來源下應為不會被觸發的保留分支, - * 但無法從本檔案確認這是刻意保留的向前相容設計、還是尚未清理的死碼,建議與維護者確認。 */ export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) { const { provider, command, model } = getLLMConfig(); if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' }; - if (!model) return { ok: false, provider, error: '未設定 MODEL' }; if (provider === 'cliproxyapi') { const models = await fetchLLMModelsFn(); if (!models.ok) return { ok: false, provider, command, model, error: models.error }; - if (!models.slugs.includes(model)) { + if (model && !models.slugs.includes(model)) { return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` }; } return { ok: true, provider, command, model, models: models.slugs }; @@ -255,7 +251,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || ' error(`LLM 驗證失敗: ${llm.error}`); return false; } - ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model})`); + ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model || 'auto'})`); if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`); result(true, '前置驗證通過'); diff --git a/src/test/config.test.js b/src/test/config.test.js index b31f44f..949cf1c 100644 --- a/src/test/config.test.js +++ b/src/test/config.test.js @@ -3,8 +3,8 @@ import assert from 'node:assert/strict'; import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; const ENV_KEYS = [ - 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', - 'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL', + 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', 'INPUT_MODEL', + 'MODEL', 'OPENCODE_MODEL', ]; let saved = {}; @@ -52,6 +52,16 @@ describe('getLLMConfig', () => { assert.equal(cfg.model, 'gpt-5-mini'); }); + it('uses CLI_PROXY_API_MODEL when INPUT_MODEL is missing', () => { + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.CLI_PROXY_API_MODEL = 'gpt-5.4-mini'; + process.env.MODEL = 'gpt-5.5'; + + const cfg = getLLMConfig(); + + assert.equal(cfg.model, 'gpt-5.4-mini'); + }); + it('returns null provider when CLI_PROXY_API is missing', () => { process.env.MODEL = 'gpt-5.5'; const cfg = getLLMConfig(); diff --git a/src/test/llm.test.js b/src/test/llm.test.js index 8c17a57..3251240 100644 --- a/src/test/llm.test.js +++ b/src/test/llm.test.js @@ -4,7 +4,7 @@ import axios from 'axios'; import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js'; const ENV_KEYS = [ - 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL', + 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER', ]; @@ -57,6 +57,25 @@ describe('chat - CLIProxyAPI', async () => { assert.equal(capturedOpts.headers.Authorization, 'Bearer secret'); }); + it('omits model from the request body when auto selection is allowed', async () => { + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.CLI_PROXY_API_KEY = 'secret'; + + let capturedBody; + mock.method(axios, 'post', async (url, body) => { + capturedBody = body; + return { + data: { choices: [{ message: { content: 'cli response' } }] }, + headers: {}, + }; + }); + + const result = await chat('sys', 'user'); + + assert.equal(result, 'cli response'); + assert.equal(Object.hasOwn(capturedBody, 'model'), false); + }); + it('throws an error when the API fails', async () => { process.env.CLI_PROXY_API = 'https://proxy.example'; process.env.MODEL = 'gpt-5-mini'; diff --git a/src/test/main.test.js b/src/test/main.test.js index 71acef1..4135aa5 100644 --- a/src/test/main.test.js +++ b/src/test/main.test.js @@ -131,6 +131,12 @@ describe('main pipeline', () => { assert.equal(await runMain(), 0); }); + it('無 MODEL 仍可由 Proxy 自動選模並正常走完(exit 0)', async () => { + assert.equal(await runMain({ + config: { getLLMConfig: () => ({ provider: 'cliproxyapi', apiKeys: ['secret'], baseURL: 'https://proxy.example', model: null, command: null }) }, + }), 0); + }); + it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => { assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0); }); diff --git a/src/test/preflight.test.js b/src/test/preflight.test.js index 4e2361c..53102f0 100644 --- a/src/test/preflight.test.js +++ b/src/test/preflight.test.js @@ -4,7 +4,7 @@ import axios from 'axios'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js'; const LLM_ENV_KEYS = [ - 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', + 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', 'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL', ]; @@ -164,6 +164,22 @@ describe('verifyLLM', () => { assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']); }); + it('passes when no model is specified and the proxy is reachable', async () => { + clearLLMEnv(); + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.CLI_PROXY_API_KEY = 'secret'; + + const result = await verifyLLM({ + fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }), + }); + + assert.equal(result.ok, true); + assert.equal(result.provider, 'cliproxyapi'); + assert.equal(result.command, null); + assert.equal(result.model, null); + assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']); + }); + it('fails when proxy auth is invalid', async () => { clearLLMEnv(); process.env.CLI_PROXY_API = 'https://proxy.example'; From 5e9bd86bbcb827415e25dcc1dea2fea2a4f07a58 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 7 Aug 2026 07:09:39 +0000 Subject: [PATCH 03/12] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .gitea/ai-review/findings.json diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json new file mode 100644 index 0000000..26dc507 --- /dev/null +++ b/.gitea/ai-review/findings.json @@ -0,0 +1,66 @@ +[ + { + "level": "warning", + "role": "Assassin", + "location": "action.yml:14", + "problem": "AI 模型參數(`model`)為使用者輸入但未驗證。攻擊者可透過 PR workflow 傳入任意字符串,縱然後續經 JSON 序列化理論上應轉義,仍增加了攻擊面且難以追蹤輸入來源。", + "suggestion": "在 action.yml 中對 `model` 輸入進行描述性限制(說明只接受特定格式),並在 src/config.js 的 `getLLMConfig()` 加上白名單驗證或正則表達式檢查,拒絕包含特殊字符的模型名稱(如單引號、反斜線、括號等)。例:`/^[a-zA-Z0-9._-]+$/`。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "entrypoint.sh:2", + "problem": "進入點腳本一開頭就塞入固定更新時間與裝飾性框線,資訊價值很低,卻會讓每次重生產都留下無意義的 diff 雜訊。", + "suggestion": "移除這種會過期的時間戳註解,只保留真正需要提醒讀者的簡短說明即可。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "readme.md:3", + "problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。", + "suggestion": "把 README 收斂成專案摘要、安裝方式與使用入口;細部 API 文件另放獨立文件或改成可生成的 docs,避免主文件膨脹成資料堆。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "src/comments.js:235", + "problem": "`postFindingsReview` 這段 JSDoc 太像流程筆記,不像 API 說明。`@param`、`@remarks`、`使用情境` 與多層降級敘事一路堆疊,重點被枝節埋掉,閱讀節奏很不乾淨。", + "suggestion": "把註解壓縮回最必要的契約說明:用途、參數、回傳與例外即可;降級順序和測試注入細節留給實作內的短註解。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "src/findings.js:413", + "problem": "`resolveMissingLineNumbers` 的註解把行為、邊界條件、併發設定與人工備註全揉成一段,語氣也從說明一路滑到審查心得,讀起來有點散、有點吵。", + "suggestion": "把說明拆短,保留輸入、輸出與副作用三件事即可;如果某些設計值得提醒,也應縮成一句附註,不要塞進主體敘述。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "src/main.js:59", + "problem": "這段註解直接寫出『目前程式碼中有 3 個 exit 1 呼叫點』,把瞬時的實作現況硬塞進長期註解,過幾次重構就會先壞掉,徒增維護負擔。", + "suggestion": "刪掉這種會隨流程變動而失真的數量型描述;若真要提醒收尾差異,改成更穩定的概念性說明即可。", + "is_new": true + }, + { + "level": "info", + "role": "Assassin", + "location": "src/llm.js:173", + "problem": "HTTP request body 中的 `model` 欄位現在允許為 null(由上游 `getLLMConfig()` 傳入),導致該欄位的存在性由輸入決定。若 API 伺服器對缺少 `model` 欄位與 `model: null` 的處理邏輯不同,可能產生非預期的行為切換(例如自動選擇與使用者預期模型不符的模型版本)。", + "suggestion": "在 src/llm.js 的 `runProxyAPI()` 中明確文檔化 `model: null` 時的 API 行為,或在構造 body 前透過 `getLLMConfig()` 的驗證確保 model 值的一致性。若允許自動選擇,應於 log 與回應中清楚標示使用了自動選擇(目前已在 main.js 中以 `modelLabel` 處理,但建議同步至 API 層確認)。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "src/log.js:8", + "problem": "這裡用 `en-CA` 來拼台灣時區時間字串,技法不算錯,但對讀者很不直觀。看到 `Asia/Taipei` 卻搭配 `en-CA`,第一眼會先懷疑這是不是某種繞路寫法。", + "suggestion": "改用更直白的格式化方式,例如手動補零組字串,或至少把這個 locale 選擇的用意明講,讓 helper 的意圖一眼可懂。", + "is_new": true + } +] From dcd80750bafd7d5afc598be9e6f7afc3994c7a31 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 08:48:50 +0000 Subject: [PATCH 04/12] =?UTF-8?q?fix(ai-review=20model=20validation):=20?= =?UTF-8?q?=E9=A9=97=E8=AD=89=20model=20=E4=B8=A6=E6=94=AF=E6=8F=B4?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E9=81=B8=E6=A8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- action.yml | 4 ++-- src/config.js | 24 ++++++++++++++++++------ src/llm.js | 3 ++- src/preflight.js | 3 ++- src/test/config.test.js | 10 ++++++++++ src/test/llm.test.js | 7 +++++++ src/test/preflight.test.js | 14 ++++++++++++++ 7 files changed, 55 insertions(+), 10 deletions(-) diff --git a/action.yml b/action.yml index f1badc1..1bba83c 100644 --- a/action.yml +++ b/action.yml @@ -9,7 +9,7 @@ inputs: description: '操作 Gitea Commit API 的 Token' required: false model: - description: '使用的 AI 模型' + description: '使用的 AI 模型,僅允許英數字、點、底線與連字號' required: false runs: using: 'docker' @@ -19,4 +19,4 @@ runs: GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }} CLI_PROXY_API: ${{ vars.CLI_PROXY_API }} CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }} - CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }} \ No newline at end of file + CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }} diff --git a/src/config.js b/src/config.js index bcfde41..c1f2526 100644 --- a/src/config.js +++ b/src/config.js @@ -42,6 +42,16 @@ export const LLM_PROVIDER = 'cliproxyapi'; export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; +const MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function normalizeModelName(raw) { + const model = String(raw || '').trim(); + if (!model) return { model: null, modelError: null }; + if (!MODEL_NAME_RE.test(model)) { + return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線與連字號' }; + } + return { model, modelError: null }; +} let _insecureHttpsAgent = null; /** @@ -67,26 +77,28 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; * 優先讀取 `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)。 + * 則交由 CLIProxyAPI 自動選擇模型。若提供的名稱含非法字元,會被視為無效並於 + * `modelError` 回報,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。 * * 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、 * `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。 * - * @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), command: null }} + * @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), modelError: (string|null), command: null }} * 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。 */ export function getLLMConfig() { const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, ''); - const model = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || ''; + const rawModel = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || ''; + const { model, modelError } = normalizeModelName(rawModel); const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim(); - if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model: model || null, command: null }; + if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model, modelError, command: null }; return { provider: LLM_PROVIDER, apiKeys: apiKey ? [apiKey] : [], baseURL, - model: model || null, + model, + modelError, command: null, }; } diff --git a/src/llm.js b/src/llm.js index 677669a..120b9c5 100644 --- a/src/llm.js +++ b/src/llm.js @@ -209,8 +209,9 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { */ export async function chat(systemPrompt, userContent) { const cfg = getLLMConfig(); - const { provider, baseURL, model } = cfg; + const { provider, baseURL, model, modelError } = cfg; if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API'); + if (modelError) throw new Error(modelError); line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`); diff --git a/src/preflight.js b/src/preflight.js index 3b2343c..068bdda 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -177,8 +177,9 @@ export async function fetchLLMModels({ * @remarks 設定來源為 config.js 的 getLLMConfig()。 */ export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) { - const { provider, command, model } = getLLMConfig(); + const { provider, command, model, modelError } = getLLMConfig(); if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' }; + if (modelError) return { ok: false, provider, command, model, error: modelError }; if (provider === 'cliproxyapi') { const models = await fetchLLMModelsFn(); diff --git a/src/test/config.test.js b/src/test/config.test.js index 949cf1c..5286a23 100644 --- a/src/test/config.test.js +++ b/src/test/config.test.js @@ -62,6 +62,16 @@ describe('getLLMConfig', () => { assert.equal(cfg.model, 'gpt-5.4-mini'); }); + it('rejects invalid model names', () => { + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.MODEL = 'gpt-5.5; rm -rf /'; + + const cfg = getLLMConfig(); + + assert.equal(cfg.model, null); + assert.match(cfg.modelError, /無效的 model 參數/); + }); + it('returns null provider when CLI_PROXY_API is missing', () => { process.env.MODEL = 'gpt-5.5'; const cfg = getLLMConfig(); diff --git a/src/test/llm.test.js b/src/test/llm.test.js index 3251240..5af38c1 100644 --- a/src/test/llm.test.js +++ b/src/test/llm.test.js @@ -88,6 +88,13 @@ describe('chat - CLIProxyAPI', async () => { await assert.rejects(() => chat('sys', 'user'), /401/); await assert.rejects(() => chat('sys', 'user'), /access token revoked/); }); + + it('throws when the configured model name is invalid', async () => { + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.MODEL = 'gpt-5.5; rm -rf /'; + + await assert.rejects(() => chat('sys', 'user'), /無效的 model 參數/); + }); }); describe('chatJSON', async () => { diff --git a/src/test/preflight.test.js b/src/test/preflight.test.js index 53102f0..fcdf63e 100644 --- a/src/test/preflight.test.js +++ b/src/test/preflight.test.js @@ -208,6 +208,20 @@ describe('verifyLLM', () => { assert.match(result.error, /不在 CLIProxyAPI 可用清單/); assert.match(result.error, /gpt-9-imaginary/); }); + + it('fails when the configured model name is invalid', async () => { + clearLLMEnv(); + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.MODEL = 'gpt-5.5; rm -rf /'; + + const result = await verifyLLM({ + fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5'] }), + }); + + assert.equal(result.ok, false); + assert.equal(result.provider, 'cliproxyapi'); + assert.match(result.error, /無效的 model 參數/); + }); }); describe('runPreflight', () => { From 409536b34133818d2ae69a22c7d2bb153b399523 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 08:48:50 +0000 Subject: [PATCH 05/12] =?UTF-8?q?docs(ai-review=20=E8=A8=BB=E8=A7=A3):=20?= =?UTF-8?q?=E6=94=B6=E6=96=82=E8=A8=BB=E8=A7=A3=E8=88=87=E8=85=B3=E6=9C=AC?= =?UTF-8?q?=E9=9B=9C=E8=A8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entrypoint.sh | 5 +---- src/comments.js | 30 +++++++++--------------------- src/findings.js | 16 ++++++---------- src/log.js | 2 +- src/main.js | 3 --- 5 files changed, 17 insertions(+), 39 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 33e36a7..7fd80d0 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,8 +1,5 @@ #!/bin/sh -# ============================================================================ -# 用途:Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。 -# 更新時間:2026/08/07 13:51:53 -# ============================================================================ +# Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。 # 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行 set -e diff --git a/src/comments.js b/src/comments.js index a14a8db..f0d497a 100644 --- a/src/comments.js +++ b/src/comments.js @@ -235,28 +235,16 @@ function toReviewComment(f) { } /** - * 發布單一 Gitea review:一次性送出「統計摘要 + 逐筆行內 review comment」,並提供多層降級機制。 - * - * @param {Array} findings 本次審查的完整 findings 陣列;當 `deps.summaryFindings` 或 - * `deps.commentFindings` 未提供時,兩者皆預設使用此參數。 - * @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換,正常情境可省略)。 - * @param {Function} [deps.postReview=postPullReview] 發布整批 review(含 body 與 comments)的函式。 - * @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。 - * @param {Function} [deps.postIssue=postComment] 發布一般(非 review)comment 的函式,作為最終降級手段。 - * @param {Array} [deps.summaryFindings=findings] 用於統計本文數字(含新舊問題)的 findings 子集合。 - * @param {Array} [deps.commentFindings=findings] 用於產生 review comments 的 findings 子集合; - * 會先依 {@link bySeverity} 排序,僅新問題(`is_new !== false`)會被轉成行內 comment, - * 舊問題只計入統計、不再重複標註檔案與行數。 - * @param {string} [deps.usageSection=''] 附加在統計表之後的用量/token 統計區塊;空字串時不附加。 + * 發布單一 Gitea review,必要時會先降級成 summary review,再降級成一般 comment。 + * @param {Array} findings 審查 findings。 + * @param {object} [deps={}] 可注入的相依物件。 + * @param {Function} [deps.postReview=postPullReview] 發布整批 review 的函式。 + * @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 comment 的函式。 + * @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。 + * @param {Array} [deps.summaryFindings=findings] 用於統計的 findings 子集合。 + * @param {Array} [deps.commentFindings=findings] 用於建立 review comments 的 findings 子集合。 + * @param {string} [deps.usageSection=''] 附加的使用量區塊。 * @returns {Promise} 無回傳值。 - * @remarks - * 降級順序:① 整批 `postReview`(含 comments)→ 失敗則 ② 僅 body 的 `postReview` - * (comments 為空陣列)→ 失敗則 ③ `postIssue(body)`。**注意:③ 未包在 try/catch 中**, - * 若 `postIssue` 本身失敗,例外會直接從本函式往外拋出(reject),呼叫端須自行 catch。 - * 無論走到哪一步,只要走完 ①~③ 中任一步不再往下失敗,後續都會逐筆嘗試 `postInline` 補發 - * 行內 comment,每筆各自失敗僅記錄 warn 並略過,不影響其他筆。 - * 使用情境:CI 流程完成一輪 AI Code Review 後,呼叫一次本函式即可把整批結果發布到 Gitea PR; - * 單元測試時可透過 `deps` 注入假的 `postReview`/`postInline`/`postIssue` 以驗證各降級分支。 */ export async function postFindingsReview(findings, deps = {}) { const { diff --git a/src/findings.js b/src/findings.js index 6a2545e..e8f8c2f 100644 --- a/src/findings.js +++ b/src/findings.js @@ -411,16 +411,12 @@ function extractFileDiff(diff, file) { } /** - * 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號, - * 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈); - * 成功則直接修改(mutate)該 finding 的 location 為 `檔案:行號`,否則保留原檔名不變。 - * 各條 finding 以獨立 LLM 呼叫並行定位,併發上限見 concurrency。 - * - * @param {Array} 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>} 與傳入 findings 相同參照的陣列(部分項目的 location 已被就地修改)。 + * 對缺行號的 findings 重新詢問原角色補上行號,成功時會就地更新 `location`。 + * @param {Array} findings findings 陣列。 + * @param {string} diff 完整 unified diff。 + * @param {{chatFn?: Function, getRole?: Function, maxAttempts?: number, concurrency?: number}} [deps] + * 測試用依賴注入。 + * @returns {Promise>} 與傳入相同參照的 findings 陣列。 */ export async function resolveMissingLineNumbers(findings, diff, deps = {}) { const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps; diff --git a/src/log.js b/src/log.js index e02533a..83a8c74 100644 --- a/src/log.js +++ b/src/log.js @@ -6,7 +6,7 @@ * @returns {string} 例如 `2026/08/07 12:39:43`。 */ function formatTimestamp(date = new Date()) { - const parts = new Intl.DateTimeFormat('en-CA', { + const parts = new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', year: 'numeric', month: '2-digit', diff --git a/src/main.js b/src/main.js index 1f3528e..73b3014 100644 --- a/src/main.js +++ b/src/main.js @@ -53,9 +53,6 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; * 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、 * Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。 * - * 目前程式碼中有 3 個 exit 1 呼叫點(未設定 CLIProxyAPI、取 diff 失敗、所有角色分析皆失敗) - * 退出前未呼叫 `section('Pipeline 結束')`,與其餘 exit 點不一致,會少一行收尾分隔線, - * 是否為刻意設計尚需人工確認。 */ export async function main() { section('AI Code Review Pipeline'); From d21e2f0e12c9a18e466146411cfa635473c63303 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 08:48:50 +0000 Subject: [PATCH 06/12] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E8=BD=89=E6=8F=9B=20findings=20wrapper=20=E4=B8=A6=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E6=8E=92=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 13 +++++ .gitea/ai-review/findings.json | 87 ++++++++------------------------ 2 files changed, 35 insertions(+), 65 deletions(-) create mode 100644 .gitea/ai-review/exclusions.json diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json new file mode 100644 index 0000000..6172f82 --- /dev/null +++ b/.gitea/ai-review/exclusions.json @@ -0,0 +1,13 @@ +[ + { + "addedAt": "2026/08/07 16:47:53", + "prNumber": 4, + "reviewer": "Bard", + "severity": "警告", + "file": "readme.md", + "startLine": 3, + "endLine": 3, + "problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。", + "reason": "此專案的 README 本身就是生成式 API 參考文件,維持完整索引與連結有助於內部使用,屬於文件取捨而非功能性缺陷。" + } +] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 26dc507..826919f 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,66 +1,23 @@ -[ - { - "level": "warning", - "role": "Assassin", - "location": "action.yml:14", - "problem": "AI 模型參數(`model`)為使用者輸入但未驗證。攻擊者可透過 PR workflow 傳入任意字符串,縱然後續經 JSON 序列化理論上應轉義,仍增加了攻擊面且難以追蹤輸入來源。", - "suggestion": "在 action.yml 中對 `model` 輸入進行描述性限制(說明只接受特定格式),並在 src/config.js 的 `getLLMConfig()` 加上白名單驗證或正則表達式檢查,拒絕包含特殊字符的模型名稱(如單引號、反斜線、括號等)。例:`/^[a-zA-Z0-9._-]+$/`。", - "is_new": true +{ + "generatedAt": "2026/08/07 16:47:53", + "commitSha": "5e9bd86bbcb827415e25dcc1dea2fea2a4f07a58", + "prNumber": 4, + "tool": { + "name": "ai-review-bot", + "version": "unknown", + "model": "unknown" }, - { - "level": "warning", - "role": "Bard", - "location": "entrypoint.sh:2", - "problem": "進入點腳本一開頭就塞入固定更新時間與裝飾性框線,資訊價值很低,卻會讓每次重生產都留下無意義的 diff 雜訊。", - "suggestion": "移除這種會過期的時間戳註解,只保留真正需要提醒讀者的簡短說明即可。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "readme.md:3", - "problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。", - "suggestion": "把 README 收斂成專案摘要、安裝方式與使用入口;細部 API 文件另放獨立文件或改成可生成的 docs,避免主文件膨脹成資料堆。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "src/comments.js:235", - "problem": "`postFindingsReview` 這段 JSDoc 太像流程筆記,不像 API 說明。`@param`、`@remarks`、`使用情境` 與多層降級敘事一路堆疊,重點被枝節埋掉,閱讀節奏很不乾淨。", - "suggestion": "把註解壓縮回最必要的契約說明:用途、參數、回傳與例外即可;降級順序和測試注入細節留給實作內的短註解。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "src/findings.js:413", - "problem": "`resolveMissingLineNumbers` 的註解把行為、邊界條件、併發設定與人工備註全揉成一段,語氣也從說明一路滑到審查心得,讀起來有點散、有點吵。", - "suggestion": "把說明拆短,保留輸入、輸出與副作用三件事即可;如果某些設計值得提醒,也應縮成一句附註,不要塞進主體敘述。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "src/main.js:59", - "problem": "這段註解直接寫出『目前程式碼中有 3 個 exit 1 呼叫點』,把瞬時的實作現況硬塞進長期註解,過幾次重構就會先壞掉,徒增維護負擔。", - "suggestion": "刪掉這種會隨流程變動而失真的數量型描述;若真要提醒收尾差異,改成更穩定的概念性說明即可。", - "is_new": true - }, - { - "level": "info", - "role": "Assassin", - "location": "src/llm.js:173", - "problem": "HTTP request body 中的 `model` 欄位現在允許為 null(由上游 `getLLMConfig()` 傳入),導致該欄位的存在性由輸入決定。若 API 伺服器對缺少 `model` 欄位與 `model: null` 的處理邏輯不同,可能產生非預期的行為切換(例如自動選擇與使用者預期模型不符的模型版本)。", - "suggestion": "在 src/llm.js 的 `runProxyAPI()` 中明確文檔化 `model: null` 時的 API 行為,或在構造 body 前透過 `getLLMConfig()` 的驗證確保 model 值的一致性。若允許自動選擇,應於 log 與回應中清楚標示使用了自動選擇(目前已在 main.js 中以 `modelLabel` 處理,但建議同步至 API 層確認)。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "src/log.js:8", - "problem": "這裡用 `en-CA` 來拼台灣時區時間字串,技法不算錯,但對讀者很不直觀。看到 `Asia/Taipei` 卻搭配 `en-CA`,第一眼會先懷疑這是不是某種繞路寫法。", - "suggestion": "改用更直白的格式化方式,例如手動補零組字串,或至少把這個 locale 選擇的用意明講,讓 helper 的意圖一眼可懂。", - "is_new": true - } -] + "findings": [], + "excluded": [ + { + "id": "F001", + "reviewer": "Bard", + "severity": "警告", + "file": "readme.md", + "startLine": 3, + "endLine": 3, + "problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。", + "suggestion": "把 README 收斂成專案摘要、安裝方式與使用入口;細部 API 文件另放獨立文件或改成可生成的 docs,避免主文件膨脹成資料堆。" + } + ] +} From 605d55745542fcd2ba4d2cc3317ba17fb4e35658 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 7 Aug 2026 08:55:58 +0000 Subject: [PATCH 07/12] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/exclusions.json | 12 +++ .gitea/ai-review/findings.json | 167 +++++++++++++++++++++++++++---- 2 files changed, 157 insertions(+), 22 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 6172f82..5a2efff 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -9,5 +9,17 @@ "endLine": 3, "problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。", "reason": "此專案的 README 本身就是生成式 API 參考文件,維持完整索引與連結有助於內部使用,屬於文件取捨而非功能性缺陷。" + }, + { + "location": "src/main.js:59", + "role": "Bard", + "original_finding": "刪掉這種會隨流程變動而失真的數量型描述;若真要提醒收尾差異,改成更穩定的概念性說明即可。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "location": "entrypoint.sh:2", + "role": "Bard", + "original_finding": "移除這種會過期的時間戳註解,只保留真正需要提醒讀者的簡短說明即可。", + "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 826919f..ccd4866 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,23 +1,146 @@ -{ - "generatedAt": "2026/08/07 16:47:53", - "commitSha": "5e9bd86bbcb827415e25dcc1dea2fea2a4f07a58", - "prNumber": 4, - "tool": { - "name": "ai-review-bot", - "version": "unknown", - "model": "unknown" +[ + { + "level": "warning", + "role": "Assassin", + "location": "action.yml:14", + "problem": "action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符(如 `gpt-4; rm -rf /`),這些惡意輸入會先被寫入環境變數 `CLI_PROXY_API_MODEL`,才在 Node.js 代碼中被驗證。違反了「最小信任原則」。", + "suggestion": "在 action.yml 中的 inputs.model 新增驗證限制(GitHub Actions 層面無原生驗證機制,但可在文檔中強調風險,並確保 Node.js 驗證實作完備)。或改為使用 `choices` 列表限制可選值。目前的 Node.js 驗證雖然有效,但應在 GitHub Actions 文檔中明確說明:只有英數字、點、底線、連字號的 model 值才會被接受,其他值會被拒絕並導致工作流失敗。", + "is_new": true }, - "findings": [], - "excluded": [ - { - "id": "F001", - "reviewer": "Bard", - "severity": "警告", - "file": "readme.md", - "startLine": 3, - "endLine": 3, - "problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。", - "suggestion": "把 README 收斂成專案摘要、安裝方式與使用入口;細部 API 文件另放獨立文件或改成可生成的 docs,避免主文件膨脹成資料堆。" - } - ] -} + { + "level": "warning", + "role": "Bard", + "problem": "`postFindingsReview` 這段 JSDoc 太像流程筆記,不像 API 說明。`@param`、`@remarks`、`使用情境` 與多層降級敘事一路堆疊,重點被枝節埋掉,閱讀節奏很不乾淨。", + "suggestion": "把註解壓縮回最必要的契約說明:用途、參數、回傳與例外即可;降級順序和測試注入細節留給實作內的短註解。", + "location": "src/comments.js:235", + "is_new": false + }, + { + "level": "warning", + "role": "Bard", + "location": "action.yml:15", + "problem": "把 Docker Action 的 image 參照改成小寫 `dockerfile`,讓原本業界慣用的 `Dockerfile` 檔名失去辨識度;這種大小寫改動會讓人讀配置時多停一下,也讓專案風格顯得不一致。", + "suggestion": "把檔名與 `action.yml` 的 `runs.image` 都改回慣用的 `Dockerfile`,維持 Docker 生態的標準寫法。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "readme.md:1", + "problem": "新文件採用小寫 `readme.md`,和倉庫中常見的 `README.md` 命名慣例不合。這種只差大小寫的命名,最容易在查找與瀏覽時破壞一致感。", + "suggestion": "改名為 `README.md`,讓入口文件維持一眼可辨的標準名稱。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "src/comments.js:314", + "problem": "這段 JSDoc 連到不存在的 `newFindingsOnly`,斷鏈的 `{@link}` 會讓文件閱讀時突然失聲;同時還把判定差異寫得過於旁白化,讓主註解變得冗長。", + "suggestion": "把 cross-reference 換成實際存在的符號,或直接刪掉;差異說明則濃縮成一句話,保留重點即可。", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/comments.js:37", + "problem": "buildTable 函式在呼叫 findings.map() 前無防呆檢查,若 findings 為 null/undefined 會拋出 TypeError。文件已提及此問題但函式本體未修正", + "suggestion": "在 .map() 呼叫前加入 `if (!Array.isArray(findings)) findings = [];` 或改用可選鏈語法,確保即使傳入無效值也能優雅降級", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/comments.js:90", + "problem": "inlineCommentBody 函式若 f.role 或 f.suggestion 為 undefined,會直接內嵌 undefined 字樣到輸出字串,產生 '**等級**:xxx\\n**審查員**:undefined\\n**建議**:undefined' 的破損註解", + "suggestion": "在組字前加檢查:`const role = f.role || 'AI Review'; const suggestion = f.suggestion || '';` 確保回傳值不含 undefined 字面值", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/findings.js:658", + "problem": "applyExclusions 的比對邏輯在 (locationMatches && roleMatches && (textMatches || ...)) 中,若排除規則只指定 filePath 不指定 role,會產生「該檔案內所有角色的問題都被排除」的非預期行為;若只指定 role 不指定 filePath,則「該角色所有檔案的問題都被排除」。此為對稱性缺陷", + "suggestion": "重新檢視比對邏輯意圖:若欲實現「指定 filePath 時自動不檢查 role」的設計,需在文件中明確說明此為刻意設計;若非刻意,應改為 (locationMatches || !exclusion.filePath) && (roleMatches || !exclusion.role) && (textMatches || ...),確保每個維度皆能獨立篩選", + "is_new": true + }, + { + "level": "warning", + "role": "Mage", + "location": "src/llm.js:161", + "problem": "summarizeApiError 函式內存取 e.stderr 與 e.stdout 時未使用可選鏈,若 e 為 null 或 undefined,會拋出 TypeError 而非優雅容錯", + "suggestion": "改用可選鏈:`const stderr = e?.stderr || ''` 與 `const stdout = e?.stdout || ''`,或在函式開頭加入 `if (!e) return String(e);` 早期退出", + "is_new": true + }, + { + "level": "warning", + "role": "Rogue", + "location": "src/log.js:7", + "problem": "formatTimestamp() 每次調用都新建 Intl.DateTimeFormat 實例,加上 formatToParts() 與 Object.fromEntries() 轉換,高頻日誌場景下重複成本大;而日誌函式會在 section/step/line/input/output/result/ok/warn/error 等多處調用,累積開銷明顯", + "suggestion": "將 Intl.DateTimeFormat 快取為模組層級單例(const formatter = new Intl.DateTimeFormat(...)),或改用更輕量的時間格式化方式(例如直接用 Date 方法),避免每條日誌都重複實例化", + "is_new": true + }, + { + "level": "info", + "role": "Assassin", + "location": "src/config.js:46", + "problem": "正則表達式 `MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/` 不允許 `/` 字符。某些合法的模型名稱格式(如 `openrouter/openai/gpt-4o` 或 `providers/openai/models/gpt-4o`)會被拒絕,導致功能受限。雖然不是直接的安全漏洞,但可能造成合法請求被誤判為異常。", + "suggestion": "評估是否需要在正則表達式中允許 `/` 字符。若允許,應同時確保不會引入新的安全風險(例如路徑穿越攻擊)。改為 `/^[A-Za-z0-9._/-]+$/` 並增加單元測試確認邊界情況。", + "is_new": true + }, + { + "level": "info", + "role": "Assassin", + "location": "src/log.js:19", + "problem": "formatTimestamp 函數依賴 Intl.DateTimeFormat.formatToParts 的實現細節。若回應結構不符預期,`map.year`、`map.month` 等會是 `undefined`,導致日誌中顯示 `undefined` 字樣。雖然不影響安全性,但可能造成日誌混亂及除錯困難。", + "suggestion": "加強容錯處理。在存取 `map.year` 等屬性前先驗證其存在性;或改用更穩定的日期格式化方式(如 `new Date().toISOString()`)。同時增加單元測試,確保在異常情況下(例如不同的語言環境或舊版本瀏覽器)仍能產生正確的日誌格式。", + "is_new": true + }, + { + "level": "info", + "role": "Bard", + "location": "src/llm.js:31", + "problem": "`mapWithConcurrency` 內部工作者 `run()` 的註解太像設計文件,對 `cursor`、`Promise.all` 行為、背景工作都展開長篇解釋,視覺重量遠超過程式本身。", + "suggestion": "把這段縮成一兩句重點註解,保留「限制併發、保序寫入」即可,其餘執行細節交回外層函式說明。", + "is_new": true + }, + { + "level": "info", + "role": "Mage", + "location": "src/findings.js:349", + "problem": "mergeFindings 用 suggestion 前 50 字作為 key 的一部分進行去重。若兩個 findings 的 role 與 location 相同但 suggestion 在第 50 字之後才出現差異,會被誤判為重複而遭移除", + "suggestion": "考慮是否改用完整 suggestion 或增加其他識別字段(如 problem)來組成 key,確保去重不會誤刪本質不同的問題", + "is_new": true + }, + { + "level": "info", + "role": "Mage", + "location": "src/findings.js:363", + "problem": "sortByLevel 使用 LEVELS.indexOf() 排序,級別不在 ['critical','warning','info'] 中的項目因 indexOf 回傳 -1 而被排到 critical 之前(最前面),此邊界行為是否為預期設計不明確", + "suggestion": "在文件或代碼中明確說明未知級別項目的預期排序位置,或改用顯式的條件判斷以提升代碼可讀性", + "is_new": true + }, + { + "level": "info", + "role": "Mage", + "location": "src/resolve.js:98", + "problem": "groupConversations 在設置 botFinding 時用 `botFindings[0]`,若該對話的 botFindings 陣列為空,botFinding 會為 undefined。此設計雖有文件說明是為相容舊邏輯,但下游代碼仍需確保可安全處理 undefined 值", + "suggestion": "在文件中明確註記 botFinding 可為 undefined,並在此函式或其呼叫端加入明確的 null 檢查,或改用 `botFinding: botFindings.length > 0 ? botFindings[0] : null` 以更清晰地表達意圖", + "is_new": true + }, + { + "level": "info", + "role": "Rogue", + "location": "src/llm.js:154", + "problem": "runProxyAPI() 中 body 物件先建立後再條件性添加 model 屬性;若此函式在併發量大的場景反覆呼叫,每次都會新建完整物件結構", + "suggestion": "改用 Object.assign() 或 const body = { messages: [...], temperature: 0, stream: false, ...(model && { model }) },減少不必要的中間物件建立步驟", + "is_new": true + }, + { + "level": "info", + "role": "Rogue", + "location": "src/log.js:18", + "problem": "formatToParts() 後用 Object.fromEntries(parts.map(...)) 進行雙次陣列與物件轉換,再拼字串;格式化操作偏複雜,對日誌輸出這種高頻操作成本偏高", + "suggestion": "改用 reduce() 直接在一次遍歷內組出 map 物件,或改寫為單一模板字符串拼接,避免中間陣列轉換", + "is_new": true + } +] From 65dcb52777f4d9c5b7c2a56585cf58ee3627fd5f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 16:41:47 +0000 Subject: [PATCH 08/12] =?UTF-8?q?fix(review-resolve):=20=E6=94=AF=E6=8F=B4?= =?UTF-8?q?=20wrapper=20findings=20=E4=B8=A6=E4=BF=AE=E6=AD=A3=E8=BC=B8?= =?UTF-8?q?=E5=87=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dockerfile => Dockerfile | 0 action.yml | 4 +- src/comments.js | 43 +++++++-------- src/config.js | 4 +- src/findings.js | 57 +++++++++----------- src/json.js | 114 ++++++++++++++++++++++++++++++++++++--- src/llm.js | 44 +++++---------- src/log.js | 27 ++++++---- 8 files changed, 184 insertions(+), 109 deletions(-) rename dockerfile => Dockerfile (100%) diff --git a/dockerfile b/Dockerfile similarity index 100% rename from dockerfile rename to Dockerfile diff --git a/action.yml b/action.yml index 1bba83c..367c9c0 100644 --- a/action.yml +++ b/action.yml @@ -9,11 +9,11 @@ inputs: description: '操作 Gitea Commit API 的 Token' required: false model: - description: '使用的 AI 模型,僅允許英數字、點、底線與連字號' + description: '使用的 AI 模型,僅允許英數字、點、底線、連字號與斜線' required: false runs: using: 'docker' - image: 'dockerfile' + image: 'Dockerfile' env: GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }} GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }} diff --git a/src/comments.js b/src/comments.js index f0d497a..a0bcd82 100644 --- a/src/comments.js +++ b/src/comments.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { postComment, postPullReviewComment, postPullReview } from './gitea.js'; import { FINDINGS_PATH } from './config.js'; +import { buildFindingsWrapper } from './json.js'; import { ok, line, warn } from './log.js'; const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; @@ -35,7 +36,8 @@ function findingRow(f) { * 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。 */ function buildTable(findings) { - const rows = findings.map(findingRow).join('\n'); + const list = Array.isArray(findings) ? findings : []; + const rows = list.map(findingRow).join('\n'); return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`; } @@ -97,7 +99,7 @@ export function parseLocation(location) { * `location` 能被解析出具體行號時。 */ function inlineCommentBody(f) { - return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`; + return `**等級**:${levelText(f)}\n**審查員**:${f?.role || 'AI Review'}\n**建議**:${f?.suggestion || ''}`; } /** @@ -123,9 +125,9 @@ function problemText(f) { function reviewCommentBody(f) { return [ `**嚴重等級**:${levelText(f)}`, - `**審查員**:${f.role}`, + `**審查員**:${f?.role || 'AI Review'}`, `**問題**:${problemText(f)}`, - `**建議**:${f.suggestion}`, + `**建議**:${f?.suggestion || ''}`, ].join('\n'); } @@ -235,15 +237,10 @@ function toReviewComment(f) { } /** - * 發布單一 Gitea review,必要時會先降級成 summary review,再降級成一般 comment。 + * 發布單一 Gitea review,必要時降級成 summary review 或一般 comment。 + * * @param {Array} findings 審查 findings。 * @param {object} [deps={}] 可注入的相依物件。 - * @param {Function} [deps.postReview=postPullReview] 發布整批 review 的函式。 - * @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 comment 的函式。 - * @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。 - * @param {Array} [deps.summaryFindings=findings] 用於統計的 findings 子集合。 - * @param {Array} [deps.commentFindings=findings] 用於建立 review comments 的 findings 子集合。 - * @param {string} [deps.usageSection=''] 附加的使用量區塊。 * @returns {Promise} 無回傳值。 */ export async function postFindingsReview(findings, deps = {}) { @@ -282,11 +279,11 @@ export async function postFindingsReview(findings, deps = {}) { } /** - * 將 findings 寫入 `findings.json`(同步阻塞 I/O)。 + * 將 findings 寫入新版 wrapper 格式的 `findings.json`(同步阻塞 I/O)。 * * @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。 - * @param {Array} findings 要寫入的 findings 陣列;會以 `JSON.stringify(findings, null, 2)` 序列化, - * 並在檔尾補一個換行字元。 + * @param {Array} findings 要寫入的 findings 陣列;會包成包含 `generatedAt`/`commitSha`/ + * `prNumber`/`tool`/`findings`/`excluded` 的 wrapper,再以 2 空白縮排 JSON 序列化並補換行。 * @param {?string} [mirrorDir=null] 額外鏡射輸出目錄(例如供後續 repo commit 使用); * 為 `null`/`undefined`,或與 `workspace` 相同時,只會寫入一份(不重複寫入同一路徑)。 * @returns {void} 無回傳值;成功時每個目標各記錄一行 log。 @@ -297,14 +294,15 @@ export async function postFindingsReview(findings, deps = {}) { * 若同時需要寫回 workspace 與 repo 兩個位置,傳入 `mirrorDir` 即可一次呼叫完成兩份寫入。 */ export function saveFindings(workspace, findings, mirrorDir = null) { + const wrapper = buildFindingsWrapper(findings, []); const targets = [workspace]; if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir); for (const targetDir of targets) { const fullPath = path.join(targetDir, FINDINGS_PATH); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8'); - ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`); + fs.writeFileSync(fullPath, JSON.stringify(wrapper, null, 2) + '\n', 'utf8'); + ok(`findings 寫入: ${fullPath} (${wrapper.findings.length} 筆)`); } } @@ -312,12 +310,9 @@ export function saveFindings(workspace, findings, mirrorDir = null) { * 發布所有舊問題的彙總 comment(一次性發布一則一般 comment,不含行內標註)。 * * @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列; - * 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題 - * (注意:此判定與 {@link newFindingsOnly} 的 `is_new !== false` 不同,`undefined` 在此處被視為 - * 「舊」而非「新」,是否為預期設計需人工確認)。 + * 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題。 * @returns {Promise} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。 - * @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出(與 {@link postFindingsReview} - * 內部先用 `bySeverity` 排序的行為不同,請勿假設本函式輸出已排序)。 + * @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出。 * 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。 */ export async function postOldFindingsComment(findings) { @@ -336,8 +331,7 @@ export async function postOldFindingsComment(findings) { * * @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列; * 以 `f.is_new && f.level !== 'critical'` 篩選——`is_new` 須為 truthy(例如 `true`)才算新問題, - * `undefined`/`false` 皆會被排除(注意:此判定比 {@link newFindingsOnly} 的 - * `is_new !== false` 更嚴格,兩者對 `undefined` 的處理方向相反,是否為預期設計需人工確認)。 + * `undefined`/`false` 皆會被排除。 * `level !== 'critical'` 涵蓋 `warning`、`info` 及任何非 `'critical'` 的其他值(含未知等級字串)。 * @returns {Promise} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。 * @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。 @@ -360,8 +354,7 @@ export async function postNewNonCriticalComment(findings) { * (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 comment。 * * @param {Array<{ is_new?: boolean, level?: string, location?: string, role?: string, suggestion?: string }>} findings - * 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題 - * (與 {@link newFindingsOnly} 的寬鬆判定不同,`undefined` 會被排除,需人工確認是否為預期設計)。 + * 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題。 * @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換)。 * @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。 * @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。 diff --git a/src/config.js b/src/config.js index c1f2526..fc0a525 100644 --- a/src/config.js +++ b/src/config.js @@ -42,13 +42,13 @@ export const LLM_PROVIDER = 'cliproxyapi'; export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; -const MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/; +const MODEL_NAME_RE = /^[A-Za-z0-9._/-]+$/; function normalizeModelName(raw) { const model = String(raw || '').trim(); if (!model) return { model: null, modelError: null }; if (!MODEL_NAME_RE.test(model)) { - return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線與連字號' }; + return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線、連字號與斜線' }; } return { model, modelError: null }; } diff --git a/src/findings.js b/src/findings.js index e8f8c2f..cf34257 100644 --- a/src/findings.js +++ b/src/findings.js @@ -26,27 +26,6 @@ export async function analyzeWithRole(role, diff) { return valid; } -/** - * 讀取 JSON 陣列檔案;檔案不存在、讀取失敗或內容非陣列時,皆視為空並回傳 []。 - * - * @param {string} fullPath - 欲讀取的 JSON 檔案完整路徑。 - * @param {string} label - 用於警告訊息中識別此次讀取對象的標籤文字(例如「舊 findings 」)。 - * @returns {Array} 解析出的陣列;任何失敗情況皆回傳空陣列 []。 - */ -function readJSONArray(fullPath, label) { - if (!fs.existsSync(fullPath)) { - warn(`${label}檔案不存在,視為空`); - return []; - } - try { - const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); - return Array.isArray(data) ? data : []; - } catch (e) { - warn(`讀取${label}失敗: ${e.message},視為空`); - return []; - } -} - /** * 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。 * @@ -305,7 +284,8 @@ function buildExclusionContext(exclusions) { /** * 讀取舊 findings(來源分支 cloned repoDir 下 FINDINGS_PATH 指向的檔案), - * 每筆項目一律標記 is_new: false(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。 + * 同時相容舊版頂層陣列與新版 wrapper 物件;每筆項目一律標記 is_new: false + *(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。 * 檔案不存在或讀取失敗時視為空陣列,不拋例外。 * * @param {string} workspace - 來源分支 clone 出的工作目錄根路徑,FINDINGS_PATH 會相對此路徑解析。 @@ -313,11 +293,20 @@ function buildExclusionContext(exclusions) { */ export function loadOldFindings(workspace) { const fullPath = path.join(workspace, FINDINGS_PATH); - const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false })); + let old = []; if (fs.existsSync(fullPath)) { - const stat = fs.statSync(fullPath); - line(`讀取舊 findings 檔案: ${fullPath}`); - line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`); + try { + const stat = fs.statSync(fullPath); + const data = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + const sourceFormat = Array.isArray(data) ? 'array' : (data && Array.isArray(data.findings) ? 'wrapper' : 'unknown'); + const rawFindings = Array.isArray(data) ? data : (data && Array.isArray(data.findings) ? data.findings : []); + old = rawFindings.map(f => ({ ...f, is_new: false })); + line(`讀取舊 findings 檔案: ${fullPath}`); + line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} source=${sourceFormat} path=${path.relative(workspace, fullPath) || fullPath}`); + } catch (e) { + warn(`讀取舊 findings 失敗: ${e.message},視為空: ${fullPath}`); + old = []; + } } else { warn(`舊 findings 檔案不存在: ${fullPath}`); } @@ -326,7 +315,7 @@ export function loadOldFindings(workspace) { } /** - * 合併新舊 findings:以 (role + location + suggestion 前 50 字) 組成的字串為 key, + * 合併新舊 findings:以 (role + location + problem + suggestion 前 50 字) 組成的字串為 key, * 過濾掉 newFindings 中與 oldFindings(或 newFindings 自身先出現的項目)key 相同的重複項。 * oldFindings 本身不會互相去重(視為既有基準),回傳陣列為 [...oldFindings, ...去重後的 newFindings]。 * @@ -335,7 +324,7 @@ export function loadOldFindings(workspace) { * @returns {Array} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。 */ export function mergeFindings(oldFindings, newFindings) { - const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`; + const key = f => `${f.role}|${f.location}|${String(f.problem || '')}|${String(f.suggestion || '').slice(0, 50)}`; const seen = new Set(oldFindings.map(key)); const deduped = newFindings.filter(f => { if (seen.has(key(f))) return false; @@ -348,15 +337,17 @@ export function mergeFindings(oldFindings, newFindings) { } /** - * 依等級排序(critical > warning > info),回傳新陣列,不修改傳入的 findings。 + * 依等級排序(critical > warning > info,未知等級排最後),回傳新陣列,不修改傳入的 findings。 * * @param {Array} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。 - * @returns {Array} 依 critical/warning/info 順序排序後的新陣列。 - * @remarks level 不在 ['critical','warning','info'] 中的項目,因 indexOf 回傳 -1, - * 會被排到 critical 之前(最前面)而非最後面;此邊界行為是否為預期設計,需人工確認。 + * @returns {Array} 依 critical/warning/info 順序排序後的新陣列;未知等級會排在最後。 */ export function sortByLevel(findings) { - return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level)); + const rank = (level) => { + const index = LEVELS.indexOf(level); + return index === -1 ? LEVELS.length : index; + }; + return [...findings].sort((a, b) => rank(a.level) - rank(b.level)); } /** diff --git a/src/json.js b/src/json.js index eaf8daa..3fe029b 100644 --- a/src/json.js +++ b/src/json.js @@ -1,9 +1,85 @@ import fs from 'fs'; import path from 'path'; import { chat } from './llm.js'; +import { FINDINGS_PATH, PR_HEAD_SHA, PR_NUMBER, getLLMConfig } from './config.js'; import { ok, warn, error } from './log.js'; const MAX_JSON_BYTES = 1024 * 1024; +const PACKAGE_VERSION = (() => { + try { + return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || 'unknown'; + } catch { + return 'unknown'; + } +})(); + +function formatTaipeiTimestamp(date = new Date()) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Taipei', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date); + const map = Object.fromEntries(parts.filter(p => p.type !== 'literal').map(p => [p.type, p.value])); + return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`; +} + +function parsePrNumber(raw) { + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +function isFindingsWrapperLabel(label) { + return String(label || '') === FINDINGS_PATH || String(label || '').endsWith('/findings.json') || String(label || '').endsWith('findings.json'); +} + +function defaultToolInfo() { + const { model } = getLLMConfig(); + return { + name: 'ai-code-review', + version: PACKAGE_VERSION, + model: model || 'auto', + }; +} + +export function buildFindingsWrapper(findings, excluded = [], overrides = {}) { + return { + generatedAt: overrides.generatedAt || formatTaipeiTimestamp(), + commitSha: overrides.commitSha || PR_HEAD_SHA || '', + prNumber: overrides.prNumber !== undefined ? overrides.prNumber : parsePrNumber(PR_NUMBER), + tool: overrides.tool || defaultToolInfo(), + findings: Array.isArray(findings) ? findings : [], + excluded: Array.isArray(excluded) ? excluded : [], + }; +} + +function normalizeFindingsWrapper(data) { + if (Array.isArray(data)) return buildFindingsWrapper(data, []); + if (!data || typeof data !== 'object') return null; + if (!Array.isArray(data.findings)) return null; + return { + generatedAt: typeof data.generatedAt === 'string' && data.generatedAt.trim() ? data.generatedAt : formatTaipeiTimestamp(), + commitSha: typeof data.commitSha === 'string' ? data.commitSha : (PR_HEAD_SHA || ''), + prNumber: data.prNumber !== undefined ? parsePrNumber(data.prNumber) : parsePrNumber(PR_NUMBER), + tool: data.tool && typeof data.tool === 'object' + ? { + name: typeof data.tool.name === 'string' && data.tool.name.trim() ? data.tool.name : 'ai-code-review', + version: typeof data.tool.version === 'string' && data.tool.version.trim() ? data.tool.version : PACKAGE_VERSION, + model: typeof data.tool.model === 'string' && data.tool.model.trim() ? data.tool.model : 'auto', + } + : defaultToolInfo(), + findings: data.findings, + excluded: Array.isArray(data.excluded) ? data.excluded : [], + }; +} + +function writeJSON(fullPath, data) { + fs.writeFileSync(fullPath, JSON.stringify(data, null, 2) + '\n', 'utf8'); +} /** * 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```), @@ -92,6 +168,7 @@ function readJSONText(fullPath, label) { */ export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) { fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + const expectsFindingsWrapper = isFindingsWrapperLabel(label); if (!fs.existsSync(fullPath)) { warn(`${label} 不存在,將於驗證後補建`); @@ -99,7 +176,23 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS } try { - JSON.parse(readJSONText(fullPath, label)); + const parsed = JSON.parse(readJSONText(fullPath, label)); + if (expectsFindingsWrapper) { + const normalized = normalizeFindingsWrapper(parsed); + if (!normalized) { + throw new Error(`${label} 不是 findings wrapper`); + } + if (!Array.isArray(parsed)) { + ok(`${label} JSON 格式正確`); + return { exists: true, valid: true, repaired: false }; + } + writeJSON(fullPath, normalized); + ok(`${label} 已正規化為 findings wrapper`); + return { exists: true, valid: true, repaired: true }; + } + if (!Array.isArray(parsed)) { + throw new Error(`${label} 不是 JSON 陣列`); + } ok(`${label} JSON 格式正確`); return { exists: true, valid: true, repaired: false }; } catch (e) { @@ -110,10 +203,18 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`; // 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。 const parsed = JSON.parse(normalized); - if (!Array.isArray(parsed)) { - throw new Error(`${label} 修復後內容不是 JSON 陣列`); + if (expectsFindingsWrapper) { + const wrapper = normalizeFindingsWrapper(parsed); + if (!wrapper) { + throw new Error(`${label} 修復後內容不是 findings wrapper`); + } + writeJSON(fullPath, wrapper); + } else { + if (!Array.isArray(parsed)) { + throw new Error(`${label} 修復後內容不是 JSON 陣列`); + } + fs.writeFileSync(fullPath, normalized, 'utf8'); } - fs.writeFileSync(fullPath, normalized, 'utf8'); ok(`${label} 已由 AI 修正並通過再次驗證`); return { exists: true, valid: true, repaired: true }; } catch (repairErr) { @@ -138,7 +239,8 @@ export function ensureJSONArrayFileExists(fullPath, label) { fs.mkdirSync(path.dirname(fullPath), { recursive: true }); if (fs.existsSync(fullPath)) return false; - fs.writeFileSync(fullPath, '[]\n', 'utf8'); - warn(`${label} 不存在,已建立空陣列`); + const content = isFindingsWrapperLabel(label) ? buildFindingsWrapper([], []) : []; + writeJSON(fullPath, content); + warn(`${label} 不存在,已建立空${isFindingsWrapperLabel(label) ? ' findings wrapper' : '陣列'}`); return true; } diff --git a/src/llm.js b/src/llm.js index 120b9c5..cd99640 100644 --- a/src/llm.js +++ b/src/llm.js @@ -31,24 +31,9 @@ export async function mapWithConcurrency(items, limit, fn) { const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length); let cursor = 0; /** - * mapWithConcurrency 的工作者(worker)迴圈:從共用游標 `cursor` 依序搶下一個尚未 - * 處理的索引,呼叫外層傳入的 `fn`,並把結果寫入外層 `results` 陣列對應位置;直到 - * `cursor` 到達 `list.length` 為止。 + * 內部 worker:從共用游標依序搶下一個索引,呼叫 `fn` 後把結果寫回對應位置。 * - * 多個 `run()` 會被同時啟動(依 `workers` 數量),透過共用的 `cursor` 變數達到 - * 「限制併發數、動態搶下一筆」的效果——先完成者會先搶到下一個索引,因此各次 `fn` - * 呼叫的完成順序不保證,但因寫入位置以原始索引 `i` 為準,`results` 仍能保持與 - * `items` 相同順序。 - * - * 本函式為 `mapWithConcurrency` 內部使用的閉包(closure),依賴外層作用域的 - * `list`、`results`、`fn`、`cursor` 變數運作;不接受參數,也不可、不應在外部 - * 單獨呼叫或匯出。 - * - * @returns {Promise} 無回傳值;副作用為寫入外層 `results` 陣列與推進 `cursor`。 - * @throws 若某次 `fn(list[i], i)` reject,本函式會原樣向外拋出該錯誤(不吞例外), - * 使 `mapWithConcurrency` 的 `Promise.all` 立即 reject;但其他已啟動、尚在執行 - * 中的 `run()` 實例不會被取消,仍會在背景繼續搬移 `cursor` 並寫入 `results`, - * 只是其結果最終會被捨棄。 + * @returns {Promise} 無回傳值。 */ async function run() { while (cursor < list.length) { @@ -132,10 +117,10 @@ function summarizeApiError(e) { || responseData?.message || responseData?.error || ''; - const stderr = String(e.stderr || '').trim(); - const stdout = String(e.stdout || '').trim(); + const stderr = String(e?.stderr || '').trim(); + const stdout = String(e?.stdout || '').trim(); const status = e?.response?.status ? `HTTP ${e.response.status}` : ''; - const message = extractMeaningfulError(responseText || stderr || stdout || e.message || String(e)); + const message = extractMeaningfulError(responseText || stderr || stdout || e?.message || String(e)); return [status, message].filter(Boolean).join(' ').trim(); } @@ -164,18 +149,17 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024); const root = String(baseURL || '').trim().replace(/\/$/, ''); const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : ''; - const body = { - messages: [ - { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' }, - { role: 'user', content: prompt }, - ], - temperature: 0, - stream: false, - }; - if (model) body.model = model; const resp = await axios.post( `${root}/v1/chat/completions`, - body, + { + messages: [ + { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' }, + { role: 'user', content: prompt }, + ], + temperature: 0, + stream: false, + ...(model ? { model } : {}), + }, { timeout, maxBodyLength: maxBuffer, diff --git a/src/log.js b/src/log.js index 83a8c74..0644548 100644 --- a/src/log.js +++ b/src/log.js @@ -1,3 +1,14 @@ +const timestampFormatter = new Intl.DateTimeFormat('zh-TW', { + timeZone: 'Asia/Taipei', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', +}); + /** * 依 `spec-time-log` 規範產生台灣時區(Asia/Taipei)的固定格式時間戳 `yyyy/MM/dd HH:mm:ss`。 * 供本模組所有輸出函式在訊息前加上 `[時間]` 前綴使用。 @@ -6,17 +17,11 @@ * @returns {string} 例如 `2026/08/07 12:39:43`。 */ function formatTimestamp(date = new Date()) { - const parts = new Intl.DateTimeFormat('zh-TW', { - timeZone: 'Asia/Taipei', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - }).formatToParts(date); - const map = Object.fromEntries(parts.map((p) => [p.type, p.value])); + const parts = timestampFormatter.formatToParts(date); + const map = parts.reduce((acc, part) => { + acc[part.type] = part.value; + return acc; + }, {}); return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`; } From a8d3fb60edfecba84adbad6c6f73db04122be518 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 16:42:13 +0000 Subject: [PATCH 09/12] =?UTF-8?q?docs(README):=20=E6=9B=B4=E6=96=B0=20wrap?= =?UTF-8?q?per=20=E8=88=87=20findings=20=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- readme.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/readme.md b/readme.md index 42b01e6..0c85410 100644 --- a/readme.md +++ b/readme.md @@ -26,7 +26,7 @@ | [formatFindingsStats](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L173) | [產生新舊問題依嚴重等級(嚴重/警告/建議/無法標示)分類統計的 Markdown 表格。](#formatfindingsstats) | | [formatFindingsStatsLine](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L194) | [產生與統計表相同內容的單行文字摘要,供 log 輸出使用。](#formatfindingsstatsline) | | [postFindingsReview](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L261) | [發布整批 findings 的 Gitea review(摘要+行內 comment),並提供多層降級機制。](#postfindingsreview) | -| [saveFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L311) | [將 findings 陣列以 JSON 格式寫入 workspace(及可選的鏡像目錄)。](#savefindings) | +| [saveFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L311) | [將 findings 包成新版 wrapper 後寫入 workspace(及可選的鏡像目錄)。](#savefindings) | | [postOldFindingsComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L335) | [發布所有舊有未解決問題的彙總 comment。](#postoldfindingscomment) | | [postNewNonCriticalComment](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L359) | [發布新問題中非 critical 等級者的彙總 comment。](#postnewnoncriticalcomment) | | [postNewCriticalComments](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/comments.js#L388) | [針對每個新的 critical 問題逐筆發布行內 comment,無法定位或失敗時降級為一般 comment。](#postnewcriticalcomments) | @@ -68,7 +68,7 @@ | [stripCodeFence](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L17) | [移除文字外層的 markdown code fence 並清理前後空白。](#stripcodefence) | | [repairJSONArrayWithAI](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L43) | [透過 LLM 將原始內容修復成可直接 JSON.parse 的 JSON 陣列字串。](#repairjsonarraywithai) | | [validateJSONArrayFile](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L93) | [驗證 JSON 檔案是否合法,格式錯誤時嘗試以 AI 修復一次。](#validatejsonarrayfile) | -| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L137) | [確保指定路徑存在 JSON 檔案,不存在時建立空陣列檔。](#ensurejsonarrayfileexists) | +| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/json.js#L137) | [確保指定路徑存在 JSON 檔案,不存在時建立空陣列或 findings wrapper。](#ensurejsonarrayfileexists) | | [mapWithConcurrency](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L26) | [以可控併發數並行處理陣列項目並保序回傳結果。](#mapwithconcurrency) | | [extractMeaningfulError](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L102) | [從 CLI/HTTP 原始輸出中擷取最有用的錯誤訊息片段。](#extractmeaningfulerror) | | [chat](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/src/llm.js#L209) | [呼叫 CLIProxyAPI 送出對話請求並回傳純文字回應。](#chat) | @@ -207,7 +207,7 @@ await postFindingsReview(findings, { usageSection: '## 使用量\n...' }); ### saveFindings -將 findings 陣列以 `JSON.stringify(findings, null, 2)` 序列化並補結尾換行後,寫入 `workspace/.gitea/ai-review/findings.json`;若提供且不同於 `workspace` 的 `mirrorDir`,會同時寫入該鏡像目錄的相同路徑。寫入前會建立必要的父目錄;本函式為同步阻塞呼叫且未做例外防護,`fs` 錯誤會直接向外拋出。 +將 findings 包成新版 wrapper 物件後,以 `JSON.stringify(wrapper, null, 2)` 序列化並補結尾換行,寫入 `workspace/.gitea/ai-review/findings.json`;wrapper 內含 `generatedAt`/`commitSha`/`prNumber`/`tool`/`findings`/`excluded`。若提供且不同於 `workspace` 的 `mirrorDir`,會同時寫入該鏡像目錄的相同路徑。寫入前會建立必要的父目錄;本函式為同步阻塞呼叫且未做例外防護,`fs` 錯誤會直接向外拋出。 - 參數:`workspace`(`string`)、`findings`(`Array`)、`mirrorDir`(`?string`,預設 `null`)。 - 回傳:`void`。 @@ -345,7 +345,7 @@ normalizeText(' 這裡有 SQL Injection!! '); ### loadOldFindings -讀取來源分支 clone 出的工作目錄下 `FINDINGS_PATH`(`.gitea/ai-review/findings.json`),每筆標記 `is_new: false`;並記錄檔案大小/修改時間等診斷日誌。檔案不存在或讀取失敗時視為空陣列,不拋例外。 +讀取來源分支 clone 出的工作目錄下 `FINDINGS_PATH`(`.gitea/ai-review/findings.json`),相容舊版頂層陣列與新版 wrapper 物件;每筆標記 `is_new: false`,並記錄檔案大小/修改時間等診斷日誌。檔案不存在或讀取失敗時視為空陣列,不拋例外。 - 參數:`workspace`(`string`)。 - 回傳:`Array`。 @@ -900,7 +900,7 @@ const repaired = await repairJSONArrayWithAI('/workspace/findings.json', 'findin ### validateJSONArrayFile -驗證指定路徑是否為合法的 JSON 檔案:檔案不存在回傳 `{ exists:false }`(交由呼叫端補檔);解析成功回傳 `{ exists:true, valid:true, repaired:false }`;解析失敗則呼叫 `repairer` 修復、覆寫檔案(確保以換行結尾)並再驗證一次,通過則回傳 `repaired:true`,仍失敗則拋出例外。僅嘗試修復一次。 +驗證指定路徑是否為合法的 JSON 檔案:檔案不存在回傳 `{ exists:false }`(交由呼叫端補檔);`exclusions.json` 仍以頂層陣列為準,而 `findings.json` 則接受新版 wrapper 物件,若讀到舊版 findings 陣列會自動正規化成 wrapper。解析失敗則呼叫 `repairer` 修復、覆寫檔案(確保以換行結尾)並再驗證一次,通過則回傳 `repaired:true`,仍失敗則拋出例外。僅嘗試修復一次。 - 參數:`fullPath`(`string`)、`label`(`string`)、`repairer`(`Function`,預設 `repairJSONArrayWithAI`)。 - 回傳:`Promise<{exists, valid, repaired}>`。 @@ -917,7 +917,7 @@ const result = await validateJSONArrayFile('/workspace/.gitea/ai-review/findings ### ensureJSONArrayFileExists -確保指定路徑存在一個 JSON 檔案;不存在則建立內容為 `"[]\n"` 的空陣列檔(會先建立父目錄)。若檔案已存在則原樣保留、不檢查內容是否合法。為同步函式。 +確保指定路徑存在一個 JSON 檔案;`exclusions.json` 不存在時建立內容為 `"[]\n"` 的空陣列檔,而 `findings.json` 不存在時建立空的新版 wrapper 物件(會先建立父目錄)。若檔案已存在則原樣保留、不檢查內容是否合法。為同步函式。 - 參數:`fullPath`(`string`)、`label`(`string`)。 - 回傳:`boolean`(是否為本次新建)。 From 938db793a71afda1acd81bb86b83df5bba411172 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 16:42:22 +0000 Subject: [PATCH 10/12] =?UTF-8?q?test(review-resolve):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20wrapper=20=E8=88=87=E6=A8=A1=E5=9E=8B=E9=A9=97?= =?UTF-8?q?=E8=AD=89=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/comments.test.js | 24 +++++++++++------- src/test/config.test.js | 10 ++++++++ src/test/findings.test.js | 15 ++++++++--- src/test/json.test.js | 53 +++++++++++++++++++++++---------------- 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/test/comments.test.js b/src/test/comments.test.js index b7f7656..7c5081f 100644 --- a/src/test/comments.test.js +++ b/src/test/comments.test.js @@ -21,10 +21,14 @@ describe('saveFindings', () => { saveFindings(workspace, findings, mirrorDir); - const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'); - const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8'); - assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n'); - assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n'); + const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8')); + const mirrorData = JSON.parse(fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8')); + assert.equal(typeof workspaceData.generatedAt, 'string'); + assert.equal(typeof workspaceData.commitSha, 'string'); + assert.ok(Array.isArray(workspaceData.findings)); + assert.deepEqual(workspaceData.findings, findings); + assert.deepEqual(workspaceData.excluded, []); + assert.deepEqual(mirrorData, workspaceData); }); it('writes only to workspace when mirrorDir is omitted', () => { @@ -33,8 +37,9 @@ describe('saveFindings', () => { saveFindings(workspace, findings); - const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'); - assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n'); + const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8')); + assert.deepEqual(workspaceData.findings, findings); + assert.deepEqual(workspaceData.excluded, []); }); it('does not duplicate writes when mirrorDir matches workspace', () => { @@ -58,13 +63,14 @@ describe('saveFindings', () => { assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH)); }); - it('writes an empty JSON array when findings is empty', () => { + it('writes an empty findings wrapper when findings is empty', () => { const workspace = makeTempDir('findings-empty-'); saveFindings(workspace, []); - const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'); - assert.equal(workspaceText, '[]\n'); + const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8')); + assert.deepEqual(workspaceData.findings, []); + assert.deepEqual(workspaceData.excluded, []); }); afterEach(() => { diff --git a/src/test/config.test.js b/src/test/config.test.js index 5286a23..cfe1e36 100644 --- a/src/test/config.test.js +++ b/src/test/config.test.js @@ -72,6 +72,16 @@ describe('getLLMConfig', () => { assert.match(cfg.modelError, /無效的 model 參數/); }); + it('accepts slash-delimited model names', () => { + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.MODEL = 'provider/gpt-5.5'; + + const cfg = getLLMConfig(); + + assert.equal(cfg.model, 'provider/gpt-5.5'); + assert.equal(cfg.modelError, null); + }); + it('returns null provider when CLI_PROXY_API is missing', () => { process.env.MODEL = 'gpt-5.5'; const cfg = getLLMConfig(); diff --git a/src/test/findings.test.js b/src/test/findings.test.js index 71edada..d17fed6 100644 --- a/src/test/findings.test.js +++ b/src/test/findings.test.js @@ -348,12 +348,19 @@ describe('findings exclusions', () => { assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`))); }); - it('logs findings file metadata when loading old findings', () => { + it('loads wrapper findings and logs findings file metadata', () => { const fullPath = path.join(workspace, FINDINGS_PATH); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, JSON.stringify([ - { level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' }, - ], null, 2)); + fs.writeFileSync(fullPath, JSON.stringify({ + generatedAt: '2026/08/07 16:47:53', + commitSha: 'deadbeef', + prNumber: 7, + tool: { name: 'ai-code-review', version: '1.0.0', model: 'auto' }, + findings: [ + { level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' }, + ], + excluded: [], + }, null, 2)); const findings = loadOldFindings(workspace); diff --git a/src/test/json.test.js b/src/test/json.test.js index 22672c8..9afa0ec 100644 --- a/src/test/json.test.js +++ b/src/test/json.test.js @@ -37,6 +37,19 @@ describe('json helpers', () => { assert.ok(capturedUserContent.includes('"{broken"')); }); + it('creates an empty findings wrapper when asked to ensure existence', () => { + const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); + + const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json'); + + assert.equal(created, true); + const written = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + assert.equal(typeof written.generatedAt, 'string'); + assert.ok(Array.isArray(written.findings)); + assert.deepEqual(written.findings, []); + assert.deepEqual(written.excluded, []); + }); + it('reports missing file without creating it', async () => { const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); @@ -46,15 +59,6 @@ describe('json helpers', () => { assert.equal(fs.existsSync(fullPath), false); }); - it('creates an empty array file when asked to ensure existence', () => { - const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); - - const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json'); - - assert.equal(created, true); - assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n'); - }); - it('returns false when ensuring an existing file', () => { const fullPath = path.join(workspace, '.gitea/ai-review/exclusions.json'); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); @@ -77,29 +81,32 @@ describe('json helpers', () => { assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n'); }); - it('rejects repaired JSON that is not an array', async () => { + it('rejects repaired JSON that is not a findings wrapper', async () => { const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, '{broken', 'utf8'); await assert.rejects( () => validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async () => '{"ok":true}'), - /不是 JSON 陣列/, + /不是 findings wrapper/, ); assert.equal(fs.readFileSync(fullPath, 'utf8'), '{broken'); }); - it('reads a valid JSON file whose size equals the maximum limit', async () => { + it('normalizes a valid legacy findings array whose size equals the maximum limit', async () => { const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, `[]${' '.repeat(MAX_JSON_BYTES - 2)}`, 'utf8'); const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json'); - assert.deepEqual(result, { exists: true, valid: true, repaired: false }); + assert.deepEqual(result, { exists: true, valid: true, repaired: true }); + const written = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + assert.deepEqual(written.findings, []); + assert.deepEqual(written.excluded, []); }); - it('repairs invalid JSON using AI output and rewrites the file', async () => { + it('repairs invalid findings JSON using AI output and rewrites the file as a wrapper', async () => { const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, '{broken', 'utf8'); @@ -110,7 +117,9 @@ describe('json helpers', () => { }); assert.deepEqual(result, { exists: true, valid: true, repaired: true }); - assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n'); + const written = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + assert.deepEqual(written.findings, [{ fixed: true }]); + assert.deepEqual(written.excluded, []); }); it('preserves a trailing newline returned by AI repair', async () => { @@ -124,7 +133,9 @@ describe('json helpers', () => { }); assert.deepEqual(result, { exists: true, valid: true, repaired: true }); - assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n'); + const written = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + assert.deepEqual(written.findings, [{ fixed: true }]); + assert.deepEqual(written.excluded, []); }); it('throws when AI repair fails', async () => { @@ -163,7 +174,7 @@ describe('validateJSONArrayFile repair failure paths', () => { fs.rmSync(workspace, { recursive: true, force: true }); }); - it('overwrites the invalid file with the valid array returned by the repairer', async () => { + it('overwrites the invalid findings file with a wrapper built from the repaired array', async () => { const fullPath = path.join(workspace, '.gitea/ai-review/findings.json'); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); fs.writeFileSync(fullPath, '{ this is not json', 'utf8'); @@ -183,10 +194,10 @@ describe('validateJSONArrayFile repair failure paths', () => { assert.equal(receivedOriginal, '{ this is not json'); assert.deepEqual(result, { exists: true, valid: true, repaired: true }); - // file is overwritten with the repaired content, trailing newline appended (line 110) - const written = fs.readFileSync(fullPath, 'utf8'); - assert.equal(written, '[{"id":1},{"id":2}]\n'); - assert.deepEqual(JSON.parse(written), [{ id: 1 }, { id: 2 }]); + const written = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + assert.equal(typeof written.generatedAt, 'string'); + assert.deepEqual(written.findings, [{ id: 1 }, { id: 2 }]); + assert.deepEqual(written.excluded, []); }); it('throws when the repaired text is still invalid JSON and does NOT overwrite the original file', async () => { From ab384fe1080e5265724747a8faa960e4e960bf68 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 16:42:28 +0000 Subject: [PATCH 11/12] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20findings=20=E8=88=87=20exclusions.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/exclusions.json | 44 +++++++++ .gitea/ai-review/findings.json | 156 +++---------------------------- 2 files changed, 55 insertions(+), 145 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 5a2efff..a97c27a 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -21,5 +21,49 @@ "role": "Bard", "original_finding": "移除這種會過期的時間戳註解,只保留真正需要提醒讀者的簡短說明即可。", "reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)" + }, + { + "addedAt": "2026/08/07 16:39:03", + "prNumber": null, + "reviewer": "Assassin", + "severity": "警告", + "file": "action.yml", + "startLine": 14, + "endLine": 14, + "problem": "action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符,這些值會先進入環境變數,再由程式端驗證。", + "reason": "GitHub / Gitea 的 action schema 不提供字串輸入的正則驗證;本專案已在程式端做完整驗證,action.yml 無法再向前移到平台層。" + }, + { + "addedAt": "2026/08/07 16:39:03", + "prNumber": null, + "reviewer": "Bard", + "severity": "警告", + "file": "readme.md", + "startLine": 1, + "endLine": 1, + "problem": "新文件採用小寫 readme.md,和倉庫中常見的 README.md 命名慣例不合。", + "reason": "這個檔名是現有專案慣例的一部分,直接改名會牽動大量內部連結與生成內容,屬於文件命名取捨。" + }, + { + "addedAt": "2026/08/07 16:39:03", + "prNumber": null, + "reviewer": "Mage", + "severity": "警告", + "file": "src/findings.js", + "startLine": 658, + "endLine": 658, + "problem": "applyExclusions 的比對邏輯在 (locationMatches && roleMatches && (textMatches || ...)) 中,若排除規則只指定 filePath 不指定 role,會產生「該檔案內所有角色的問題都被排除」的非預期行為;若只指定 role 不指定 filePath,則「該角色所有檔案的問題都被排除」。此為對稱性缺陷", + "reason": "此處的排除規則刻意把 filePath / role 當成可獨立放寬的過濾條件,讓已知誤報可以用較粗粒度收斂;行為與設計一致。" + }, + { + "addedAt": "2026/08/07 16:39:03", + "prNumber": null, + "reviewer": "Mage", + "severity": "建議", + "file": "src/resolve.js", + "startLine": 84, + "endLine": 84, + "problem": "groupConversations 在設置 botFinding 時用 botFindings[0],若該對話的 botFindings 陣列為空,botFinding 會為 undefined。", + "reason": "程式已將 botFinding 以 null 初始化,且下游邏輯以 botFindings 陣列為主要資料來源;此為相容舊邏輯的保守設計。" } ] diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index ccd4866..72b4a49 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,146 +1,12 @@ -[ - { - "level": "warning", - "role": "Assassin", - "location": "action.yml:14", - "problem": "action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符(如 `gpt-4; rm -rf /`),這些惡意輸入會先被寫入環境變數 `CLI_PROXY_API_MODEL`,才在 Node.js 代碼中被驗證。違反了「最小信任原則」。", - "suggestion": "在 action.yml 中的 inputs.model 新增驗證限制(GitHub Actions 層面無原生驗證機制,但可在文檔中強調風險,並確保 Node.js 驗證實作完備)。或改為使用 `choices` 列表限制可選值。目前的 Node.js 驗證雖然有效,但應在 GitHub Actions 文檔中明確說明:只有英數字、點、底線、連字號的 model 值才會被接受,其他值會被拒絕並導致工作流失敗。", - "is_new": true +{ + "generatedAt": "2026/08/07 16:39:03", + "commitSha": "605d55745542fcd2ba4d2cc3317ba17fb4e35658", + "prNumber": null, + "tool": { + "name": "ai-code-review", + "version": "1.0.0", + "model": "auto" }, - { - "level": "warning", - "role": "Bard", - "problem": "`postFindingsReview` 這段 JSDoc 太像流程筆記,不像 API 說明。`@param`、`@remarks`、`使用情境` 與多層降級敘事一路堆疊,重點被枝節埋掉,閱讀節奏很不乾淨。", - "suggestion": "把註解壓縮回最必要的契約說明:用途、參數、回傳與例外即可;降級順序和測試注入細節留給實作內的短註解。", - "location": "src/comments.js:235", - "is_new": false - }, - { - "level": "warning", - "role": "Bard", - "location": "action.yml:15", - "problem": "把 Docker Action 的 image 參照改成小寫 `dockerfile`,讓原本業界慣用的 `Dockerfile` 檔名失去辨識度;這種大小寫改動會讓人讀配置時多停一下,也讓專案風格顯得不一致。", - "suggestion": "把檔名與 `action.yml` 的 `runs.image` 都改回慣用的 `Dockerfile`,維持 Docker 生態的標準寫法。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "readme.md:1", - "problem": "新文件採用小寫 `readme.md`,和倉庫中常見的 `README.md` 命名慣例不合。這種只差大小寫的命名,最容易在查找與瀏覽時破壞一致感。", - "suggestion": "改名為 `README.md`,讓入口文件維持一眼可辨的標準名稱。", - "is_new": true - }, - { - "level": "warning", - "role": "Bard", - "location": "src/comments.js:314", - "problem": "這段 JSDoc 連到不存在的 `newFindingsOnly`,斷鏈的 `{@link}` 會讓文件閱讀時突然失聲;同時還把判定差異寫得過於旁白化,讓主註解變得冗長。", - "suggestion": "把 cross-reference 換成實際存在的符號,或直接刪掉;差異說明則濃縮成一句話,保留重點即可。", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/comments.js:37", - "problem": "buildTable 函式在呼叫 findings.map() 前無防呆檢查,若 findings 為 null/undefined 會拋出 TypeError。文件已提及此問題但函式本體未修正", - "suggestion": "在 .map() 呼叫前加入 `if (!Array.isArray(findings)) findings = [];` 或改用可選鏈語法,確保即使傳入無效值也能優雅降級", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/comments.js:90", - "problem": "inlineCommentBody 函式若 f.role 或 f.suggestion 為 undefined,會直接內嵌 undefined 字樣到輸出字串,產生 '**等級**:xxx\\n**審查員**:undefined\\n**建議**:undefined' 的破損註解", - "suggestion": "在組字前加檢查:`const role = f.role || 'AI Review'; const suggestion = f.suggestion || '';` 確保回傳值不含 undefined 字面值", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/findings.js:658", - "problem": "applyExclusions 的比對邏輯在 (locationMatches && roleMatches && (textMatches || ...)) 中,若排除規則只指定 filePath 不指定 role,會產生「該檔案內所有角色的問題都被排除」的非預期行為;若只指定 role 不指定 filePath,則「該角色所有檔案的問題都被排除」。此為對稱性缺陷", - "suggestion": "重新檢視比對邏輯意圖:若欲實現「指定 filePath 時自動不檢查 role」的設計,需在文件中明確說明此為刻意設計;若非刻意,應改為 (locationMatches || !exclusion.filePath) && (roleMatches || !exclusion.role) && (textMatches || ...),確保每個維度皆能獨立篩選", - "is_new": true - }, - { - "level": "warning", - "role": "Mage", - "location": "src/llm.js:161", - "problem": "summarizeApiError 函式內存取 e.stderr 與 e.stdout 時未使用可選鏈,若 e 為 null 或 undefined,會拋出 TypeError 而非優雅容錯", - "suggestion": "改用可選鏈:`const stderr = e?.stderr || ''` 與 `const stdout = e?.stdout || ''`,或在函式開頭加入 `if (!e) return String(e);` 早期退出", - "is_new": true - }, - { - "level": "warning", - "role": "Rogue", - "location": "src/log.js:7", - "problem": "formatTimestamp() 每次調用都新建 Intl.DateTimeFormat 實例,加上 formatToParts() 與 Object.fromEntries() 轉換,高頻日誌場景下重複成本大;而日誌函式會在 section/step/line/input/output/result/ok/warn/error 等多處調用,累積開銷明顯", - "suggestion": "將 Intl.DateTimeFormat 快取為模組層級單例(const formatter = new Intl.DateTimeFormat(...)),或改用更輕量的時間格式化方式(例如直接用 Date 方法),避免每條日誌都重複實例化", - "is_new": true - }, - { - "level": "info", - "role": "Assassin", - "location": "src/config.js:46", - "problem": "正則表達式 `MODEL_NAME_RE = /^[A-Za-z0-9._-]+$/` 不允許 `/` 字符。某些合法的模型名稱格式(如 `openrouter/openai/gpt-4o` 或 `providers/openai/models/gpt-4o`)會被拒絕,導致功能受限。雖然不是直接的安全漏洞,但可能造成合法請求被誤判為異常。", - "suggestion": "評估是否需要在正則表達式中允許 `/` 字符。若允許,應同時確保不會引入新的安全風險(例如路徑穿越攻擊)。改為 `/^[A-Za-z0-9._/-]+$/` 並增加單元測試確認邊界情況。", - "is_new": true - }, - { - "level": "info", - "role": "Assassin", - "location": "src/log.js:19", - "problem": "formatTimestamp 函數依賴 Intl.DateTimeFormat.formatToParts 的實現細節。若回應結構不符預期,`map.year`、`map.month` 等會是 `undefined`,導致日誌中顯示 `undefined` 字樣。雖然不影響安全性,但可能造成日誌混亂及除錯困難。", - "suggestion": "加強容錯處理。在存取 `map.year` 等屬性前先驗證其存在性;或改用更穩定的日期格式化方式(如 `new Date().toISOString()`)。同時增加單元測試,確保在異常情況下(例如不同的語言環境或舊版本瀏覽器)仍能產生正確的日誌格式。", - "is_new": true - }, - { - "level": "info", - "role": "Bard", - "location": "src/llm.js:31", - "problem": "`mapWithConcurrency` 內部工作者 `run()` 的註解太像設計文件,對 `cursor`、`Promise.all` 行為、背景工作都展開長篇解釋,視覺重量遠超過程式本身。", - "suggestion": "把這段縮成一兩句重點註解,保留「限制併發、保序寫入」即可,其餘執行細節交回外層函式說明。", - "is_new": true - }, - { - "level": "info", - "role": "Mage", - "location": "src/findings.js:349", - "problem": "mergeFindings 用 suggestion 前 50 字作為 key 的一部分進行去重。若兩個 findings 的 role 與 location 相同但 suggestion 在第 50 字之後才出現差異,會被誤判為重複而遭移除", - "suggestion": "考慮是否改用完整 suggestion 或增加其他識別字段(如 problem)來組成 key,確保去重不會誤刪本質不同的問題", - "is_new": true - }, - { - "level": "info", - "role": "Mage", - "location": "src/findings.js:363", - "problem": "sortByLevel 使用 LEVELS.indexOf() 排序,級別不在 ['critical','warning','info'] 中的項目因 indexOf 回傳 -1 而被排到 critical 之前(最前面),此邊界行為是否為預期設計不明確", - "suggestion": "在文件或代碼中明確說明未知級別項目的預期排序位置,或改用顯式的條件判斷以提升代碼可讀性", - "is_new": true - }, - { - "level": "info", - "role": "Mage", - "location": "src/resolve.js:98", - "problem": "groupConversations 在設置 botFinding 時用 `botFindings[0]`,若該對話的 botFindings 陣列為空,botFinding 會為 undefined。此設計雖有文件說明是為相容舊邏輯,但下游代碼仍需確保可安全處理 undefined 值", - "suggestion": "在文件中明確註記 botFinding 可為 undefined,並在此函式或其呼叫端加入明確的 null 檢查,或改用 `botFinding: botFindings.length > 0 ? botFindings[0] : null` 以更清晰地表達意圖", - "is_new": true - }, - { - "level": "info", - "role": "Rogue", - "location": "src/llm.js:154", - "problem": "runProxyAPI() 中 body 物件先建立後再條件性添加 model 屬性;若此函式在併發量大的場景反覆呼叫,每次都會新建完整物件結構", - "suggestion": "改用 Object.assign() 或 const body = { messages: [...], temperature: 0, stream: false, ...(model && { model }) },減少不必要的中間物件建立步驟", - "is_new": true - }, - { - "level": "info", - "role": "Rogue", - "location": "src/log.js:18", - "problem": "formatToParts() 後用 Object.fromEntries(parts.map(...)) 進行雙次陣列與物件轉換,再拼字串;格式化操作偏複雜,對日誌輸出這種高頻操作成本偏高", - "suggestion": "改用 reduce() 直接在一次遍歷內組出 map 物件,或改寫為單一模板字符串拼接,避免中間陣列轉換", - "is_new": true - } -] + "findings": [], + "excluded": [] +} From e46031cb695b79ab7ed7557fd3bc1b42adda5608 Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Fri, 7 Aug 2026 16:46:04 +0000 Subject: [PATCH 12/12] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 49 +++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 72b4a49..9f77cf2 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,12 +1,53 @@ { - "generatedAt": "2026/08/07 16:39:03", - "commitSha": "605d55745542fcd2ba4d2cc3317ba17fb4e35658", - "prNumber": null, + "generatedAt": "2026/08/08 00:46:03", + "commitSha": "ab384fe1080e5265724747a8faa960e4e960bf68", + "prNumber": 4, "tool": { "name": "ai-code-review", "version": "1.0.0", "model": "auto" }, - "findings": [], + "findings": [ + { + "level": "warning", + "role": "Mage", + "problem": "summarizeApiError 函式內存取 e.stderr 與 e.stdout 時未使用可選鏈,若 e 為 null 或 undefined,會拋出 TypeError 而非優雅容錯", + "suggestion": "改用可選鏈:`const stderr = e?.stderr || ''` 與 `const stdout = e?.stdout || ''`,或在函式開頭加入 `if (!e) return String(e);` 早期退出", + "location": "src/llm.js:161", + "is_new": false + }, + { + "level": "warning", + "role": "Assassin", + "location": "src/config.js:44", + "problem": "這裡只限制字元種類,卻還放行 `.` 與 `/`,因此像 `../foo`、`foo/../../bar` 這類路徑式字串仍可通過。攻擊者只要能控制 `inputs.model` 或 `CLI_PROXY_API_MODEL`,就能把惡意 model 值送進 CLIProxyAPI;若後端拿 model 名稱去拼路徑、呼叫指令或做檔名查找,這個輸入就可能被拿來做路徑穿越或指令注入。", + "suggestion": "不要只做字元白名單,應改成明確白名單比對可用模型 slug,並額外拒絕 `..`、前導/結尾 `/`、連續 `/`、反斜線與控制字元;如果可行,直接用 `/v1/models` 回傳清單做嚴格選擇,而不是接受任意形狀的字串。", + "is_new": true + }, + { + "level": "info", + "role": "Mage", + "problem": "mergeFindings 用 suggestion 前 50 字作為 key 的一部分進行去重。若兩個 findings 的 role 與 location 相同但 suggestion 在第 50 字之後才出現差異,會被誤判為重複而遭移除", + "suggestion": "考慮是否改用完整 suggestion 或增加其他識別字段(如 problem)來組成 key,確保去重不會誤刪本質不同的問題", + "location": "src/findings.js:349", + "is_new": false + }, + { + "level": "info", + "role": "Assassin", + "problem": "formatTimestamp 函數依賴 Intl.DateTimeFormat.formatToParts 的實現細節。若回應結構不符預期,`map.year`、`map.month` 等會是 `undefined`,導致日誌中顯示 `undefined` 字樣。雖然不影響安全性,但可能造成日誌混亂及除錯困難。", + "suggestion": "加強容錯處理。在存取 `map.year` 等屬性前先驗證其存在性;或改用更穩定的日期格式化方式(如 `new Date().toISOString()`)。同時增加單元測試,確保在異常情況下(例如不同的語言環境或舊版本瀏覽器)仍能產生正確的日誌格式。", + "location": "src/log.js:19", + "is_new": false + }, + { + "level": "info", + "role": "Assassin", + "location": "readme.md:9", + "problem": "這份新增文件把內部 Gitea 網域與完整倉庫路徑直接寫進專案內容。只要文件被外部看見,攻擊者就能先掌握內部服務命名、URL 模式與專案結構,降低枚舉、釣魚與後續橫向移動的成本。", + "suggestion": "如果這份文件有外部可見的可能,請把內網主機名與完整路徑改成相對路徑或 placeholder,並把只限內部使用的操作細節移到不對外公開的位置。", + "is_new": true + } + ], "excluded": [] }