Compare commits
11
Commits
v0.0.2
...
v0.0.4-beta.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab384fe108 | ||
|
|
938db793a7 | ||
|
|
a8d3fb60ed | ||
|
|
65dcb52777 | ||
|
|
605d557455 | ||
|
|
d21e2f0e12 | ||
|
|
409536b341 | ||
|
|
dcd80750ba | ||
|
|
5e9bd86bbc | ||
|
|
651e221e90 | ||
|
|
0eb30cf9d4 |
@@ -0,0 +1,69 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"addedAt": "2026/08/07 16:47:53",
|
||||||
|
"prNumber": 4,
|
||||||
|
"reviewer": "Bard",
|
||||||
|
"severity": "警告",
|
||||||
|
"file": "readme.md",
|
||||||
|
"startLine": 3,
|
||||||
|
"endLine": 3,
|
||||||
|
"problem": "這份 README 已經長成機械化的 API 編目,還把時間戳與大量硬編碼連結一起寫進來,讓主文件變得又厚又脆,讀者很難快速抓到重點。",
|
||||||
|
"reason": "此專案的 README 本身就是生成式 API 參考文件,維持完整索引與連結有助於內部使用,屬於文件取捨而非功能性缺陷。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "src/main.js:59",
|
||||||
|
"role": "Bard",
|
||||||
|
"original_finding": "刪掉這種會隨流程變動而失真的數量型描述;若真要提醒收尾差異,改成更穩定的概念性說明即可。",
|
||||||
|
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "entrypoint.sh:2",
|
||||||
|
"role": "Bard",
|
||||||
|
"original_finding": "移除這種會過期的時間戳註解,只保留真正需要提醒讀者的簡短說明即可。",
|
||||||
|
"reason": "AI 對話收斂判定為誤報(問題在最新程式碼中不成立或不適用)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addedAt": "2026/08/07 16:39:03",
|
||||||
|
"prNumber": null,
|
||||||
|
"reviewer": "Assassin",
|
||||||
|
"severity": "警告",
|
||||||
|
"file": "action.yml",
|
||||||
|
"startLine": 14,
|
||||||
|
"endLine": 14,
|
||||||
|
"problem": "action.yml 中新增的 inputs.model 沒有在 GitHub Actions 層面進行輸入驗證。雖然描述寫著「僅允許英數字、點、底線與連字號」,但使用者可以提供任意字符,這些值會先進入環境變數,再由程式端驗證。",
|
||||||
|
"reason": "GitHub / Gitea 的 action schema 不提供字串輸入的正則驗證;本專案已在程式端做完整驗證,action.yml 無法再向前移到平台層。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addedAt": "2026/08/07 16:39:03",
|
||||||
|
"prNumber": null,
|
||||||
|
"reviewer": "Bard",
|
||||||
|
"severity": "警告",
|
||||||
|
"file": "readme.md",
|
||||||
|
"startLine": 1,
|
||||||
|
"endLine": 1,
|
||||||
|
"problem": "新文件採用小寫 readme.md,和倉庫中常見的 README.md 命名慣例不合。",
|
||||||
|
"reason": "這個檔名是現有專案慣例的一部分,直接改名會牽動大量內部連結與生成內容,屬於文件命名取捨。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addedAt": "2026/08/07 16:39:03",
|
||||||
|
"prNumber": null,
|
||||||
|
"reviewer": "Mage",
|
||||||
|
"severity": "警告",
|
||||||
|
"file": "src/findings.js",
|
||||||
|
"startLine": 658,
|
||||||
|
"endLine": 658,
|
||||||
|
"problem": "applyExclusions 的比對邏輯在 (locationMatches && roleMatches && (textMatches || ...)) 中,若排除規則只指定 filePath 不指定 role,會產生「該檔案內所有角色的問題都被排除」的非預期行為;若只指定 role 不指定 filePath,則「該角色所有檔案的問題都被排除」。此為對稱性缺陷",
|
||||||
|
"reason": "此處的排除規則刻意把 filePath / role 當成可獨立放寬的過濾條件,讓已知誤報可以用較粗粒度收斂;行為與設計一致。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"addedAt": "2026/08/07 16:39:03",
|
||||||
|
"prNumber": null,
|
||||||
|
"reviewer": "Mage",
|
||||||
|
"severity": "建議",
|
||||||
|
"file": "src/resolve.js",
|
||||||
|
"startLine": 84,
|
||||||
|
"endLine": 84,
|
||||||
|
"problem": "groupConversations 在設置 botFinding 時用 botFindings[0],若該對話的 botFindings 陣列為空,botFinding 會為 undefined。",
|
||||||
|
"reason": "程式已將 botFinding 以 null 初始化,且下游邏輯以 botFindings 陣列為主要資料來源;此為相容舊邏輯的保守設計。"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026/08/07 16:39:03",
|
||||||
|
"commitSha": "605d55745542fcd2ba4d2cc3317ba17fb4e35658",
|
||||||
|
"prNumber": null,
|
||||||
|
"tool": {
|
||||||
|
"name": "ai-code-review",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"model": "auto"
|
||||||
|
},
|
||||||
|
"findings": [],
|
||||||
|
"excluded": []
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# 用途:CI workflow 的 command-file 草稿,保留原始流程並補上逐行說明。
|
# ============================================================================
|
||||||
# 更新時間:2026/07/11 19:00:45
|
# 用途:這是 pull request 時計算版本、視情況發佈 release、並在目標分支為 develop 的 beta 情境下執行 AI Code Review 的 CI workflow。
|
||||||
|
# 更新時間:2026/08/07 13:51:53
|
||||||
|
# ============================================================================
|
||||||
# workflow 名稱,對應 Gitea UI 中的顯示標題。
|
# workflow 名稱,對應 Gitea UI 中的顯示標題。
|
||||||
name: CI
|
name: CI
|
||||||
# 定義此 workflow 的觸發事件。
|
# 定義此 workflow 的觸發事件。
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 用途:master workflow 的 command-file 草稿,保留原始流程並補上逐行說明。
|
# ============================================================================
|
||||||
# 更新時間:2026/07/11 19:00:45
|
# 用途:推送到 master 分支後,輸出 Gitea context、查詢 commit 對應 tag,作為部署前資訊檢查的 CD workflow
|
||||||
|
# 更新時間:2026/08/07 13:51:53
|
||||||
|
# ============================================================================
|
||||||
# workflow 名稱,對應 Gitea UI 中的顯示標題。
|
# workflow 名稱,對應 Gitea UI 中的顯示標題。
|
||||||
name: CD
|
name: CD
|
||||||
# 定義此 workflow 的觸發事件。
|
# 定義此 workflow 的觸發事件。
|
||||||
|
|||||||
+44
-35
@@ -1,49 +1,58 @@
|
|||||||
# GITEA NODE ACTION 工作流說明草稿
|
# Gitea Workflow 說明文件
|
||||||
|
|
||||||
更新時間:2026/07/11 18:54:51
|
更新時間:2026/08/07 13:51:53
|
||||||
|
|
||||||
## 總覽
|
## 總覽
|
||||||
|
|
||||||
此專案目前包含兩個 workflow:
|
此專案(`ai-code-review`)目前包含兩個 workflow:
|
||||||
|
|
||||||
- `CI`:處理 pull request 期間的版本計算、釋出與 AI 程式碼審查。
|
| Workflow 名稱 | 檔案位置 | 觸發條件 | 大致用途 |
|
||||||
- `CD`:處理推送到 `master` 分支後的部署相關檢查與資訊輸出。
|
| --- | --- | --- | --- |
|
||||||
|
| `CI` | `.gitea/workflows/ci.yaml` | `pull_request`(`opened`、`synchronize`) | 計算版本號、必要時建立 release,並在目標分支為 `develop`(beta 情境)時執行 AI 程式碼審查(呼叫本專案自身發佈的 `ai-code-review` action) |
|
||||||
|
| `CD` | `.gitea/workflows/master.yaml` | `push` 到 `master` 分支 | 輸出完整 Gitea event context,並查詢指定 commit 對應的 tag,作為後續部署流程的基礎資訊 |
|
||||||
|
|
||||||
|
以下依 workflow 檔案逐一整理細節。
|
||||||
|
|
||||||
## Workflow 明細
|
## Workflow 明細
|
||||||
|
|
||||||
### CI
|
### CI(`.gitea/workflows/ci.yaml`)
|
||||||
|
|
||||||
- 檔案位置:`.gitea/workflows/ci.yaml`
|
- **workflow 名稱**:`CI`(Gitea UI 顯示標題)
|
||||||
- 用途:在 pull request 事件中計算版本,必要時建立 release,並在 beta 情境下執行 AI 程式碼審查。
|
- **檔案位置**:`.gitea/workflows/ci.yaml`
|
||||||
- 觸發條件:`pull_request`,事件類型為 `opened` 與 `synchronize`。
|
- **用途說明**:
|
||||||
- 主要輸入 / 環境參數:
|
- `build` job:計算版本號(呼叫 `calculate-version` action),並用計算出的版本建立 Gitea release/tag。
|
||||||
- `gitea.base_ref`:用來判斷是否為 `develop`,進而決定 `IS_BETA`。
|
- `test` job:僅在 `build` job 判定為 beta 情境時執行,呼叫本專案自身發佈的 `ai-code-review` action 對 PR 進行 AI 程式碼審查。
|
||||||
- `vars.ACTION_CALCULATE_VERSION`:提供 `calculate-version` action 的版本。
|
- `result` job:等待前兩個 job 完成後,將版本號輸出到 log,作為流程結尾的確認步驟。
|
||||||
- `vars.ACTION_GITEA_RELEASE_VERSION`:提供 release action 的版本。
|
- **觸發條件**:`pull_request` 事件,且事件類型限定為 `opened` 與 `synchronize`(PR 建立與後續推送同步時觸發)。
|
||||||
- `secrets.LLM_OAUTH`:設定 LLM CLI 的 OAuth。
|
- **主要輸入 / 環境參數**:
|
||||||
- `secrets.TOKEN`:提供 AI 程式碼審查 action 存取 Gitea API。
|
- `gitea.base_ref`:用來判斷 PR 目標分支是否為 `develop`,據以設定 `IS_BETA` 環境變數。
|
||||||
- `vars.LLM_NAME`:指定審查使用的模型名稱。
|
- `vars.ACTION_CALCULATE_VERSION`:`calculate-version` action 的版本(`build` job 使用)。
|
||||||
- 重要注意事項:
|
- `vars.ACTION_GITEA_RELEASE_VERSION`:`akkuman/gitea-release-action` 的版本(`build` job 使用)。
|
||||||
- `test` job 只會在 `IS_BETA == true` 時執行,也就是 pull request 目標分支為 `develop` 時。
|
- `gitea.event.repository.name`、`gitea.sha`:組成 release 名稱與 `target_commitish`。
|
||||||
- `Publishing Release` 會使用 `VERSION` 與 `gitea.sha` 建立 release 與 tag。
|
- `needs.build.outputs.version`、`needs.build.outputs.is_beta`:`test`、`result` job 透過 job 輸出取得版本號與 beta 判定結果。
|
||||||
- 若變數或 secret 未設定,對應步驟會失敗,需人工確認部署前置條件。
|
- **重要注意事項**:
|
||||||
|
- `test` job 的執行條件為 `needs.build.outputs.is_beta == 'true'`,即 PR 目標分支(`gitea.base_ref`)為 `develop` 時才會執行 AI 程式碼審查。
|
||||||
|
- `Publishing Release` 步驟會用 `VERSION` 與 `gitea.sha` 建立對應的 release 與 tag(`v${{ env.VERSION }}`),beta 情境下標記為 prerelease。
|
||||||
|
- `test` job 的「Run AI Code Review」步驟在 `ci.yaml` 中本身**未**透過 `with` 或 `env` 傳入任何額外參數;該步驟呼叫的是本專案自身發佈的 `ai-code-review@v{VERSION}` action,實際會用到哪些 `secrets.*`/`vars.*`(例如 action 內部的 token、CLI Proxy 相關設定)屬於該 action 自身的定義範圍,不在 `ci.yaml` 這個 workflow 檔案內顯式宣告,**需人工確認**該 action 版本實際所需的機密與變數是否已在目標環境設定妥當。
|
||||||
|
- 若 `vars.ACTION_CALCULATE_VERSION`、`vars.ACTION_GITEA_RELEASE_VERSION` 等變數未設定,對應步驟會失敗,需人工確認部署前置條件是否齊備。
|
||||||
|
|
||||||
### CD
|
### CD(`.gitea/workflows/master.yaml`)
|
||||||
|
|
||||||
- 檔案位置:`.gitea/workflows/master.yaml`
|
- **workflow 名稱**:`CD`(Gitea UI 顯示標題)
|
||||||
- 用途:在 `master` 分支推送後輸出 Gitea context、檢查提交標籤,作為後續部署流程的基礎。
|
- **檔案位置**:`.gitea/workflows/master.yaml`
|
||||||
- 觸發條件:`push` 到 `master` 分支。
|
- **用途說明**:單一 `deploy` job,在推送到 `master` 分支後,輸出完整的 Gitea event context,並取回完整原始碼與 tag 歷史後,查詢指定 commit 對應的 tag,將結果印出。整個 workflow 目前僅做資訊輸出與查詢,未包含實際部署動作。
|
||||||
- 主要輸入 / 環境參數:
|
- **觸發條件**:`push` 事件,且限定分支為 `master`。
|
||||||
- `gitea` 事件內容:轉成 `GITEA_CONTEXT` 後交給 `jq` 顯示。
|
- **主要輸入 / 環境參數**:
|
||||||
- `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢 commit tag。
|
- `gitea`(整個 context,透過 `toJSON(gitea)` 轉字串):以 `GITEA_CONTEXT` 環境變數輸出並用 `jq` 顯示。
|
||||||
- `vars.ACTION_CHECKOUT_VERSION`:提供 `actions/checkout` 的版本。
|
- `gitea.event.commits[1].id`:作為 `COMMIT_SHA`,用來查詢該 commit 對應的 tag。
|
||||||
- `GITEA_OUTPUT`:寫入 `git describe --contains` 的結果。
|
- `vars.ACTION_CHECKOUT_VERSION`:`actions/checkout` action 的版本。
|
||||||
- 重要注意事項:
|
- `GITEA_OUTPUT`:`Get Commit Tag` 步驟將 `git describe --contains` 的查詢結果寫入此檔案,供 `steps.commit.outputs.tag` 讀取。
|
||||||
- `COMMIT_SHA` 取用 commits 陣列的第 2 筆資料,若 push 事件實際只有 1 筆 commit,需人工確認是否會發生索引風險。
|
- **重要注意事項**:
|
||||||
- `Get Commit Tag` 依賴完整的 git 歷史與 tags,因此 checkout 已設定 `fetch-depth: 0` 與 `fetch-tags: true`。
|
- **需人工確認**:`COMMIT_SHA` 目前固定取用 `gitea.event.commits` 陣列的第 2 筆(索引 1,即 `commits[1]`)。若一次 `push` 事件只包含 1 筆 commit,該索引將不存在,`COMMIT_SHA` 可能為空值,導致後續 `git describe --contains` 查詢失敗或行為不符預期;是否需改為取最後一筆(例如 `commits[-1]` 或依陣列長度動態取值)需人工確認並評估是否調整(本文件僅整理現況,未變更任何 workflow 實際邏輯)。
|
||||||
- `Show Gitea Context` 會輸出完整事件內容,若包含敏感資訊,需注意執行環境的日誌保存策略。
|
- `Get Commit Tag` 步驟依賴完整的 git 歷史與 tag 資訊,因此 `Source Code Checkout` 已設定 `fetch-depth: 0` 與 `fetch-tags: true`,若移除這兩個設定會導致 `git describe --contains` 查不到結果。
|
||||||
|
- `Show Gitea Context` 會將完整事件內容輸出到 log,若事件內容包含敏感資訊,需注意執行環境的日誌保存與存取權限策略。
|
||||||
|
|
||||||
## 備註
|
## 備註
|
||||||
|
|
||||||
- 本檔為草稿版本,僅整理 workflow 行為與設定重點,不修改任何 workflow 實際邏輯。
|
- 本文件僅整理 `ci.yaml`、`master.yaml` 兩個 workflow 檔案目前的行為與參數重點,內容依實際檔案內容彙整,未新增或臆測未在檔案中出現的流程與參數;標註「需人工確認」之處為既有設計中需要人工再次確認的風險點,非文件本身待補內容。
|
||||||
- 若後續要覆蓋正式檔,請先確認 `ci.yaml` 與 `master.yaml` 的變數與 secret 已在目標環境中正確配置。
|
- 若後續要以本文件覆蓋既有說明文件,請先確認 `ci.yaml` 與 `master.yaml` 所引用的 `vars.*`、`secrets.*` 已在目標環境(Gitea repo/organization 設定)中正確配置。
|
||||||
|
|||||||
+13
@@ -1,10 +1,23 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# 用途:Docker 容器 action 的建置檔,以 Node 24 Alpine 為基底安裝 git 與
|
||||||
|
# ca-certificates,並將 action 原始碼與進入點腳本複製進容器、設定執行進入點。
|
||||||
|
# 更新時間:2026/08/07 13:51:53
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# 使用 Node 24 的 Alpine 精簡映像檔作為基底,提供 node 執行環境並縮小最終映像檔體積
|
||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
# 安裝 git 與 ca-certificates:action 執行期需要 clone/操作 git 倉庫,且透過 HTTPS 呼叫外部 API 時需要憑證驗證;--no-cache 可避免留下 apk 索引快取、進一步縮小映像檔
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
# 複製 action 主程式原始碼到容器內的 /action/src/,供 entrypoint 執行時呼叫
|
||||||
COPY src/ /action/src/
|
COPY src/ /action/src/
|
||||||
|
|
||||||
|
# 複製容器啟動時要執行的進入點腳本到 /action/entrypoint.sh
|
||||||
COPY entrypoint.sh /action/entrypoint.sh
|
COPY entrypoint.sh /action/entrypoint.sh
|
||||||
|
|
||||||
|
# 賦予 entrypoint.sh 執行權限,確保容器啟動時能直接執行該腳本
|
||||||
RUN chmod +x /action/entrypoint.sh
|
RUN chmod +x /action/entrypoint.sh
|
||||||
|
|
||||||
|
# 設定容器的進入點為 entrypoint.sh,容器啟動時會執行此腳本作為 action 的入口
|
||||||
ENTRYPOINT ["/action/entrypoint.sh"]
|
ENTRYPOINT ["/action/entrypoint.sh"]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ inputs:
|
|||||||
comment_token:
|
comment_token:
|
||||||
description: '操作 Gitea Commit API 的 Token'
|
description: '操作 Gitea Commit API 的 Token'
|
||||||
required: false
|
required: false
|
||||||
|
model:
|
||||||
|
description: '使用的 AI 模型,僅允許英數字、點、底線、連字號與斜線'
|
||||||
|
required: false
|
||||||
runs:
|
runs:
|
||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'Dockerfile'
|
image: 'Dockerfile'
|
||||||
@@ -16,3 +19,4 @@ runs:
|
|||||||
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }}
|
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }}
|
||||||
CLI_PROXY_API: ${{ vars.CLI_PROXY_API }}
|
CLI_PROXY_API: ${{ vars.CLI_PROXY_API }}
|
||||||
CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }}
|
CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }}
|
||||||
|
CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# Docker 容器 action 的進入點腳本,於容器啟動時執行 Node 主程式並轉傳所有參數。
|
||||||
|
|
||||||
|
# 遇到任何指令執行失敗時立即中止腳本,避免錯誤被吞掉而繼續往下執行
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# 以 exec 取代目前 shell 程序執行 Node 主程式,並將容器收到的所有參數("$@")原樣轉傳給它
|
||||||
exec node /action/src/main.js "$@"
|
exec node /action/src/main.js "$@"
|
||||||
|
|||||||
+85
-21
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { postComment, postPullReviewComment, postPullReview } from './gitea.js';
|
import { postComment, postPullReviewComment, postPullReview } from './gitea.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from './config.js';
|
||||||
|
import { buildFindingsWrapper } from './json.js';
|
||||||
import { ok, line, warn } from './log.js';
|
import { ok, line, warn } from './log.js';
|
||||||
|
|
||||||
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
||||||
@@ -28,11 +29,15 @@ function findingRow(f) {
|
|||||||
* 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。
|
* 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。
|
||||||
*
|
*
|
||||||
* @param {Array<object>} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。
|
* @param {Array<object>} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。
|
||||||
|
* 注意:本參數必須是陣列,傳入 null/undefined 會在 `.map` 呼叫時拋出 TypeError(未防呆,需人工確認是否要補強)。
|
||||||
* @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。
|
* @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。
|
||||||
* @remarks 內部輔助函式,供發布舊問題、新問題(非嚴重)、單筆嚴重問題等 comment 內文使用。
|
* @remarks 內部輔助函式,供 {@link postOldFindingsComment}、{@link postNewNonCriticalComment}、
|
||||||
|
* {@link postNewCriticalComments} 組裝 comment 內文使用。
|
||||||
|
* 使用情境:任何要把一批 findings 呈現成單一 Markdown 表格的地方,先篩好要顯示的子集合再呼叫本函式。
|
||||||
*/
|
*/
|
||||||
function buildTable(findings) {
|
function buildTable(findings) {
|
||||||
const rows = findings.map(findingRow).join('\n');
|
const list = Array.isArray(findings) ? findings : [];
|
||||||
|
const rows = list.map(findingRow).join('\n');
|
||||||
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +65,16 @@ const bySeverity = (a, b) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。
|
* 解析 finding 的 `location` 欄位,取出檔案路徑與(起始)行號,供行內 comment 標註使用。
|
||||||
* 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。
|
*
|
||||||
|
* @param {string} location finding 的位置字串。支援 `"file:19"`(單行)與 `"file:70-82"`(範圍,僅取起始行 19/70);
|
||||||
|
* 若包含逗號(代表對應多個檔案)、非字串、或無法比對出行號,一律視為無法定位。
|
||||||
|
* @returns {{ file: string, line: number } | null}
|
||||||
|
* 可解析時回傳 `{ file, line }`(line 為正整數起始行);`location` 非字串、含逗號、格式不符、
|
||||||
|
* 或行號非正整數時回傳 `null`。
|
||||||
|
* @remarks 供 {@link toReviewComment} 與 {@link postNewCriticalComments} 判斷 finding 是否能標註到
|
||||||
|
* diff 中的具體檔案行號;回傳 `null` 時呼叫端會降級為一般(非行內)comment。
|
||||||
|
* 使用情境:任何要把 finding 轉成 Gitea 行內 review comment 前,都應先呼叫本函式確認可定位。
|
||||||
*/
|
*/
|
||||||
export function parseLocation(location) {
|
export function parseLocation(location) {
|
||||||
if (typeof location !== 'string') return null;
|
if (typeof location !== 'string') return null;
|
||||||
@@ -73,9 +86,20 @@ export function parseLocation(location) {
|
|||||||
return line > 0 ? { file: match[1], line } : null;
|
return line > 0 ? { file: match[1], line } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 行內 comment 內容:等級/審查員/建議 */
|
/**
|
||||||
|
* 產生單一 finding 的行內(inline)review comment 內文:等級/審查員/建議三行。
|
||||||
|
*
|
||||||
|
* @param {{ level?: string, role?: string, suggestion?: string }} f 單筆審查問題物件。
|
||||||
|
* `role`、`suggestion` 未定義時會直接輸出 `undefined` 字樣(未做防呆轉換)。
|
||||||
|
* @returns {string} 三行 Markdown 字串,以 `\n` 連接。
|
||||||
|
* @remarks 內部輔助函式,僅供 {@link postNewCriticalComments} 在成功解析出行號({@link parseLocation}
|
||||||
|
* 回傳非 null)時,組裝要標註到具體檔案行號的行內 comment 使用;相較 {@link reviewCommentBody}
|
||||||
|
* 少了「問題」一行,因為行內位置本身已能定位問題所在。
|
||||||
|
* 使用情境:僅用於新(`is_new` 為 truthy)且等級為 `critical` 的 finding,且該 finding 的
|
||||||
|
* `location` 能被解析出具體行號時。
|
||||||
|
*/
|
||||||
function inlineCommentBody(f) {
|
function inlineCommentBody(f) {
|
||||||
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
return `**等級**:${levelText(f)}\n**審查員**:${f?.role || 'AI Review'}\n**建議**:${f?.suggestion || ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,9 +125,9 @@ function problemText(f) {
|
|||||||
function reviewCommentBody(f) {
|
function reviewCommentBody(f) {
|
||||||
return [
|
return [
|
||||||
`**嚴重等級**:${levelText(f)}`,
|
`**嚴重等級**:${levelText(f)}`,
|
||||||
`**審查員**:${f.role}`,
|
`**審查員**:${f?.role || 'AI Review'}`,
|
||||||
`**問題**:${problemText(f)}`,
|
`**問題**:${problemText(f)}`,
|
||||||
`**建議**:${f.suggestion}`,
|
`**建議**:${f?.suggestion || ''}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +237,11 @@ function toReviewComment(f) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 發布單一 Gitea review:
|
* 發布單一 Gitea review,必要時降級成 summary review 或一般 comment。
|
||||||
* - summaryFindings 只用來統計本文數字(含新舊問題)
|
*
|
||||||
* - commentFindings 用來產生 review comments,並依嚴重等級排序;
|
* @param {Array<object>} findings 審查 findings。
|
||||||
* 只為新問題加上行內標註,舊問題(is_new === false)僅計入統計、不再重複標註檔案與行數
|
* @param {object} [deps={}] 可注入的相依物件。
|
||||||
|
* @returns {Promise<void>} 無回傳值。
|
||||||
*/
|
*/
|
||||||
export async function postFindingsReview(findings, deps = {}) {
|
export async function postFindingsReview(findings, deps = {}) {
|
||||||
const {
|
const {
|
||||||
@@ -254,23 +279,41 @@ export async function postFindingsReview(findings, deps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 寫入 findings.json。
|
* 將 findings 寫入新版 wrapper 格式的 `findings.json`(同步阻塞 I/O)。
|
||||||
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
|
*
|
||||||
|
* @param {string} workspace 主要輸出目錄;實際寫入路徑為 `path.join(workspace, FINDINGS_PATH)`。
|
||||||
|
* @param {Array<object>} findings 要寫入的 findings 陣列;會包成包含 `generatedAt`/`commitSha`/
|
||||||
|
* `prNumber`/`tool`/`findings`/`excluded` 的 wrapper,再以 2 空白縮排 JSON 序列化並補換行。
|
||||||
|
* @param {?string} [mirrorDir=null] 額外鏡射輸出目錄(例如供後續 repo commit 使用);
|
||||||
|
* 為 `null`/`undefined`,或與 `workspace` 相同時,只會寫入一份(不重複寫入同一路徑)。
|
||||||
|
* @returns {void} 無回傳值;成功時每個目標各記錄一行 log。
|
||||||
|
* @remarks 每個目標寫入前皆會以 `fs.mkdirSync(..., { recursive: true })` 建立必要的父目錄。
|
||||||
|
* 本函式為同步阻塞呼叫,且**未做例外防護**——`fs.mkdirSync`/`fs.writeFileSync` 拋出的例外
|
||||||
|
* (例如權限不足、磁碟已滿)會直接向呼叫端傳播,需人工確認呼叫端是否需要額外 try/catch。
|
||||||
|
* 使用情境:每輪 AI Code Review 完成、findings 已定案後呼叫一次,將結果落地成 JSON 檔;
|
||||||
|
* 若同時需要寫回 workspace 與 repo 兩個位置,傳入 `mirrorDir` 即可一次呼叫完成兩份寫入。
|
||||||
*/
|
*/
|
||||||
export function saveFindings(workspace, findings, mirrorDir = null) {
|
export function saveFindings(workspace, findings, mirrorDir = null) {
|
||||||
|
const wrapper = buildFindingsWrapper(findings, []);
|
||||||
const targets = [workspace];
|
const targets = [workspace];
|
||||||
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
|
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
|
||||||
|
|
||||||
for (const targetDir of targets) {
|
for (const targetDir of targets) {
|
||||||
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
const fullPath = path.join(targetDir, FINDINGS_PATH);
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
|
fs.writeFileSync(fullPath, JSON.stringify(wrapper, null, 2) + '\n', 'utf8');
|
||||||
ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`);
|
ok(`findings 寫入: ${fullPath} (${wrapper.findings.length} 筆)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 發布所有舊問題 comment(一次發布,依等級排序)
|
* 發布所有舊問題的彙總 comment(一次性發布一則一般 comment,不含行內標註)。
|
||||||
|
*
|
||||||
|
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||||
|
* 本函式以 `!f.is_new` 篩選舊問題——`is_new` 為 `false`、`undefined` 或其他 falsy 值皆視為舊問題。
|
||||||
|
* @returns {Promise<void>} 無回傳值;`old.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||||||
|
* @remarks 資料列**未依等級排序**,維持 `findings` 原始輸入順序輸出。
|
||||||
|
* 使用情境:每輪 AI Code Review 收斂新舊問題後,統一針對「仍未解決的舊問題」發一則彙總說明。
|
||||||
*/
|
*/
|
||||||
export async function postOldFindingsComment(findings) {
|
export async function postOldFindingsComment(findings) {
|
||||||
const old = findings.filter(f => !f.is_new);
|
const old = findings.filter(f => !f.is_new);
|
||||||
@@ -284,7 +327,16 @@ export async function postOldFindingsComment(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 發布新問題中非 critical 的 comment(一次發布)
|
* 發布新問題中非 critical 等級者的彙總 comment(一次性發布一則一般 comment)。
|
||||||
|
*
|
||||||
|
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||||
|
* 以 `f.is_new && f.level !== 'critical'` 篩選——`is_new` 須為 truthy(例如 `true`)才算新問題,
|
||||||
|
* `undefined`/`false` 皆會被排除。
|
||||||
|
* `level !== 'critical'` 涵蓋 `warning`、`info` 及任何非 `'critical'` 的其他值(含未知等級字串)。
|
||||||
|
* @returns {Promise<void>} 無回傳值;`items.length === 0` 時直接 return,不會呼叫 `postComment`。
|
||||||
|
* @remarks 資料列未依等級排序,維持 `findings` 原始輸入順序輸出。
|
||||||
|
* 使用情境:每輪 AI Code Review 中,把「明確標記為新(`is_new === true`)且非嚴重」的問題
|
||||||
|
* 統一彙總成一則 comment 通知,嚴重問題另由 {@link postNewCriticalComments} 逐筆單獨發布。
|
||||||
*/
|
*/
|
||||||
export async function postNewNonCriticalComment(findings) {
|
export async function postNewNonCriticalComment(findings) {
|
||||||
const items = findings.filter(f => f.is_new && f.level !== 'critical');
|
const items = findings.filter(f => f.is_new && f.level !== 'critical');
|
||||||
@@ -298,9 +350,21 @@ export async function postNewNonCriticalComment(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每個新 critical 問題各發一個 comment。
|
* 針對每個新的 critical 問題各發一個 comment;優先用 Gitea 行內 review comment 標註問題檔案與行數
|
||||||
* 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議);
|
* (內容為等級/審查員/建議),無法定位或行內發布失敗時降級為一般 comment。
|
||||||
* 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 comment。
|
*
|
||||||
|
* @param {Array<{ is_new?: boolean, level?: string, location?: string, role?: string, suggestion?: string }>} findings
|
||||||
|
* 審查問題陣列;以 `f.is_new && f.level === 'critical'` 篩選——`is_new` 須為 truthy 才算新問題。
|
||||||
|
* @param {object} [deps={}] 可覆寫的相依注入物件(主要供測試替換)。
|
||||||
|
* @param {Function} [deps.postInline=postPullReviewComment] 發布單筆行內 review comment 的函式。
|
||||||
|
* @param {Function} [deps.postIssue=postComment] 發布一般 comment 的降級函式。
|
||||||
|
* @returns {Promise<void>} 無回傳值;`criticals.length === 0` 時直接 return。
|
||||||
|
* @remarks 每筆 critical finding 依序處理:`location` 能解析出行號且 `postInline` 成功時只發行內
|
||||||
|
* comment;否則(無法解析,或 `postInline` 失敗且已被捕捉記錄 warn)改用 `postIssue` 發一般 comment。
|
||||||
|
* **注意**:`postIssue` 呼叫未包在 try/catch 中,若其拋出例外會中斷整個迴圈,導致排在後面的
|
||||||
|
* critical findings 不會被處理,此為需人工確認的行為,是否要補強視情況而定。
|
||||||
|
* 使用情境:每輪 AI Code Review 中,把「明確標記為新且等級為 critical」的問題逐筆單獨標註到
|
||||||
|
* PR 對應的檔案行號,讓審查者能直接在 diff 上看到問題。
|
||||||
*/
|
*/
|
||||||
export async function postNewCriticalComments(findings, deps = {}) {
|
export async function postNewCriticalComments(findings, deps = {}) {
|
||||||
const { postInline = postPullReviewComment, postIssue = postComment } = deps;
|
const { postInline = postPullReviewComment, postIssue = postComment } = deps;
|
||||||
|
|||||||
+29
-16
@@ -42,23 +42,28 @@ export const LLM_PROVIDER = 'cliproxyapi';
|
|||||||
|
|
||||||
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
|
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
|
||||||
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
|
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
|
||||||
|
const MODEL_NAME_RE = /^[A-Za-z0-9._/-]+$/;
|
||||||
|
|
||||||
|
function normalizeModelName(raw) {
|
||||||
|
const model = String(raw || '').trim();
|
||||||
|
if (!model) return { model: null, modelError: null };
|
||||||
|
if (!MODEL_NAME_RE.test(model)) {
|
||||||
|
return { model: null, modelError: '無效的 model 參數,僅允許英數字、點、底線、連字號與斜線' };
|
||||||
|
}
|
||||||
|
return { model, modelError: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let _insecureHttpsAgent = null;
|
||||||
/**
|
/**
|
||||||
* 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent,
|
* 取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供連接使用自簽或無效憑證的內部服務時使用
|
||||||
* 供連接使用自簽或無效憑證的內部服務時使用。
|
* (例如自架 Gitea、CLIProxyAPI)。
|
||||||
*
|
*
|
||||||
* @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用,
|
* @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用,
|
||||||
* 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。
|
* 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。
|
||||||
* 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。
|
* 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用;
|
||||||
|
* 若需要完整 TLS 安全性,應改用預設 `https.Agent`,不要調用這個函式。
|
||||||
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
|
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
|
||||||
*/
|
*/
|
||||||
let _insecureHttpsAgent = null;
|
|
||||||
/**
|
|
||||||
* 取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。
|
|
||||||
*
|
|
||||||
* @remarks 只應在信任的內網或測試環境使用;若需要完整 TLS 安全性,應改用預設
|
|
||||||
* `https.Agent`,不要調用這個函式。
|
|
||||||
*/
|
|
||||||
export function getInsecureHttpsAgent() {
|
export function getInsecureHttpsAgent() {
|
||||||
return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
|
return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
|
||||||
}
|
}
|
||||||
@@ -69,23 +74,31 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
|
|||||||
/**
|
/**
|
||||||
* 依環境變數解析並回傳 CLIProxyAPI 設定。
|
* 依環境變數解析並回傳 CLIProxyAPI 設定。
|
||||||
*
|
*
|
||||||
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL,`INPUT_MODEL` / `MODEL`
|
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線),
|
||||||
* 作為模型名稱,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰。
|
* `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,
|
||||||
|
* 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供,
|
||||||
|
* 則交由 CLIProxyAPI 自動選擇模型。若提供的名稱含非法字元,會被視為無效並於
|
||||||
|
* `modelError` 回報,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。
|
||||||
*
|
*
|
||||||
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), command: null }}
|
* 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、
|
||||||
|
* `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。
|
||||||
|
*
|
||||||
|
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), modelError: (string|null), command: null }}
|
||||||
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
|
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
|
||||||
*/
|
*/
|
||||||
export function getLLMConfig() {
|
export function getLLMConfig() {
|
||||||
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
|
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
|
||||||
const model = process.env.INPUT_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
|
const rawModel = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
|
||||||
|
const { model, modelError } = normalizeModelName(rawModel);
|
||||||
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
|
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
|
||||||
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model: model || null, command: null };
|
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model, modelError, command: null };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: LLM_PROVIDER,
|
provider: LLM_PROVIDER,
|
||||||
apiKeys: apiKey ? [apiKey] : [],
|
apiKeys: apiKey ? [apiKey] : [],
|
||||||
baseURL,
|
baseURL,
|
||||||
model: model || null,
|
model,
|
||||||
|
modelError,
|
||||||
command: null,
|
command: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-53
@@ -8,8 +8,14 @@ import { line, ok, warn } from './log.js';
|
|||||||
const LEVELS = ['critical', 'warning', 'info'];
|
const LEVELS = ['critical', 'warning', 'info'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用單一角色分析 diff,回傳 findings 陣列。
|
* 用單一角色分析 diff,呼叫 LLM 取得該角色視角下的 code review 問題並回傳 findings 陣列。
|
||||||
* role 欄位一律以角色定義的 name 為準,避免 LLM 自行填入不一致的名稱。
|
* role 欄位一律以角色定義的 name 為準(覆寫 LLM 回傳值),避免 LLM 自行填入不一致的角色名稱。
|
||||||
|
*
|
||||||
|
* @param {{name: string}} role - 審查角色定義物件,至少需含 name。
|
||||||
|
* @param {string} diff - 欲分析的 unified diff 文字內容。
|
||||||
|
* @returns {Promise<Array<object>>} 有效 findings 陣列(僅保留同時具備 level/location/suggestion 者),
|
||||||
|
* 每筆皆補上 role(角色名稱)與 is_new: true。
|
||||||
|
* @throws 當 chatJSON 呼叫失敗(LLM 錯誤、額度限制等)時直接拋出例外,本函式不做降級處理。
|
||||||
*/
|
*/
|
||||||
export async function analyzeWithRole(role, diff) {
|
export async function analyzeWithRole(role, diff) {
|
||||||
line(`[${role.name}] 開始分析`);
|
line(`[${role.name}] 開始分析`);
|
||||||
@@ -20,23 +26,6 @@ export async function analyzeWithRole(role, diff) {
|
|||||||
return valid;
|
return valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 讀取 JSON 陣列檔案,失敗或不存在時回傳空陣列
|
|
||||||
*/
|
|
||||||
function readJSONArray(fullPath, label) {
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
|
||||||
warn(`${label}檔案不存在,視為空`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
} catch (e) {
|
|
||||||
warn(`讀取${label}失敗: ${e.message},視為空`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
|
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
|
||||||
*
|
*
|
||||||
@@ -101,21 +90,18 @@ function cleanText(value) {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 將文字正規化為比對用形式:NFKC、小寫、標點/符號/空白統一為單一空白後壓縮。
|
|
||||||
*
|
|
||||||
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
|
|
||||||
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
|
|
||||||
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
|
|
||||||
* 因為比對常對同一段文字重複呼叫(findings × exclusions 笛卡爾積),
|
|
||||||
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複跑 NFKC/正則替換。
|
|
||||||
*/
|
|
||||||
const _normalizeTextCache = new Map();
|
const _normalizeTextCache = new Map();
|
||||||
/**
|
/**
|
||||||
* 將文字正規化成比對用形式。
|
* 將文字正規化為比對用形式:先以 cleanText 轉為安全字串,NFKC 正規化、轉小寫,
|
||||||
|
* 並把所有標點/符號/空白字元壓縮成單一空白(再壓縮連續空白、去頭尾空白)。
|
||||||
|
* 因常對同一段文字重複呼叫(findings × exclusions 笛卡爾積比對),
|
||||||
|
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複執行 NFKC/正則運算。
|
||||||
*
|
*
|
||||||
* @param {*} value - 任意值。
|
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串(不會寫入快取)。
|
||||||
* @remarks 適合用於誤報過濾與排除條目比對。
|
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
|
||||||
|
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
|
||||||
|
* 快取為模組層級、程序生命週期內不會清除,需人工確認長期執行(如常駐服務)情境下是否有記憶體成長風險;
|
||||||
|
* 在本專案作為一次性 CI 腳本執行的用法下應無實際影響。
|
||||||
*/
|
*/
|
||||||
export function normalizeText(value) {
|
export function normalizeText(value) {
|
||||||
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
|
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
|
||||||
@@ -297,15 +283,30 @@ function buildExclusionContext(exclusions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH)
|
* 讀取舊 findings(來源分支 cloned repoDir 下 FINDINGS_PATH 指向的檔案),
|
||||||
|
* 同時相容舊版頂層陣列與新版 wrapper 物件;每筆項目一律標記 is_new: false
|
||||||
|
*(代表非本次新產生),並記錄檔案大小/修改時間等診斷日誌。
|
||||||
|
* 檔案不存在或讀取失敗時視為空陣列,不拋例外。
|
||||||
|
*
|
||||||
|
* @param {string} workspace - 來源分支 clone 出的工作目錄根路徑,FINDINGS_PATH 會相對此路徑解析。
|
||||||
|
* @returns {Array<object>} 舊 findings 陣列,每筆皆含 is_new: false;讀取失敗或檔案不存在時回傳空陣列。
|
||||||
*/
|
*/
|
||||||
export function loadOldFindings(workspace) {
|
export function loadOldFindings(workspace) {
|
||||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||||
const old = readJSONArray(fullPath, '舊 findings ').map(f => ({ ...f, is_new: false }));
|
let old = [];
|
||||||
if (fs.existsSync(fullPath)) {
|
if (fs.existsSync(fullPath)) {
|
||||||
|
try {
|
||||||
const stat = fs.statSync(fullPath);
|
const stat = fs.statSync(fullPath);
|
||||||
|
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
const sourceFormat = Array.isArray(data) ? 'array' : (data && Array.isArray(data.findings) ? 'wrapper' : 'unknown');
|
||||||
|
const rawFindings = Array.isArray(data) ? data : (data && Array.isArray(data.findings) ? data.findings : []);
|
||||||
|
old = rawFindings.map(f => ({ ...f, is_new: false }));
|
||||||
line(`讀取舊 findings 檔案: ${fullPath}`);
|
line(`讀取舊 findings 檔案: ${fullPath}`);
|
||||||
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} path=${path.relative(workspace, fullPath) || fullPath}`);
|
line(`舊 findings 檔案資訊: bytes=${stat.size} mtime=${formatFileTime(stat.mtimeMs)} source=${sourceFormat} path=${path.relative(workspace, fullPath) || fullPath}`);
|
||||||
|
} catch (e) {
|
||||||
|
warn(`讀取舊 findings 失敗: ${e.message},視為空: ${fullPath}`);
|
||||||
|
old = [];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
warn(`舊 findings 檔案不存在: ${fullPath}`);
|
warn(`舊 findings 檔案不存在: ${fullPath}`);
|
||||||
}
|
}
|
||||||
@@ -314,10 +315,16 @@ export function loadOldFindings(workspace) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合併新舊 findings,以 (role + location + suggestion前50字) 為 key 去除重複
|
* 合併新舊 findings:以 (role + location + problem + suggestion 前 50 字) 組成的字串為 key,
|
||||||
|
* 過濾掉 newFindings 中與 oldFindings(或 newFindings 自身先出現的項目)key 相同的重複項。
|
||||||
|
* oldFindings 本身不會互相去重(視為既有基準),回傳陣列為 [...oldFindings, ...去重後的 newFindings]。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} oldFindings - 既有(上一輪)findings 陣列,作為去重比對基準,原樣保留於結果前段。
|
||||||
|
* @param {Array<object>} newFindings - 本輪新產生的 findings 陣列,將依 key 去除與 oldFindings 重複者。
|
||||||
|
* @returns {Array<object>} 合併後的 findings 陣列,不修改傳入的兩個陣列本身。
|
||||||
*/
|
*/
|
||||||
export function mergeFindings(oldFindings, newFindings) {
|
export function mergeFindings(oldFindings, newFindings) {
|
||||||
const key = f => `${f.role}|${f.location}|${String(f.suggestion).slice(0, 50)}`;
|
const key = f => `${f.role}|${f.location}|${String(f.problem || '')}|${String(f.suggestion || '').slice(0, 50)}`;
|
||||||
const seen = new Set(oldFindings.map(key));
|
const seen = new Set(oldFindings.map(key));
|
||||||
const deduped = newFindings.filter(f => {
|
const deduped = newFindings.filter(f => {
|
||||||
if (seen.has(key(f))) return false;
|
if (seen.has(key(f))) return false;
|
||||||
@@ -330,14 +337,27 @@ export function mergeFindings(oldFindings, newFindings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 依等級排序(critical > warning > info)
|
* 依等級排序(critical > warning > info,未知等級排最後),回傳新陣列,不修改傳入的 findings。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} findings - 欲排序的 findings 陣列(各筆需含 level 欄位)。
|
||||||
|
* @returns {Array<object>} 依 critical/warning/info 順序排序後的新陣列;未知等級會排在最後。
|
||||||
*/
|
*/
|
||||||
export function sortByLevel(findings) {
|
export function sortByLevel(findings) {
|
||||||
return [...findings].sort((a, b) => LEVELS.indexOf(a.level) - LEVELS.indexOf(b.level));
|
const rank = (level) => {
|
||||||
|
const index = LEVELS.indexOf(level);
|
||||||
|
return index === -1 ? LEVELS.length : index;
|
||||||
|
};
|
||||||
|
return [...findings].sort((a, b) => rank(a.level) - rank(b.level));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 呼叫失敗時的統一降級處理
|
* AI 呼叫失敗時的統一降級處理:記錄警告訊息後原樣回傳 findings(不做任何篩選),
|
||||||
|
* 確保 AI(去重/誤報過濾等)暫時性失敗時不會誤刪合法問題。
|
||||||
|
*
|
||||||
|
* @param {string} label - 用於警告訊息中識別此次失敗的處理名稱(例如「AI 去重」)。
|
||||||
|
* @param {Array<object>} findings - 發生失敗前的 findings 陣列,將原樣回傳。
|
||||||
|
* @param {Error} e - 捕捉到的錯誤物件;若 e.response.status 為 402 或 429,訊息會顯示為「額度/限流」,否則顯示 e.message。
|
||||||
|
* @returns {Array<object>} 原樣回傳的 findings(與傳入的參照相同,未複製)。
|
||||||
*/
|
*/
|
||||||
function fallback(label, findings, e) {
|
function fallback(label, findings, e) {
|
||||||
const status = e.response?.status;
|
const status = e.response?.status;
|
||||||
@@ -348,7 +368,13 @@ function fallback(label, findings, e) {
|
|||||||
|
|
||||||
const MAX_LOCATE_ATTEMPTS = 3;
|
const MAX_LOCATE_ATTEMPTS = 3;
|
||||||
|
|
||||||
/** 從 location 取出行號;無 `檔案:行號`(或多檔逗號)時回 null。 */
|
/**
|
||||||
|
* 從 location(格式如「檔案:行號」或「檔案:起始行-結束行」)取出行號。
|
||||||
|
*
|
||||||
|
* @param {string|null|undefined} location - finding 的 location 欄位。
|
||||||
|
* @returns {number|null} 解析出的(起始)行號;若 location 為空、包含逗號(代表多檔案)
|
||||||
|
* 或不符合「檔案:數字」格式,回傳 null。範圍格式僅回傳起始行號,不回傳結束行號。
|
||||||
|
*/
|
||||||
function findingLine(location) {
|
function findingLine(location) {
|
||||||
const s = String(location || '').trim();
|
const s = String(location || '').trim();
|
||||||
if (!s || s.includes(',')) return null;
|
if (!s || s.includes(',')) return null;
|
||||||
@@ -356,7 +382,14 @@ function findingLine(location) {
|
|||||||
return m ? Number(m[2]) : null;
|
return m ? Number(m[2]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 從整份 unified diff 擷取指定檔案的區段,找不到時回退整份 diff。 */
|
/**
|
||||||
|
* 從整份 unified diff 擷取指定檔案的區段(依 `diff --git a/... b/...` 標頭切分);找不到對應區段時回退回傳整份 diff。
|
||||||
|
*
|
||||||
|
* @param {string} diff - 完整的 unified diff 文字。
|
||||||
|
* @param {string} file - 欲擷取的檔案路徑(會以 includes 比對是否出現在 diff --git 標頭的 a/、b/ 路徑中)。
|
||||||
|
* @returns {string} 該檔案對應的 diff 區段文字;若無法定位,回退回傳原始 diff 字串。
|
||||||
|
* @remarks 檔名比對採子字串 includes,若 file 恰為另一檔案路徑的子字串,可能誤判擷取到錯誤區段,此為已知限制,需人工確認是否需要更嚴謹的邊界比對。
|
||||||
|
*/
|
||||||
function extractFileDiff(diff, file) {
|
function extractFileDiff(diff, file) {
|
||||||
const lines = String(diff || '').split('\n');
|
const lines = String(diff || '').split('\n');
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -369,9 +402,12 @@ function extractFileDiff(diff, file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 對「只有檔名、缺行號」的 findings,反問原角色依該檔 diff 找出行號,
|
* 對缺行號的 findings 重新詢問原角色補上行號,成功時會就地更新 `location`。
|
||||||
* 重複嘗試直到取得有效行號(每條最多 maxAttempts 次,避免無限迴圈);
|
* @param {Array<object>} findings findings 陣列。
|
||||||
* 成功則把 location 補成 `檔案:行號`,否則保留原檔名。
|
* @param {string} diff 完整 unified diff。
|
||||||
|
* @param {{chatFn?: Function, getRole?: Function, maxAttempts?: number, concurrency?: number}} [deps]
|
||||||
|
* 測試用依賴注入。
|
||||||
|
* @returns {Promise<Array<object>>} 與傳入相同參照的 findings 陣列。
|
||||||
*/
|
*/
|
||||||
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
||||||
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
|
const { chatFn = chatJSON, getRole = loadRole, maxAttempts = MAX_LOCATE_ATTEMPTS, concurrency = LLM_CONCURRENCY } = deps;
|
||||||
@@ -418,7 +454,13 @@ function toAIPayload(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
|
* 呼叫 LLM(Paladin 角色)進行語意去重:合併「同位置+同問題本質」的重複 findings,重複者保留等級較高者。
|
||||||
|
* 為避免 LLM 幻覺出不存在的內容,回傳結果會逐筆以 (location + suggestion 前 50 字) 對應回原始 findings,
|
||||||
|
* 對應不到、結果為空、非陣列或數量超過輸入筆數者,皆視為異常並整批降級為保留所有原始 findings(不篩選)。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} findings - 欲去重的 findings 陣列;為空陣列時直接原樣回傳。
|
||||||
|
* @returns {Promise<Array<object>>} 去重後的原始 finding 物件陣列(非 LLM 回傳的精簡版);
|
||||||
|
* AI 呼叫失敗或結果驗證異常時,降級回傳原始 findings(未經任何篩選)。
|
||||||
*/
|
*/
|
||||||
export async function deduplicateWithAI(findings) {
|
export async function deduplicateWithAI(findings) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
@@ -445,7 +487,15 @@ export async function deduplicateWithAI(findings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH)
|
* 讀取排除問題檔案(來源分支 cloned repoDir 下 EXCLUSIONS_PATH),正規化並去重後回傳。
|
||||||
|
* 若偵測到檔案為舊格式(非頂層陣列,如 { exclusions: [...] } 或 { excluded_findings: [...] }),
|
||||||
|
* 會就地把該檔案覆寫為標準頂層陣列格式(若提供 mirrorWorkspace 且路徑不同,也會同步寫入 mirror 目錄)。
|
||||||
|
* 檔案不存在或讀取/解析失敗時,皆視為空陣列,不拋出例外。
|
||||||
|
*
|
||||||
|
* @param {string} workspace - 來源分支工作目錄根路徑,EXCLUSIONS_PATH 會相對此路徑解析。
|
||||||
|
* @param {object|null} [repoState] - 可選的來源分支狀態(branch/shortSha 或 headSha/commitTime),僅用於診斷日誌。
|
||||||
|
* @param {string|null} [mirrorWorkspace] - 可選的鏡像工作目錄;當原始格式非頂層陣列時,會同步覆寫此目錄下的 exclusions.json。
|
||||||
|
* @returns {Array<object>} 正規化並去重後的排除條目陣列;讀取失敗或檔案不存在時回傳空陣列。
|
||||||
*/
|
*/
|
||||||
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
|
export function loadExclusions(workspace, repoState = null, mirrorWorkspace = null) {
|
||||||
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
|
||||||
@@ -494,8 +544,15 @@ export function loadExclusions(workspace, repoState = null, mirrorWorkspace = nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把新的排除條目(raw 形式)append 到 exclusions.json,去重後以頂層陣列寫回 workspace 與 mirror。
|
* 把新的排除條目(raw 形式,未經 normalizeExclusionEntry 加工)append 到 exclusions.json,
|
||||||
* 去重以「檔案路徑 + 正規化原文」為準。回傳合併後的 raw 陣列(無新增時回傳既有陣列)。
|
* 以「檔案路徑(location 冒號前段)+ normalizeText 後的原文」為簽名去重後,
|
||||||
|
* 以頂層陣列格式寫回 workspace(及提供且路徑不同的 mirrorWorkspace)。
|
||||||
|
*
|
||||||
|
* @param {string} workspace - 目標工作目錄,EXCLUSIONS_PATH 相對此路徑解析並寫入。
|
||||||
|
* @param {Array<object>} newEntries - 欲新增的排除條目(raw 形式);為空或未提供時直接回傳 null(無操作)。
|
||||||
|
* @param {string|null} [mirrorWorkspace] - 可選鏡像目錄;提供且與 workspace 路徑不同時,會同步寫入相同內容。
|
||||||
|
* @returns {Array<object>|null} 合併後的 raw 排除條目陣列;newEntries 為空時回傳 null;
|
||||||
|
* 若 newEntries 皆與既有條目重複(無實際新增)則回傳既有陣列(未寫檔)。
|
||||||
*/
|
*/
|
||||||
export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) {
|
export function appendExclusions(workspace, newEntries, mirrorWorkspace = null) {
|
||||||
if (!newEntries || newEntries.length === 0) return null;
|
if (!newEntries || newEntries.length === 0) return null;
|
||||||
@@ -538,8 +595,19 @@ export function appendExclusions(workspace, newEntries, mirrorWorkspace = null)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 套用排除規則,過濾掉符合排除條件的 findings
|
* 套用排除規則,過濾掉符合任一排除條件的 findings。
|
||||||
* location 只比對檔案路徑(忽略行數),suggestion 省略時視為萬用
|
* exclusions 為空時原樣回傳 findings(新陣列,不修改原輸入)。
|
||||||
|
*
|
||||||
|
* 比對規則(對每個 exclusion,locationMatches && roleMatches && (有指定 path 或 role ? 一律視為符合 : textMatches)):
|
||||||
|
* - location 只比對檔案路徑(忽略行號),exclusion 未指定 filePath 時視為萬用;
|
||||||
|
* - role 未指定時視為萬用,否則需與 finding.role 完全相等;
|
||||||
|
* - 僅當 exclusion 同時未指定 filePath 與 role 時,才會實際比對正規化後文字(suggestion/title 等)是否互相包含。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} findings - 欲過濾的 findings 陣列。
|
||||||
|
* @param {Array<object>} exclusions - 排除條目陣列(建議為已正規化含 filePath 的條目)。
|
||||||
|
* @returns {Array<object>} 過濾後的新陣列。
|
||||||
|
* @remarks 「只要 exclusion 指定了 filePath 或 role,文字比對即完全略過」是否為刻意設計,需人工確認;
|
||||||
|
* 若非刻意,可能造成排除範圍比預期寬(例如同檔案下所有問題都被排除,而非僅特定描述的問題)。
|
||||||
*/
|
*/
|
||||||
export function applyExclusions(findings, exclusions) {
|
export function applyExclusions(findings, exclusions) {
|
||||||
if (exclusions.length === 0) return findings;
|
if (exclusions.length === 0) return findings;
|
||||||
@@ -558,7 +626,15 @@ export function applyExclusions(findings, exclusions) {
|
|||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 派一個「防守方」sub-agent 裁決單一 finding 是否為誤報;任何失敗都保守視為成立(保留)。 */
|
/**
|
||||||
|
* 派一個「防守方」角色裁決單一 finding 是否為誤報;任何失敗都保守視為「成立」(即保留該問題)。
|
||||||
|
*
|
||||||
|
* @param {object} finding - 欲裁決的單一 finding。
|
||||||
|
* @param {object} defender - 防守方角色定義(通常為 Paladin),供 buildVerdictPrompt 組系統提示。
|
||||||
|
* @param {string} exclusionHint - 已知誤報清單的提示文字(可為空字串),供 AI 判斷是否與已知誤報類似。
|
||||||
|
* @param {Function} chatFn - 實際呼叫 LLM 的函式(簽名同 chatJSON),供測試時注入替換。
|
||||||
|
* @returns {Promise<boolean>} true 表示裁決為誤報(應剔除);false 表示成立或裁決失敗(保守保留)。
|
||||||
|
*/
|
||||||
async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) {
|
async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, chatFn) {
|
||||||
const systemPrompt = buildVerdictPrompt(defender, exclusionHint);
|
const systemPrompt = buildVerdictPrompt(defender, exclusionHint);
|
||||||
try {
|
try {
|
||||||
@@ -571,8 +647,13 @@ async function judgeFindingIsFalsePositive(finding, defender, exclusionHint, cha
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由「防守方」角色(Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。
|
* 由「防守方」角色(固定為 Paladin)逐條裁決 findings 是否為誤報,剔除誤報、保留成立者。
|
||||||
* 多個問題時各派一個 sub-agent 平行裁決;任一裁決失敗保守保留該問題,不中斷流程。
|
* 多個問題時各派一個裁決任務平行處理(併發上限 LLM_CONCURRENCY);任一裁決失敗保守保留該問題,不中斷流程。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} findings - 欲裁決的 findings 陣列;為空陣列時直接原樣回傳。
|
||||||
|
* @param {Array<object>} [exclusions=[]] - 已知誤報排除條目,用於組裝提示,引導 AI 對相似的誤報更寬鬆判定。
|
||||||
|
* @param {Function} [chatFn=chatJSON] - 實際呼叫 LLM 的函式,供測試時注入替換。
|
||||||
|
* @returns {Promise<Array<object>>} 裁決為「非誤報」而保留下來的原始 finding 物件陣列。
|
||||||
*/
|
*/
|
||||||
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
|
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
|
||||||
if (findings.length === 0) return findings;
|
if (findings.length === 0) return findings;
|
||||||
|
|||||||
+22
-1
@@ -159,6 +159,14 @@ export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
|||||||
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
|
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
|
||||||
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
|
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
|
||||||
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
|
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
|
||||||
|
*
|
||||||
|
* @param {string} workspace 寫入暫存 askpass 腳本的目錄。
|
||||||
|
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||||||
|
* 測試用依賴注入:覆寫底層同步 spawn 實作。
|
||||||
|
* @returns {{ok: boolean, error?: string}}
|
||||||
|
* `ok: true` 表示 ls-remote 成功;`ok: false` 並附上 `error`(例外訊息)表示失敗。
|
||||||
|
* @remarks 本函式內部已捕捉所有例外,不會向外拋出;預設使用 `GITEA_TOKEN`(唯讀用途)。
|
||||||
|
* @remarks 查詢的分支為 `PR_HEAD_BRANCH || 'HEAD'`;未帶入 PR 上下文(`PR_HEAD_BRANCH` 為空)時會改驗證 `HEAD`。
|
||||||
*/
|
*/
|
||||||
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
|
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
@@ -173,7 +181,18 @@ export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone PR head branch to workspace/repo (idempotent)
|
* 將 PR head branch clone 到 `workspace/repo`(idempotent):
|
||||||
|
* 若目標目錄不存在則以 `--depth=1 --branch <PR_HEAD_BRANCH>` clone;
|
||||||
|
* 若已存在則改為 `fetch` 最新後 `checkout` 到該分支,避免重複完整 clone。
|
||||||
|
*
|
||||||
|
* @param {string} workspace 工作區根目錄,clone 目的地為 `workspace/repo`,
|
||||||
|
* 亦是暫存 askpass 腳本的寫入位置。
|
||||||
|
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||||||
|
* 測試用依賴注入:覆寫底層同步 spawn 實作。
|
||||||
|
* @returns {string} repo 的本機路徑(即 `path.join(workspace, 'repo')`)。
|
||||||
|
* @throws 透傳 clone / fetch / checkout 任一步驟失敗時的例外(不吞例外)。
|
||||||
|
* @remarks 使用 `withAskpass` 搭配預設 `GITEA_TOKEN` 進行 git HTTP 認證,
|
||||||
|
* clone/fetch 會帶認證環境,checkout 為本機操作不需認證。
|
||||||
*/
|
*/
|
||||||
export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
@@ -207,6 +226,8 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
|||||||
* 測試用依賴注入:覆寫底層同步 spawn。
|
* 測試用依賴注入:覆寫底層同步 spawn。
|
||||||
* @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數;
|
* @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數;
|
||||||
* 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。
|
* 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。
|
||||||
|
* @remarks `_sourceRoot` 實際用途需人工確認:目前函式主體未引用此參數,
|
||||||
|
* 且測試檔會傳入實際值,無法從程式碼可靠判斷其設計意圖或是否可安全移除。
|
||||||
* @param {'success'|'failure'} [reviewOutcome='success']
|
* @param {'success'|'failure'} [reviewOutcome='success']
|
||||||
* 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。
|
* 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。
|
||||||
* @returns {Promise<void>} 無回傳值;所有失敗皆以 log 記錄後吞掉。
|
* @returns {Promise<void>} 無回傳值;所有失敗皆以 log 記錄後吞掉。
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function parseReviewIgnore(text) {
|
|||||||
/**
|
/**
|
||||||
* 從被審 PR 的 head ref 取得 `.reviewignore` 並解析為排除清單。
|
* 從被審 PR 的 head ref 取得 `.reviewignore` 並解析為排除清單。
|
||||||
* 檔案不存在或為空時退回 {@link DEFAULT_REVIEW_IGNORE}。
|
* 檔案不存在或為空時退回 {@link DEFAULT_REVIEW_IGNORE}。
|
||||||
|
* @remarks 成功套用 .reviewignore 時會透過 line() 輸出套用規則數的日誌行(副作用,不影響回傳值)。
|
||||||
* @returns {Promise<string[]>} 套用於 diff 過濾的排除前綴清單。
|
* @returns {Promise<string[]>} 套用於 diff 過濾的排除前綴清單。
|
||||||
*/
|
*/
|
||||||
export async function getReviewIgnore() {
|
export async function getReviewIgnore() {
|
||||||
@@ -159,6 +160,7 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT
|
|||||||
* 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/<prefix>` 做 startsWith 比對。
|
* 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/<prefix>` 做 startsWith 比對。
|
||||||
* @param {string} diff - 完整的 unified diff 文字。
|
* @param {string} diff - 完整的 unified diff 文字。
|
||||||
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
|
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
|
||||||
|
* @remarks `diff` 必須為字串,非字串輸入會拋出 TypeError。
|
||||||
* @returns {string} 過濾後重新接合的 diff 文字。
|
* @returns {string} 過濾後重新接合的 diff 文字。
|
||||||
*/
|
*/
|
||||||
export function filterDiff(diff, excludePrefixes = []) {
|
export function filterDiff(diff, excludePrefixes = []) {
|
||||||
|
|||||||
+105
-3
@@ -1,9 +1,85 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { chat } from './llm.js';
|
import { chat } from './llm.js';
|
||||||
|
import { FINDINGS_PATH, PR_HEAD_SHA, PR_NUMBER, getLLMConfig } from './config.js';
|
||||||
import { ok, warn, error } from './log.js';
|
import { ok, warn, error } from './log.js';
|
||||||
|
|
||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
|
const PACKAGE_VERSION = (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || 'unknown';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function formatTaipeiTimestamp(date = new Date()) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Taipei',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(date);
|
||||||
|
const map = Object.fromEntries(parts.filter(p => p.type !== 'literal').map(p => [p.type, p.value]));
|
||||||
|
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrNumber(raw) {
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFindingsWrapperLabel(label) {
|
||||||
|
return String(label || '') === FINDINGS_PATH || String(label || '').endsWith('/findings.json') || String(label || '').endsWith('findings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultToolInfo() {
|
||||||
|
const { model } = getLLMConfig();
|
||||||
|
return {
|
||||||
|
name: 'ai-code-review',
|
||||||
|
version: PACKAGE_VERSION,
|
||||||
|
model: model || 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFindingsWrapper(findings, excluded = [], overrides = {}) {
|
||||||
|
return {
|
||||||
|
generatedAt: overrides.generatedAt || formatTaipeiTimestamp(),
|
||||||
|
commitSha: overrides.commitSha || PR_HEAD_SHA || '',
|
||||||
|
prNumber: overrides.prNumber !== undefined ? overrides.prNumber : parsePrNumber(PR_NUMBER),
|
||||||
|
tool: overrides.tool || defaultToolInfo(),
|
||||||
|
findings: Array.isArray(findings) ? findings : [],
|
||||||
|
excluded: Array.isArray(excluded) ? excluded : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFindingsWrapper(data) {
|
||||||
|
if (Array.isArray(data)) return buildFindingsWrapper(data, []);
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
if (!Array.isArray(data.findings)) return null;
|
||||||
|
return {
|
||||||
|
generatedAt: typeof data.generatedAt === 'string' && data.generatedAt.trim() ? data.generatedAt : formatTaipeiTimestamp(),
|
||||||
|
commitSha: typeof data.commitSha === 'string' ? data.commitSha : (PR_HEAD_SHA || ''),
|
||||||
|
prNumber: data.prNumber !== undefined ? parsePrNumber(data.prNumber) : parsePrNumber(PR_NUMBER),
|
||||||
|
tool: data.tool && typeof data.tool === 'object'
|
||||||
|
? {
|
||||||
|
name: typeof data.tool.name === 'string' && data.tool.name.trim() ? data.tool.name : 'ai-code-review',
|
||||||
|
version: typeof data.tool.version === 'string' && data.tool.version.trim() ? data.tool.version : PACKAGE_VERSION,
|
||||||
|
model: typeof data.tool.model === 'string' && data.tool.model.trim() ? data.tool.model : 'auto',
|
||||||
|
}
|
||||||
|
: defaultToolInfo(),
|
||||||
|
findings: data.findings,
|
||||||
|
excluded: Array.isArray(data.excluded) ? data.excluded : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJSON(fullPath, data) {
|
||||||
|
fs.writeFileSync(fullPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),
|
* 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),
|
||||||
@@ -92,6 +168,7 @@ function readJSONText(fullPath, label) {
|
|||||||
*/
|
*/
|
||||||
export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) {
|
export async function validateJSONArrayFile(fullPath, label, repairer = repairJSONArrayWithAI) {
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
const expectsFindingsWrapper = isFindingsWrapperLabel(label);
|
||||||
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
warn(`${label} 不存在,將於驗證後補建`);
|
warn(`${label} 不存在,將於驗證後補建`);
|
||||||
@@ -99,7 +176,23 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JSON.parse(readJSONText(fullPath, label));
|
const parsed = JSON.parse(readJSONText(fullPath, label));
|
||||||
|
if (expectsFindingsWrapper) {
|
||||||
|
const normalized = normalizeFindingsWrapper(parsed);
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error(`${label} 不是 findings wrapper`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
ok(`${label} JSON 格式正確`);
|
||||||
|
return { exists: true, valid: true, repaired: false };
|
||||||
|
}
|
||||||
|
writeJSON(fullPath, normalized);
|
||||||
|
ok(`${label} 已正規化為 findings wrapper`);
|
||||||
|
return { exists: true, valid: true, repaired: true };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${label} 不是 JSON 陣列`);
|
||||||
|
}
|
||||||
ok(`${label} JSON 格式正確`);
|
ok(`${label} JSON 格式正確`);
|
||||||
return { exists: true, valid: true, repaired: false };
|
return { exists: true, valid: true, repaired: false };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -110,10 +203,18 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
|||||||
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
|
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
|
||||||
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
|
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
|
||||||
const parsed = JSON.parse(normalized);
|
const parsed = JSON.parse(normalized);
|
||||||
|
if (expectsFindingsWrapper) {
|
||||||
|
const wrapper = normalizeFindingsWrapper(parsed);
|
||||||
|
if (!wrapper) {
|
||||||
|
throw new Error(`${label} 修復後內容不是 findings wrapper`);
|
||||||
|
}
|
||||||
|
writeJSON(fullPath, wrapper);
|
||||||
|
} else {
|
||||||
if (!Array.isArray(parsed)) {
|
if (!Array.isArray(parsed)) {
|
||||||
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
|
throw new Error(`${label} 修復後內容不是 JSON 陣列`);
|
||||||
}
|
}
|
||||||
fs.writeFileSync(fullPath, normalized, 'utf8');
|
fs.writeFileSync(fullPath, normalized, 'utf8');
|
||||||
|
}
|
||||||
ok(`${label} 已由 AI 修正並通過再次驗證`);
|
ok(`${label} 已由 AI 修正並通過再次驗證`);
|
||||||
return { exists: true, valid: true, repaired: true };
|
return { exists: true, valid: true, repaired: true };
|
||||||
} catch (repairErr) {
|
} catch (repairErr) {
|
||||||
@@ -138,7 +239,8 @@ export function ensureJSONArrayFileExists(fullPath, label) {
|
|||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
if (fs.existsSync(fullPath)) return false;
|
if (fs.existsSync(fullPath)) return false;
|
||||||
|
|
||||||
fs.writeFileSync(fullPath, '[]\n', 'utf8');
|
const content = isFindingsWrapperLabel(label) ? buildFindingsWrapper([], []) : [];
|
||||||
warn(`${label} 不存在,已建立空陣列`);
|
writeJSON(fullPath, content);
|
||||||
|
warn(`${label} 不存在,已建立空${isFindingsWrapperLabel(label) ? ' findings wrapper' : '陣列'}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-24
@@ -12,12 +12,16 @@ export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0
|
|||||||
* 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。
|
* 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。
|
||||||
*
|
*
|
||||||
* limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。
|
* limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。
|
||||||
* fn 需自行處理例外(內部 try/catch);本函式不會因單一項目 reject 而中斷其餘工作。
|
* fn 需自行處理例外(內部 try/catch);若 fn 未處理而 reject,本函式會立即向外
|
||||||
|
* 拋出該錯誤(Promise.all fail-fast),但其他已啟動、尚在執行中的併發工作並不會
|
||||||
|
* 被取消,仍會在背景繼續處理剩餘項目,只是其結果會被捨棄。
|
||||||
|
*
|
||||||
* @template T, R
|
* @template T, R
|
||||||
* @param {T[]} items - 要處理的項目。
|
* @param {T[]} items - 要處理的項目;非陣列(含 null/undefined)會被視為空陣列,不拋錯。
|
||||||
* @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。
|
* @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。
|
||||||
* @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
|
* @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
|
||||||
* @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。
|
* @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。
|
||||||
|
* @throws 若任一次 fn 呼叫 reject 且未在內部處理,該錯誤會直接向外傳播。
|
||||||
*/
|
*/
|
||||||
export async function mapWithConcurrency(items, limit, fn) {
|
export async function mapWithConcurrency(items, limit, fn) {
|
||||||
const list = Array.isArray(items) ? items : [];
|
const list = Array.isArray(items) ? items : [];
|
||||||
@@ -26,6 +30,11 @@ export async function mapWithConcurrency(items, limit, fn) {
|
|||||||
const n = Number(limit);
|
const n = Number(limit);
|
||||||
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
|
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
/**
|
||||||
|
* 內部 worker:從共用游標依序搶下一個索引,呼叫 `fn` 後把結果寫回對應位置。
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>} 無回傳值。
|
||||||
|
*/
|
||||||
async function run() {
|
async function run() {
|
||||||
while (cursor < list.length) {
|
while (cursor < list.length) {
|
||||||
const i = cursor++;
|
const i = cursor++;
|
||||||
@@ -37,7 +46,19 @@ export async function mapWithConcurrency(items, limit, fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將既有 system/user prompt 合併成一次 HTTP 呼叫用的輸入。
|
* 將 system prompt 與 user content 合併成單一文字,作為送往 CLIProxyAPI 的
|
||||||
|
* 「使用者訊息」內容。
|
||||||
|
*
|
||||||
|
* 注意:此函式回傳的合併文字,會被 chat() 整段放入 HTTP request 的 user role
|
||||||
|
* 內容;實際送出的 HTTP system role 訊息是固定的通用指示(見 runProxyAPI),
|
||||||
|
* 並非這裡傳入的 systemPrompt——systemPrompt 是以 `<system>` 標籤形式內嵌在
|
||||||
|
* user 內容中,而非透過 API 的 system role 傳遞。
|
||||||
|
*
|
||||||
|
* @param {string} systemPrompt - 系統提示詞內容,會被包在 `<system>...</system>`
|
||||||
|
* 標籤內;`null`/`undefined` 會被視為空字串。
|
||||||
|
* @param {string} userContent - 使用者輸入內容,會被包在 `<user>...</user>`
|
||||||
|
* 標籤內;`null`/`undefined` 會被視為空字串。
|
||||||
|
* @returns {string} 合併後、以換行分隔的完整 prompt 文字。
|
||||||
*/
|
*/
|
||||||
function buildPrompt(systemPrompt, userContent) {
|
function buildPrompt(systemPrompt, userContent) {
|
||||||
return [
|
return [
|
||||||
@@ -73,11 +94,20 @@ export function extractMeaningfulError(raw, limit = 1000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將 HTTP 例外整理成較精簡的錯誤摘要。
|
* 將 HTTP 例外整理成較精簡的錯誤摘要,格式為 `"HTTP <status> <訊息>"`
|
||||||
|
* (無 status 時只有訊息)。
|
||||||
*
|
*
|
||||||
* @param {*} e - 被拋出的錯誤物件,可能含 `stderr`、`stdout`、`message`。
|
* 依序嘗試:`response.data`(字串或物件的 error.message/message/error 欄位)→
|
||||||
|
* `stderr` → `stdout` → `e.message` → `String(e)`,取第一個非空來源後交給
|
||||||
|
* extractMeaningfulError 濃縮成精簡訊息,再與 HTTP 狀態碼(若有)合併。
|
||||||
|
*
|
||||||
|
* @param {*} e - 被拋出的錯誤物件,預期含 `response.data`/`response.status`/
|
||||||
|
* `stderr`/`stdout`/`message` 其中之一或多個。
|
||||||
|
* @returns {string} 精簡後的錯誤訊息;兩者皆空則回傳空字串。
|
||||||
* @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
|
* @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
|
||||||
* @remarks 若錯誤物件結構和預期不同,仍會退回字串化處理,屬保守容錯。
|
* @remarks 容錯僅涵蓋「e 是物件但欄位缺失或型態不符」的情況;若 e 本身為
|
||||||
|
* `null`/`undefined`,存取 `e.stderr`/`e.stdout`/`e.message` 會直接拋出
|
||||||
|
* TypeError,並非完全的保守容錯(需人工確認是否要補上 optional chaining 修正)。
|
||||||
*/
|
*/
|
||||||
function summarizeApiError(e) {
|
function summarizeApiError(e) {
|
||||||
const responseData = e?.response?.data;
|
const responseData = e?.response?.data;
|
||||||
@@ -87,21 +117,32 @@ function summarizeApiError(e) {
|
|||||||
|| responseData?.message
|
|| responseData?.message
|
||||||
|| responseData?.error
|
|| responseData?.error
|
||||||
|| '';
|
|| '';
|
||||||
const stderr = String(e.stderr || '').trim();
|
const stderr = String(e?.stderr || '').trim();
|
||||||
const stdout = String(e.stdout || '').trim();
|
const stdout = String(e?.stdout || '').trim();
|
||||||
const status = e?.response?.status ? `HTTP ${e.response.status}` : '';
|
const status = e?.response?.status ? `HTTP ${e.response.status}` : '';
|
||||||
const message = extractMeaningfulError(responseText || stderr || stdout || e.message || String(e));
|
const message = extractMeaningfulError(responseText || stderr || stdout || e?.message || String(e));
|
||||||
return [status, message].filter(Boolean).join(' ').trim();
|
return [status, message].filter(Boolean).join(' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 透過 CLIProxyAPI 執行一次對話並回傳純文字結果。
|
* 透過 CLIProxyAPI 執行一次對話 HTTP 請求,回傳 API 的原始回應資料(物件),
|
||||||
|
* 並記錄回應 header 中的速率配額資訊。
|
||||||
*
|
*
|
||||||
* @param {{provider: string, baseURL: string, apiKeys: string[], model: string}} cfg - 連線設定。
|
* 僅送出一次請求,不含任何重試邏輯——失敗(逾時、網路錯誤、非 2xx 狀態碼)時
|
||||||
* @param {string} prompt - 送給 API 的完整 prompt 內容。
|
* 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用
|
||||||
* @remarks 適合用在需呼叫外部 AI API 的情境。
|
* `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。
|
||||||
* @remarks 逾時與輸出上限由環境變數控制,預設值是保守設定。
|
*
|
||||||
* @remarks 若 HTTP 回傳非 2xx,錯誤訊息會由上層摘要處理。
|
* @param {{provider: string, baseURL: string, apiKeys: string[], model?: string|null}} cfg - 連線設定;
|
||||||
|
* 僅使用 `apiKeys[0]`;`model` 可省略,省略時交由 CLIProxyAPI 自動選擇。
|
||||||
|
* @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容;
|
||||||
|
* HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `<system>` 內容無關。
|
||||||
|
* @returns {Promise<any>} API 回應的原始資料物件(`resp.data`),並非純文字;
|
||||||
|
* 純文字需由呼叫端自行從 `data.choices[0].message.content` 等欄位擷取。
|
||||||
|
* @throws 當 HTTP 請求失敗(逾時、網路錯誤、非 2xx 狀態碼)時,`axios` 拋出的
|
||||||
|
* 例外會原樣向外傳播,本函式不攔截、不重試。
|
||||||
|
* @remarks 逾時與輸出上限由環境變數 `AI_ASSISTANT_TIMEOUT_MS`/`AI_ASSISTANT_MAX_BUFFER`
|
||||||
|
* 控制,預設值為 15 分鐘/20 MB。
|
||||||
|
* @remarks 使用 `getInsecureHttpsAgent()`(停用 TLS 憑證驗證),適用內部自簽憑證環境。
|
||||||
*/
|
*/
|
||||||
async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
||||||
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
|
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
|
||||||
@@ -111,13 +152,13 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
|||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
`${root}/v1/chat/completions`,
|
`${root}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
model,
|
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
|
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
|
||||||
{ role: 'user', content: prompt },
|
{ role: 'user', content: prompt },
|
||||||
],
|
],
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
...(model ? { model } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout,
|
timeout,
|
||||||
@@ -139,19 +180,24 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
|||||||
* 對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應。
|
* 對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應。
|
||||||
*
|
*
|
||||||
* 從設定取得 provider/baseURL/model;未偵測到 proxy 時拋錯。成功時記錄一次
|
* 從設定取得 provider/baseURL/model;未偵測到 proxy 時拋錯。成功時記錄一次
|
||||||
* usage 呼叫並回傳內容。
|
* usage 呼叫並回傳內容。**不含任何重試邏輯**——無論是設定缺失、底層 HTTP 請求
|
||||||
|
* 失敗,或回應內容為空,都是失敗一次即向外拋出(重新包裝為新的 Error,只保留
|
||||||
|
* 摘要後訊息),不會自動重試或切換金鑰/provider。呼叫前後皆會透過 line() 記錄
|
||||||
|
* 一行 log(成功記啟動資訊,失敗記錯誤摘要)。
|
||||||
*
|
*
|
||||||
* @param {string} systemPrompt - 系統提示詞。
|
* @param {string} systemPrompt - 系統提示詞。
|
||||||
* @param {string} userContent - 使用者輸入內容。
|
* @param {string} userContent - 使用者輸入內容。
|
||||||
* @returns {Promise<string>} 模型回應的純文字內容。
|
* @returns {Promise<string>} 模型回應的純文字內容。
|
||||||
* @throws {Error} 當未偵測到可用 CLIProxyAPI,或 API 呼叫失敗時。
|
* @throws {Error} 當未偵測到可用 CLIProxyAPI 設定、底層 API 呼叫失敗,或回應
|
||||||
|
* 缺少可用文字內容時。
|
||||||
*/
|
*/
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
const { provider, baseURL, model } = cfg;
|
const { provider, baseURL, model, modelError } = cfg;
|
||||||
if (!provider || !baseURL || !model) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API 與 MODEL');
|
if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
|
||||||
|
if (modelError) throw new Error(modelError);
|
||||||
|
|
||||||
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model}`);
|
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent));
|
const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent));
|
||||||
@@ -174,12 +220,16 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
/**
|
/**
|
||||||
* 對 CLIProxyAPI 送出對話並將回應解析為 JSON 物件/陣列。
|
* 對 CLIProxyAPI 送出對話並將回應解析為 JSON 物件/陣列。
|
||||||
*
|
*
|
||||||
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。
|
* 先呼叫 {@link chat} 取得文字回應,再經 {@link extractJSONText} 抽出 JSON 片段後
|
||||||
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
|
* 以 JSON.parse 解析。**僅 JSON 解析失敗時容錯**(記錄錯誤並回傳空陣列 `[]`,不
|
||||||
|
* 向外拋錯);若 `chat()` 本身失敗(例如未偵測到可用 CLIProxyAPI 設定、API 呼叫
|
||||||
|
* 失敗,或回應缺少文字內容),該例外不會被本函式攔截,會直接向外拋出。
|
||||||
*
|
*
|
||||||
* @param {string} systemPrompt - 系統提示詞。
|
* @param {string} systemPrompt - 系統提示詞。
|
||||||
* @param {string} userContent - 使用者輸入內容。
|
* @param {string} userContent - 使用者輸入內容。
|
||||||
* @returns {Promise<any>} 解析後的 JSON 值;解析失敗時回傳空陣列 `[]`。
|
* @returns {Promise<any>} 解析後的 JSON 值;僅當 JSON 解析失敗時回傳空陣列 `[]`。
|
||||||
|
* @throws {Error} 當底層 {@link chat} 呼叫失敗時(設定缺失、API 錯誤、回應無文字
|
||||||
|
* 內容等),例外會原樣向外傳播。
|
||||||
*/
|
*/
|
||||||
export async function chatJSON(systemPrompt, userContent) {
|
export async function chatJSON(systemPrompt, userContent) {
|
||||||
const text = await chat(systemPrompt, userContent);
|
const text = await chat(systemPrompt, userContent);
|
||||||
|
|||||||
+46
-10
@@ -1,13 +1,41 @@
|
|||||||
|
const timestampFormatter = new Intl.DateTimeFormat('zh-TW', {
|
||||||
|
timeZone: 'Asia/Taipei',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 輸出最上層的「區塊/章節」分隔標題(前綴空行 + `=== 標題 ===`)。
|
* 依 `spec-time-log` 規範產生台灣時區(Asia/Taipei)的固定格式時間戳 `yyyy/MM/dd HH:mm:ss`。
|
||||||
|
* 供本模組所有輸出函式在訊息前加上 `[時間]` 前綴使用。
|
||||||
|
*
|
||||||
|
* @param {Date} [date] - 要格式化的時間點;省略時使用呼叫當下的系統時間。
|
||||||
|
* @returns {string} 例如 `2026/08/07 12:39:43`。
|
||||||
|
*/
|
||||||
|
function formatTimestamp(date = new Date()) {
|
||||||
|
const parts = timestampFormatter.formatToParts(date);
|
||||||
|
const map = parts.reduce((acc, part) => {
|
||||||
|
acc[part.type] = part.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return `${map.year}/${map.month}/${map.day} ${map.hour}:${map.minute}:${map.second}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 輸出最上層的「區塊/章節」分隔標題(前綴空行 + `[時間][INF]: === 標題 ===`)。
|
||||||
* 用於切分整個執行流程中彼此獨立的大段落(例如「環境檢查」「執行審查」「發布結果」),
|
* 用於切分整個執行流程中彼此獨立的大段落(例如「環境檢查」「執行審查」「發布結果」),
|
||||||
* 讓 CI log 在視覺上分群;屬於最高層級的分隔,內部再以 step / line 等細分。
|
* 讓 CI log 在視覺上分群;屬於最高層級的分隔,內部再以 step / line 等細分。
|
||||||
*
|
*
|
||||||
* @param {string} title - 區塊標題文字。
|
* @param {string} title - 區塊標題文字。
|
||||||
* @returns {void} 無回傳值,僅將標題寫入 stdout。
|
* @returns {void} 無回傳值,僅將標題寫入 stdout。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function section(title) {
|
export function section(title) {
|
||||||
console.log(`\n=== ${title} ===`);
|
console.log(`\n[${formatTimestamp()}][INF]: === ${title} ===`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,9 +46,10 @@ export function section(title) {
|
|||||||
* @param {string} stepName - 步驟代號或編號,會以中括號包覆顯示。
|
* @param {string} stepName - 步驟代號或編號,會以中括號包覆顯示。
|
||||||
* @param {string} title - 步驟標題文字。
|
* @param {string} title - 步驟標題文字。
|
||||||
* @returns {void} 無回傳值,僅將步驟標題寫入 stdout。
|
* @returns {void} 無回傳值,僅將步驟標題寫入 stdout。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function step(stepName, title) {
|
export function step(stepName, title) {
|
||||||
console.log(`\n[${stepName}] ${title}`);
|
console.log(`\n[${formatTimestamp()}][INF]: [${stepName}] ${title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,9 +59,10 @@ export function step(stepName, title) {
|
|||||||
*
|
*
|
||||||
* @param {string} message - 要顯示的明細訊息。
|
* @param {string} message - 要顯示的明細訊息。
|
||||||
* @returns {void} 無回傳值,僅將明細寫入 stdout。
|
* @returns {void} 無回傳值,僅將明細寫入 stdout。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function line(message) {
|
export function line(message) {
|
||||||
console.log(` - ${message}`);
|
console.log(`[${formatTimestamp()}][INF]: - ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,9 +71,10 @@ export function line(message) {
|
|||||||
*
|
*
|
||||||
* @param {string} message - 描述輸入內容的訊息。
|
* @param {string} message - 描述輸入內容的訊息。
|
||||||
* @returns {void} 無回傳值,僅將輸入描述寫入 stdout。
|
* @returns {void} 無回傳值,僅將輸入描述寫入 stdout。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function input(message) {
|
export function input(message) {
|
||||||
console.log(` ← 輸入:${message}`);
|
console.log(`[${formatTimestamp()}][INF]: ← 輸入:${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +83,10 @@ export function input(message) {
|
|||||||
*
|
*
|
||||||
* @param {string} message - 描述輸出內容的訊息。
|
* @param {string} message - 描述輸出內容的訊息。
|
||||||
* @returns {void} 無回傳值,僅將輸出描述寫入 stdout。
|
* @returns {void} 無回傳值,僅將輸出描述寫入 stdout。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function output(message) {
|
export function output(message) {
|
||||||
console.log(` → 輸出:${message}`);
|
console.log(`[${formatTimestamp()}][INF]: → 輸出:${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,9 +97,10 @@ export function output(message) {
|
|||||||
* @param {boolean} passed - 結果是否通過;`true` 顯示成功、`false` 顯示失敗。
|
* @param {boolean} passed - 結果是否通過;`true` 顯示成功、`false` 顯示失敗。
|
||||||
* @param {string} message - 描述該結果的訊息。
|
* @param {string} message - 描述該結果的訊息。
|
||||||
* @returns {void} 無回傳值,僅將結果寫入 stdout。
|
* @returns {void} 無回傳值,僅將結果寫入 stdout。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴(成功為 `INF`、失敗為 `ERR`);此為單純呈現方式調整,仍維持一律寫入 stdout(不因失敗改寫 stderr),未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function result(passed, message) {
|
export function result(passed, message) {
|
||||||
console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`);
|
console.log(`[${formatTimestamp()}][${passed ? 'INF' : 'ERR'}]: ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,9 +110,10 @@ export function result(passed, message) {
|
|||||||
*
|
*
|
||||||
* @param {string} message - 描述成功內容的訊息。
|
* @param {string} message - 描述成功內容的訊息。
|
||||||
* @returns {void} 無回傳值,僅將成功訊息寫入 stdout。
|
* @returns {void} 無回傳值,僅將成功訊息寫入 stdout。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]` 前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function ok(message) {
|
export function ok(message) {
|
||||||
console.log(` ✓ ${message}`);
|
console.log(`[${formatTimestamp()}][INF]: ✓ ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,9 +123,10 @@ export function ok(message) {
|
|||||||
*
|
*
|
||||||
* @param {string} message - 要顯示的警告訊息。
|
* @param {string} message - 要顯示的警告訊息。
|
||||||
* @returns {void} 無回傳值,僅將警告訊息寫入 stderr。
|
* @returns {void} 無回傳值,僅將警告訊息寫入 stderr。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]`(`WRN`)前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function warn(message) {
|
export function warn(message) {
|
||||||
console.warn(` ! ${message}`);
|
console.warn(`[${formatTimestamp()}][WRN]: ! ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,7 +136,8 @@ export function warn(message) {
|
|||||||
*
|
*
|
||||||
* @param {string} message - 要顯示的錯誤訊息。
|
* @param {string} message - 要顯示的錯誤訊息。
|
||||||
* @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。
|
* @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。
|
||||||
|
* @remarks 依 spec-time-log 規範,訊息統一加上 `[時間][等級]`(`ERR`)前綴;此為單純呈現方式調整,未改變輸出的實際語意或呼叫時機。
|
||||||
*/
|
*/
|
||||||
export function error(message) {
|
export function error(message) {
|
||||||
console.error(` x ${message}`);
|
console.error(`[${formatTimestamp()}][ERR]: x ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-6
@@ -37,7 +37,7 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
|||||||
* - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。
|
* - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。
|
||||||
* - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。
|
* - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。
|
||||||
* - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號;
|
* - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號;
|
||||||
* 未設定 API Key 或取 diff 失敗 exit 1,diff 為空 exit 0。
|
* 未設定 CLIProxyAPI 或取 diff 失敗 exit 1,diff 為空 exit 0。
|
||||||
* - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。
|
* - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。
|
||||||
* - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。
|
* - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。
|
||||||
* - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。
|
* - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。
|
||||||
@@ -46,11 +46,13 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
|||||||
* - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。
|
* - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。
|
||||||
*
|
*
|
||||||
* 退出行為:
|
* 退出行為:
|
||||||
* - exit 1:前置驗證未過、上輪 bot failure、未設定 LLM Key、取 diff 失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。
|
* - exit 1:前置驗證未過、上輪 bot failure、未設定 CLIProxyAPI(base URL)、取 diff 失敗、
|
||||||
|
* 所有角色分析皆失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。
|
||||||
* - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。
|
* - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。
|
||||||
*
|
*
|
||||||
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
|
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
|
||||||
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
|
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
export async function main() {
|
export async function main() {
|
||||||
section('AI Code Review Pipeline');
|
section('AI Code Review Pipeline');
|
||||||
@@ -114,9 +116,10 @@ export async function main() {
|
|||||||
section('Pipeline 結束');
|
section('Pipeline 結束');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
input(`LLM=${provider}/${model};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`);
|
const modelLabel = model || 'auto';
|
||||||
|
input(`LLM=${provider}/${modelLabel};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`);
|
||||||
try {
|
try {
|
||||||
await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`);
|
await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${modelLabel}`);
|
||||||
line('角色介紹 comment 已發布');
|
line('角色介紹 comment 已發布');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
@@ -186,9 +189,9 @@ export async function main() {
|
|||||||
const runUsage = getRunUsage();
|
const runUsage = getRunUsage();
|
||||||
const quota = await fetchAccountQuota(provider, { apiKeys, baseURL });
|
const quota = await fetchAccountQuota(provider, { apiKeys, baseURL });
|
||||||
const rate = getRateLimit();
|
const rate = getRateLimit();
|
||||||
const usageSection = formatUsageStats(provider, model, runUsage, quota, rate);
|
const usageSection = formatUsageStats(provider, modelLabel, runUsage, quota, rate);
|
||||||
input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`);
|
input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`);
|
||||||
line(`使用量: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`);
|
line(`使用量: ${formatUsageStatsLine(provider, modelLabel, runUsage, quota, rate)}`);
|
||||||
try {
|
try {
|
||||||
await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection });
|
await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection });
|
||||||
output('Gitea Review 已發布');
|
output('Gitea Review 已發布');
|
||||||
|
|||||||
+21
-8
@@ -50,6 +50,9 @@ function giteaErr(e) {
|
|||||||
* @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。
|
* @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。
|
||||||
* @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。
|
* @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。
|
||||||
* @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。
|
* @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。
|
||||||
|
* @remarks CLI Proxy API 的檢查(`missing` 中的 `'CLI_PROXY_API'`)恆直接讀取
|
||||||
|
* `process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API`,不受 `opts` 參數覆寫,
|
||||||
|
* 與 token/repo/pr 三項可測試注入的行為不同,測試時需留意此差異。
|
||||||
*/
|
*/
|
||||||
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
|
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@@ -96,6 +99,16 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從 CLIProxyAPI 回應中解析出模型 ID/slug 清單。
|
||||||
|
*
|
||||||
|
* 依序嘗試以 `data.data`(OpenAI 相容格式常見欄位)或 `data.models` 陣列作為來源;
|
||||||
|
* 陣列中的字串元素直接視為 ID,物件元素則依序取 `id`/`slug`/`name`;
|
||||||
|
* 其他型態或無法取得有效值的元素會被過濾掉,結果不會包含空字串。
|
||||||
|
* @param {unknown} data - CLIProxyAPI `/v1/models` 回應解析後的 JSON;可能為 null/undefined
|
||||||
|
* 或不符預期的結構,函式對此類輸入具容錯性。
|
||||||
|
* @returns {string[]} 解析出的模型 ID/slug 陣列;輸入非物件、或找不到可用陣列時回傳空陣列 `[]`。
|
||||||
|
*/
|
||||||
function extractModelIds(data) {
|
function extractModelIds(data) {
|
||||||
if (!data || typeof data !== 'object') return [];
|
if (!data || typeof data !== 'object') return [];
|
||||||
const source = Array.isArray(data.data) ? data.data : (Array.isArray(data.models) ? data.models : []);
|
const source = Array.isArray(data.data) ? data.data : (Array.isArray(data.models) ? data.models : []);
|
||||||
@@ -152,26 +165,26 @@ export async function fetchLLMModels({
|
|||||||
/**
|
/**
|
||||||
* 驗證 LLM proxy 設定可用。
|
* 驗證 LLM proxy 設定可用。
|
||||||
*
|
*
|
||||||
* 確認目前環境可偵測到 CLIProxyAPI 且已解析出 model;額外向模型清單端點確認
|
* 確認目前環境可偵測到 CLIProxyAPI;若有明確指定 model,則額外向模型清單端點確認
|
||||||
* proxy 可連線且設定的 model 在可用清單內(不送 prompt)。
|
* 該 model 在可用清單內(不送 prompt)。當 model 未指定時,只要求 proxy 與模型清單端點可連線。
|
||||||
* @param {object} [deps] - 可注入相依,供測試。
|
* @param {object} [deps] - 可注入相依,供測試。
|
||||||
* @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。
|
* @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。
|
||||||
* @returns {Promise<
|
* @returns {Promise<
|
||||||
* {ok: true, provider: string, command: null, model: string, models?: string[]} |
|
* {ok: true, provider: string, command: null, model: string|null, models?: string[]} |
|
||||||
* {ok: false, provider?: string, command?: null, model?: string, error: string}
|
* {ok: false, provider?: string, command?: null, model?: string|null, error: string}
|
||||||
* >}
|
* >}
|
||||||
* 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。
|
* 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。
|
||||||
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
||||||
*/
|
*/
|
||||||
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
|
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
|
||||||
const { provider, command, model } = getLLMConfig();
|
const { provider, command, model, modelError } = getLLMConfig();
|
||||||
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
|
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
|
||||||
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
|
if (modelError) return { ok: false, provider, command, model, error: modelError };
|
||||||
|
|
||||||
if (provider === 'cliproxyapi') {
|
if (provider === 'cliproxyapi') {
|
||||||
const models = await fetchLLMModelsFn();
|
const models = await fetchLLMModelsFn();
|
||||||
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
|
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
|
||||||
if (!models.slugs.includes(model)) {
|
if (model && !models.slugs.includes(model)) {
|
||||||
return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` };
|
return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` };
|
||||||
}
|
}
|
||||||
return { ok: true, provider, command, model, models: models.slugs };
|
return { ok: true, provider, command, model, models: models.slugs };
|
||||||
@@ -239,7 +252,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
|
|||||||
error(`LLM 驗證失敗: ${llm.error}`);
|
error(`LLM 驗證失敗: ${llm.error}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model})`);
|
ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model || 'auto'})`);
|
||||||
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
|
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
|
||||||
|
|
||||||
result(true, '前置驗證通過');
|
result(true, '前置驗證通過');
|
||||||
|
|||||||
+41
-7
@@ -46,7 +46,11 @@ function levelToKey(raw) {
|
|||||||
/**
|
/**
|
||||||
* 嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。
|
* 嘗試把一則 review comment 內文解析回 bot 產生的 finding 欄位。
|
||||||
* 同時支援 review comment(嚴重等級/審查員/問題/建議)與行內 critical comment(等級/審查員/建議)格式。
|
* 同時支援 review comment(嚴重等級/審查員/問題/建議)與行內 critical comment(等級/審查員/建議)格式。
|
||||||
* 不符合格式(例如人工自由留言)時回傳 null。
|
* 不符合格式(例如人工自由留言、缺少必要欄位)時回傳 null。
|
||||||
|
* @param {string} body - 留言原始內文(可能含 \r\n,函式內會自行正規化)。
|
||||||
|
* @returns {{level: ('critical'|'warning'|'info'), role: string, problem: string, suggestion: string} | null}
|
||||||
|
* 解析結果;level 缺省時補 'warning'、role 缺省時補 'AI Review'、suggestion 缺省時退回 problem 再退回 ''。
|
||||||
|
* 非 bot 格式(level 與 role 皆缺,或 problem 與 suggestion 皆缺)時回傳 null。
|
||||||
*/
|
*/
|
||||||
export function parseBotReviewComment(body) {
|
export function parseBotReviewComment(body) {
|
||||||
if (typeof body !== 'string' || !body.includes('**')) return null;
|
if (typeof body !== 'string' || !body.includes('**')) return null;
|
||||||
@@ -68,7 +72,14 @@ export function parseBotReviewComment(body) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 PR 上的行內 review comment 依「檔案路徑 + 行號」收斂成對話(同一處的留言與回覆視為一段對話)。
|
* 把 PR 上的行內 review comment 依「檔案路徑 + 行號」收斂成對話(同一處的留言與回覆視為一段對話)。
|
||||||
* 對話只要任一則 comment 帶有 resolver 即視為已解決;同時嘗試解析出該對話對應的 bot finding。
|
* 對話只要任一則 comment 帶有 resolver 即視為已解決;同時嘗試解析出該對話對應的每一則 bot finding。
|
||||||
|
* 缺少 path 的留言(無法定位)會整筆跳過,不併入任何群組。
|
||||||
|
* @param {Array<{id?: any, path?: string, position?: number, new_position?: number,
|
||||||
|
* original_position?: number, body?: string, resolver?: any}>} comments - Gitea PR review comments 原始陣列(容許 null/undefined)。
|
||||||
|
* @returns {Array<{key: string, path: string, line: number, commentIds: Array, bodies: string[],
|
||||||
|
* resolved: boolean, botFinding: object|null, botFindings: object[], thread: string}>}
|
||||||
|
* 依 path|line 收斂後的對話陣列;botFinding 為 botFindings 的第一筆(相容舊邏輯),
|
||||||
|
* thread 為該對話所有留言內容以 '\n---\n' 串接的結果。
|
||||||
*/
|
*/
|
||||||
export function groupConversations(comments) {
|
export function groupConversations(comments) {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
@@ -98,7 +109,13 @@ export function groupConversations(comments) {
|
|||||||
/** codeWindow 預設的上下文行數(目標行上下各取幾行)。 */
|
/** codeWindow 預設的上下文行數(目標行上下各取幾行)。 */
|
||||||
export const CODE_WINDOW_RADIUS = 20;
|
export const CODE_WINDOW_RADIUS = 20;
|
||||||
|
|
||||||
/** 取目標行附近的程式碼片段(含行號),讓 AI 對照判斷問題是否已解決。 */
|
/**
|
||||||
|
* 取目標行附近的程式碼片段(含 1-based 行號前綴),讓 AI 對照判斷問題是否已解決。
|
||||||
|
* @param {string} content - 檔案完整內容(falsy 時直接回傳空字串)。
|
||||||
|
* @param {number} lineNum - 目標行號(1-based);非正數或非有限數時退回以檔案第一行為中心。
|
||||||
|
* @param {number} [radius=CODE_WINDOW_RADIUS] - 目標行上下各擷取的行數。
|
||||||
|
* @returns {string} 擷取範圍內每行以 `"<行號>: <內容>"` 格式、以 \n 串接的字串;content 為空時回傳空字串。
|
||||||
|
*/
|
||||||
export function codeWindow(content, lineNum, radius = CODE_WINDOW_RADIUS) {
|
export function codeWindow(content, lineNum, radius = CODE_WINDOW_RADIUS) {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
@@ -123,7 +140,11 @@ const JUDGE_SYSTEM_PROMPT = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 批次請 AI 將每個對話判為 resolved / false_positive / open。
|
* 批次請 AI 將每個對話判為 resolved / false_positive / open。
|
||||||
* 回傳與輸入等長、依 idx 對齊的 [{ idx, verdict }];無法辨識者一律視為 'open'(寧可保留)。
|
* @param {Array<{idx: number, path: string, line: number, thread: string, code: string}>} items - 待判斷的對話清單。
|
||||||
|
* @param {(system: string, user: string) => Promise<any>} [chatFn=chatJSON] - 呼叫 AI 並回傳已解析 JSON 的函式,
|
||||||
|
* 可由呼叫端注入替換(例如測試時 mock),預設使用 `./llm.js` 的 `chatJSON`。
|
||||||
|
* @returns {Promise<Array<{idx: number, verdict: ('resolved'|'false_positive'|'open')}>>}
|
||||||
|
* 與 items 等長、依 idx 對齊的判斷結果;AI 回傳非陣列、缺漏或不合法的 idx,其 verdict 一律降級為 'open'(寧可保留)。
|
||||||
*/
|
*/
|
||||||
export async function judgeConversations(items, chatFn = chatJSON) {
|
export async function judgeConversations(items, chatFn = chatJSON) {
|
||||||
if (!items || items.length === 0) return [];
|
if (!items || items.length === 0) return [];
|
||||||
@@ -141,10 +162,11 @@ export async function judgeConversations(items, chatFn = chatJSON) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 將一段仍成立(open)對話對應的 bot finding 加入結轉清單,標記 is_new=false 表示為延續的舊問題。
|
* 將一段仍成立(open)對話對應的 bot findings 加入結轉清單,各自標記 is_new=false 表示為延續的舊問題。
|
||||||
* 若該對話無 botFinding 則不做任何事。
|
* 優先使用 conversation.botFindings(多筆);若無則退回 conversation.botFinding(單筆,相容舊資料)。
|
||||||
|
* 若該對話完全沒有可用的 bot finding 則不做任何事。
|
||||||
* @param {Array<object>} target - 接收結轉 finding 的陣列(會被就地 push)。
|
* @param {Array<object>} target - 接收結轉 finding 的陣列(會被就地 push)。
|
||||||
* @param {{botFinding: object|null}} conversation - 對話群組(取其 botFinding)。
|
* @param {{botFinding: object|null, botFindings?: object[]}} conversation - 對話群組。
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function pushCarried(target, conversation) {
|
function pushCarried(target, conversation) {
|
||||||
@@ -192,6 +214,11 @@ export function isSafeRepoPath(p) {
|
|||||||
* - 'false_positive'(誤報)→ 寫入 exclusions 並從舊問題移除(excludedFindings);
|
* - 'false_positive'(誤報)→ 寫入 exclusions 並從舊問題移除(excludedFindings);
|
||||||
* - 'open'(仍成立)→ 加入舊問題集合(carriedFindings)。
|
* - 'open'(仍成立)→ 加入舊問題集合(carriedFindings)。
|
||||||
* 任一外部呼叫失敗都降級處理(保守視為 open),不中斷整體 pipeline。
|
* 任一外部呼叫失敗都降級處理(保守視為 open),不中斷整體 pipeline。
|
||||||
|
* @param {{listComments?: () => Promise<Array>, resolveComment?: (id: any) => Promise<any>,
|
||||||
|
* getFileContent?: (path: string) => Promise<string>, judge?: Function}} [deps] - 可覆寫的外部相依(供測試注入)。
|
||||||
|
* @returns {Promise<{resolvedFindings: object[], excludedFindings: object[], carriedFindings: object[],
|
||||||
|
* resolvedCount: number, falsePositiveCount: number, openCount: number, closedCount: number, unresolvedCount: number}>}
|
||||||
|
* 收斂結果統計與三類 findings 清單。
|
||||||
*/
|
*/
|
||||||
export async function reconcileConversations(deps = {}) {
|
export async function reconcileConversations(deps = {}) {
|
||||||
const {
|
const {
|
||||||
@@ -337,6 +364,9 @@ function findingSig(f) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 從 findings 中移除「已解決對話」對應的問題(以檔案路徑+建議內容比對,避免行號漂移誤判)。
|
* 從 findings 中移除「已解決對話」對應的問題(以檔案路徑+建議內容比對,避免行號漂移誤判)。
|
||||||
|
* @param {Array<object>} findings - 目前的 findings 清單。
|
||||||
|
* @param {Array<{location?: string, suggestion?: string}>} [resolvedFindings=[]] - 本輪判定為已修復的 findings。
|
||||||
|
* @returns {Array<object>} 移除已解決項目後的新陣列;resolvedFindings 為空時回傳 findings 原引用(未複製)。
|
||||||
*/
|
*/
|
||||||
export function dropResolvedFindings(findings, resolvedFindings = []) {
|
export function dropResolvedFindings(findings, resolvedFindings = []) {
|
||||||
if (!resolvedFindings || resolvedFindings.length === 0) return findings;
|
if (!resolvedFindings || resolvedFindings.length === 0) return findings;
|
||||||
@@ -346,6 +376,10 @@ export function dropResolvedFindings(findings, resolvedFindings = []) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 把「未解決對話」對應、但目前 findings 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準)。
|
* 把「未解決對話」對應、但目前 findings 清單中已遺漏的問題加回(去重以檔案路徑+建議內容為準)。
|
||||||
|
* 有實際新增項目時會透過 ok() 輸出一行提示訊息。
|
||||||
|
* @param {Array<object>} findings - 目前的 findings 清單。
|
||||||
|
* @param {Array<{location?: string, suggestion?: string}>} [carriedFindings=[]] - 本輪判定仍成立(open)的 findings。
|
||||||
|
* @returns {Array<object>} 加回缺漏項目後的新陣列;carriedFindings 為空時回傳 findings 原引用(未複製)。
|
||||||
*/
|
*/
|
||||||
export function addCarriedFindings(findings, carriedFindings = []) {
|
export function addCarriedFindings(findings, carriedFindings = []) {
|
||||||
if (!carriedFindings || carriedFindings.length === 0) return findings;
|
if (!carriedFindings || carriedFindings.length === 0) return findings;
|
||||||
|
|||||||
+2
-2
@@ -61,8 +61,8 @@ function readRoleFiles() {
|
|||||||
/**
|
/**
|
||||||
* 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。
|
* 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。
|
||||||
*
|
*
|
||||||
* 供 Step3 產生 findings 階段使用。防守方角色(如 Paladin)不在回傳之列,
|
* 供 Step5(角色分析產生 findings)階段使用。防守方角色(如 Paladin)不在回傳之列,
|
||||||
* 其裁決邏輯由去重 / 誤報過濾流程處理。
|
* 其裁決邏輯(誤報判定)由 `buildVerdictPrompt` 搭配去重 / 誤報過濾流程處理。
|
||||||
*
|
*
|
||||||
* @returns {Array<ReturnType<typeof parseRoleFile>>} 攻擊方角色物件陣列。
|
* @returns {Array<ReturnType<typeof parseRoleFile>>} 攻擊方角色物件陣列。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ describe('saveFindings', () => {
|
|||||||
|
|
||||||
saveFindings(workspace, findings, mirrorDir);
|
saveFindings(workspace, findings, mirrorDir);
|
||||||
|
|
||||||
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'));
|
||||||
const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8');
|
const mirrorData = JSON.parse(fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8'));
|
||||||
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
|
assert.equal(typeof workspaceData.generatedAt, 'string');
|
||||||
assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n');
|
assert.equal(typeof workspaceData.commitSha, 'string');
|
||||||
|
assert.ok(Array.isArray(workspaceData.findings));
|
||||||
|
assert.deepEqual(workspaceData.findings, findings);
|
||||||
|
assert.deepEqual(workspaceData.excluded, []);
|
||||||
|
assert.deepEqual(mirrorData, workspaceData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes only to workspace when mirrorDir is omitted', () => {
|
it('writes only to workspace when mirrorDir is omitted', () => {
|
||||||
@@ -33,8 +37,9 @@ describe('saveFindings', () => {
|
|||||||
|
|
||||||
saveFindings(workspace, findings);
|
saveFindings(workspace, findings);
|
||||||
|
|
||||||
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'));
|
||||||
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
|
assert.deepEqual(workspaceData.findings, findings);
|
||||||
|
assert.deepEqual(workspaceData.excluded, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not duplicate writes when mirrorDir matches workspace', () => {
|
it('does not duplicate writes when mirrorDir matches workspace', () => {
|
||||||
@@ -58,13 +63,14 @@ describe('saveFindings', () => {
|
|||||||
assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH));
|
assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes an empty JSON array when findings is empty', () => {
|
it('writes an empty findings wrapper when findings is empty', () => {
|
||||||
const workspace = makeTempDir('findings-empty-');
|
const workspace = makeTempDir('findings-empty-');
|
||||||
|
|
||||||
saveFindings(workspace, []);
|
saveFindings(workspace, []);
|
||||||
|
|
||||||
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
|
const workspaceData = JSON.parse(fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8'));
|
||||||
assert.equal(workspaceText, '[]\n');
|
assert.deepEqual(workspaceData.findings, []);
|
||||||
|
assert.deepEqual(workspaceData.excluded, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
+32
-2
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
|
|||||||
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
|
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', 'INPUT_MODEL',
|
||||||
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
|
'MODEL', 'OPENCODE_MODEL',
|
||||||
];
|
];
|
||||||
|
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -52,6 +52,36 @@ describe('getLLMConfig', () => {
|
|||||||
assert.equal(cfg.model, 'gpt-5-mini');
|
assert.equal(cfg.model, 'gpt-5-mini');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses CLI_PROXY_API_MODEL when INPUT_MODEL is missing', () => {
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.CLI_PROXY_API_MODEL = 'gpt-5.4-mini';
|
||||||
|
process.env.MODEL = 'gpt-5.5';
|
||||||
|
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
|
||||||
|
assert.equal(cfg.model, 'gpt-5.4-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid model names', () => {
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.MODEL = 'gpt-5.5; rm -rf /';
|
||||||
|
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
|
||||||
|
assert.equal(cfg.model, null);
|
||||||
|
assert.match(cfg.modelError, /無效的 model 參數/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts slash-delimited model names', () => {
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.MODEL = 'provider/gpt-5.5';
|
||||||
|
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
|
||||||
|
assert.equal(cfg.model, 'provider/gpt-5.5');
|
||||||
|
assert.equal(cfg.modelError, null);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null provider when CLI_PROXY_API is missing', () => {
|
it('returns null provider when CLI_PROXY_API is missing', () => {
|
||||||
process.env.MODEL = 'gpt-5.5';
|
process.env.MODEL = 'gpt-5.5';
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
|
|||||||
@@ -348,12 +348,19 @@ describe('findings exclusions', () => {
|
|||||||
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
assert.ok(logs.some(line => line.includes(`path=${path.relative(workspace, fullPath)}`)));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs findings file metadata when loading old findings', () => {
|
it('loads wrapper findings and logs findings file metadata', () => {
|
||||||
const fullPath = path.join(workspace, FINDINGS_PATH);
|
const fullPath = path.join(workspace, FINDINGS_PATH);
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, JSON.stringify([
|
fs.writeFileSync(fullPath, JSON.stringify({
|
||||||
|
generatedAt: '2026/08/07 16:47:53',
|
||||||
|
commitSha: 'deadbeef',
|
||||||
|
prNumber: 7,
|
||||||
|
tool: { name: 'ai-code-review', version: '1.0.0', model: 'auto' },
|
||||||
|
findings: [
|
||||||
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
|
{ level: 'info', role: 'Maya', location: 'README.md:12', suggestion: 'keep' },
|
||||||
], null, 2));
|
],
|
||||||
|
excluded: [],
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
const findings = loadOldFindings(workspace);
|
const findings = loadOldFindings(workspace);
|
||||||
|
|
||||||
|
|||||||
+32
-21
@@ -37,6 +37,19 @@ describe('json helpers', () => {
|
|||||||
assert.ok(capturedUserContent.includes('"{broken"'));
|
assert.ok(capturedUserContent.includes('"{broken"'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates an empty findings wrapper when asked to ensure existence', () => {
|
||||||
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
|
|
||||||
|
const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json');
|
||||||
|
|
||||||
|
assert.equal(created, true);
|
||||||
|
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
assert.equal(typeof written.generatedAt, 'string');
|
||||||
|
assert.ok(Array.isArray(written.findings));
|
||||||
|
assert.deepEqual(written.findings, []);
|
||||||
|
assert.deepEqual(written.excluded, []);
|
||||||
|
});
|
||||||
|
|
||||||
it('reports missing file without creating it', async () => {
|
it('reports missing file without creating it', async () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
|
|
||||||
@@ -46,15 +59,6 @@ describe('json helpers', () => {
|
|||||||
assert.equal(fs.existsSync(fullPath), false);
|
assert.equal(fs.existsSync(fullPath), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates an empty array file when asked to ensure existence', () => {
|
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
|
||||||
|
|
||||||
const created = ensureJSONArrayFileExists(fullPath, '.gitea/ai-review/findings.json');
|
|
||||||
|
|
||||||
assert.equal(created, true);
|
|
||||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when ensuring an existing file', () => {
|
it('returns false when ensuring an existing file', () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/exclusions.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/exclusions.json');
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
@@ -77,29 +81,32 @@ describe('json helpers', () => {
|
|||||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[]\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects repaired JSON that is not an array', async () => {
|
it('rejects repaired JSON that is not a findings wrapper', async () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async () => '{"ok":true}'),
|
() => validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json', async () => '{"ok":true}'),
|
||||||
/不是 JSON 陣列/,
|
/不是 findings wrapper/,
|
||||||
);
|
);
|
||||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '{broken');
|
assert.equal(fs.readFileSync(fullPath, 'utf8'), '{broken');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads a valid JSON file whose size equals the maximum limit', async () => {
|
it('normalizes a valid legacy findings array whose size equals the maximum limit', async () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, `[]${' '.repeat(MAX_JSON_BYTES - 2)}`, 'utf8');
|
fs.writeFileSync(fullPath, `[]${' '.repeat(MAX_JSON_BYTES - 2)}`, 'utf8');
|
||||||
|
|
||||||
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json');
|
const result = await validateJSONArrayFile(fullPath, '.gitea/ai-review/findings.json');
|
||||||
|
|
||||||
assert.deepEqual(result, { exists: true, valid: true, repaired: false });
|
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||||
|
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
assert.deepEqual(written.findings, []);
|
||||||
|
assert.deepEqual(written.excluded, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('repairs invalid JSON using AI output and rewrites the file', async () => {
|
it('repairs invalid findings JSON using AI output and rewrites the file as a wrapper', async () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
fs.writeFileSync(fullPath, '{broken', 'utf8');
|
||||||
@@ -110,7 +117,9 @@ describe('json helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
assert.deepEqual(written.findings, [{ fixed: true }]);
|
||||||
|
assert.deepEqual(written.excluded, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves a trailing newline returned by AI repair', async () => {
|
it('preserves a trailing newline returned by AI repair', async () => {
|
||||||
@@ -124,7 +133,9 @@ describe('json helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||||
assert.equal(fs.readFileSync(fullPath, 'utf8'), '[{"fixed":true}]\n');
|
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
|
assert.deepEqual(written.findings, [{ fixed: true }]);
|
||||||
|
assert.deepEqual(written.excluded, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when AI repair fails', async () => {
|
it('throws when AI repair fails', async () => {
|
||||||
@@ -163,7 +174,7 @@ describe('validateJSONArrayFile repair failure paths', () => {
|
|||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overwrites the invalid file with the valid array returned by the repairer', async () => {
|
it('overwrites the invalid findings file with a wrapper built from the repaired array', async () => {
|
||||||
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
fs.writeFileSync(fullPath, '{ this is not json', 'utf8');
|
fs.writeFileSync(fullPath, '{ this is not json', 'utf8');
|
||||||
@@ -183,10 +194,10 @@ describe('validateJSONArrayFile repair failure paths', () => {
|
|||||||
|
|
||||||
assert.equal(receivedOriginal, '{ this is not json');
|
assert.equal(receivedOriginal, '{ this is not json');
|
||||||
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||||
// file is overwritten with the repaired content, trailing newline appended (line 110)
|
const written = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
||||||
const written = fs.readFileSync(fullPath, 'utf8');
|
assert.equal(typeof written.generatedAt, 'string');
|
||||||
assert.equal(written, '[{"id":1},{"id":2}]\n');
|
assert.deepEqual(written.findings, [{ id: 1 }, { id: 2 }]);
|
||||||
assert.deepEqual(JSON.parse(written), [{ id: 1 }, { id: 2 }]);
|
assert.deepEqual(written.excluded, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when the repaired text is still invalid JSON and does NOT overwrite the original file', async () => {
|
it('throws when the repaired text is still invalid JSON and does NOT overwrite the original file', async () => {
|
||||||
|
|||||||
+27
-1
@@ -4,7 +4,7 @@ import axios from 'axios';
|
|||||||
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
|
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
|
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
|
||||||
'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -57,6 +57,25 @@ describe('chat - CLIProxyAPI', async () => {
|
|||||||
assert.equal(capturedOpts.headers.Authorization, 'Bearer secret');
|
assert.equal(capturedOpts.headers.Authorization, 'Bearer secret');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits model from the request body when auto selection is allowed', async () => {
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.CLI_PROXY_API_KEY = 'secret';
|
||||||
|
|
||||||
|
let capturedBody;
|
||||||
|
mock.method(axios, 'post', async (url, body) => {
|
||||||
|
capturedBody = body;
|
||||||
|
return {
|
||||||
|
data: { choices: [{ message: { content: 'cli response' } }] },
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await chat('sys', 'user');
|
||||||
|
|
||||||
|
assert.equal(result, 'cli response');
|
||||||
|
assert.equal(Object.hasOwn(capturedBody, 'model'), false);
|
||||||
|
});
|
||||||
|
|
||||||
it('throws an error when the API fails', async () => {
|
it('throws an error when the API fails', async () => {
|
||||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
process.env.MODEL = 'gpt-5-mini';
|
process.env.MODEL = 'gpt-5-mini';
|
||||||
@@ -69,6 +88,13 @@ describe('chat - CLIProxyAPI', async () => {
|
|||||||
await assert.rejects(() => chat('sys', 'user'), /401/);
|
await assert.rejects(() => chat('sys', 'user'), /401/);
|
||||||
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
|
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when the configured model name is invalid', async () => {
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.MODEL = 'gpt-5.5; rm -rf /';
|
||||||
|
|
||||||
|
await assert.rejects(() => chat('sys', 'user'), /無效的 model 參數/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chatJSON', async () => {
|
describe('chatJSON', async () => {
|
||||||
|
|||||||
+12
-16
@@ -4,6 +4,8 @@ import { section, step, line, input, output, result, ok, warn, error } from '../
|
|||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
|
const TS = '\\[\\d{4}/\\d{2}/\\d{2} \\d{2}:\\d{2}:\\d{2}\\]';
|
||||||
|
|
||||||
describe('log helpers', () => {
|
describe('log helpers', () => {
|
||||||
it('formats section and step messages', () => {
|
it('formats section and step messages', () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -14,10 +16,8 @@ describe('log helpers', () => {
|
|||||||
section('Pipeline');
|
section('Pipeline');
|
||||||
step('Step1', 'Start');
|
step('Step1', 'Start');
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.match(calls[0], new RegExp(`^\\n${TS}\\[INF\\]: === Pipeline ===$`));
|
||||||
'\n=== Pipeline ===',
|
assert.match(calls[1], new RegExp(`^\\n${TS}\\[INF\\]: \\[Step1\\] Start$`));
|
||||||
'\n[Step1] Start',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats line and ok messages with console.log', () => {
|
it('formats line and ok messages with console.log', () => {
|
||||||
@@ -29,10 +29,8 @@ describe('log helpers', () => {
|
|||||||
line('hello');
|
line('hello');
|
||||||
ok('done');
|
ok('done');
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.match(calls[0], new RegExp(`^${TS}\\[INF\\]: - hello$`));
|
||||||
' - hello',
|
assert.match(calls[1], new RegExp(`^${TS}\\[INF\\]: ✓ done$`));
|
||||||
' ✓ done',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats input/output and pass/fail result messages', () => {
|
it('formats input/output and pass/fail result messages', () => {
|
||||||
@@ -46,12 +44,10 @@ describe('log helpers', () => {
|
|||||||
result(true, '通過');
|
result(true, '通過');
|
||||||
result(false, '未通過');
|
result(false, '未通過');
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.match(calls[0], new RegExp(`^${TS}\\[INF\\]: ← 輸入:5 筆$`));
|
||||||
' ← 輸入:5 筆',
|
assert.match(calls[1], new RegExp(`^${TS}\\[INF\\]: → 輸出:3 筆$`));
|
||||||
' → 輸出:3 筆',
|
assert.match(calls[2], new RegExp(`^${TS}\\[INF\\]: ✅ 成功:通過$`));
|
||||||
' ✅ 成功:通過',
|
assert.match(calls[3], new RegExp(`^${TS}\\[ERR\\]: ❌ 失敗:未通過$`));
|
||||||
' ❌ 失敗:未通過',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats warn messages with console.warn', () => {
|
it('formats warn messages with console.warn', () => {
|
||||||
@@ -62,7 +58,7 @@ describe('log helpers', () => {
|
|||||||
|
|
||||||
warn('careful');
|
warn('careful');
|
||||||
|
|
||||||
assert.deepEqual(calls, [' ! careful']);
|
assert.match(calls[0], new RegExp(`^${TS}\\[WRN\\]: ! careful$`));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('formats error messages with console.error', () => {
|
it('formats error messages with console.error', () => {
|
||||||
@@ -73,6 +69,6 @@ describe('log helpers', () => {
|
|||||||
|
|
||||||
error('boom');
|
error('boom');
|
||||||
|
|
||||||
assert.deepEqual(calls, [' x boom']);
|
assert.match(calls[0], new RegExp(`^${TS}\\[ERR\\]: x boom$`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ describe('main pipeline', () => {
|
|||||||
assert.equal(await runMain(), 0);
|
assert.equal(await runMain(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('無 MODEL 仍可由 Proxy 自動選模並正常走完(exit 0)', async () => {
|
||||||
|
assert.equal(await runMain({
|
||||||
|
config: { getLLMConfig: () => ({ provider: 'cliproxyapi', apiKeys: ['secret'], baseURL: 'https://proxy.example', model: null, command: null }) },
|
||||||
|
}), 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => {
|
it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => {
|
||||||
assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0);
|
assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import axios from 'axios';
|
|||||||
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js';
|
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js';
|
||||||
|
|
||||||
const LLM_ENV_KEYS = [
|
const LLM_ENV_KEYS = [
|
||||||
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
|
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
|
||||||
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
|
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -164,6 +164,22 @@ describe('verifyLLM', () => {
|
|||||||
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
|
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes when no model is specified and the proxy is reachable', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.CLI_PROXY_API_KEY = 'secret';
|
||||||
|
|
||||||
|
const result = await verifyLLM({
|
||||||
|
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.provider, 'cliproxyapi');
|
||||||
|
assert.equal(result.command, null);
|
||||||
|
assert.equal(result.model, null);
|
||||||
|
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
|
||||||
|
});
|
||||||
|
|
||||||
it('fails when proxy auth is invalid', async () => {
|
it('fails when proxy auth is invalid', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
@@ -192,6 +208,20 @@ describe('verifyLLM', () => {
|
|||||||
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
|
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
|
||||||
assert.match(result.error, /gpt-9-imaginary/);
|
assert.match(result.error, /gpt-9-imaginary/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails when the configured model name is invalid', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||||
|
process.env.MODEL = 'gpt-5.5; rm -rf /';
|
||||||
|
|
||||||
|
const result = await verifyLLM({
|
||||||
|
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.equal(result.provider, 'cliproxyapi');
|
||||||
|
assert.match(result.error, /無效的 model 參數/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runPreflight', () => {
|
describe('runPreflight', () => {
|
||||||
|
|||||||
+68
-7
@@ -20,6 +20,11 @@ function num(x) {
|
|||||||
* 支援:OpenAI 相容 usage、OpenAI Responses(input/output_tokens)、
|
* 支援:OpenAI 相容 usage、OpenAI Responses(input/output_tokens)、
|
||||||
* Gemini usageMetadata、Ollama 原生 eval_count、OpenCode tokens。
|
* Gemini usageMetadata、Ollama 原生 eval_count、OpenCode tokens。
|
||||||
* 回應中沒有任何可辨識的 usage 時回傳 null。
|
* 回應中沒有任何可辨識的 usage 時回傳 null。
|
||||||
|
* @param {*} data 平台回應本體(通常為 HTTP response 的 JSON 內容)。
|
||||||
|
* @returns {{promptTokens:number, completionTokens:number, totalTokens:number}|null}
|
||||||
|
* 正規化後的 usage;資料非物件或無任何可辨識欄位時為 null。
|
||||||
|
* @remarks 每個分支在 prompt、completion、total 三者皆為 0 時視為「未辨識」而繼續往下嘗試其他平台格式;
|
||||||
|
* 若某平台的合法回應恰好三者皆為 0,會被誤判為未辨識並回傳 null。是否為預期行為需人工確認。
|
||||||
*/
|
*/
|
||||||
export function extractUsage(data) {
|
export function extractUsage(data) {
|
||||||
if (!data || typeof data !== 'object') return null;
|
if (!data || typeof data !== 'object') return null;
|
||||||
@@ -61,7 +66,12 @@ export function extractUsage(data) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 記錄一次 LLM 呼叫的 usage(無法解析時仍計一次呼叫,但 token 計 0)。 */
|
/**
|
||||||
|
* 記錄一次 LLM 呼叫的 usage(無法解析時仍計一次呼叫,但 token 計 0),並累加進模組級 runUsage。
|
||||||
|
* @param {*} data 平台回應本體,會轉交 extractUsage 解析。
|
||||||
|
* @returns {{promptTokens:number, completionTokens:number, totalTokens:number}|null}
|
||||||
|
* 本次解析出的 usage;無法解析時為 null(但呼叫次數仍已累加)。
|
||||||
|
*/
|
||||||
export function recordUsage(data) {
|
export function recordUsage(data) {
|
||||||
runUsage.calls += 1;
|
runUsage.calls += 1;
|
||||||
const u = extractUsage(data);
|
const u = extractUsage(data);
|
||||||
@@ -73,12 +83,19 @@ export function recordUsage(data) {
|
|||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取得本次執行至今的 token 累計(複本)。 */
|
/**
|
||||||
|
* 取得本次執行至今的 token 累計(複本)。
|
||||||
|
* @returns {{calls:number, promptTokens:number, completionTokens:number, totalTokens:number}}
|
||||||
|
* 目前累計的淺拷貝,修改回傳值不影響內部狀態。
|
||||||
|
*/
|
||||||
export function getRunUsage() {
|
export function getRunUsage() {
|
||||||
return { ...runUsage };
|
return { ...runUsage };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置累計(測試用)。 */
|
/**
|
||||||
|
* 重置累計(測試用)。
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
export function resetRunUsage() {
|
export function resetRunUsage() {
|
||||||
runUsage.calls = 0;
|
runUsage.calls = 0;
|
||||||
runUsage.promptTokens = 0;
|
runUsage.promptTokens = 0;
|
||||||
@@ -105,6 +122,8 @@ function lowerCaseKeys(obj) {
|
|||||||
* 從回應 header 擷取速率配額剩餘量/上限。
|
* 從回應 header 擷取速率配額剩餘量/上限。
|
||||||
* 支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropic(anthropic-ratelimit-tokens-*),
|
* 支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropic(anthropic-ratelimit-tokens-*),
|
||||||
* 兩者皆缺時退而採用 requests 維度。記錄「最近一次」的數值(即最新的視窗狀態)。
|
* 兩者皆缺時退而採用 requests 維度。記錄「最近一次」的數值(即最新的視窗狀態)。
|
||||||
|
* @param {Object<string, *>|null|undefined} headers HTTP 回應 headers(大小寫不拘)。
|
||||||
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export function recordRateLimit(headers) {
|
export function recordRateLimit(headers) {
|
||||||
if (!headers || typeof headers !== 'object') return;
|
if (!headers || typeof headers !== 'object') return;
|
||||||
@@ -126,12 +145,19 @@ export function recordRateLimit(headers) {
|
|||||||
rateLimit.kind = kind;
|
rateLimit.kind = kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取得最近一次的速率配額快照(複本)。 */
|
/**
|
||||||
|
* 取得最近一次的速率配額快照(複本)。
|
||||||
|
* @returns {{hasData:boolean, remaining:number|null, limit:number|null, kind:('tokens'|'requests'|null)}}
|
||||||
|
* 目前快照的淺拷貝。
|
||||||
|
*/
|
||||||
export function getRateLimit() {
|
export function getRateLimit() {
|
||||||
return { ...rateLimit };
|
return { ...rateLimit };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置速率配額快照(測試用)。 */
|
/**
|
||||||
|
* 重置速率配額快照(測試用)。
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
export function resetRateLimit() {
|
export function resetRateLimit() {
|
||||||
rateLimit.hasData = false;
|
rateLimit.hasData = false;
|
||||||
rateLimit.remaining = null;
|
rateLimit.remaining = null;
|
||||||
@@ -206,6 +232,15 @@ const QUOTA_STRATEGIES = {
|
|||||||
/**
|
/**
|
||||||
* 取得指定平台的帳號額度。任何失敗都降級為 { available: false, reason },不丟例外。
|
* 取得指定平台的帳號額度。任何失敗都降級為 { available: false, reason },不丟例外。
|
||||||
* deps.get 可注入以利測試(預設 axios.get)。
|
* deps.get 可注入以利測試(預設 axios.get)。
|
||||||
|
* @param {string} provider 平台識別字串(如 'openai'、'claude'、'cliproxyapi' 等,須存在於 QUOTA_STRATEGIES)。
|
||||||
|
* @param {{apiKey?:string, apiKeys?:string[], baseURL?:string}} [config={}] 該平台連線設定。
|
||||||
|
* @param {{get?: function(string, object): Promise<{data:*}>}} [deps={}]
|
||||||
|
* 可注入依賴,deps.get 為 HTTP GET 函式,預設 axios.get(供測試替換)。
|
||||||
|
* @returns {Promise<{available:boolean, reason?:string, used?:number, limit?:number|null,
|
||||||
|
* remaining?:number|null, currency?:string, source?:string}>}
|
||||||
|
* 額度資訊;不支援或查詢失敗時 available 為 false 並附 reason。
|
||||||
|
* @remarks apiKeys 為陣列時目前固定取第一個元素(而非合併或輪詢多組 key),
|
||||||
|
* 此為既有設計決策,程式碼未說明理由,需人工確認是否為預期行為。
|
||||||
*/
|
*/
|
||||||
export async function fetchAccountQuota(provider, config = {}, deps = {}) {
|
export async function fetchAccountQuota(provider, config = {}, deps = {}) {
|
||||||
const get = deps.get || axios.get;
|
const get = deps.get || axios.get;
|
||||||
@@ -275,6 +310,12 @@ function calculatePercent(remaining, limit) {
|
|||||||
* 1. 帳號額度(quota 有有效上限)→ 剩餘 credits / 上限;
|
* 1. 帳號額度(quota 有有效上限)→ 剩餘 credits / 上限;
|
||||||
* 2. 速率配額(rate limit header,有有效上限)→ 當前視窗剩餘 / 上限;
|
* 2. 速率配額(rate limit header,有有效上限)→ 當前視窗剩餘 / 上限;
|
||||||
* 上限或剩餘為無效值(null/0/負數/NaN/Infinity)時跳過計算,落到 { percent: null, reason }。
|
* 上限或剩餘為無效值(null/0/負數/NaN/Infinity)時跳過計算,落到 { percent: null, reason }。
|
||||||
|
* @param {{available:boolean, limit?:number|null, remaining?:number|null, used?:number, reason?:string, currency?:string}|null|undefined} quota
|
||||||
|
* fetchAccountQuota 的回傳結果。
|
||||||
|
* @param {{hasData:boolean, remaining?:number|null, limit?:number|null, kind?:string}|null|undefined} rate
|
||||||
|
* getRateLimit 的回傳結果。
|
||||||
|
* @returns {{percent:number, basis:string, remaining:number, limit:number, unit:string}|{percent:null, reason:string}}
|
||||||
|
* 可計算時附百分比與明細;否則附無法計算的原因。
|
||||||
*/
|
*/
|
||||||
export function resolveRemainingPercent(quota, rate) {
|
export function resolveRemainingPercent(quota, rate) {
|
||||||
if (quota?.available && quota.limit != null) {
|
if (quota?.available && quota.limit != null) {
|
||||||
@@ -312,7 +353,16 @@ function remainingLine(pct) {
|
|||||||
return `剩餘可用 **${pct.percent}%**(${detail})`;
|
return `剩餘可用 **${pct.percent}%**(${detail})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊。 */
|
/**
|
||||||
|
* 產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊。
|
||||||
|
* @param {string} provider 平台識別字串(如 'openai'、'claude')。
|
||||||
|
* @param {string} model 模型名稱。
|
||||||
|
* @param {{calls:number, promptTokens:number, completionTokens:number, totalTokens:number}} usage
|
||||||
|
* getRunUsage 的回傳結果。
|
||||||
|
* @param {*} quota fetchAccountQuota 的回傳結果,轉交 resolveRemainingPercent。
|
||||||
|
* @param {*} rate getRateLimit 的回傳結果,轉交 resolveRemainingPercent。
|
||||||
|
* @returns {string} 多行 Markdown 字串(含標題、表格、剩餘可用說明)。
|
||||||
|
*/
|
||||||
export function formatUsageStats(provider, model, usage, quota, rate) {
|
export function formatUsageStats(provider, model, usage, quota, rate) {
|
||||||
const pct = resolveRemainingPercent(quota, rate);
|
const pct = resolveRemainingPercent(quota, rate);
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -331,7 +381,18 @@ export function formatUsageStats(provider, model, usage, quota, rate) {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 產生單行 log 用的使用量摘要。 */
|
/**
|
||||||
|
* 產生單行 log 用的使用量摘要。
|
||||||
|
* @param {string} provider 平台識別字串(如 'openai'、'claude')。
|
||||||
|
* @param {string} model 模型名稱。
|
||||||
|
* @param {{calls:number, promptTokens:number, completionTokens:number, totalTokens:number}} usage
|
||||||
|
* getRunUsage 的回傳結果。
|
||||||
|
* @param {*} quota fetchAccountQuota 的回傳結果,轉交 resolveRemainingPercent。
|
||||||
|
* @param {*} rate getRateLimit 的回傳結果,轉交 resolveRemainingPercent。
|
||||||
|
* @returns {string} 單行純文字摘要(token 用量 + 剩餘可用百分比或原因)。
|
||||||
|
* @remarks tokenPart 中的 token 數字未套用 fmt 千分位格式化,與 formatUsageStats 的表格欄位處理方式不同,
|
||||||
|
* 是否為刻意設計(單行 log 保持精簡)或屬遺漏,需人工確認。
|
||||||
|
*/
|
||||||
export function formatUsageStatsLine(provider, model, usage, quota, rate) {
|
export function formatUsageStatsLine(provider, model, usage, quota, rate) {
|
||||||
const pct = resolveRemainingPercent(quota, rate);
|
const pct = resolveRemainingPercent(quota, rate);
|
||||||
const tokenPart = `本次 ${provider}/${model}: 提示${usage.promptTokens} + 回應${usage.completionTokens} = ${usage.totalTokens} token(${usage.calls} 次呼叫)`;
|
const tokenPart = `本次 ${provider}/${model}: 提示${usage.promptTokens} + 回應${usage.completionTokens} = ${usage.totalTokens} token(${usage.calls} 次呼叫)`;
|
||||||
|
|||||||
Reference in New Issue
Block a user