From 1378f0359553f2188083d97b344dab48aad8f8e7 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 14:16:04 +0800 Subject: [PATCH] =?UTF-8?q?docs(ai-code-review):=20=E8=A3=9C=E9=BD=8A?= =?UTF-8?q?=E5=90=84=E6=A8=A1=E7=B5=84=20JSDoc=E3=80=81=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E6=AA=94=E9=80=90=E8=A1=8C=E8=A8=BB=E8=A7=A3=E4=B8=A6=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/cd.yaml | 19 + .gitea/workflows/ci.yaml | 24 + Dockerfile | 25 + README.md | 1197 ++++++++++++++++++++++++++++++++++++++ app/comments.js | 102 ++++ app/config.js | 19 + app/findings.js | 98 +++- app/git.js | 96 +++ app/gitea.js | 114 +++- app/json.js | 73 ++- app/llm.js | 92 +++ app/log.js | 75 ++- app/main.js | 38 ++ app/preflight.js | 94 ++- app/resolve.js | 53 +- app/roles.js | 102 +++- app/usage.js | 71 ++- entrypoint.sh | 19 + 18 files changed, 2243 insertions(+), 68 deletions(-) create mode 100644 README.md mode change 100644 => 100755 entrypoint.sh diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 30b1952..8beb6ff 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1,12 +1,31 @@ +# ============================================================================= +# 用途:Gitea CD(持續部署)workflow +# 當有變更 push 到 master 分支時,自動釋出並標註(tag)一個成品版本。 +# 更新日期:2026/06/26 11:34:46 +# ============================================================================= + +# workflow 名稱,顯示於 Gitea Actions 介面 name: CD +# 觸發條件設定 on: + # 以 push 事件觸發 push: + # 限定觸發的分支 branches: + # 只有當變更 push 到 master 分支時才會啟動此 workflow(CD 釋出版本) - master +# 此 workflow 包含的工作(jobs) jobs: + # job 識別碼:負責釋出並標註版本 release-tag-version: + # job 在 Gitea Actions 介面上顯示的名稱 name: Release Tag Version + # 指定執行此 job 的 runner 標籤(ubuntu runner) runs-on: ubuntu + # 此 job 的執行步驟清單 steps: + # 步驟名稱(中文):釋出並標註成品版本 - name: 釋出並標註成品版本 + # 引用外部 composite action 來執行釋出與標註版本的流程; + # @ 後方版本由 Gitea 變數 vars.ACTION_RELEASE_TAG_VERSION 動態指定,便於統一管理版本。 uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }} diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b9d4eed..c71099d 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,19 +1,43 @@ +# ============================================================ +# 用途:Gitea CI,在 pull request 上執行 AI 程式碼審查(AI code review on pull requests) +# 更新日期:2026/06/26 11:34:46 +# ============================================================ + +# workflow 名稱,會顯示在 Gitea Actions 介面上 name: CI +# 觸發條件設定 on: + # 針對 pull request 事件觸發 pull_request: + # 忽略指定目標分支:當 PR 目標分支為 master 時不觸發此 workflow branches-ignore: - master + # 觸發的 PR 事件類型:opened(PR 開啟)、synchronize(PR 有新 commit 推送) types: [opened, synchronize] +# 定義此 workflow 的 jobs jobs: + # job 識別碼:ai-code-review ai-code-review: + # job 顯示名稱 name: AI Code Review + # 指定執行環境的 runner 標籤:ubuntu runs-on: ubuntu + # 此 job 所需的權限設定 permissions: + # 對 repository 內容的寫入權限(讀寫程式碼/檔案) contents: write + # 對 pull request 的寫入權限(讓 AI 可在 PR 上留言/審查) pull-requests: write + # 對 issues 的寫入權限(建立/更新 issue 留言所需) issues: write + # job 的執行步驟 steps: + # 步驟名稱:呼叫 OpenCode 進行 AI 程式碼審查 - name: AI 程式碼審查 by OpenCode + # 使用外部 composite action 執行審查邏輯 + # 版本由 repository variable ACTION_OPENCODE_CODE_REVIEW_VERSION 決定,便於集中管理版本 uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }} + # 傳遞給 composite action 的輸入參數 with: + # 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言 comment_token: ${{ secrets.COMMENT_TOKEN }} diff --git a/Dockerfile b/Dockerfile index e894c60..d708574 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,37 @@ +# ============================================================================= +# 用途:建置「AI 程式碼審查」Docker action 映像檔。 +# 以 Alpine Linux 為基底,安裝 bash / git / Node.js / npm 等執行環境, +# 將 app/ 程式碼與相依套件打包進映像,並透過 entrypoint.sh 作為容器進入點, +# 供 CI(Gitea Actions)以 Docker action 形式執行 AI code review 流程。 +# 更新日期:2026/06/26 11:34:46 +# ============================================================================= + +# 指定基底映像為 Alpine Linux 最新版;Alpine 體積小,可縮小最終映像大小並加快拉取速度。 +# 需人工確認:使用 latest tag 會在不同時間建置出不同基底版本,可能影響可重現性, +# 建議釘選明確版本(例如 alpine:3.20)以確保建置一致。 FROM alpine:latest # 安裝必要的工具 +# 安裝執行 code review 所需的工具:bash(執行 entrypoint 腳本)、git(前置遠端驗證/取得 diff)、 +# nodejs 與 npm(執行 app 內的 Node.js 程式)。 +# --no-cache:不保留 apk 套件索引快取,避免殘留在映像層中以減少映像大小。 +# 需人工確認:--no-check-certificate 會略過套件來源的憑證驗證,存在中間人攻擊風險, +# 僅在內網或憑證受限環境下使用;正式環境建議移除以維持安全性。 RUN apk add --no-cache --no-check-certificate bash git nodejs npm +# 將專案的 app/ 目錄複製到映像內的 /app;包含 Node.js 程式碼與 package.json 等相依宣告。 COPY ./app /app + +# 進入 /app 安裝 npm 相依套件,使 Node.js 程式可在容器內正常執行。 +# 副作用:會在 /app/node_modules 產生套件檔案,並依 package-lock.json(若存在)解析版本。 RUN cd /app && npm install +# 將容器進入點腳本 entrypoint.sh 複製到映像根目錄 /entrypoint.sh。 COPY entrypoint.sh /entrypoint.sh + +# 賦予 entrypoint.sh 可執行權限,確保容器啟動時能直接執行該腳本。 RUN chmod +x /entrypoint.sh +# 設定容器進入點為 /entrypoint.sh(exec 形式,不經過 shell 解析); +# 容器啟動時即執行此腳本,作為 Docker action 的實際入口。 ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f8428c --- /dev/null +++ b/README.md @@ -0,0 +1,1197 @@ +# ai-code-review + +`ai-code-review` 是一個以 Node.js(ESM)撰寫、封裝為 Gitea Docker action 的 AI 程式碼審查執行器。它會取得目前 Pull Request 的 Git diff、過濾掉不需審查的 CI/文件路徑,再交由多個審查角色(multi-role)的 LLM 進行分析,產生結構化 findings;接著對 findings 進行語意去重、AI 誤報過濾、行號定位、與排除清單比對,最後將結果以 Gitea PR review 與留言形式發布、並把 findings/exclusions 結轉回 PR head branch。流程同時統計 token 用量與帳號額度,並提供完整的前置檢查(preflight)與機器人自我觸發迴圈防護。 + +更新時間:2026/06/26 11:34:46 + +## 專案列表 + +### 專案描述 + +| 專案名稱 | 專案描述 | +| --- | --- | +| [ai-code-review](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app) | 整合 Gitea PR、多角色 LLM 審查、findings 合併去重與誤報過濾、使用量統計的 Node.js Docker action 執行器。 | + +### 參考專案 + +| 專案名稱 | 參考專案列表 | +| --- | --- | +| [ai-code-review](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app) | 無 | + +### NuGet 套件 + +| 專案名稱 | NuGet 套件列表 | +| --- | --- | +| [ai-code-review](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app) | axios ^1.6.7
js-yaml ^4.1.0 | + +## 功能列表 + +### comments.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [parseLocation](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L63) | [解析 finding 的 location 字串,取出檔案路徑與起始行號。](#parselocation) | +| [formatFindingsStats](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L148) | [產生 findings 統計的 Markdown 表格(新舊問題 × 各嚴重度)。](#formatfindingsstats) | +| [formatFindingsStatsLine](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L169) | [產生 findings 統計的單行文字摘要,供 log 使用。](#formatfindingsstatsline) | +| [postFindingsReview](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L218) | [發布單一 Gitea review(統計本文加新問題行內標註),含多層失敗降級。](#postfindingsreview) | +| [saveFindings](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L257) | [將 findings 以格式化 JSON 寫入 findings 檔,可同時鏡像寫入。](#savefindings) | +| [postOldFindingsComment](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L272) | [一次發布所有舊問題的彙整 comment(表格呈現)。](#postoldfindingscomment) | +| [postNewNonCriticalComment](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L286) | [一次發布新問題中等級非 critical 者的彙整 comment。](#postnewnoncriticalcomment) | +| [postNewCriticalComments](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/comments.js#L302) | [為每個新的 critical 問題各發一則 comment,優先行內標註、失敗則降級。](#postnewcriticalcomments) | + +### config.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [getOpenCodeHttpsAgent](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/config.js#L23) | [建立停用 TLS 憑證驗證的 HTTPS Agent,供連接自簽憑證的 OpenCode 服務使用。](#getopencodehttpsagent) | +| [getLLMConfig](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/config.js#L38) | [依環境變數解析並回傳 LLM 提供者設定。](#getllmconfig) | + +### findings.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [analyzeWithRole](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L14) | [以單一審查角色分析 Git diff,回傳結構化 findings 陣列。](#analyzewithrole) | +| [loadOldFindings](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L290) | [從工作區讀取上一輪 findings,每筆標記為舊問題。](#loadoldfindings) | +| [mergeFindings](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L307) | [合併新舊 findings,保留全部舊項目並附加新項目中未重複者。](#mergefindings) | +| [sortByLevel](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L323) | [依嚴重度排序 findings(critical 大於 warning 大於 info)。](#sortbylevel) | +| [resolveMissingLineNumbers](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L364) | [對缺行號的 findings 反問審查角色定位行號並補回 location。](#resolvemissinglinenumbers) | +| [deduplicateWithAI](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L410) | [以 LLM 對 findings 做語意去重,合併同位置同性質者並保留較高等級。](#deduplicatewithai) | +| [loadExclusions](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L432) | [讀取並正規化、去重排除清單,必要時改寫為標準陣列格式。](#loadexclusions) | +| [appendExclusions](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L482) | [將新排除條目去重後追加寫回排除清單檔。](#appendexclusions) | +| [applyExclusions](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L526) | [依排除清單過濾 findings,移除命中任一排除條目者。](#applyexclusions) | +| [filterFalsePositivesWithAI](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/findings.js#L559) | [以防守方角色逐條平行裁決 findings 是否為誤報並剔除。](#filterfalsepositiveswithai) | + +### git.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [getRepoState](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/git.js#L111) | [讀取 git 工作目錄目前狀態,回傳 HEAD SHA、分支與 commit 時間。](#getrepostate) | +| [getHeadCommitMessage](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/git.js#L129) | [取得 HEAD commit 的完整訊息。](#getheadcommitmessage) | +| [isBotAutoCommit](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/git.js#L144) | [判斷 HEAD commit 是否為審查機器人自己產生的自動 commit。](#isbotautocommit) | +| [verifyRemoteAccess](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/git.js#L154) | [以唯讀 ls-remote 前置驗證 git 對 remote 的認證與連線。](#verifyremoteaccess) | +| [cloneRepo](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/git.js#L169) | [冪等地將 PR head branch 取到工作區的 repo 目錄。](#clonerepo) | +| [commitAndPush](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/git.js#L207) | [將審查產出的 review 檔結轉、commit 並 push 回 PR head branch。](#commitandpush) | + +### gitea.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [getBotReviewOutcome](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L40) | [解析文字中的機器人標記,判斷上一次自動審查的結果。](#getbotreviewoutcome) | +| [getPRDiff](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L51) | [取得目前 PR 的完整 Git diff,並排除不需審查的路徑。](#getprdiff) | +| [getCommitMessageBySha](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L67) | [依 commit SHA 向 Gitea 查詢該 commit 的訊息。](#getcommitmessagebysha) | +| [getBranchHeadCommitMessage](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L88) | [取得指定分支 head commit 的訊息。](#getbranchheadcommitmessage) | +| [shouldSkipBotCommit](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L113) | [判斷目前 PR head 是否為 bot 自動提交而應跳過審查。](#shouldskipbotcommit) | +| [filterDiff](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L130) | [過濾 unified diff,移除路徑前綴命中排除清單的區塊。](#filterdiff) | +| [postComment](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L147) | [在目前 PR 下發布一則一般留言。](#postcomment) | +| [postPullReviewComment](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L166) | [在 PR 指定檔案的指定新版行號發布一筆行內 review comment。](#postpullreviewcomment) | +| [postPullReview](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L189) | [一次建立一個 PR review,本文放統計摘要、批次放行內 comments。](#postpullreview) | +| [listPullReviews](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L209) | [取得目前 PR 上的所有 review 清單。](#listpullreviews) | +| [getPullReviewComments](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L223) | [取得指定 review 底下的所有行內 comment。](#getpullreviewcomments) | +| [listAllReviewComments](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L237) | [取得目前 PR 上所有 review 的行內 comment 並展平。](#listallreviewcomments) | +| [resolvePullReviewComment](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L259) | [將指定 review comment 所屬的對話標記為已解決。](#resolvepullreviewcomment) | +| [getFileContentAtRef](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/gitea.js#L276) | [取得指定 ref 下某檔案的文字內容,base64 自動解碼。](#getfilecontentatref) | + +### json.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [stripCodeFence](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/json.js#L17) | [移除 AI 回傳文字外層的 markdown code fence 並去除前後空白。](#stripcodefence) | +| [repairJSONArrayWithAI](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/json.js#L43) | [透過 LLM 將任意原始內容修復成可解析的 JSON 陣列字串。](#repairjsonarraywithai) | +| [validateJSONArrayFile](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/json.js#L93) | [驗證檔案是否為合法 JSON,錯誤時嘗試以 AI 修復一次後再驗。](#validatejsonarrayfile) | +| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/json.js#L132) | [確保指定路徑存在 JSON 檔案,不存在則建立空陣列檔。](#ensurejsonarrayfileexists) | + +### llm.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [chat](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/llm.js#L102) | [對 OpenCode server 送出一次對話請求並回傳純文字回應。](#chat) | +| [chatJSON](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/llm.js#L131) | [在 chat 之上加一層 JSON 解析,抽出並解析回應中的 JSON。](#chatjson) | + +### log.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [section](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L9) | [輸出最上層的區塊章節分隔標題。](#section) | +| [step](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L22) | [輸出某個步驟標題(步驟代號加標題)。](#step) | +| [line](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L34) | [輸出一筆縮排的中性明細列。](#line) | +| [input](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L45) | [輸出階段輸入描述。](#input) | +| [output](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L56) | [輸出階段輸出描述。](#output) | +| [result](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L69) | [依布林結果輸出成功或失敗的把關結果列。](#result) | +| [ok](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L81) | [印出成功訊息。](#ok) | +| [warn](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L93) | [輸出警告訊息(stderr)。](#warn) | +| [error](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/log.js#L105) | [輸出錯誤訊息(stderr,最高嚴重層級)。](#error) | + +### preflight.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [checkRequiredEnv](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/preflight.js#L82) | [檢查必要環境變數是否齊全,回傳齊全旗標與缺項清單。](#checkrequiredenv) | +| [verifyGiteaToken](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/preflight.js#L99) | [以唯讀請求驗證 Gitea token 對該 repo 是否有讀取權限。](#verifygiteatoken) | +| [verifyCommentToken](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/preflight.js#L116) | [驗證選用的 comment token,未提供則視為通過並標記 skipped。](#verifycommenttoken) | +| [verifyLLM](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/preflight.js#L135) | [驗證 LLM 連線與 provider、model 設定是否可用。](#verifyllm) | +| [runPreflight](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/preflight.js#L171) | [集中執行所有唯讀前置驗證,任一失敗即回 false。](#runpreflight) | + +### resolve.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [parseBotReviewComment](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/resolve.js#L51) | [將一則 review comment 反向解析回 bot finding 欄位。](#parsebotreviewcomment) | +| [groupConversations](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/resolve.js#L73) | [將行內 comment 依檔案加行號收斂成對話群組。](#groupconversations) | +| [codeWindow](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/resolve.js#L100) | [擷取指定行附近含行號前綴的程式碼片段。](#codewindow) | +| [judgeConversations](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/resolve.js#L126) | [批次請 AI 將各對話判為已解決、誤報或仍開啟。](#judgeconversations) | +| [reconcileConversations](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/resolve.js#L190) | [對話收斂主流程,決定每個 finding 移除、排除或結轉。](#reconcileconversations) | +| [dropResolvedFindings](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/resolve.js#L329) | [以簽章比對從 findings 移除已解決對話對應的問題。](#dropresolvedfindings) | +| [addCarriedFindings](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/resolve.js#L338) | [將未解決但已遺漏的結轉問題加回 findings 並去重。](#addcarriedfindings) | + +### roles.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [parseRoleFile](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/roles.js#L25) | [解析角色 Markdown 檔,切出 YAML frontmatter 與本文。](#parserolefile) | +| [loadRoles](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/roles.js#L71) | [載入所有攻擊方角色並依檔名排序。](#loadroles) | +| [loadRole](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/roles.js#L85) | [依名稱(不分大小寫)取得單一角色,找不到回 null。](#loadrole) | +| [buildAnalysisPrompt](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/roles.js#L104) | [由攻擊方角色組出分析用 system prompt 字串。](#buildanalysisprompt) | +| [buildLocateLinePrompt](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/roles.js#L149) | [組出補行號用的 system prompt 字串。](#buildlocatelineprompt) | +| [buildVerdictPrompt](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/roles.js#L173) | [由防守方角色組出單條 finding 誤報裁決的 system prompt。](#buildverdictprompt) | +| [getRoleIntro](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/roles.js#L206) | [由角色陣列產生團隊介紹用的 Markdown 表格。](#getroleintro) | + +### usage.js + +| 功能名稱 | 功能描述 | +| --- | --- | +| [extractUsage](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L24) | [將各平台 LLM 回應的 token usage 正規化為統一結構。](#extractusage) | +| [recordUsage](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L65) | [記錄一次 LLM 呼叫的 token 用量並累加進本次執行累計。](#recordusage) | +| [getRunUsage](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L77) | [取得本次執行至今的 token 累計快照(淺複本)。](#getrunusage) | +| [resetRunUsage](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L82) | [重置本次執行的 token 累計(四欄位歸零)。](#resetrunusage) | +| [recordRateLimit](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L109) | [從 HTTP header 擷取速率配額剩餘量與上限存為快照。](#recordratelimit) | +| [getRateLimit](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L130) | [取得最近一次速率配額快照(淺複本)。](#getratelimit) | +| [resetRateLimit](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L135) | [重置速率配額快照。](#resetratelimit) | +| [fetchAccountQuota](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L208) | [依 provider 查策略表取得帳號額度,失敗一律降級回報。](#fetchaccountquota) | +| [resolveRemainingPercent](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L277) | [依優先序由帳號額度或速率配額計算剩餘可用百分比。](#resolveremainingpercent) | +| [formatUsageStats](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L314) | [產生 PR Review 本文用的使用量 Markdown 區塊。](#formatusagestats) | +| [formatUsageStatsLine](https://gitea.jsc.idv.tw/docker-actions/ai-code-review/src/branch/develop/app/usage.js#L333) | [產生單行 log 用的使用量摘要。](#formatusagestatsline) | + +## 使用範例 + +> 以下範例為各模組的 sibling import(同層 ESM 模組)。多數函式相依環境變數(Gitea / OpenCode 設定)與 `./config.js`,於正式流程中由 action 入口統一注入。範例中標示 `需人工確認` 者,為依草稿行為推導之保守情境式範例。 + +### comments.js + + +### parseLocation + +解析 finding 的 location 字串,取出檔案路徑與起始行號;支援 `file:19` 與 `file:70-82`(範圍取起始行)。非字串、含逗號(多檔)、或無行號格式時回傳 `null`,不丟例外。 + +```js +import { parseLocation } from './comments.js'; + +parseLocation('src/a.js:42'); // → { file: 'src/a.js', line: 42 } +parseLocation('src/a.js:70-82'); // → { file: 'src/a.js', line: 70 } +parseLocation('a.js,b.js:1'); // → null +``` + + +### formatFindingsStats + +產生 findings 統計的 Markdown 表格(新問題/舊問題 × 嚴重/警告/建議/無法標示)。`findings` 須為陣列(非陣列拋 `TypeError`);`is_new === false` 計入舊問題,其餘計入新問題。空陣列仍輸出各欄為 0 的表格。 + +```js +import { formatFindingsStats } from './comments.js'; + +const findings = [ + { is_new: true, level: 'critical' }, + { is_new: false, level: 'info' }, +]; +const table = formatFindingsStats(findings); // 含表頭、分隔列與新舊兩資料列的 Markdown +``` + + +### formatFindingsStatsLine + +產生 findings 統計的單行文字摘要,供 log 使用;新舊判定同 `formatFindingsStats`。 + +```js +import { formatFindingsStatsLine } from './comments.js'; +import { line } from './log.js'; + +line(formatFindingsStatsLine(findings)); // 形如「新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...」 +``` + + +### postFindingsReview + +發布單一 Gitea review:本文放統計摘要、新問題(`is_new !== false`)加行內標註,舊問題僅計入統計。含「整批 review → 僅 summary → 一般 comment」多層失敗降級。需 Gitea 設定就緒(供預設的 `postPullReview` / `postPullReviewComment` / `postComment`);`deps` 可注入以利測試。 + +```js +import { postFindingsReview } from './comments.js'; + +// 正式流程:使用模組預設依賴 +await postFindingsReview(findings); + +// 測試:注入假的發布依賴 +await postFindingsReview(findings, { + postReview: async () => ({ id: 1 }), + postIssue: async () => ({ id: 2 }), +}); +``` + + +### saveFindings + +將 findings 以縮排 2 的格式化 JSON 寫入 `path.join(workspace, FINDINGS_PATH)`,可同時鏡像寫入 `mirrorDir`(為 `null` 或與 workspace 相同時不重複寫)。中間目錄會自動建立;I/O 失敗時拋 fs 例外。 + +```js +import { saveFindings } from './comments.js'; + +saveFindings('/workspace/repo', findings); // 寫入 repo 內 findings 檔 +saveFindings('/workspace/repo', findings, '/workspace'); // 同時鏡像到另一目錄 +``` + + +### postOldFindingsComment + +一次發布所有舊問題(`!f.is_new`,未設定者也算舊問題)的彙整 comment(表格呈現)。無舊問題時僅 log 並提前返回;`postComment` 失敗則例外向外傳播。 + +```js +import { postOldFindingsComment } from './comments.js'; + +await postOldFindingsComment(findings); // 發布「## 📋 舊有未解決問題(N 筆)」 +``` + + +### postNewNonCriticalComment + +一次發布新問題中等級非 critical 者(`is_new && level !== 'critical'`)的彙整 comment。無符合項目時僅 log 並提前返回。 + +```js +import { postNewNonCriticalComment } from './comments.js'; + +await postNewNonCriticalComment(findings); // 發布「## 🔍 新發現問題(N 筆)」 +``` + + +### postNewCriticalComments + +為每個新的 critical 問題(`is_new && level === 'critical'`)各發一則 comment,優先以行內標註檔案與行數,失敗(常因該行不在 diff 範圍)則 warn 後降級為一般 comment。`deps` 可注入;降級用 `postIssue` 失敗時例外向外傳播。 + +```js +import { postNewCriticalComments } from './comments.js'; + +await postNewCriticalComments(findings); +``` + +### config.js + + +### getOpenCodeHttpsAgent + +建立並回傳停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent,供連接自簽或無效憑證的 OpenCode 服務使用。每次呼叫回傳全新實例(不快取),建議呼叫端重用。僅限受信任內部環境(有 MITM 風險)。 + +```js +import axios from 'axios'; +import { getOpenCodeHttpsAgent } from './config.js'; + +const httpsAgent = getOpenCodeHttpsAgent(); +await axios.get('https://opencode.internal/health', { httpsAgent }); +``` + + +### getLLMConfig + +依環境變數即時解析並回傳 LLM 提供者設定。設了 `OPENCODE_BASE_URL` 時回傳 OpenCode 設定(`model` 取自 `OPENCODE_MODEL`,預設 `gemini-2.5-flash`,`apiKeys` 為固定佔位 `['opencode']`);未設則回傳「無提供者」設定。不丟例外,由呼叫端判斷 `provider` 是否為 null。 + +```js +import { getLLMConfig } from './config.js'; + +const cfg = getLLMConfig(); +// 設了 OPENCODE_BASE_URL → { provider: 'opencode', apiKeys: ['opencode'], baseURL, model } +// 未設 → { provider: null, apiKeys: [], baseURL: null, model: null } +if (!cfg.provider) throw new Error('未設定 LLM 提供者'); +``` + +### findings.js + + +### analyzeWithRole + +以單一審查角色分析 Git diff,回傳僅保留同時具 level / location / suggestion、並統一 role 名稱、補 `is_new: true` 的 findings 陣列。`role` 至少含 `name`;`diff` 為 unified diff 字串;需 LLM(`chatJSON`)可用。`chatJSON` 失敗或回傳非陣列時例外向外拋出。 + +```js +import { analyzeWithRole } from './findings.js'; +import { getPRDiff } from './gitea.js'; + +const diff = await getPRDiff(); +const findings = await analyzeWithRole({ name: 'security' }, diff); +``` + + +### loadOldFindings + +從工作區內 `FINDINGS_PATH` 讀取上一輪 findings,每筆標記 `is_new=false`。需提供 repo clone 後的目錄;讀取或解析失敗安全降級為空陣列並輸出診斷 log。 + +```js +import { loadOldFindings } from './findings.js'; + +const old = loadOldFindings('/workspace/repo'); // 每筆已標 is_new=false +``` + + +### mergeFindings + +合併新舊 findings,保留全部舊項目並附加新項目中未重複者(去重鍵為 `role + location + suggestion 前 50 字`,新項彼此亦去重)。兩參數皆為陣列,不丟例外。 + +```js +import { mergeFindings } from './findings.js'; + +const merged = mergeFindings(oldFindings, newFindings); // [...old, ...新項去重] +``` + + +### sortByLevel + +依嚴重度排序 findings(critical 高於 warning 高於 info),回傳新陣列、不修改輸入。未列於 `LEVELS` 的未知等級會被排在 critical 之前。 + +```js +import { sortByLevel } from './findings.js'; + +const sorted = sortByLevel(findings); // critical 在前 +``` + + +### resolveMissingLineNumbers + +對缺行號(僅有檔名)的 findings,反問原審查角色依該檔 diff 定位行號,成功則把 location 補成 `檔案:行號`,否則保留檔名(就地修改傳入陣列)。`deps` 可注入(`chatFn` 預設 `chatJSON`、`getRole` 預設 `loadRole`、`maxAttempts` 預設 3)。任一筆失敗都不中斷、不對外拋例外。 + +```js +import { resolveMissingLineNumbers } from './findings.js'; + +await resolveMissingLineNumbers(findings, diff); // 回傳與傳入相同參考,部分 location 已補行號 +``` + + +### deduplicateWithAI + +以 LLM(Paladin 裁判)對 findings 做語意去重,合併「同位置加同問題本質」者並保留較高等級。空陣列直接回傳;LLM 回傳空陣列、非陣列或拋錯時一律經 fallback 保留全部原始 findings,不對外拋例外。 + +```js +import { deduplicateWithAI } from './findings.js'; + +const deduped = await deduplicateWithAI(findings); +``` + + +### loadExclusions + +從工作區內 `EXCLUSIONS_PATH` 讀取並正規化、去重排除清單;若原檔非頂層陣列格式,順手改寫為標準陣列並可同步到 mirror(具寫入副作用)。`repoState` 僅供診斷 log。不存在或讀寫過程拋錯時降級為空陣列。 + +```js +import { loadExclusions } from './findings.js'; + +const exclusions = loadExclusions('/workspace/repo', repoState, '/workspace'); +``` + + +### appendExclusions + +將新排除條目去重後追加到 `exclusions.json`,以標準頂層陣列寫回 workspace 與 mirror(去重以「檔案路徑加正規化原文」為準)。`newEntries` 空或未提供時直接回 `null`;寫檔階段失敗會向外拋出。 + +```js +import { appendExclusions } from './findings.js'; + +const merged = appendExclusions('/workspace/repo', newEntries, '/workspace'); +// newEntries 為空 → 回 null;無新增 → 回既有陣列 +``` + + +### applyExclusions + +依排除清單過濾 findings,命中任一排除條目者被移除(location 只比檔案路徑、role 省略視為萬用、文字雙向包含且僅在排除條目未指定路徑也未指定角色時才以文字把關)。空排除清單時不過濾,不丟例外。 + +```js +import { applyExclusions } from './findings.js'; + +const kept = applyExclusions(findings, exclusions); // 輸出前後筆數 log +``` + + +### filterFalsePositivesWithAI + +以防守方角色(Paladin)逐條平行裁決 findings 是否為誤報,剔除誤報、保留成立者。空陣列直接回傳;`exclusions`(預設 `[]`)用於組裝提示讓相似問題更易判為誤報;`chatFn`(預設 `chatJSON`)可注入。任一裁決失敗時保守保留該問題。 + +```js +import { filterFalsePositivesWithAI } from './findings.js'; + +const real = await filterFalsePositivesWithAI(findings, exclusions); +``` + +### git.js + +> 以下函式接受 `_spawnSync` 等依賴注入參數(預設為真實實作),正常使用時省略即可。 + + +### getRepoState + +讀取指定 git 工作目錄的目前狀態,回傳 HEAD SHA、短 SHA、目前分支與 commit 時間(ISO 8601)。任一查詢失敗時對應欄位為空字串,不丟例外。 + +```js +import { getRepoState } from './git.js'; + +const state = getRepoState('/workspace/repo'); +// { repoDir, branch, headSha, shortSha, commitTime } +``` + + +### getHeadCommitMessage + +取得 HEAD commit 的完整訊息(含 subject 與 body)。`repoDir` 為 git 工作目錄;失敗時回傳空字串。 + +```js +import { getHeadCommitMessage } from './git.js'; + +const message = getHeadCommitMessage('/workspace/repo'); +``` + + +### isBotAutoCommit + +判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit(訊息含 `[ai-review-bot]` 標記),用於避免自我觸發迴圈。HEAD 訊息含標記回傳 `true`,否則(含讀取失敗)回傳 `false`。 + +```js +import { isBotAutoCommit } from './git.js'; + +if (isBotAutoCommit('/workspace/repo')) { + // 上一筆 commit 為 bot 自動提交,跳過審查 +} +``` + + +### verifyRemoteAccess + +以與 push 相同的 askpass 認證機制執行唯讀 `git ls-remote`,前置驗證 git 對 remote 的認證與連線是否可用。`workspace` 須可寫(暫存 askpass 腳本);config 需有 `GITEA_TOKEN`、remote URL;驗證 ref 取 `PR_HEAD_BRANCH`,未設定則退回 `HEAD`。成功回 `{ ok: true }`,失敗回 `{ ok: false, error }`,不丟例外。 + +```js +import { verifyRemoteAccess } from './git.js'; + +const r = verifyRemoteAccess('/workspace'); +if (!r.ok) console.error(r.error); +``` + + +### cloneRepo + +將 PR head branch 取到 `/repo`(冪等):不存在則 `--depth=1` 淺層 clone,已存在則 fetch 加 checkout 取最新。config 需有 `PR_HEAD_BRANCH`、`GITEA_TOKEN` 與 remote URL;clone/fetch/checkout 失敗會丟例外。 + +```js +import { cloneRepo } from './git.js'; + +const repoDir = cloneRepo('/workspace'); // → '/workspace/repo' +``` + + +### commitAndPush + +將審查產出的 review 檔(findings / exclusions)結轉到 repo 並 commit、push 回 PR head branch,commit 訊息帶機器人標記與結果標籤。`reviewOutcome` 為 `'success'` 或 `'failure'`。無變更則跳過 commit;push 失敗只記 warning,其餘步驟例外皆被吞掉,整體不丟例外。 + +```js +import { commitAndPush } from './git.js'; + +await commitAndPush('/workspace', '/workspace/repo', undefined, null, 'success'); +``` + +### gitea.js + +> 本模組所有 axios 請求皆使用 `rejectUnauthorized: false` 的 httpsAgent(容許自簽憑證)。PR 編號、repo、token 等取自 `./config.js`。 + + +### getBotReviewOutcome + +解析文字中的 `[ai-review-bot][success|failure]` 標記,判斷上一次自動審查的結果。純函式、無 API;命中標記且有後綴回傳對應小寫結果,否則回傳 `'unknown'`。 + +```js +import { getBotReviewOutcome } from './gitea.js'; + +getBotReviewOutcome('chore: [ai-review-bot][failure] ...'); // → 'failure' +getBotReviewOutcome('一般 commit'); // → 'unknown' +``` + + +### getPRDiff + +取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(`.gitea/`、`.github/`、`README.md`、`TODO.md`)。需 config 設定 `GITEA_REPOSITORY`、`PR_NUMBER`、`GITEA_SERVER_URL`、`GITEA_TOKEN`;API 失敗會拋例外。 + +```js +import { getPRDiff } from './gitea.js'; + +const diff = await getPRDiff(); // 已過濾的 diff 文字 +``` + + +### getCommitMessageBySha + +依 commit SHA 向 Gitea 查詢該 commit 的訊息。`sha` falsy 時直接回空字串不發 API;查無或失敗時回空字串(記 warning,不拋例外)。 + +```js +import { getCommitMessageBySha } from './gitea.js'; + +const msg = await getCommitMessageBySha('a4a0f75'); +``` + + +### getBranchHeadCommitMessage + +取得指定分支 head commit 的訊息(先查分支取 SHA,再查該 commit),預設使用 `PR_HEAD_BRANCH`。查無或失敗時回空字串,不拋例外。 + +```js +import { getBranchHeadCommitMessage } from './gitea.js'; + +const msg = await getBranchHeadCommitMessage('feature/x'); // 省略參數則用 PR_HEAD_BRANCH +``` + + +### shouldSkipBotCommit + +判斷目前 PR head(commit 或分支 head)的訊息是否帶 `[ai-review-bot]` 標記,是則代表 bot 自動提交應跳過審查。可不帶參數(使用 config 預設);命中回 `true`,否則 `false`。 + +```js +import { shouldSkipBotCommit } from './gitea.js'; + +if (await shouldSkipBotCommit()) return; // 跳過本輪審查 +``` + + +### filterDiff + +過濾 unified diff,移除檔案路徑前綴命中 `excludePrefixes` 的區塊後重新接合。純函式;資料夾以 `/` 結尾。 + +```js +import { filterDiff } from './gitea.js'; + +const cleaned = filterDiff(rawDiff, ['.gitea/', '.github/', 'README.md']); +``` + + +### postComment + +在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言)。`body` 支援 Markdown;config 有 `PR_NUMBER`、`GITEA_REPOSITORY`,優先使用 `GITEA_COMMENT_TOKEN`。請求失敗會拋例外。 + +```js +import { postComment } from './gitea.js'; + +const comment = await postComment('## AI 審查摘要\n...'); +``` + + +### postPullReviewComment + +在 PR 指定檔案的指定新版行號發布一筆行內 review comment(建立只含單一 comment 的 COMMENT review)。`line` 為新版檔案行號(diff 右側)且須在 diff 範圍內;超出範圍或請求失敗會拋例外(呼叫端可降級為一般留言)。 + +```js +import { postPullReviewComment } from './gitea.js'; + +await postPullReviewComment({ path: 'src/a.js', line: 42, body: '這裡有風險' }); +``` + + +### postPullReview + +一次建立一個 PR review,本文放統計摘要、`comments` 批次放多筆行內 review comments(`{ path, body, new_position? }`,行號須在 diff 範圍內)。任一行號超出 diff 或請求失敗會拋例外。 + +```js +import { postPullReview } from './gitea.js'; + +await postPullReview({ + body: '## 審查統計\n...', + comments: [{ path: 'src/a.js', body: '建議修正', new_position: 42 }], +}); +``` + + +### listPullReviews + +取得目前 PR 上的所有 review 清單。需 config 有 `PR_NUMBER`、`GITEA_REPOSITORY`、`GITEA_TOKEN`;非陣列回應時回 `[]`,請求失敗會拋例外。 + +```js +import { listPullReviews } from './gitea.js'; + +const reviews = await listPullReviews(); +``` + + +### getPullReviewComments + +取得指定 review 底下的所有行內 comment。`reviewId` 為 number 或 string;非陣列回應時回 `[]`,請求失敗會拋例外。 + +```js +import { getPullReviewComments } from './gitea.js'; + +const comments = await getPullReviewComments(123); +``` + + +### listAllReviewComments + +取得目前 PR 上所有 review 的行內 comment 並展平為單一陣列。單一 review 取 comment 失敗會記 warning 並略過,但 `listPullReviews` 失敗會拋例外。 + +```js +import { listAllReviewComments } from './gitea.js'; + +const all = await listAllReviewComments(); +``` + + +### resolvePullReviewComment + +將指定 review comment 所屬的對話標記為已解決(resolve)。優先使用 `GITEA_COMMENT_TOKEN`;請求失敗會拋例外。 + +```js +import { resolvePullReviewComment } from './gitea.js'; + +await resolvePullReviewComment(456); +``` + + +### getFileContentAtRef + +取得指定 ref(預設 PR head)下某檔案的文字內容,base64 內容自動解碼為 UTF-8。`filePath` 為 repo 內相對路徑;檔案不存在、非文字或請求失敗時回空字串,不拋例外。 + +```js +import { getFileContentAtRef } from './gitea.js'; + +const content = await getFileContentAtRef('src/a.js'); // 用 PR head +const atSha = await getFileContentAtRef('src/a.js', 'a4a0f75'); // 指定 ref +``` + +### json.js + + +### stripCodeFence + +移除 AI 回傳文字外層的 markdown code fence(如 ```json 區塊)並去除前後空白,使內容可直接交給 `JSON.parse`。非字串會以 `String()` 轉型;純函式,不丟例外。 + +```js +import { stripCodeFence } from './json.js'; + +const clean = stripCodeFence('```json\n[1,2,3]\n```'); // → '[1,2,3]' +JSON.parse(clean); +``` + + +### repairJSONArrayWithAI + +透過 LLM 將任意原始內容修復成「可直接 `JSON.parse` 的 JSON 陣列」字串。`fullPath` / `label` 僅供提示詞參考(不讀檔);`chatFn` 可注入(預設 `chat`)。回傳經 fence 清理後的修復字串(不保證合法,需呼叫端再驗證);`chatFn` 失敗時例外向上拋出。 + +```js +import { repairJSONArrayWithAI } from './json.js'; + +const fixed = await repairJSONArrayWithAI('/workspace/repo/findings.json', 'findings', rawText); +``` + + +### validateJSONArrayFile + +驗證指定檔案是否為合法 JSON,格式錯誤時嘗試以 AI 修復一次後再次驗證(`repairer` 可注入)。檔案大小上限約 1 MB。回傳 `{ exists, valid, repaired }`;修復後仍失敗則拋例外。 + +```js +import { validateJSONArrayFile } from './json.js'; + +const r = await validateJSONArrayFile('/workspace/repo/.gitea/ai-review/findings.json', 'findings'); +// { exists: true, valid: true, repaired: false } +``` + + +### ensureJSONArrayFileExists + +確保指定路徑存在一個 JSON 檔案,不存在則建立內容為 `"[]\n"` 的空陣列檔(父目錄自動建立)。同步函式,不驗證既有檔案內容。本次新建回 `true`,原本即存在回 `false`;建立目錄或寫檔失敗會拋 IO 例外。 + +```js +import { ensureJSONArrayFileExists } from './json.js'; + +const created = ensureJSONArrayFileExists('/workspace/repo/.gitea/ai-review/findings.json', 'findings'); +``` + +### llm.js + + +### chat + +對 OpenCode server 送出一次對話請求(建立 session → 送訊息 → 抽取回應),回傳模型純文字回應並記錄用量。環境/config 須設定 OpenCode(`getLLMConfig()` 須回傳 `provider`/`baseURL`/`model`,缺 `provider` 會拋錯)。OpenCode 呼叫失敗時記錄錯誤並以 `process.exit(1)` 終止整個行程(不會回傳)。 + +```js +import { chat } from './llm.js'; + +const text = await chat('你是程式碼審查員', '請審查以下 diff ...'); +``` + + +### chatJSON + +在 `chat` 之上加一層 JSON 解析,抽出回應中的 JSON 片段並 `JSON.parse`,解析失敗時回傳空陣列(不拋解析錯誤)。前置條件同 `chat`,預期回應為 JSON(多為陣列)。 + +```js +import { chatJSON } from './llm.js'; + +const findings = await chatJSON('你是審查員,只輸出 JSON 陣列', diff); +// 解析失敗 → [] +``` + +### log.js + +> log 模組為純輸出工具,皆無回傳值;以下合併示範。 + + +### section + +輸出最上層的「區塊/章節」分隔標題(前綴空行加 `=== 標題 ===`),用於切分執行流程中彼此獨立的大段落。 + +```js +import { section } from './log.js'; + +section('AI Code Review 開始'); +``` + + +### step + +輸出某個「步驟」標題(前綴空行加 `[步驟代號] 標題`),層級介於 section 與細項之間。簽名為 `step(stepName, title)`。 + +```js +import { step } from './log.js'; + +step('Step3', '多角色分析'); +``` + + +### line + +輸出一筆縮排的中性明細列(` - 訊息`),用於列出不帶成敗語意的資訊。 + +```js +import { line } from './log.js'; + +line('已載入 3 個審查角色'); +``` + + +### input + +輸出「階段輸入」描述(` ← 輸入:訊息`),標示此步驟吃進什麼資料。 + +```js +import { input } from './log.js'; + +input('PR diff,共 1200 行'); +``` + + +### output + +輸出「階段輸出」描述(` → 輸出:訊息`),與 input 對應標示此步驟產出什麼。 + +```js +import { output } from './log.js'; + +output('findings 共 5 筆'); +``` + + +### result + +依布林結果以 `✅ 成功` 或 `❌ 失敗` 為前綴輸出一筆把關結果列(成敗皆寫 stdout)。簽名為 `result(passed, message)`。 + +```js +import { result } from './log.js'; + +result(true, 'Gitea token 驗證通過'); +result(false, 'LLM 連線失敗'); +``` + + +### ok + +輸出一筆成功/完成的單向訊息(` ✓ 訊息`)。 + +```js +import { ok } from './log.js'; + +ok('前置檢查通過'); +``` + + +### warn + +透過 `console.warn` 輸出一筆警告訊息(` ! 訊息`)到 stderr,用於非致命但需注意的狀況。 + +```js +import { warn } from './log.js'; + +warn('行內標註失敗,降級為一般留言'); +``` + + +### error + +透過 `console.error` 輸出一筆錯誤訊息(` x 訊息`)到 stderr,為最高嚴重層級。 + +```js +import { error } from './log.js'; + +error('Gitea token 驗證失敗'); +``` + +### preflight.js + + +### checkRequiredEnv + +檢查 code review 所需的必要環境變數(`GITEA_TOKEN` / `GITEA_REPOSITORY` / `PR_NUMBER`)是否齊全,缺項即列出。純函式,預設取模組層級常數、可注入覆寫;回傳 `{ ok, missing }`,全齊時 `ok: true`、`missing: []`。 + +```js +import { checkRequiredEnv } from './preflight.js'; + +const { ok, missing } = checkRequiredEnv(); +if (!ok) throw new Error('缺少環境變數: ' + missing.join(', ')); +``` + + +### verifyGiteaToken + +以唯讀 `GET /repos/{repo}` 探測,驗證 Gitea token 有效且對該 repo 有讀取權限。成功回 `{ ok: true }`,失敗回 `{ ok: false, error }`,不丟例外。 + +```js +import { verifyGiteaToken } from './preflight.js'; + +const r = await verifyGiteaToken(); +if (!r.ok) console.error(r.error); +``` + + +### verifyCommentToken + +驗證選用的 comment token(以 `GET /user` 探測);token 為 falsy 時直接回 `{ ok: true, skipped: true }` 不發請求,有 token 時成功回 `{ ok: true }`、失敗回 `{ ok: false, error }`,不丟例外。 + +```js +import { verifyCommentToken } from './preflight.js'; + +const r = await verifyCommentToken(); // 未設 comment token → { ok: true, skipped: true } +``` + + +### verifyLLM + +驗證 LLM(OpenCode server)設定可用:確認 provider、base URL、health 端點連線,且 OpenCode 已列出指定 provider 與 model(設定來自 `getLLMConfig()`)。通過回 `{ ok: true, provider }`,失敗回對應 error,不丟例外。 + +```js +import { verifyLLM } from './preflight.js'; + +const r = await verifyLLM(); +if (!r.ok) console.error(r.error); +``` + + +### runPreflight + +集中執行所有唯讀前置驗證(環境變數、Gitea token、comment token、git 遠端、LLM),任一失敗即記錄錯誤並回 `false`,全通過回 `true`。`workspace` 預設取 `GITHUB_WORKSPACE` 或 `/workspace`;`deps` 可注入覆寫各檢查函式以利測試。全程不發 comment。 + +```js +import { runPreflight } from './preflight.js'; + +if (!(await runPreflight())) process.exit(1); // 通過才繼續主流程 +``` + +### resolve.js + + +### parseBotReviewComment + +將一則 PR review comment 內文反向解析回 bot 產生的 finding 欄位(兼容 review comment 與行內 critical comment 兩種格式)。`body` 須為字串且含 `**`,並至少能取得 level 或 role、且有 suggestion 或 problem 才成立;否則回 `null`(level 預設 `'warning'`、role 預設 `'AI Review'`)。 + +```js +import { parseBotReviewComment } from './resolve.js'; + +const finding = parseBotReviewComment(comment.body); // { level, role, problem, suggestion } | null +``` + + +### groupConversations + +將 PR 行內 review comment 依「檔案路徑加行號」收斂成對話群組,任一則帶 resolver 即整段視為已解決,並嘗試解析對應 bot finding。傳入 Gitea 行內 comment 陣列(容許 null);無 path 的留言會被略過。 + +```js +import { groupConversations } from './resolve.js'; +import { listAllReviewComments } from './gitea.js'; + +const groups = groupConversations(await listAllReviewComments()); +// 每組:{ key, path, line, commentIds, bodies, resolved, botFinding, thread } +``` + + +### codeWindow + +擷取指定行附近的程式碼片段(每行含 1-based 行號前綴),供 AI 對照判斷問題是否已解決。`content` 為 falsy 回 `''`;`lineNum` 非有限數或小於等於 0 時以第 1 行為中心,`radius` 預設 20。 + +```js +import { codeWindow } from './resolve.js'; + +const window = codeWindow(fileContent, 42); // 預設半徑 20 +const wide = codeWindow(fileContent, 42, 10); // 上下各 10 行 +``` + + +### judgeConversations + +批次請 AI(Paladin 裁判)將每個對話判為 `resolved` / `false_positive` / `open`,回傳與輸入等長且依 idx 對齊的結果。`items` 為空回 `[]`;AI 回傳非陣列、缺漏或 verdict 不合法者一律視為 `open`;`chatFn`(預設 `chatJSON`)拋例外會向上傳遞。 + +```js +import { judgeConversations } from './resolve.js'; + +const verdicts = await judgeConversations(groups); // [{ idx, verdict }, ...] +``` + + +### reconcileConversations + +對話收斂主流程:取得 PR 所有行內 comment、呼叫 Gitea resolve 關閉未解決留言、取最新程式碼交 AI 判斷,決定每個 finding 去向(移除/寫入 exclusions/結轉保留)。`deps` 可注入 `listComments` / `resolveComment` / `getFileContent` / `judge`;任一外部呼叫失敗皆降級,不中斷 pipeline。 + +```js +import { reconcileConversations } from './resolve.js'; + +const { resolvedFindings, excludedFindings, carriedFindings } = await reconcileConversations(); +``` + + +### dropResolvedFindings + +從 findings 移除「已解決對話」對應的問題,以「檔案路徑加正規化建議內容」簽章比對以避免行號漂移誤判。`findings` 須為陣列;`resolvedFindings` 為空時原樣回傳,否則回傳過濾後的新陣列。 + +```js +import { dropResolvedFindings } from './resolve.js'; + +const remaining = dropResolvedFindings(findings, resolvedFindings); +``` + + +### addCarriedFindings + +將「未解決對話」對應、但目前 findings 已遺漏的問題加回(補齊結轉問題),以簽章去重。`findings` 須為陣列;`carriedFindings` 為空時原樣回傳,有新增時輸出 log 並回傳新陣列。 + +```js +import { addCarriedFindings } from './resolve.js'; + +const all = addCarriedFindings(findings, carriedFindings); +``` + +### roles.js + + +### parseRoleFile + +解析單一角色 Markdown 檔,將 CRLF 正規化後以 `---` 切出 YAML frontmatter 與本文,frontmatter 欄位攤平並附上 `body`。`content` 須含合法 `---` frontmatter 區塊,否則拋 `Error('角色檔缺少 frontmatter')`;純字串處理無 IO。 + +```js +import { parseRoleFile } from './roles.js'; +import fs from 'node:fs'; + +const role = parseRoleFile(fs.readFileSync('prompts/roles/security.md', 'utf8')); +// { name, side, badge, focus, personality, body, ... } +``` + + +### loadRoles + +載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序,供 Step3 產生 findings。首次呼叫會觸發 `prompts/roles/*.md` 的同步檔案讀取(含快取)。 + +```js +import { loadRoles } from './roles.js'; + +const roles = loadRoles(); // 攻擊方角色陣列 +``` + + +### loadRole + +依 frontmatter `name`(比對不分大小寫)取得單一角色,不分攻防皆可查得,找不到回 `null`(不拋例外)。 + +```js +import { loadRole } from './roles.js'; + +const role = loadRole('paladin'); // → 角色物件 | null +``` + + +### buildAnalysisPrompt + +由攻擊方角色定義組出分析用 system prompt 字串,含徽章/名稱/面向/個性/審查重點,並要求以固定 JSON 陣列格式回傳帶 `檔案路徑:行號` 的 findings。`role` 須含 `name`、`body`;`role` 為 null/undefined 會拋 `TypeError`。 + +```js +import { buildAnalysisPrompt } from './roles.js'; +import { chatJSON } from './llm.js'; + +const system = buildAnalysisPrompt(role); +const findings = await chatJSON(system, diff); +``` + + +### buildLocateLinePrompt + +組出「補行號」system prompt 字串:當先前 finding 的 location 只有檔名缺行號時,請 LLM 對照該檔 Git Diff 找出實際行號,並只回 `{"line": 數字}`(找不到回 `{"line": 0}`)。`role` 可省略/為 null(名稱退回 `'AI Review'`)。 + +```js +import { buildLocateLinePrompt } from './roles.js'; + +const system = buildLocateLinePrompt(role); +``` + + +### buildVerdictPrompt + +由防守方角色定義組出「單條 finding 誤報裁決」system prompt 字串,要求判定成立/誤報並只回 `{"verdict","reason"}`,無法確定一律回 `"confirmed"`。`role` 為空值時退回通用 Paladin persona;`exclusionHint` 預設空字串時該行被過濾。 + +```js +import { buildVerdictPrompt } from './roles.js'; + +const system = buildVerdictPrompt(role, '相似問題曾被標記為誤報'); +``` + + +### getRoleIntro + +由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格(角色/面向/個性三欄),常用於 PR 留言或報告開頭。`roles` 須為可迭代陣列,空陣列回只有標題與表頭的表格。 + +```js +import { getRoleIntro, loadRoles } from './roles.js'; + +const introTable = getRoleIntro(loadRoles()); +``` + +### usage.js + + +### extractUsage + +將各平台 LLM 回應的 token usage 正規化為統一結構,依序嘗試 OpenAI 相容/Gemini/Ollama/OpenCode 格式。`data` 非物件回 `null`;total 缺漏時以 prompt 加 completion 推算;數值經安全轉換不回 NaN,皆不命中回 `null`。 + +```js +import { extractUsage } from './usage.js'; + +const usage = extractUsage(apiResponse); // { promptTokens, completionTokens, totalTokens } | null +``` + + +### recordUsage + +記錄一次 LLM 呼叫的 token 用量並累加進本次執行的全域累計(runUsage),即使無法解析仍計一次呼叫。副作用變更模組層級狀態;回傳本次解析出的 usage 或 `null`,不丟例外。 + +```js +import { recordUsage } from './usage.js'; + +recordUsage(apiResponse.data); // 直接傳入原始回應,內部會嘗試解析 +``` + + +### getRunUsage + +取得本次執行至今的 token 累計快照(淺複本,修改不影響內部狀態)。無參數、無副作用。 + +```js +import { getRunUsage } from './usage.js'; + +const run = getRunUsage(); // { calls, promptTokens, completionTokens, totalTokens } +``` + + +### resetRunUsage + +重置本次執行的 token 累計(四欄位歸零),主要供測試在案例間隔離狀態;無回傳值。 + +```js +import { resetRunUsage } from './usage.js'; + +resetRunUsage(); +``` + + +### recordRateLimit + +從 HTTP 回應 header 擷取速率配額剩餘量與上限,優先採 token 維度、退而採 requests 維度,存為「最近一次」快照。`headers` 非物件直接 return;兩維度皆缺則不更新;成功時設 `hasData=true` 並覆蓋前次。 + +```js +import { recordRateLimit } from './usage.js'; + +recordRateLimit(apiResponse.headers); +``` + + +### getRateLimit + +取得最近一次速率配額快照(淺複本)。無參數、無副作用。 + +```js +import { getRateLimit } from './usage.js'; + +const rl = getRateLimit(); // { hasData, remaining, limit, kind } +``` + + +### resetRateLimit + +重置速率配額快照(`hasData=false`,其餘為 null),主要供測試使用;無回傳值。 + +```js +import { resetRateLimit } from './usage.js'; + +resetRateLimit(); +``` + + +### fetchAccountQuota + +依 provider 查策略表取得帳號額度(多數官方平台僅憑 API key 無法取得,會誠實回報原因),任何失敗都降級為 `{ available: false, reason }`。`config.apiKeys` 為陣列時取第一個否則取 `config.apiKey`;`deps.get` 可注入(預設 `axios.get`);保證不丟例外。 + +```js +import { fetchAccountQuota } from './usage.js'; +import { getLLMConfig } from './config.js'; + +const cfg = getLLMConfig(); +const quota = await fetchAccountQuota(cfg.provider, cfg); +// { available, used?, limit?, remaining?, reason? } +``` + + +### resolveRemainingPercent + +依優先序計算「剩餘可用百分比」:先帳號額度(有有效上限),再速率配額(rate header)。兩者上限/剩餘皆無效(null/0/負/NaN/Infinity)時回 `{ percent: null, reason }`;無副作用,不丟例外。 + +```js +import { resolveRemainingPercent, getRateLimit } from './usage.js'; + +const r = resolveRemainingPercent(quota, getRateLimit()); +// { percent, basis, remaining, limit, unit } | { percent: null, reason } +``` + + +### formatUsageStats + +產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊,含 provider/model/呼叫次數、千分位 token 表格與剩餘可用百分比。簽名為 `formatUsageStats(provider, model, usage, quota, rate)`。 + +```js +import { formatUsageStats, getRunUsage, getRateLimit, fetchAccountQuota } from './usage.js'; +import { getLLMConfig } from './config.js'; + +const cfg = getLLMConfig(); +const quota = await fetchAccountQuota(cfg.provider, cfg); +const block = formatUsageStats(cfg.provider, cfg.model, getRunUsage(), quota, getRateLimit()); +``` + + +### formatUsageStatsLine + +產生單行 log 用的使用量摘要(token 用量加剩餘可用百分比),與 `formatUsageStats` 不同在於 token 數字直接內插、不做千分位格式化。參數與 `formatUsageStats` 相同,無副作用。 + +```js +import { formatUsageStatsLine, getRunUsage, getRateLimit } from './usage.js'; +import { line } from './log.js'; +import { getLLMConfig } from './config.js'; + +const cfg = getLLMConfig(); +line(formatUsageStatsLine(cfg.provider, cfg.model, getRunUsage(), null, getRateLimit())); +``` diff --git a/app/comments.js b/app/comments.js index 6755752..97371ae 100644 --- a/app/comments.js +++ b/app/comments.js @@ -8,16 +8,47 @@ const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' }; const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' }; const LEVEL_ORDER = ['critical', 'warning', 'info']; +/** + * 將單一 finding 格式化為 Markdown 表格的一列(等級|審查員|位置|建議)。 + * + * @param {{ level?: string, role?: string, location?: string, suggestion?: string }} f + * 單筆審查問題物件。`level` 若不在 critical/warning/info 之內,emoji 留空、標籤回退為原始 level 值; + * `role`、`location`、`suggestion` 直接內嵌字串(未定義時會輸出 undefined 字樣)。傳入 null/undefined 會拋 TypeError(需人工確認是否需防呆)。 + * @returns {string} 形如 `| 🔴 嚴重 | role | location | suggestion |` 的表格列字串。 + * @remarks 內部輔助函式,供 {@link buildTable} 逐列組裝表格使用,本身不含換行。 + */ function findingRow(f) { return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`; } +/** + * 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。 + * + * @param {Array} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。 + * @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。 + * @remarks 內部輔助函式,供發布舊問題、新問題(非嚴重)、單筆嚴重問題等 comment 內文使用。 + */ function buildTable(findings) { const rows = findings.map(findingRow).join('\n'); return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`; } +/** + * 取得 finding 等級的人類可讀字串(emoji + 中文標籤),已去除頭尾空白。 + * + * @param {{ level?: string }} f 單筆審查問題物件。`level` 查無對應時 emoji 留空、標籤回退為原始 level 值。 + * @returns {string} 例如 `🔴 嚴重`;無法對應時回退為原始 level 字串(無 emoji)。 + * @remarks 內部輔助函式,供 {@link inlineCommentBody} 與 {@link reviewCommentBody} 組裝 comment 內文使用。 + */ const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim(); +/** + * findings 排序比較器:先依嚴重等級(critical < warning < info < 其他),同級再依 location 字串排序。 + * + * @param {{ level?: string, location?: string }} a 比較項 A。 + * @param {{ level?: string, location?: string }} b 比較項 B。 + * @returns {number} 負值代表 a 排在 b 之前,正值代表之後,0 代表相等(供 Array.prototype.sort 使用)。 + * @remarks 不在 LEVEL_ORDER 內的等級一律視為最低優先(排在最後);location 未定義時以空字串參與比較,因此排序穩定不會丟例外。 + */ const bySeverity = (a, b) => { const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length; const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length; @@ -43,10 +74,26 @@ function inlineCommentBody(f) { return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`; } +/** + * 從 finding 取出問題原因描述,依序嘗試多個可能欄位。 + * + * @param {{ problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f + * 單筆審查問題物件;依序取第一個有值(truthy)的欄位。所有欄位皆無值時回退為「未提供問題原因」。 + * @returns {string} 問題原因字串。 + * @remarks 內部輔助函式,供 {@link reviewCommentBody} 組裝 comment 內文使用,用以容忍不同來源 finding 的欄位命名差異。 + */ function problemText(f) { return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因'; } +/** + * 產生 review comment 內文(嚴重等級/審查員/問題/建議四行)。 + * + * @param {{ level?: string, role?: string, suggestion?: string, problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f + * 單筆審查問題物件。 + * @returns {string} 多行 Markdown 字串。 + * @remarks 內部輔助函式,供 {@link toReviewComment} 產生批次 review comment 內文使用。比 {@link inlineCommentBody} 多了「問題」一行。 + */ function reviewCommentBody(f) { return [ `**嚴重等級**:${levelText(f)}`, @@ -56,17 +103,48 @@ function reviewCommentBody(f) { ].join('\n'); } +/** + * 計算陣列中符合條件的元素數量。 + * + * @param {Array} findings 待計數的陣列。 + * @param {(item: T) => boolean} predicate 判斷函式;回傳 true 的元素計入。 + * @returns {number} 符合條件的元素數量。 + * @template T + * @remarks 內部輔助函式,供 {@link formatFindingsStats} 與 {@link formatFindingsStatsLine} 統計各等級筆數使用。 + */ function countBy(findings, predicate) { return findings.filter(predicate).length; } +/** + * 過濾出新問題(is_new 不等於 false 者)。 + * + * @param {Array<{ is_new?: boolean }>} findings 審查問題陣列。 + * @returns {Array} 新問題子集合。 + * @remarks 內部輔助函式。判定採 `is_new !== false`,因此未設定 is_new(undefined)的 finding 也視為新問題;僅明確 `is_new === false` 會被排除。供統計與 review 發布判斷使用。 + */ function newFindingsOnly(findings) { return findings.filter(f => f.is_new !== false); } // 等級無法歸入 critical/warning/info(例如缺漏或無法辨識)時,歸入「無法標示」 +/** + * 判斷 finding 等級是否無法歸入 critical/warning/info(無法標示)。 + * + * @param {{ level?: string }} f 單筆審查問題物件。 + * @returns {boolean} 等級不在 LEVEL_ORDER 內時為 true。 + * @remarks 內部輔助函式,供統計表的「⚪ 無法標示」欄位計數使用。 + */ const isUnclassified = f => !LEVEL_ORDER.includes(f.level); +/** + * 產生 findings 統計的 Markdown 表格(新問題/舊問題 × 嚴重/警告/建議/無法標示)。 + * + * @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列; + * `is_new === false` 計入舊問題,其餘計入新問題。 + * @returns {string} 含表頭、分隔列與兩資料列的 Markdown 表格字串。 + * @remarks 供 {@link buildReviewSummary} 組裝 review 統計本文使用。空陣列時仍輸出表格(各欄為 0 筆)。 + */ export function formatFindingsStats(findings) { const oldFindings = findings.filter(f => f.is_new === false); const newFindings = newFindingsOnly(findings); @@ -80,6 +158,14 @@ export function formatFindingsStats(findings) { ].join('\n'); } +/** + * 產生 findings 統計的單行文字摘要(供 log 使用)。 + * + * @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列; + * `is_new === false` 計入舊問題,其餘計入新問題。 + * @returns {string} 形如 `新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...` 的單行字串。 + * @remarks 供 {@link postFindingsReview} 在 log 輸出統計時呼叫。內容與 {@link formatFindingsStats} 一致,僅格式為單行純文字。 + */ export function formatFindingsStatsLine(findings) { const oldFindings = findings.filter(f => f.is_new === false); const newFindings = newFindingsOnly(findings); @@ -87,6 +173,14 @@ export function formatFindingsStatsLine(findings) { return `新: ${row(newFindings)};舊: ${row(oldFindings)}`; } +/** + * 組裝 review 本文:標題 + findings 統計表 +(選擇性)用量區塊。 + * + * @param {Array} findings 用於統計的審查問題陣列。 + * @param {string} [usageSection=''] 額外附加的用量/token 統計區塊;空字串時不附加。 + * @returns {string} review 本文(Markdown)。 + * @remarks 內部輔助函式,供 {@link postFindingsReview} 產生整批 review 的 body。 + */ function buildReviewSummary(findings, usageSection = '') { const parts = [ '## AI Code Review 統計', @@ -97,6 +191,14 @@ function buildReviewSummary(findings, usageSection = '') { return parts.join('\n'); } +/** + * 將 finding 轉為 Gitea review comment 物件(含檔案路徑、內文、行號)。 + * + * @param {{ location?: string, level?: string, role?: string, suggestion?: string }} f 單筆審查問題物件。 + * @returns {{ path: string, body: string, new_position: number } | null} + * 可定位時回傳 comment 物件;location 無法解析出行號時回傳 null。 + * @remarks 內部輔助函式,供 {@link postFindingsReview} 在 map 後以 `filter(Boolean)` 濾除無法定位的項目。 + */ function toReviewComment(f) { const loc = parseLocation(f.location); if (!loc) return null; diff --git a/app/config.js b/app/config.js index 7c82513..9ecc17b 100644 --- a/app/config.js +++ b/app/config.js @@ -12,10 +12,29 @@ export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || ''; export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; +/** + * 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent, + * 供連接使用自簽或無效憑證的 OpenCode 服務時使用。 + * + * @remarks 每次呼叫都會回傳全新的 Agent 實例(不快取),建議呼叫端重用以共用連線池。 + * 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。 + * @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 實例。 + */ export function getOpenCodeHttpsAgent() { return new https.Agent({ rejectUnauthorized: false }); } +/** + * 依環境變數解析並回傳 LLM 提供者設定。 + * + * 當設定了 `OPENCODE_BASE_URL` 時回傳 OpenCode 提供者設定 + * (model 取自 `OPENCODE_MODEL`,預設為 `gemini-2.5-flash`); + * 否則回傳各欄位皆為空/null 的「無提供者」設定,由呼叫端據此判斷是否略過 LLM 流程。 + * + * @remarks 每次呼叫都會即時讀取 `process.env`。`apiKeys` 在 OpenCode 模式下為固定佔位值 `['opencode']`,並非真實金鑰。 + * @returns {{ provider: ('opencode'|null), apiKeys: string[], baseURL: (string|null), model: (string|null) }} + * LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。 + */ export function getLLMConfig() { if (process.env.OPENCODE_BASE_URL) { return { diff --git a/app/findings.js b/app/findings.js index c055672..9ede242 100644 --- a/app/findings.js +++ b/app/findings.js @@ -37,6 +37,13 @@ function readJSONArray(fullPath, label) { } } +/** + * 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。 + * + * @param {Array|{exclusions?: Array, excluded_findings?: Array}|*} data - 任意形式的排除資料來源。 + * @returns {Array} 對應的排除條目陣列;無法辨識時回傳空陣列。 + * @remarks 與 detectExclusionSource 搭配,相容舊有多種 exclusions.json 結構。 + */ function normalizeExclusions(data) { if (Array.isArray(data)) return data; if (data && Array.isArray(data.exclusions)) return data.exclusions; @@ -44,6 +51,13 @@ function normalizeExclusions(data) { return []; } +/** + * 偵測排除資料的原始容器格式,回傳格式標籤。 + * + * @param {Array|{exclusions?: *, excluded_findings?: *}|*} data - 任意形式的排除資料來源。 + * @returns {('array'|'exclusions'|'excluded_findings'|'unknown')} 對應的格式標籤。 + * @remarks 供 loadExclusions 判斷是否需把非陣列格式改寫成標準頂層陣列。 + */ function detectExclusionSource(data) { if (Array.isArray(data)) return 'array'; if (data && Array.isArray(data.exclusions)) return 'exclusions'; @@ -51,19 +65,49 @@ function detectExclusionSource(data) { return 'unknown'; } +/** + * 以標準格式(2 空白縮排 JSON 陣列、結尾換行、UTF-8)將排除條目寫回檔案,覆蓋原內容。 + * + * @param {string} fullPath - 目標檔案路徑;上層目錄須事先存在(本函式不建立目錄)。 + * @param {Array} exclusions - 欲寫入的排除條目陣列。 + * @returns {void} + * @throws 檔案寫入失敗(權限不足、目錄不存在等)時拋出 fs 錯誤。 + * @remarks 統一輸出格式,使 exclusions.json 永遠是可預期的頂層陣列。 + */ function writeCanonicalExclusions(fullPath, exclusions) { fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8'); } +/** + * 將檔案 mtime(毫秒時間戳)格式化為 ISO 字串,無效值回傳 'unknown'。 + * + * @param {number} mtimeMs - 毫秒時間戳(通常為 fs.Stats.mtimeMs)。 + * @returns {string} ISO 8601 時間字串,或在輸入非有限數時回傳 'unknown'。 + * @remarks 僅用於診斷日誌,呈現舊 findings / exclusions 檔案的修改時間。 + */ function formatFileTime(mtimeMs) { if (!Number.isFinite(mtimeMs)) return 'unknown'; return new Date(mtimeMs).toISOString(); } +/** + * 安全取字串:字串則去頭尾空白,其餘型別(含 null/undefined/數字)一律回傳空字串。 + * + * @param {*} value - 任意值。 + * @returns {string} 去除頭尾空白後的字串,或空字串。 + * @remarks 作為 normalizeText、toKeyText、getExclusionText 等的基礎防呆。 + */ function cleanText(value) { return typeof value === 'string' ? value.trim() : ''; } +/** + * 將文字正規化為比對用形式:NFKC、小寫、標點/符號/空白統一為單一空白後壓縮。 + * + * @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。 + * @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。 + * @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。 + */ function normalizeText(value) { return cleanText(value) .normalize('NFKC') @@ -73,6 +117,14 @@ function normalizeText(value) { .trim(); } +/** + * 將文字壓縮成無分隔符的鍵值:NFKC 後移除所有標點/符號/空白。 + * + * @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。 + * @returns {string} 去除所有分隔符的緊湊字串(可能為空字串)。 + * @remarks 用於 normalizeExclusionEntry 的 textKey 與 fingerprint,以及群組鍵。 + * 不確定:是否刻意不轉小寫(與 normalizeText 不同),需人工確認此差異是否預期。 + */ function toKeyText(value) { return cleanText(value) .normalize('NFKC') @@ -80,6 +132,13 @@ function toKeyText(value) { .trim(); } +/** + * 從排除條目取出代表性文字,依優先序 original_finding > title > suggestion > reason > note 取第一個非空值。 + * + * @param {object|null|undefined} exclusion - 排除條目物件(可為 null/undefined)。 + * @returns {string} 第一個非空的代表性文字,皆空時回傳空字串。 + * @remarks 供 normalizeExclusionEntry 產生比對文字;相容多種人工撰寫的排除欄位命名。 + */ function getExclusionText(exclusion) { return cleanText(exclusion?.original_finding) || cleanText(exclusion?.title) @@ -88,6 +147,14 @@ function getExclusionText(exclusion) { || cleanText(exclusion?.note); } +/** + * 正規化單一排除條目,補上 filePath、text、textKey 與唯一 fingerprint,保留原始欄位。 + * + * @param {object} exclusion - 原始排除條目(可能僅含部分欄位)。 + * @param {number} index - 條目在來源陣列中的索引;無文字可用時用於產生 fallback 指紋(entry-N)。 + * @returns {object} 合併原欄位與衍生欄位(location、filePath、role、text、textKey、fingerprint)的新物件。 + * @remarks fingerprint 以 filePath|role|textKey 組成,缺值以 '*' 或 entry-N 補位,供 dedupeExclusions 去重。 + */ function normalizeExclusionEntry(exclusion, index) { const location = cleanText(exclusion?.location); const filePath = location ? location.split(':')[0] : ''; @@ -106,6 +173,13 @@ function normalizeExclusionEntry(exclusion, index) { }; } +/** + * 依 fingerprint 去除重複的排除條目,保留首次出現者並維持原順序。 + * + * @param {Array} exclusions - 已正規化(含 fingerprint)的排除條目陣列。 + * @returns {Array} 去重後的排除條目陣列。 + * @remarks 須先呼叫 normalizeExclusionEntry 補上 fingerprint,否則缺指紋的條目可能被誤併。 + */ function dedupeExclusions(exclusions) { const seen = new Set(); return exclusions.filter(exclusion => { @@ -115,6 +189,14 @@ function dedupeExclusions(exclusions) { }); } +/** + * 將排除條目依 textKey 分組統計,產生供 AI prompt 使用的群組摘要(含出現次數、涉及路徑與角色、樣本)。 + * + * @param {Array} exclusions - 已正規化(含 textKey、filePath、role、text、fingerprint)的排除條目。 + * @returns {Array<{text: string, count: number, paths: string[], roles: string[], samples: string[]}>} + * 依出現次數、涉及路徑數、文字字典序排序的群組摘要陣列。 + * @remarks 每組最多保留 2 筆樣本,避免後續 prompt 過長;供 buildExclusionContext 取前 N 組組裝提示。 + */ function groupExclusionsForAI(exclusions) { const groups = new Map(); for (const exclusion of exclusions) { @@ -147,6 +229,14 @@ function groupExclusionsForAI(exclusions) { })); } +/** + * 由原始排除條目建立「已知誤報」上下文:正規化、去重、分組後,產生計數摘要與可直接嵌入 prompt 的文字。 + * + * @param {Array} exclusions - 原始(未正規化)排除條目陣列。 + * @returns {{rawCount: number, uniqueCount: number, groupCount?: number, groups: Array, prompt: string}} + * 含計數、前 12 組群組摘要與 prompt 字串;空輸入時 prompt 為空字串且不含 groupCount。 + * @remarks 供 loadExclusions 日誌與 filterFalsePositivesWithAI 組裝防守方提示使用;prompt 最多展開 12 類群組。 + */ function buildExclusionContext(exclusions) { if (exclusions.length === 0) { return { @@ -303,7 +393,13 @@ export async function resolveMissingLineNumbers(findings, diff, deps = {}) { return findings; } -/** 只保留 AI 需要的欄位,減少 token 用量 */ +/** + * 將 findings 精簡為僅含 level、role、location、problem、suggestion 的物件,移除多餘欄位以節省 token。 + * + * @param {Array} findings - 完整 findings 陣列。 + * @returns {Array<{level: *, role: *, location: *, problem: *, suggestion: *}>} 精簡後的 payload 陣列。 + * @remarks 送往 LLM 前的瘦身步驟;原始欄位(如 is_new)需由呼叫端事後依鍵補回。 + */ function toAIPayload(findings) { return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion })); } diff --git a/app/git.js b/app/git.js index f201ad9..9a315aa 100644 --- a/app/git.js +++ b/app/git.js @@ -8,6 +8,21 @@ const REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; export const BOT_COMMIT_MARKER = '[ai-review-bot]'; +/** + * 建立一個同步執行 git 子行程的 runner。透過注入 `spawn` 以利測試 + * (正式環境傳入 `child_process.spawnSync`,測試可傳入 stub)。 + * + * 回傳的 `run(args, cwd, env)` 會以 utf8 編碼執行 `git `, + * 成功回傳經 trim 的 stdout,失敗則丟出 Error。 + * + * @param {(cmd: string, args: string[], opts: object) => {error?: Error & {code?: string}, status?: number, stdout?: string, stderr?: string}} spawn + * 同步 spawn 實作(依賴注入,通常為 `spawnSync`)。 + * @returns {(args: string[], cwd?: string, env?: object) => string} + * 執行 git 的函式:回傳 trim 後的 stdout。 + * @throws {Error} 找不到 git 指令時(`ENOENT`)丟出中文提示。 + * @throws {Error} git 子行程本身的 `error`(非 ENOENT)原樣丟出。 + * @throws {Error} git 離開碼非 0 時,以 stderr/stdout 內容丟出。 + */ function makeRunner(spawn) { return function run(args, cwd, env) { const opts = { cwd, encoding: 'utf8' }; @@ -22,6 +37,23 @@ function makeRunner(spawn) { }; } +/** + * 包裝一段需要 git HTTP 認證的工作:先在 workspace 寫出暫時的 + * `.git-askpass.sh`(透過 `GIT_ASKPASS` 提供 token),呼叫 `fn(credEnv)`, + * 再清除該暫存腳本。 + * + * 清理時機會依 `fn` 回傳型別自動判斷: + * 同步回傳會立即清理;回傳 Promise(含 async 回呼)則延後到 Promise + * settle 後才清理,避免在第一個 await 就刪掉腳本,導致後續 git push + * 因 `cannot exec .git-askpass.sh` 而失敗。 + * + * @template T + * @param {string} workspace 寫入暫存 askpass 腳本的目錄。 + * @param {(credEnv: NodeJS.ProcessEnv) => T} fn 帶入憑證環境變數執行的回呼。 + * @returns {T} 即 `fn` 的回傳值(Promise 會被包成 `.finally(cleanup)` 後回傳)。 + * @throws 透傳 `fn` 丟出的任何例外(同步路徑會先清理暫存腳本再 re-throw)。 + * @remarks askpass 腳本以權限 0o700 寫出;token 取自 config 的 `GITEA_TOKEN`。 + */ function withAskpass(workspace, fn) { const askpassScript = path.join(workspace, '.git-askpass.sh'); fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 }); @@ -44,6 +76,19 @@ function withAskpass(workspace, fn) { return result; } +/** + * 以容錯方式執行 git 讀取指令:成功回傳 trim 後的輸出, + * 任何錯誤都吞掉並回傳空字串。適用於「失敗也不該中斷流程」的唯讀查詢 + * (例如取 HEAD SHA、分支名、commit 時間)。 + * + * @param {(args: string[], cwd?: string, env?: object) => string} run + * 由 `makeRunner` 產生的 git 執行函式。 + * @param {string[]} args git 子指令與參數。 + * @param {string} [cwd] 執行目錄。 + * @param {object} [env] 環境變數覆寫。 + * @returns {string} git 的 trim 輸出;失敗時回傳空字串。 + * @remarks 不會拋出例外,也不記錄錯誤。 + */ function readGitOutput(run, args, cwd, env) { try { return run(args, cwd, env); @@ -52,6 +97,17 @@ function readGitOutput(run, args, cwd, env) { } } +/** + * 讀取指定 repo 目錄的目前狀態(HEAD SHA、短 SHA、目前分支、commit 時間)。 + * 所有查詢皆採容錯讀取,任一失敗對應欄位即為空字串,不會丟出例外。 + * + * @param {string} repoDir git 工作目錄路徑。 + * @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync] + * 測試用依賴注入:覆寫底層的同步 spawn 實作。 + * @returns {{repoDir: string, branch: string, headSha: string, shortSha: string, commitTime: string}} + * repo 狀態快照;無法取得的欄位為空字串。 + * @remarks `commitTime` 為 `%cI` 格式(committer date, ISO 8601 嚴格格式)。 + */ export function getRepoState(repoDir, _spawnSync = spawnSync) { const run = makeRunner(_spawnSync); const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir); @@ -61,11 +117,30 @@ export function getRepoState(repoDir, _spawnSync = spawnSync) { return { repoDir, branch, headSha, shortSha, commitTime }; } +/** + * 取得 HEAD commit 的完整 commit message(`%B`,含 subject 與 body)。 + * 容錯讀取:失敗時回傳空字串。 + * + * @param {string} repoDir git 工作目錄路徑。 + * @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync] + * 測試用依賴注入。 + * @returns {string} HEAD 的完整 commit 訊息;失敗時為空字串。 + */ export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) { const run = makeRunner(_spawnSync); return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir); } +/** + * 判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit + * (commit message 含 `BOT_COMMIT_MARKER`)。常用於避免機器人 commit + * 反覆觸發新一輪審查。 + * + * @param {string} repoDir git 工作目錄路徑。 + * @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync] + * 測試用依賴注入。 + * @returns {boolean} HEAD 訊息含機器人標記時為 true;讀取失敗時安全地回傳 false。 + */ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) { return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER); } @@ -108,6 +183,27 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) { }); } +/** + * 將 AI 審查產出的 review 檔(findings / exclusions)結轉到 repo,並 commit、 + * push 回 PR head branch。流程:設定機器人 git 身分 → fetch + hard reset 對齊 + * 遠端 → 從 workspace 複製存在的 review 檔到 repo 並 add → 若無變更則跳過 → + * 以含 `BOT_COMMIT_MARKER` 與結果標籤的訊息 commit → push。 + * + * 失敗策略:push 失敗只記 warning(commit 已在本地完成);其餘步驟的例外 + * 由外層捕捉並記 warning,函式整體**不丟出例外**,以免中斷上層流程。 + * + * @param {string} workspace review 檔來源目錄、askpass 腳本所在目錄。 + * @param {string} repoDir 目標 git repo 目錄(commit/push 的工作目錄)。 + * @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync] + * 測試用依賴注入:覆寫底層同步 spawn。 + * @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數; + * 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。 + * @param {'success'|'failure'} [reviewOutcome='success'] + * 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。 + * @returns {Promise} 無回傳值;所有失敗皆以 log 記錄後吞掉。 + * @remarks `git reset --hard origin/` 會丟棄本地未對齊變更,請確認 + * review 檔是在 reset 之後才複製進來(流程已如此安排)。 + */ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') { const run = makeRunner(_spawnSync); diff --git a/app/gitea.js b/app/gitea.js index 0750ba6..ccd6b49 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -4,9 +4,26 @@ import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, P import { line, warn } from './log.js'; const httpsAgent = new https.Agent({ rejectUnauthorized: false }); +/** + * 產生呼叫 Gitea API 所需的 HTTP headers(含 Gitea token 授權與 JSON content-type)。 + * 授權格式為 Gitea 專用的 `token `,並非 OAuth Bearer。 + * @param {string} [token=GITEA_TOKEN] - Gitea access token;讀取類用預設 token,留言/寫入類通常傳入 GITEA_COMMENT_TOKEN。 + * @returns {{Authorization: string, 'Content-Type': string}} 可直接給 axios 的 headers 物件。 + */ const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); +/** + * 將相對路徑組成 Gitea REST API v1 的完整 URL(自動去除 server URL 結尾斜線)。 + * @param {string} path - 以斜線開頭的 API 子路徑,例如 `/repos/owner/repo/pulls/1.diff`。 + * @returns {string} 形如 `/api/v1` 的完整 URL。 + */ const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; +/** + * 從 Gitea commit 相關 API 的回應中萃取 commit message,相容多種巢狀結構。 + * 依序嘗試 `message`、`commit.message`、`commit.commit.message`,皆無則回空字串。 + * @param {object|null|undefined} payload - Gitea API 回傳的物件(如 git/commits 或 branch 回應)。 + * @returns {string} commit 訊息,找不到時為空字串。 + */ function extractCommitMessage(payload) { return payload?.message || payload?.commit?.message @@ -14,13 +31,22 @@ function extractCommitMessage(payload) { || ''; } +/** + * 解析文字中的 `[ai-review-bot][success|failure]` 標記,判斷上一次自動審查結果。 + * 用於 commit 訊息或留言內容;無標記或無後綴時視為未知。 + * @param {string} message - 待解析的 commit 訊息或留言文字。 + * @returns {'success'|'failure'|'unknown'} 解析出的審查結果。 + */ export function getBotReviewOutcome(message) { const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i); return match?.[1]?.toLowerCase() || 'unknown'; } /** - * 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。 + * 取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(.gitea/、.github/、README.md、TODO.md)。 + * 透過 Gitea `GET /repos/{repo}/pulls/{index}.diff`(純文字 diff),授權使用 GITEA_TOKEN。 + * @returns {Promise} 過濾後的 diff 文字。 + * @throws {Error} 當 Gitea API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。 */ export async function getPRDiff() { const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent }); @@ -32,6 +58,12 @@ export async function getPRDiff() { ]); } +/** + * 依 commit SHA 向 Gitea 查詢該 commit 的訊息(`GET /repos/{repo}/git/commits/{sha}`)。 + * 失敗或 sha 為空時不拋例外,僅記錄警告並回傳空字串,方便呼叫端做容錯判斷。 + * @param {string} sha - commit 的完整或縮寫 SHA。 + * @returns {Promise} commit 訊息;查無、sha 空或請求失敗時為空字串。 + */ export async function getCommitMessageBySha(sha) { if (!sha) return ''; try { @@ -47,6 +79,12 @@ export async function getCommitMessageBySha(sha) { } } +/** + * 取得指定分支 head commit 的訊息(先查 `GET /repos/{repo}/branches/{branch}` 取 SHA,再查該 commit)。 + * 失敗或 branch 為空時不拋例外,記錄警告並回傳空字串。 + * @param {string} [branch=PR_HEAD_BRANCH] - 分支名稱。 + * @returns {Promise} 該分支 head commit 的訊息;查無或失敗時為空字串。 + */ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) { if (!branch) return ''; try { @@ -63,7 +101,15 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) { } } -/** 檢查 PR head(commit sha 或分支 head)的訊息是否帶 [ai-review-bot] 標記,是則代表本次是自動提交、應跳過審查。 */ +/** + * 判斷目前 PR head(commit 或分支 head)的訊息是否帶 `[ai-review-bot]` 標記; + * 若是,代表本次變更為 bot 自動提交,呼叫端應跳過審查以避免自我審查迴圈。 + * @param {object} [options] + * @param {string} [options.sha=PR_HEAD_SHA||process.env.GITHUB_SHA] - 要檢查的 commit SHA。 + * @param {string} [options.branch=PR_HEAD_BRANCH] - 要檢查的分支名稱。 + * @returns {Promise} true 表示應跳過審查。 + * @remarks 內部查詢失敗會被降級為空字串(視為未命中),因此正常情況下不會拋出例外。 + */ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) { const shaMessage = await getCommitMessageBySha(sha); if (sha && shaMessage.includes('[ai-review-bot]')) return true; @@ -75,8 +121,11 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT } /** - * 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。 - * 每個區塊以 "diff --git a/" 開頭判斷,使用 startsWith 精確比對前綴。 + * 過濾 unified diff,移除檔案路徑前綴命中 excludePrefixes 的區塊。 + * 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/` 做 startsWith 比對。 + * @param {string} diff - 完整的 unified diff 文字。 + * @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。 + * @returns {string} 過濾後重新接合的 diff 文字。 */ export function filterDiff(diff, excludePrefixes) { return diff.split(/(?=^diff --git )/m) @@ -88,6 +137,13 @@ export function filterDiff(diff, excludePrefixes) { .join(''); } +/** + * 在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言)。 + * 透過 `POST /repos/{repo}/issues/{index}/comments`,優先使用 GITEA_COMMENT_TOKEN 授權。 + * @param {string} body - 留言內容(支援 Markdown)。 + * @returns {Promise} Gitea 建立的 comment 物件。 + * @throws {Error} 請求失敗(網路、逾時或非 2xx)時拋出 axios 例外。 + */ export async function postComment(body) { const resp = await axios.post( api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`), @@ -98,9 +154,14 @@ export async function postComment(body) { } /** - * 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。 - * 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。 - * 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。 + * 在 PR 指定檔案的指定新版行號發布一筆行內 review comment(建立一個只含單一 comment 的 COMMENT review)。 + * 以 `new_position` 對應新檔行號;該行不在 diff 範圍時 Gitea 會回錯誤而拋例外,呼叫端可降級為一般留言。 + * @param {object} params + * @param {string} params.path - 檔案路徑(PR 內的相對路徑)。 + * @param {number} params.line - 新版檔案中的行號(diff 右側行)。 + * @param {string} params.body - 行內留言內容。 + * @returns {Promise} Gitea 建立的 review 物件。 + * @throws {Error} 請求失敗或行號超出 diff 範圍時拋出 axios 例外。 */ export async function postPullReviewComment({ path: filePath, line, body }) { const resp = await axios.post( @@ -117,7 +178,13 @@ export async function postPullReviewComment({ path: filePath, line, body }) { } /** - * 建立一個 PR review,本文放統計摘要,comments 放多筆行內 review comments。 + * 建立一個 PR review:本文放摘要,comments 批次放多筆行內 review comments。 + * 透過 `POST /repos/{repo}/pulls/{index}/reviews`(event=COMMENT),優先使用 GITEA_COMMENT_TOKEN。 + * @param {object} params + * @param {string} params.body - review 本文(通常為統計摘要)。 + * @param {Array<{path:string, body:string, new_position?:number}>} [params.comments=[]] - 行內 comment 陣列。 + * @returns {Promise} Gitea 建立的 review 物件。 + * @throws {Error} 請求失敗(如某筆 comment 行號不在 diff 範圍)時拋出 axios 例外。 */ export async function postPullReview({ body, comments = [] }) { const resp = await axios.post( @@ -134,7 +201,10 @@ export async function postPullReview({ body, comments = [] }) { } /** - * 取得 PR 上所有的 review(每個 review 可含多個行內 comment)。 + * 取得目前 PR 上所有 review(`GET /repos/{repo}/pulls/{index}/reviews`)。 + * 回應非陣列時回傳空陣列以保證型別一致。 + * @returns {Promise} review 物件陣列。 + * @throws {Error} 請求失敗時拋出 axios 例外。 */ export async function listPullReviews() { const resp = await axios.get( @@ -145,7 +215,10 @@ export async function listPullReviews() { } /** - * 取得單一 review 底下的所有行內 comment。 + * 取得指定 review 底下的所有行內 comment(`GET /repos/{repo}/pulls/{index}/reviews/{id}/comments`)。 + * @param {number|string} reviewId - review 的 ID。 + * @returns {Promise} comment 物件陣列;非陣列回應時為空陣列。 + * @throws {Error} 請求失敗時拋出 axios 例外。 */ export async function getPullReviewComments(reviewId) { const resp = await axios.get( @@ -156,8 +229,10 @@ export async function getPullReviewComments(reviewId) { } /** - * 取得 PR 上所有 review 的行內 comment,展平成單一陣列。 - * 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程。 + * 取得目前 PR 上所有 review 的行內 comment 並展平為單一陣列。 + * 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程;最後輸出統計日誌。 + * @returns {Promise} 所有行內 comment 的展平陣列。 + * @throws {Error} 當 listPullReviews 取得 review 清單失敗時拋出例外。 */ export async function listAllReviewComments() { const reviews = await listPullReviews(); @@ -175,8 +250,11 @@ export async function listAllReviewComments() { } /** - * 解決(resolve)一個 review comment 所屬的對話。 - * 對應 Gitea 官方 API:POST /repos/{repo}/pulls/comments/{id}/resolve。 + * 解決(resolve)指定 review comment 所屬的對話。 + * 對應 Gitea API `POST /repos/{repo}/pulls/comments/{id}/resolve`,使用 GITEA_COMMENT_TOKEN 授權。 + * @param {number|string} commentId - 要解決的 review comment ID。 + * @returns {Promise} Gitea API 回應內容。 + * @throws {Error} 請求失敗時拋出 axios 例外。 */ export async function resolvePullReviewComment(commentId) { const resp = await axios.post( @@ -188,8 +266,12 @@ export async function resolvePullReviewComment(commentId) { } /** - * 取得指定 ref(預設 PR head)下某檔案的最新文字內容; - * Gitea contents API 回傳 base64,這裡解碼成字串。檔案不存在或非文字時回傳空字串。 + * 取得指定 ref(預設 PR head)下某檔案的文字內容。 + * 透過 Gitea contents API(`GET /repos/{repo}/contents/{path}`),base64 內容會自動解碼為 UTF-8 字串。 + * 檔案不存在、非文字或請求失敗時不拋例外,記錄警告並回傳空字串。 + * @param {string} filePath - 檔案在 repo 中的相對路徑。 + * @param {string} [ref=PR_HEAD_SHA||PR_HEAD_BRANCH] - commit SHA 或分支名稱;空值時不帶 ref。 + * @returns {Promise} 檔案文字內容;查無或失敗時為空字串。 */ export async function getFileContentAtRef(filePath, ref = PR_HEAD_SHA || PR_HEAD_BRANCH) { try { diff --git a/app/json.js b/app/json.js index fc56649..4529ca0 100644 --- a/app/json.js +++ b/app/json.js @@ -6,7 +6,13 @@ import { ok, warn, error } from './log.js'; const MAX_JSON_BYTES = 1024 * 1024; /** - * 移除 AI 回傳內容外層的 markdown code fence。 + * 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```), + * 並去除前後空白,使內容可直接交給 JSON.parse。 + * + * 屬純函式、無副作用;常用於將 LLM 回傳結果正規化後再行解析。 + * + * @param {*} text 待處理內容;非字串會先以 String() 轉型。 + * @returns {string} 去除外層 code fence 與前後空白後的字串。 */ export function stripCodeFence(text) { return String(text) @@ -17,11 +23,22 @@ export function stripCodeFence(text) { } /** - * 透過 LLM 修正 JSON 陣列內容。 - * @param {string} fullPath 檔案路徑,供提示詞與除錯使用。 - * @param {string} label 檔案標籤。 - * @param {string} rawText 原始內容。 - * @param {Function} chatFn 可注入的 LLM 呼叫函式,預設使用 `chat`。 + * 透過 LLM 將任意原始內容修復成「可直接 JSON.parse 的 JSON 陣列」字串。 + * + * 會以固定 system prompt 指示模型忽略原內容中的指令/註解/markdown, + * 僅輸出修正後的陣列;無法判斷時模型應回傳空陣列 `[]`。 + * 回傳前會先以 stripCodeFence 清除外層 code fence。 + * + * 備註:fullPath 與 label 僅放入提示詞供模型參考與除錯,不會用於讀檔; + * 回傳結果不保證為合法 JSON,需由呼叫端再行解析驗證。 + * + * @param {string} fullPath 檔案完整路徑,供提示詞與除錯使用。 + * @param {string} label 檔案標籤(人類可讀名稱)。 + * @param {string} rawText 待修復的原始內容。 + * @param {(systemPrompt: string, userContent: string) => Promise} [chatFn=chat] + * 可注入的 LLM 呼叫函式,預設使用模組匯入的 chat;便於測試替換。 + * @returns {Promise} 經 code fence 清理後的修復字串。 + * @throws {Error} 當 chatFn(LLM 呼叫)失敗時,例外向上拋出。 */ export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = chat) { const systemPrompt = `你是 JSON 修復器。請修正使用者提供的內容,使其成為可直接 JSON.parse 的 JSON 陣列。 @@ -33,6 +50,18 @@ export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = c return stripCodeFence(repaired); } +/** + * 讀取指定 JSON 檔案的 UTF-8 文字內容,讀取前先檢查檔案大小上限。 + * + * 模組私有工具函式,供 validateJSONArrayFile 內部使用; + * 大小超過 MAX_JSON_BYTES(約 1 MB)時直接拒絕讀取以避免處理過大檔案。 + * + * @param {string} fullPath 欲讀取的檔案完整路徑。 + * @param {string} label 檔案標籤,用於組合錯誤訊息。 + * @returns {string} 檔案的 UTF-8 文字內容。 + * @throws {Error} 檔案大小超過 MAX_JSON_BYTES 時丟出; + * 或 fs.statSync/fs.readFileSync 因檔案不存在、無權限等丟出的 IO 例外。 + */ function readJSONText(fullPath, label) { const size = fs.statSync(fullPath).size; if (size > MAX_JSON_BYTES) { @@ -42,10 +71,24 @@ function readJSONText(fullPath, label) { } /** - * 驗證 JSON 陣列檔案是否存在且格式正確。 - * 若格式錯誤,直接嘗試透過 AI 修復,修復後再次檢查; - * 第二次檢查仍失敗才丟出例外。 - * 若檔案不存在,回傳 exists=false,交由呼叫端決定是否補檔。 + * 驗證指定路徑是否為合法的 JSON 檔案;格式錯誤時嘗試以 AI 修復一次後再次驗證。 + * + * 行為摘要: + * - 先確保父目錄存在。 + * - 檔案不存在:不丟例外,回傳 { exists:false },交由呼叫端決定是否補檔。 + * - 解析成功:回傳 { exists:true, valid:true, repaired:false }。 + * - 解析失敗:呼叫 repairer 修復、覆寫檔案(確保以換行結尾)、再驗證一次; + * 通過則回傳 repaired:true,仍失敗則丟出例外。 + * + * 備註:僅嘗試修復一次;會寫入磁碟並輸出日誌,屬有副作用之非同步函式。 + * + * @param {string} fullPath 欲驗證的 JSON 檔案完整路徑。 + * @param {string} label 檔案標籤,用於日誌與提示訊息。 + * @param {(fullPath: string, label: string, rawText: string) => Promise} [repairer=repairJSONArrayWithAI] + * 可注入的修復函式,預設使用 repairJSONArrayWithAI;便於測試替換。 + * @returns {Promise<{exists: boolean, valid: boolean, repaired: boolean}>} + * 驗證結果;repaired 表示是否經由 AI 修復後才通過驗證。 + * @throws {Error} 修復後二次驗證仍失敗,或修復/檔案讀寫過程發生例外時拋出。 */ export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) { fs.mkdirSync(path.dirname(fullPath), { recursive: true }); @@ -76,7 +119,15 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS } /** - * 若檔案不存在則建立空陣列。 + * 確保指定路徑存在一個 JSON 檔案;若不存在則建立內容為 "[]\n" 的空陣列檔。 + * + * 會先建立父目錄。若檔案已存在則原樣保留、不檢查其內容是否合法 + * (內容驗證請改用 validateJSONArrayFile)。為同步函式。 + * + * @param {string} fullPath 目標檔案完整路徑。 + * @param {string} label 檔案標籤,用於日誌訊息。 + * @returns {boolean} 是否為本次新建:新建回傳 true,原本即存在回傳 false。 + * @throws {Error} 建立目錄或寫入檔案失敗(如權限不足)時,IO 例外向上拋出。 */ export function ensureJSONArrayFileExists(fullPath, label) { fs.mkdirSync(path.dirname(fullPath), { recursive: true }); diff --git a/app/llm.js b/app/llm.js index dcc7dc9..51969fd 100644 --- a/app/llm.js +++ b/app/llm.js @@ -3,11 +3,28 @@ import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js'; import { recordUsage } from './usage.js'; import { line, error } from './log.js'; +/** + * 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。 + * + * 當字串含有 `/` 時視為 `providerID/modelID` 形式並拆解;否則 provider + * 取環境變數 `OPENCODE_PROVIDER`(預設 `google`),model 則為整個字串。 + * + * @param {string} model - 模型識別字串,例如 `"google/gemini-2.0"` 或 `"gemini-2.0"`。 + * @returns {{ providerID: string, modelID: string }} 拆解後的 provider 與 model 識別碼。 + */ function opencodeModelConfig(model) { const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; return { providerID, modelID }; } +/** + * 建立傳給 axios 的共用請求選項,統一注入 headers 與 OpenCode 專用的 HTTPS agent。 + * + * 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。 + * + * @param {Record} headers - 要附加於請求的 HTTP 標頭。 + * @returns {{ headers: Record, httpsAgent: import('https').Agent }} axios 請求選項物件。 + */ function opencodeAxiosOptions(headers) { return { headers, @@ -15,6 +32,15 @@ function opencodeAxiosOptions(headers) { }; } +/** + * 從 OpenCode 訊息回應中抽取並串接所有文字片段。 + * + * 以多重 fallback 相容不同包裹層級的回應結構(`parts` / `data.parts` / + * `info.content` / `data.info.content`),逐片段取 `text` 或 `content` 後串接。 + * + * @param {object} data - OpenCode `/session/{id}/message` 的回應資料物件。 + * @returns {string} 串接後的純文字內容;無可用片段時回傳空字串。 + */ function extractOpenCodeContent(data) { const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || []; return parts @@ -23,6 +49,21 @@ function extractOpenCodeContent(data) { .join(''); } +/** + * 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。 + * + * 先 POST `/session` 取得 session id(缺少則拋錯),再 POST + * `/session/{id}/message` 送出 system prompt 與使用者內容,最後抽取回應文字。 + * 會發出兩次 HTTP 請求;網路或 API 錯誤會向外拋出,交由呼叫端處理。 + * + * @param {string} baseURL - OpenCode server 基底 URL(尾端斜線會被去除)。 + * @param {string} model - 模型識別字串,將交由 {@link opencodeModelConfig} 解析。 + * @param {string} systemPrompt - 系統提示詞。 + * @param {string} userContent - 使用者輸入內容。 + * @param {Record} headers - 附加於請求的 HTTP 標頭。 + * @returns {Promise<{ content: string, data: object }>} 抽取後的文字內容與原始回應資料。 + * @throws {Error} 當回應中無 session id,或任一 HTTP 請求失敗時。 + */ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) { const base = baseURL.replace(/\/$/, ''); const { providerID, modelID } = opencodeModelConfig(model); @@ -46,6 +87,18 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) return { content: extractOpenCodeContent(resp.data), data: resp.data }; } +/** + * 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。 + * + * 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄 + * usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)` + * 終止整個行程(不會回傳)。 + * + * @param {string} systemPrompt - 系統提示詞。 + * @param {string} userContent - 使用者輸入內容。 + * @returns {Promise} 模型回應的純文字內容。 + * @throws {Error} 當未設定 OpenCode server(缺少 provider)時。 + */ export async function chat(systemPrompt, userContent) { const { provider, baseURL, model } = getLLMConfig(); if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL'); @@ -65,6 +118,16 @@ export async function chat(systemPrompt, userContent) { process.exit(1); } +/** + * 對 OpenCode 送出對話並將回應解析為 JSON 物件/陣列。 + * + * 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。 + * 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。 + * + * @param {string} systemPrompt - 系統提示詞。 + * @param {string} userContent - 使用者輸入內容。 + * @returns {Promise} 解析後的 JSON 值;解析失敗時回傳空陣列 `[]`。 + */ export async function chatJSON(systemPrompt, userContent) { const text = await chat(systemPrompt, userContent); try { @@ -75,6 +138,15 @@ export async function chatJSON(systemPrompt, userContent) { } } +/** + * 去除文字外層的 Markdown code fence(```),用於清理被 code block 包裹的輸出。 + * + * 會 trim、移除開頭 fence(含可選語言標籤與換行)與結尾 fence,再 trim。 + * 對非字串輸入會先以 `String()` 轉換;無 fence 時回傳 trim 後原文。 + * + * @param {*} text - 待清理的內容(會被轉為字串)。 + * @returns {string} 去除外層 fence 並 trim 後的字串。 + */ function stripOuterFence(text) { return String(text) .trim() @@ -83,6 +155,16 @@ function stripOuterFence(text) { .trim(); } +/** + * 從指定索引起,以括號平衡方式擷取一段完整配對的 JSON 子字串。 + * + * 依起始字元判定為物件(`{}`)或陣列(`[]`),逐字元計數巢狀深度, + * 並正確略過字串字面值與其中的跳脫字元,深度歸零時回傳完整片段。 + * + * @param {*} text - 來源內容(會被轉為字串)。 + * @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。 + * @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。 + */ function extractBalancedJSON(text, startIndex) { const source = String(text); const open = source[startIndex]; @@ -116,6 +198,16 @@ function extractBalancedJSON(text, startIndex) { return null; } +/** + * 從可能夾雜雜訊或被 code fence 包裹的文字中,盡力抽出可被 JSON.parse 解析的片段。 + * + * 先去除外層 fence;若整段即為合法 JSON 直接回傳;否則由左至右尋找每個 + * `{`/`[` 起點,以括號平衡擷取候選片段並試解析,回傳第一個成功者; + * 全數失敗則回傳去 fence 後的原文(仍可能非合法 JSON,交由呼叫端再處理)。 + * + * @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。 + * @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。 + */ function extractJSONText(text) { const stripped = stripOuterFence(text); try { diff --git a/app/log.js b/app/log.js index bf3b52d..ba59ea7 100644 --- a/app/log.js +++ b/app/log.js @@ -1,38 +1,107 @@ +/** + * 輸出最上層的「區塊/章節」分隔標題(前綴空行 + `=== 標題 ===`)。 + * 用於切分整個執行流程中彼此獨立的大段落(例如「環境檢查」「執行審查」「發布結果」), + * 讓 CI log 在視覺上分群;屬於最高層級的分隔,內部再以 step / line 等細分。 + * + * @param {string} title - 區塊標題文字。 + * @returns {void} 無回傳值,僅將標題寫入 stdout。 + */ export function section(title) { console.log(`\n=== ${title} ===`); } +/** + * 輸出某個「步驟」的標題(前綴空行 + `[步驟代號] 標題`)。 + * 適合在一個 section 之下標示流程中的各個有序步驟(如 `[1] 載入設定`、`[2] 呼叫模型`), + * 之後再用 input / output / line 等細項函式描述該步驟的細節。 + * + * @param {string} stepName - 步驟代號或編號,會以中括號包覆顯示。 + * @param {string} title - 步驟標題文字。 + * @returns {void} 無回傳值,僅將步驟標題寫入 stdout。 + */ export function step(stepName, title) { console.log(`\n[${stepName}] ${title}`); } +/** + * 輸出一筆縮排的一般明細列(` - 訊息`)。 + * 用於在某個 step 之下列出不帶語意成敗的中性資訊(例如逐項說明、設定值、進度敘述); + * 若要表達輸入/輸出或成敗,請改用 input / output / result / ok 等更具語意的函式。 + * + * @param {string} message - 要顯示的明細訊息。 + * @returns {void} 無回傳值,僅將明細寫入 stdout。 + */ export function line(message) { console.log(` - ${message}`); } -/** 階段輸入:這個階段吃進什麼。 */ +/** + * 輸出「階段輸入」描述(` ← 輸入:訊息`),標示目前步驟吃進了什麼資料。 + * 在一個步驟開始處理前,用來明確記錄其輸入來源或內容,方便日後對照輸出(output)追蹤資料流。 + * + * @param {string} message - 描述輸入內容的訊息。 + * @returns {void} 無回傳值,僅將輸入描述寫入 stdout。 + */ export function input(message) { console.log(` ← 輸入:${message}`); } -/** 階段輸出:這個階段產出什麼。 */ +/** + * 輸出「階段輸出」描述(` → 輸出:訊息`),標示目前步驟產出了什麼結果。 + * 在一個步驟處理完成後,用來記錄其產出,與 input 搭配可在 log 中清楚呈現該步驟的資料流向。 + * + * @param {string} message - 描述輸出內容的訊息。 + * @returns {void} 無回傳值,僅將輸出描述寫入 stdout。 + */ export function output(message) { console.log(` → 輸出:${message}`); } -/** 檢查/把關結果:明確標示成功或失敗。 */ +/** + * 輸出一筆檢查/把關結果列,依結果以 `✅ 成功` 或 `❌ 失敗` 為前綴(` ✅ 成功:訊息`)。 + * 用於明確標示某個驗證、條件判斷或 gate 的通過與否; + * 需要由布林值決定成敗、且希望成功與失敗使用一致格式時最適合(注意:失敗仍寫入 stdout,非 stderr)。 + * + * @param {boolean} passed - 結果是否通過;`true` 顯示成功、`false` 顯示失敗。 + * @param {string} message - 描述該結果的訊息。 + * @returns {void} 無回傳值,僅將結果寫入 stdout。 + */ export function result(passed, message) { console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`); } +/** + * 輸出一筆成功/完成訊息(` ✓ 訊息`)。 + * 用於確認某項動作已順利完成的正向回饋;當只需表達成功、無需處理失敗分支時使用, + * 若需依條件同時涵蓋成功與失敗請改用 result,需要警告或錯誤請改用 warn / error。 + * + * @param {string} message - 描述成功內容的訊息。 + * @returns {void} 無回傳值,僅將成功訊息寫入 stdout。 + */ export function ok(message) { console.log(` ✓ ${message}`); } +/** + * 輸出一筆警告訊息(` ! 訊息`),透過 `console.warn` 寫入 stderr。 + * 用於流程仍可繼續、但需要提醒使用者注意的非致命狀況(例如使用了預設值、跳過某項可選步驟); + * 比 line/ok 更醒目,但比 error 輕,真正導致失敗的狀況請改用 error。 + * + * @param {string} message - 要顯示的警告訊息。 + * @returns {void} 無回傳值,僅將警告訊息寫入 stderr。 + */ export function warn(message) { console.warn(` ! ${message}`); } +/** + * 輸出一筆錯誤訊息(` x 訊息`),透過 `console.error` 寫入 stderr。 + * 用於明確的失敗或例外狀況,是日誌中最高的嚴重層級; + * 適合在捕捉到錯誤或前置條件不滿足而無法繼續時使用,僅需提醒注意的非致命狀況請改用 warn。 + * + * @param {string} message - 要顯示的錯誤訊息。 + * @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。 + */ export function error(message) { console.error(` x ${message}`); } diff --git a/app/main.js b/app/main.js index 9b128cb..ec3974e 100644 --- a/app/main.js +++ b/app/main.js @@ -13,6 +13,44 @@ import { section, step, line, input, output, result, warn, error } from './log.j const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; +/** + * AI Code Review Pipeline 的總指揮(orchestrator)。 + * + * 依序串接 Step1~Step11:啟動參數讀取、前置驗證、自動提交檢查、PR 對話收斂、 + * 角色平行分析產生 findings、新舊 findings 合併與語意去重、排除規則與誤報過濾、 + * 寫入 findings 並發布 Gitea Review、findings/exclusions JSON 格式驗證、 + * 記憶區 commit/push,以及嚴重問題把關。 + * + * 結果主要透過 `process.exit()` 決定 workflow 成敗,而非以回傳值傳遞。 + * + * @async + * @returns {Promise} 流程正常走完(無嚴重問題)時 resolve;多數結束路徑會直接 + * 呼叫 `process.exit()` 結束程序,函式不會以回傳值回報審查結果。 + * @throws {Error} 內部未被個別 try/catch 攔截的未預期例外會向上拋出, + * 由頂層 `main().catch(...)` 接住並以 `process.exit(1)` 結束。 + * + * @remarks + * 流程階段(Step1~Step11): + * - Step1 啟動:讀取 repo / PR / 分支等基本參數。 + * - Step2 前置驗證:`runPreflight`,未通過則 exit 1。 + * - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。 + * - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。 + * - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號; + * 未設定 API Key 或取 diff 失敗 exit 1,diff 為空 exit 0。 + * - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。 + * - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。 + * - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。 + * - Step9 JSON 驗證:驗證 findings/exclusions 檔,格式錯誤 exit 1,缺檔則建立空陣列檔。 + * - Step10 記憶區 commit/push:依是否有 critical 計算 reviewOutcome 後推回來源分支。 + * - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。 + * + * 退出行為: + * - exit 1:前置驗證未過、上輪 bot failure、未設定 LLM Key、取 diff 失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。 + * - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。 + * + * 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、 + * Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。 + */ async function main() { section('AI Code Review Pipeline'); diff --git a/app/preflight.js b/app/preflight.js index 505526c..3f94fb6 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -13,24 +13,72 @@ import { verifyRemoteAccess } from './git.js'; import { step, line, ok, error, result } from './log.js'; const httpsAgent = new https.Agent({ rejectUnauthorized: false }); +/** + * 組出 Gitea REST API v1 的完整網址。 + * + * 會將模組層級的 GITEA_SERVER_URL 尾端斜線去除後串接 `/api/v1` 與傳入路徑。 + * @param {string} path - 以 `/` 開頭的 API 子路徑,例如 `/repos/owner/name`。 + * @returns {string} 完整可請求的 API URL。 + * @remarks 依賴模組層級常數 GITEA_SERVER_URL;若該值為空會丟出 TypeError。 + */ const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; +/** + * 產生呼叫 Gitea API 用的 HTTP headers。 + * + * Authorization 採 Gitea 的 `token ` 認證格式。 + * @param {string} token - Gitea 個人存取權杖(personal access token)。 + * @returns {{Authorization: string, 'Content-Type': string}} 可直接交給 axios 的 headers 物件。 + */ const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); +/** + * 將模型字串解析為 OpenCode 的 providerID 與 modelID。 + * + * 若 model 含 `/` 則以斜線拆分為 provider/model;否則 provider 取 + * 環境變數 OPENCODE_PROVIDER(預設 `google`),model 即原字串。 + * @param {string} model - 模型識別字串,例如 `google/gemini-2.0` 或 `gemini-2.0`。 + * @returns {{providerID: string, modelID: string}} 解析後的 provider 與 model 識別碼。 + * @remarks 讀取 process.env.OPENCODE_PROVIDER;split 上限為 2 段,多餘段落會被忽略。 + */ const opencodeModelConfig = (model) => { const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; return { providerID, modelID }; }; +/** + * 組出呼叫 OpenCode server 用的 axios 請求設定。 + * + * 固定 30 秒逾時,並套用 config.js 的 getOpenCodeHttpsAgent() 作為 httpsAgent。 + * @param {object} headers - 要套用的 HTTP headers 物件。 + * @returns {{headers: object, timeout: number, httpsAgent: import('https').Agent}} axios 設定物件。 + * @remarks 每次呼叫都會執行 getOpenCodeHttpsAgent() 取得 agent。 + */ const opencodeAxiosOptions = (headers) => ({ headers, timeout: 30000, httpsAgent: getOpenCodeHttpsAgent(), }); +/** + * 將(axios)錯誤格式化為易讀的訊息字串。 + * + * 有 HTTP 回應狀態碼時輸出 `HTTP `,否則僅輸出 message。 + * @param {Error & {response?: {status?: number}, message: string}} e - 捕捉到的錯誤物件。 + * @returns {string} 格式化後的錯誤描述。 + */ function giteaErr(e) { const status = e.response?.status; return status ? `HTTP ${status} ${e.message}` : e.message; } -/** 檢查必要環境變數是否齊全;可傳入覆寫值供測試使用 */ +/** + * 檢查 code review 所需的必要環境變數是否齊全。 + * + * 用法:preflight 第一關,缺任何一項即視為不通過並列出缺少項目。 + * @param {object} [opts] - 覆寫值,供測試注入;省略時各欄取模組層級常數預設值。 + * @param {string} [opts.token=GITEA_TOKEN] - Gitea token。 + * @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。 + * @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。 + * @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。 + */ export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) { const missing = []; if (!token) missing.push('GITEA_TOKEN'); @@ -39,7 +87,15 @@ export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, return { ok: missing.length === 0, missing }; } -/** 用 GITEA_TOKEN 讀取此 repo,同時驗證 token 有效與有讀取權限 */ +/** + * 驗證 Gitea token 有效且對指定 repo 有讀取權限。 + * + * 透過唯讀的 `GET /repos/{repo}` 探測;任何錯誤都被攔截並轉為回傳值,不會 throw。 + * 採用 rejectUnauthorized:false 的 httpsAgent(不驗證 TLS 憑證)。 + * @param {string} [token=GITEA_TOKEN] - Gitea token,可注入供測試。 + * @param {string} [repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo,可注入供測試。 + * @returns {Promise<{ok: true}|{ok: false, error: string}>} 成功僅含 ok;失敗含格式化錯誤訊息。 + */ export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSITORY) { try { await axios.get(api(`/repos/${repo}`), { headers: giteaHeaders(token), timeout: 30000, httpsAgent }); @@ -49,7 +105,14 @@ export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSIT } } -/** 若有提供 comment token,用它呼叫 /user 驗證可用;沒提供則略過 */ +/** + * 驗證選用的 comment token(GITEA_COMMENT_TOKEN)是否可用。 + * + * 未提供 token 時直接視為通過並標記 skipped:true(之後 comment 會沿用主 token); + * 有提供則以 `GET /user` 探測。錯誤被攔截轉為回傳值,不會 throw。 + * @param {string} [token=GITEA_COMMENT_TOKEN] - 專用於發布 comment 的 token,可注入供測試。 + * @returns {Promise<{ok: true, skipped?: true}|{ok: false, error: string}>} skipped 表示未提供而略過。 + */ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { if (!token) return { ok: true, skipped: true }; try { @@ -61,9 +124,13 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { } /** - * 驗證 LLM 設定可用: - * - 僅支援 OpenCode server - * - 檢查 OpenCode base URL 是否可連線,並確認 provider/model 已設定 + * 驗證 LLM(OpenCode server)設定可用。 + * + * 依序確認:已設定 provider、有 base URL、health 端點可連線、OpenCode 已設定 + * 對應 provider 且其列出指定 model。任一不符回傳對應錯誤;錯誤被攔截不會 throw。 + * @returns {Promise<{ok: true, provider: string}|{ok: false, provider?: string, error: string}>} + * 通過時含 provider;未設定 provider 的失敗分支不含 provider 欄位。 + * @remarks 設定來源為 config.js 的 getLLMConfig();provider/model 鍵的比對使用 opencodeModelConfig 解析後的 providerID/modelID。 */ export async function verifyLLM() { const { provider, baseURL, model } = getLLMConfig(); @@ -87,8 +154,19 @@ export async function verifyLLM() { } /** - * 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。 - * 僅做唯讀的認證/連線確認,不發布任何 comment。 + * 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM。 + * + * 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。 + * 各檢查可經 deps 注入覆寫,方便單元測試。 + * @param {string} [workspace=process.env.GITHUB_WORKSPACE||'/workspace'] - git 遠端驗證用的工作目錄。 + * @param {object} [deps] - 依賴注入,覆寫各檢查函式(預設為本模組/ git.js 的實作)。 + * @param {Function} [deps.checkEnv=checkRequiredEnv] - 環境變數檢查。 + * @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。 + * @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。 + * @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。 + * @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM(OpenCode)連線驗證。 + * @returns {Promise} 全部通過為 true,任一失敗為 false。 + * @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。 */ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) { const { diff --git a/app/resolve.js b/app/resolve.js index 5ac1b68..5308344 100644 --- a/app/resolve.js +++ b/app/resolve.js @@ -13,7 +13,15 @@ const FIELD_PATTERNS = { 建議: /\*\*建議\*\*[::]\s*(.+)/, }; -/** 取出 "**label**:value" 這一行的 value(單行)。 */ +/** + * 從 Markdown 內文擷取單行欄位值,對應格式為 `**標籤**:value`(全形或半形冒號皆可)。 + * 僅支援預先編譯於 FIELD_PATTERNS 的標籤:嚴重等級/等級/審查員/問題/建議; + * 標籤不在表內或無命中時回傳空字串。 + * @param {string} body - 已正規化換行(\n)的留言內文;呼叫端須先確保為字串。 + * @param {('嚴重等級'|'等級'|'審查員'|'問題'|'建議')} label - 要擷取的欄位標籤鍵。 + * @returns {string} 該行的 value(已 trim);找不到或標籤不支援時為 ''。 + * @remarks 正則為靜態定義,避免每次呼叫重建並排除以外部輸入動態組 regex 的注入風險。 + */ function fieldValue(body, label) { const re = FIELD_PATTERNS[label]; if (!re) return ''; @@ -21,6 +29,12 @@ function fieldValue(body, label) { return m ? m[1].trim() : ''; } +/** + * 將中文嚴重等級描述(如「嚴重」「警告」「建議」)映射為內部標準鍵。 + * 採子字串比對且依序判斷,第一個命中者勝出。 + * @param {string} raw - 來自留言的嚴重等級文字(可能為空)。 + * @returns {('critical'|'warning'|'info'|null)} 對應的內部鍵;空字串或無法辨識時回傳 null。 + */ function levelToKey(raw) { if (!raw) return null; if (raw.includes('嚴重')) return 'critical'; @@ -124,12 +138,24 @@ export async function judgeConversations(items, chatFn = chatJSON) { return items.map(it => ({ idx: it.idx, verdict: byIdx.get(it.idx) || 'open' })); } +/** + * 將一段仍成立(open)對話對應的 bot finding 加入結轉清單,標記 is_new=false 表示為延續的舊問題。 + * 若該對話無 botFinding 則不做任何事。 + * @param {Array} target - 接收結轉 finding 的陣列(會被就地 push)。 + * @param {{botFinding: object|null}} conversation - 對話群組(取其 botFinding)。 + * @returns {void} + */ function pushCarried(target, conversation) { if (!conversation.botFinding) return; target.push({ ...conversation.botFinding, is_new: false }); } -/** 把判定為誤報的 bot finding 轉成 exclusions.json 的排除條目。 */ +/** + * 將判定為誤報的 bot finding 轉成 exclusions.json 的排除條目。 + * original_finding 取 suggestion,缺則退回 problem 再退回空字串;reason 為固定的誤報說明。 + * @param {{location: string, role: string, suggestion?: string, problem?: string}} botFinding - 被判為誤報的 finding(呼叫端須確保非 null)。 + * @returns {{location: string, role: string, original_finding: string, reason: string}} 排除條目。 + */ function toExclusion(botFinding) { return { location: botFinding.location, @@ -140,8 +166,10 @@ function toExclusion(botFinding) { } /** - * 僅允許 repo 內的相對路徑:排除絕對路徑(/ 或 Windows 磁碟機)與含 `..` 的路徑穿越。 - * comment 的 path 源自外部(PR 內檔名),用此守衛避免被用來讀取 repo 外的檔案。 + * 安全守衛:判定路徑是否為 repo 內的相對路徑(拒絕絕對路徑、Windows 磁碟機前綴與含 `..` 的路徑穿越)。 + * 用於防止以外部 PR 檔名讀取 repo 外的檔案。 + * @param {string} p - 待檢查的檔案路徑。 + * @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。 */ function isSafeRepoPath(p) { if (typeof p !== 'string' || p === '') return false; @@ -263,10 +291,21 @@ export async function reconcileConversations(deps = {}) { }; } +/** + * 從 location(格式 `path:line`)取出檔案路徑部分(以第一個冒號切割並 trim)。 + * @param {string} location - 位置字串,可能為 `path:line` 或僅 `path`(容許 null/undefined)。 + * @returns {string} 檔案路徑;無輸入時為空字串。 + */ function fileOf(location) { return String(location || '').split(':')[0].trim(); } +/** + * 將文字正規化為穩定比對鍵:NFKC 正規化後移除所有標點/符號/空白,再 trim 並轉小寫。 + * 用於讓 finding 簽章對標點與空白差異不敏感。 + * @param {string} text - 待正規化文字(容許 null/undefined)。 + * @returns {string} 正規化後的小寫鍵。 + */ function normalizeKey(text) { return String(text || '') .normalize('NFKC') @@ -275,7 +314,11 @@ function normalizeKey(text) { .toLowerCase(); } -/** 以「檔案路徑 + 正規化建議內容」為簽章,對 line 漂移與標點差異穩定。 */ +/** + * 計算 finding 的去重簽章:以「檔案路徑 + 正規化建議內容」組成,對行號漂移與標點差異穩定。 + * @param {{location?: string, suggestion?: string}} f - finding 物件(容許欄位缺漏)。 + * @returns {string} 形如 `檔案路徑|正規化建議` 的簽章字串。 + */ function findingSig(f) { return `${fileOf(f?.location)}|${normalizeKey(f?.suggestion)}`; } diff --git a/app/roles.js b/app/roles.js index 67fd066..4f2d34e 100644 --- a/app/roles.js +++ b/app/roles.js @@ -7,8 +7,20 @@ import { warn } from './log.js'; const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles'); /** - * 解析單一角色 .md 檔:前置 YAML frontmatter(徽章、代表色、面向、個性等)+ 本文(審查重點)。 - * 回傳合併後的角色物件:{ name, side, focus, badge, color, personality, body }。 + * 解析單一角色 Markdown 檔內容,拆出前置 YAML frontmatter 與本文。 + * + * 會先將 CRLF 正規化為 LF,再以 `---` 分隔線切出 frontmatter(徽章、代表色、 + * 面向、個性等欄位)與其後的本文(審查重點 / 裁決準則)。frontmatter 欄位會 + * 被攤平到回傳物件,本文則放入 `body`(已去除頭尾空白)。 + * + * @param {string} content - 角色 `.md` 檔的完整文字內容。 + * @returns {{ name?: string, side?: string, focus?: string, badge?: string, + * color?: string, personality?: string, body: string, + * [key: string]: unknown }} 合併 frontmatter 與本文後的角色物件。 + * @throws {Error} 當內容缺少合法 `---` frontmatter 區塊時拋出「角色檔缺少 frontmatter」。 + * @throws {import('js-yaml').YAMLException} 當 frontmatter 不是合法 YAML 時(由 `yaml.load` 拋出,未攔截)。 + * + * @remarks 純字串處理,無任何檔案 IO;frontmatter 中若自帶 `body` 欄位會被本文覆蓋。 */ export function parseRoleFile(content) { const normalized = content.replace(/\r\n/g, '\n'); @@ -21,8 +33,16 @@ export function parseRoleFile(content) { let cachedRoles = null; /** - * 讀取並解析所有角色 .md,結果快取於模組層級(單次程序生命週期內檔案不變)。 - * 單一檔案解析失敗(壞 YAML、缺 frontmatter 等)時記錄警告並略過,不讓整個流程崩潰。 + * 讀取並解析 `ROLES_DIR` 下所有角色 `.md` 檔,依檔名排序後回傳角色陣列。 + * + * 結果快取於模組層級(`cachedRoles`),同一程序生命週期內只讀檔一次;之後即使 + * 角色檔有變動也不會重新載入,需重啟程序才會生效。單一檔案解析失敗(壞 YAML、 + * 缺 frontmatter 等)只記錄警告並略過,不會中斷其他角色的載入。 + * + * @returns {Array>} 已解析的角色物件陣列(依檔名排序)。 + * + * @remarks 模組私有函式;使用同步檔案 IO。目錄不存在或無權限時,`fs.readdirSync` + * 會在容錯範圍外拋出錯誤。 */ function readRoleFiles() { if (cachedRoles) return cachedRoles; @@ -39,22 +59,47 @@ function readRoleFiles() { } /** - * 載入攻擊方角色(Step3 產生 findings 用),依檔名排序。 - * 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。 + * 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。 + * + * 供 Step3 產生 findings 階段使用。防守方角色(如 Paladin)不在回傳之列, + * 其裁決邏輯由去重 / 誤報過濾流程處理。 + * + * @returns {Array>} 攻擊方角色物件陣列。 + * + * @remarks 透過 `readRoleFiles` 取得快取後的全部角色再過濾,首次呼叫會觸發檔案讀取。 */ export function loadRoles() { return readRoleFiles().filter(r => r.side === 'attack'); } -/** 依 frontmatter name 取得單一角色(不分大小寫),找不到回傳 null。 */ +/** + * 依 frontmatter `name` 取得單一角色(比對不分大小寫),找不到回傳 `null`。 + * + * 不分攻擊方 / 防守方,所有已成功載入的角色皆可查得。 + * + * @param {string} name - 角色名稱(大小寫不拘)。 + * @returns {ReturnType | null} 對應角色物件,無對應時為 `null`。 + * + * @remarks 透過 `readRoleFiles` 取得快取角色清單,首次呼叫會觸發檔案讀取。 + */ export function loadRole(name) { const target = String(name).toLowerCase(); return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null; } /** - * 由角色定義組出攻擊方的 system prompt: - * 套用其個性與審查重點本文,並要求以固定 JSON 陣列格式回傳 findings。 + * 由攻擊方角色定義組出其分析用 system prompt。 + * + * 套用角色的徽章、名稱、面向(focus,缺省為「綜合」)、個性(personality,可選) + * 與審查重點本文(body),並附上固定指示:分析 Git Diff 僅針對新增/修改處找問題, + * 並以固定 JSON 陣列格式(level / role / location / problem / suggestion)回傳 findings, + * 強制每條問題帶 `檔案路徑:行號`。 + * + * @param {ReturnType} role - 攻擊方角色物件(需含 `name`、`body`;`badge`/`focus`/`personality` 可選)。 + * @returns {string} 組裝完成的多行 system prompt 文字。 + * @throws {TypeError} 當 `role` 為 `null`/`undefined` 時(未做防呆,存取屬性即拋出)。 + * + * @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。 */ export function buildAnalysisPrompt(role) { return [ @@ -90,8 +135,16 @@ export function buildAnalysisPrompt(role) { } /** - * 由角色定義組出「補行號」的 system prompt: - * 當該角色先前提出的問題只有檔名、缺行號時,請它對照 Git Diff 找出實際行號。 + * 組出「補行號」用的 system prompt。 + * + * 用於某角色先前提出的 finding 其 `location` 只有檔名、缺行號的情境:請 LLM 對照 + * 該檔 Git Diff 找出問題對應的實際行號,並只回 `{"line": 數字}`(找不到回 `{"line": 0}`)。 + * + * @param {ReturnType | null | undefined} [role] - 角色物件;可省略或為 null, + * 此時名稱退回 `'AI Review'` 且不帶徽章與面向子句。 + * @returns {string} 組裝完成的多行 system prompt 文字。 + * + * @remarks 使用選擇性串接(`?.`),對 `role` 為空值具防呆,不會拋出例外。 */ export function buildLocateLinePrompt(role) { const name = role?.name || 'AI Review'; @@ -104,9 +157,18 @@ export function buildLocateLinePrompt(role) { } /** - * 由防守方角色定義組出「單條 finding 誤報裁決」的 system prompt: - * 套用其個性與裁決準則本文,要求對一條 finding 判定成立或誤報,回固定 JSON 物件。 - * role 為 null 時退回不帶角色的通用裁判 prompt。 + * 由防守方角色定義組出「單條 finding 誤報裁決」用的 system prompt。 + * + * `role` 存在時套用其徽章、名稱、面向(focus,缺省「裁決」)、個性與裁決準則本文(body); + * `role` 為空值時退回固定的通用裁判 persona(🛡️ Paladin 聖騎士)。prompt 要求對一條 + * 攻擊方 finding 判定「成立 / 誤報」,並只回 `{"verdict", "reason"}`;無法確定時一律回 + * `"confirmed"`(寧可保留、不冤枉)。 + * + * @param {ReturnType | null | undefined} role - 防守方角色物件;為空值時改用通用裁判 persona。 + * @param {string} [exclusionHint=''] - 額外的排除 / 已知誤報提示文字;為空字串時該行會被略過。 + * @returns {string} 組裝完成的多行 system prompt 文字。 + * + * @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。 */ export function buildVerdictPrompt(role, exclusionHint = '') { const persona = role @@ -129,6 +191,18 @@ export function buildVerdictPrompt(role, exclusionHint = '') { ].filter(l => l !== '').join('\n'); } +/** + * 由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格。 + * + * 表格含三欄:角色(粗體,含徽章)、面向(focus)、個性(personality);缺省欄位以空字串呈現。 + * 通常用於 PR 留言 / 審查報告開頭呈現參與審查的角色陣容。 + * + * @param {Array>} roles - 角色物件陣列(每個可含 `badge`/`name`/`focus`/`personality`)。 + * @returns {string} Markdown 格式的多行表格字串。 + * @throws {TypeError} 當 `roles` 非可迭代值(如 `null`/`undefined`)時,`for...of` 會拋出。 + * + * @remarks 純字串組裝,無副作用;傳入空陣列會得到只有標題與表頭的表格。 + */ export function getRoleIntro(roles) { const lines = [ '## 🤖 AI Code Review 團隊', '', diff --git a/app/usage.js b/app/usage.js index e8e668e..9376b56 100644 --- a/app/usage.js +++ b/app/usage.js @@ -4,6 +4,12 @@ import { warn } from './log.js'; /** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */ const runUsage = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 }; +/** + * 安全數字轉換:將任意輸入轉為有限數字,無法轉換或非有限值(NaN/Infinity)一律回 0。 + * 常用於正規化外部 API 回應或 HTTP header 取出的值,避免污染後續加總與百分比運算。 + * @param {*} x 任意待轉換的值。 + * @returns {number} 有限數字;否則為 0。 + */ function num(x) { const n = Number(x); return Number.isFinite(n) ? n : 0; @@ -83,7 +89,12 @@ export function resetRunUsage() { /** 最近一次回應的速率配額(rate limit)快照,用來計算「當前視窗剩餘百分比」。 */ const rateLimit = { hasData: false, remaining: null, limit: null, kind: null }; -/** 將物件的 key 全部轉小寫,方便對大小寫不敏感的 HTTP header 取值。 */ +/** + * 將物件第一層的 key 全部轉為小寫並回傳新物件,方便對大小寫不敏感的 HTTP header 取值。 + * 不修改傳入物件;僅處理第一層 key。呼叫端須自行確保傳入為物件。 + * @param {Object} obj 來源物件(通常為 HTTP response headers)。 + * @returns {Object} key 全小寫的新物件。 + */ function lowerCaseKeys(obj) { const out = {}; for (const k of Object.keys(obj)) out[k.toLowerCase()] = obj[k]; @@ -128,12 +139,20 @@ export function resetRateLimit() { rateLimit.kind = null; } +/** + * 去除字串結尾的單一斜線(常用於正規化 baseURL 以利串接路徑)。 + * 空值會被視為空字串;僅移除最後一個斜線,不處理連續尾斜線。 + * @param {*} s 來源字串(通常為 URL)。 + * @returns {string} 去除結尾斜線後的字串。 + */ const stripSlash = (s) => String(s || '').replace(/\/$/, ''); /** - * 以實際 hostname 精確比對是否為 OpenRouter(僅接受 apex 域名 `openrouter.ai`), - * 避免被偽造的 baseURL(如 `openrouter.ai.evil.com`、`evil.com/openrouter.ai` 或任何子網域) - * 矇騙而把 API key 送往非 OpenRouter 主機。 + * 以解析後的 hostname 精確比對 baseURL 是否為 OpenRouter(僅接受 apex 域名 openrouter.ai)。 + * 用於 fetchAccountQuota 的安全守門:防止被偽造或子網域 baseURL 矇騙而外洩 API key。 + * 無法解析為合法 URL 時回 false(不丟例外)。 + * @param {string} baseURL 待驗證的 base URL。 + * @returns {boolean} hostname 恰為 openrouter.ai 時為 true,否則 false。 */ function isOpenRouterBaseURL(baseURL) { try { @@ -144,8 +163,14 @@ function isOpenRouterBaseURL(baseURL) { } /** - * OpenRouter:以 API key 呼叫 GET /auth/key 取得額度(可靠)。 - * 回傳金額單位為 USD credits。 + * 呼叫 OpenRouter GET /auth/key,以 API key 取得帳號額度(單位 USD credits)。 + * remaining 缺漏時以 limit - used 推算;limit 為 null(無上限)時 remaining 亦為 null。 + * 本函式不攔截例外;HTTP/網路錯誤會向上拋出,由呼叫端負責降級。 + * @param {{apiKey:string, baseURL:string}} cfg 連線設定(API key 與 base URL)。 + * @param {function(string, object): Promise<{data:*}>} get HTTP GET 函式(可注入,預設 axios.get)。 + * @returns {Promise<{available:true, used:number, limit:number|null, remaining:number|null, currency:'USD', source:'openrouter'}>} + * 額度資訊。 + * @throws {Error} HTTP 請求失敗(逾時、非 2xx、網路錯誤等)時拋出。 */ async function fetchOpenRouterQuota({ apiKey, baseURL }, get) { const resp = await get(`${stripSlash(baseURL)}/auth/key`, { @@ -193,7 +218,11 @@ export async function fetchAccountQuota(provider, config = {}, deps = {}) { } } -/** 千分位整數/小數格式。 */ +/** + * 將數字格式化為千分位字串(保留原有小數)。null/undefined/NaN 一律回 '0'。 + * @param {number|string|null|undefined} n 待格式化的數值。 + * @returns {string} 千分位字串,例如 1234567 → '1,234,567'。 + */ function fmt(n) { if (n == null || Number.isNaN(Number(n))) return '0'; const [int, frac] = String(Number(n)).split('.'); @@ -201,10 +230,22 @@ function fmt(n) { return frac ? `${withCommas}.${frac}` : withCommas; } +/** + * 將數值格式化為金額字串:有幣別時前綴幣別(如 'USD 1,234'),無幣別則只回千分位數字。 + * @param {string} currency 幣別代碼(空字串/falsy 表示無幣別)。 + * @param {number|string|null|undefined} n 數值。 + * @returns {string} 格式化後的金額字串。 + */ function money(currency, n) { return currency ? `${currency} ${fmt(n)}` : fmt(n); } +/** + * 四捨五入到小數一位(用於百分比顯示)。 + * 不對非數字防呆;非有限輸入會得到 NaN(呼叫端應先確保為有限數)。 + * @param {number|string} n 數值。 + * @returns {number} 四捨五入到一位小數的結果。 + */ function round1(n) { return Math.round(Number(n) * 10) / 10; } @@ -212,9 +253,12 @@ function round1(n) { const RATE_KIND_LABEL = { tokens: 'token', requests: '次數' }; /** - * 計算「剩餘百分比」= remaining / limit × 100。 - * limit 或 remaining 為 null/undefined/NaN/Infinity,或 limit ≤ 0 時回 null, - * 避免算出 Infinity%/NaN%/負百分比或除以零。 + * 計算剩餘百分比(remaining / limit × 100),四捨五入到一位小數。 + * 任一參數為 null/undefined/NaN/Infinity,或 limit ≤ 0 時回 null, + * 以避免算出 Infinity%、NaN%、負百分比或除以零。 + * @param {number|null|undefined} remaining 剩餘量。 + * @param {number|null|undefined} limit 上限。 + * @returns {number|null} 百分比(一位小數);無法計算時為 null。 */ function calculatePercent(remaining, limit) { if (remaining == null || limit == null) return null; @@ -253,6 +297,13 @@ export function resolveRemainingPercent(quota, rate) { return { percent: null, reason }; } +/** + * 把 resolveRemainingPercent 的結果格式化為一行 Markdown 文字(剩餘可用百分比與明細)。 + * 無法計算時輸出帶原因的說明字串。 + * @param {{percent:number, basis:string, remaining:number, limit:number, unit:string}|{percent:null, reason:string}} pct + * resolveRemainingPercent 的回傳值。 + * @returns {string} 單行 Markdown 字串。 + */ function remainingLine(pct) { if (pct.percent == null) return `剩餘可用:無法計算百分比(${pct.reason})`; const detail = `${pct.basis}:${money(pct.unit, pct.remaining)} / ${money(pct.unit, pct.limit)}`; diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index 5aa28d5..2db8074 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,25 @@ #!/bin/bash +# ============================================================ +# 用途:Docker action 的進入點(entrypoint),負責啟動 Node.js +# 撰寫的 AI code review 執行器(review runner)。 +# 此 script 為容器啟動時執行的第一支程式。 +# 更新日期:2026/06/26 11:34:46 +# ============================================================ + +# set -e:開啟「遇到任何指令回傳非零(失敗)即立刻中止 script」模式。 +# 為什麼:避免前面步驟失敗卻仍繼續往下執行,確保 review runner 在 +# 乾淨且可預期的狀態下啟動。 +# 副作用:之後任一指令失敗會讓整支 entrypoint(連同容器)以非零碼結束。 set -e +# echo:印出啟動提示訊息到 stdout,方便在 CI/容器 log 中辨識啟動點。 +# 為什麼:提供可觀測性,確認 entrypoint 已被執行。 +# 副作用:僅輸出文字,無其他影響。 echo "🚀 AI Code Review Action 啟動" +# exec node /app/main.js:用 node 進程「取代」目前的 shell 進程來執行 +# 主程式 main.js(AI code review 的實際邏輯入口)。 +# 為什麼:使用 exec 而非直接呼叫,可讓 node 成為 PID 1,正確接收 +# 容器的訊號(如 SIGTERM),達成優雅關閉並避免殭屍 shell。 +# 副作用:此行之後的任何指令都不會被執行;node 的結束碼即為容器結束碼。 exec node /app/main.js