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