docs: 補齊 JSDoc 與指令檔註解、測試移至 app/test 並重建 README #10
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"location": "app/config.js:20",
|
||||||
|
"role": "Assassin",
|
||||||
|
"original_finding": "getOpenCodeHttpsAgent 回傳 rejectUnauthorized: false 的 HTTPS Agent,與 OpenCode 服務通訊時完全停用憑證驗證,可能遭中間人攻擊竊取機敏數據。",
|
||||||
|
"reason": "刻意設計:OpenCode 為內部自架服務,可能使用自簽憑證;此行為已於 getOpenCodeHttpsAgent 的 JSDoc 明確標註「僅限受信任的內部環境使用」,屬已知並接受的設計取捨,非本次可安全自動變更項目。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/main.js:63",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "main 函式過於龐大,擔任 Step1 到 Step11 的總指揮與細節實作,隨流程增加將難以閱讀與維護,建議拆成獨立函式。",
|
||||||
|
"reason": "刻意設計:main 為線性 pipeline orchestrator,Step1~Step11 以註解清楚分段、依序執行;拆分為多函式屬風格偏好,會增加間接性與重構風險,現階段不採納(非邏輯缺陷)。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/llm.js:232",
|
||||||
|
"role": "Leo",
|
||||||
|
"original_finding": "extractJSONText is returning an unexpected result; suggest returning an empty array or throwing when no JSON is found.",
|
||||||
|
"reason": "誤報:extractJSONText 的合約刻意是「盡力擷取,找不到合法 JSON 時回傳去 fence 後的原文」,呼叫端(chatJSON 等)依賴此行為再做後續解析與 AI 修復。改成回 [] 或丟例外會破壞既有呼叫鏈,且型別不一致(回傳值為字串非陣列),故不採納。"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"level": "critical",
|
||||||
|
"role": "Assassin",
|
||||||
|
"location": "Dockerfile:17",
|
||||||
|
"problem": "在 `apk add` 中使用了 `--no-check-certificate` 參數。這會強制忽略套件來源的 SSL/TLS 憑證檢查,攻擊者可以透過中間人攻擊(MITM)在容器建置期間植入惡意套件,導致整個建置環境被入侵。",
|
||||||
|
"suggestion": "正式環境嚴禁使用 `--no-check-certificate`。若遇到憑證問題,應排查環境配置(如是否缺少根憑證),或是確實解決憑證驗證問題,確保套件來源可信。",
|
||||||
|
"is_new": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Assassin",
|
||||||
|
"location": "Dockerfile:12",
|
||||||
|
"problem": "使用了 `alpine:latest` 作為基底映像。`latest` 標籤極不穩定,當 Alpine Linux 發布新版本時,建置出的映像環境可能會產生難以預期的變更,甚至引入不相容的套件版本,導致建置再現性(Reproducibility)崩潰。",
|
||||||
|
"suggestion": "建議釘選明確的 Alpine 版本號(例如 `alpine:3.20`),以確保每次建置的執行環境完全一致且可預測。",
|
||||||
|
"is_new": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "warning",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "Dockerfile:21",
|
||||||
|
"problem": "Dockerfile 中留下了明確的安全性警告註解(關於 --no-check-certificate)。這種將安全性風險留在程式碼中並以註解提醒的做法,容易被未來的維護者忽略,導致潛在的安全性漏洞。",
|
||||||
|
"suggestion": "若此 flag 僅為暫時性,應盡快優化環境以移除它;若不可避免,應將此設定集中管理,而非在 Dockerfile 中寫死此類不安全的設定。",
|
||||||
|
"is_new": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"role": "Leo",
|
||||||
|
"location": "app/git.js:194",
|
||||||
|
"problem": "`withAskpass` is tightly coupled with Gitea's bot-trigger behavior.",
|
||||||
|
"suggestion": "Encapsulate bot commit logic within `commitAndPush`, auto-selecting the correct `GITEA_COMMENT_TOKEN`.",
|
||||||
|
"is_new": false
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 用途:Gitea CD(持續部署)workflow
|
||||||
|
# 當有變更 push 到 master 分支時,自動釋出並標註(tag)一個成品版本。
|
||||||
|
# 更新日期:2026/06/26 11:34:46
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# workflow 名稱,顯示於 Gitea Actions 介面
|
||||||
name: CD
|
name: CD
|
||||||
|
# 觸發條件設定
|
||||||
on:
|
on:
|
||||||
|
# 以 push 事件觸發
|
||||||
push:
|
push:
|
||||||
|
# 限定觸發的分支
|
||||||
branches:
|
branches:
|
||||||
|
# 只有當變更 push 到 master 分支時才會啟動此 workflow(CD 釋出版本)
|
||||||
- master
|
- master
|
||||||
|
# 此 workflow 包含的工作(jobs)
|
||||||
jobs:
|
jobs:
|
||||||
|
# job 識別碼:負責釋出並標註版本
|
||||||
release-tag-version:
|
release-tag-version:
|
||||||
|
# job 在 Gitea Actions 介面上顯示的名稱
|
||||||
name: Release Tag Version
|
name: Release Tag Version
|
||||||
|
# 指定執行此 job 的 runner 標籤(ubuntu runner)
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
|
# 此 job 的執行步驟清單
|
||||||
steps:
|
steps:
|
||||||
|
# 步驟名稱(中文):釋出並標註成品版本
|
||||||
- name: 釋出並標註成品版本
|
- name: 釋出並標註成品版本
|
||||||
|
# 引用外部 composite action 來執行釋出與標註版本的流程;
|
||||||
|
# @ 後方版本由 Gitea 變數 vars.ACTION_RELEASE_TAG_VERSION 動態指定,便於統一管理版本。
|
||||||
uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }}
|
uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }}
|
||||||
|
|||||||
@@ -1,19 +1,48 @@
|
|||||||
|
# ============================================================
|
||||||
|
# 用途:Gitea CI,在 pull request 上執行 AI 程式碼審查(AI code review on pull requests)
|
||||||
|
# 更新日期:2026/06/26 11:34:46
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# workflow 名稱,會顯示在 Gitea Actions 介面上
|
||||||
name: CI
|
name: CI
|
||||||
|
# 觸發條件設定
|
||||||
on:
|
on:
|
||||||
|
# 針對 pull request 事件觸發
|
||||||
pull_request:
|
pull_request:
|
||||||
|
# 忽略指定目標分支:當 PR 目標分支為 master 時不觸發此 workflow
|
||||||
branches-ignore:
|
branches-ignore:
|
||||||
- master
|
- master
|
||||||
|
# 觸發的 PR 事件類型:opened(PR 開啟)、synchronize(PR 有新 commit 推送)
|
||||||
types: [opened, synchronize]
|
types: [opened, synchronize]
|
||||||
|
# 定義此 workflow 的 jobs
|
||||||
jobs:
|
jobs:
|
||||||
|
# job 識別碼:ai-code-review
|
||||||
ai-code-review:
|
ai-code-review:
|
||||||
|
# job 顯示名稱
|
||||||
name: AI Code Review
|
name: AI Code Review
|
||||||
|
# 指定執行環境的 runner 標籤:ubuntu
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
|
# 此 job 所需的權限設定
|
||||||
permissions:
|
permissions:
|
||||||
|
# 對 repository 內容的寫入權限(讀寫程式碼/檔案)
|
||||||
contents: write
|
contents: write
|
||||||
|
# 對 pull request 的寫入權限(讓 AI 可在 PR 上留言/審查)
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
# 對 issues 的寫入權限(建立/更新 issue 留言所需)
|
||||||
issues: write
|
issues: write
|
||||||
|
# job 的執行步驟
|
||||||
steps:
|
steps:
|
||||||
- name: AI 程式碼審查 by OpenCode
|
- name: 取得程式碼
|
||||||
uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }}
|
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
|
||||||
|
# 步驟名稱:呼叫 OpenCode 進行 AI 程式碼審查
|
||||||
|
- name: AI 程式碼審查
|
||||||
|
# 使用外部 composite action 執行審查邏輯
|
||||||
|
# 版本由 repository variable ACTION_OPENCODE_CODE_REVIEW_VERSION 決定,便於集中管理版本
|
||||||
|
uses: ./
|
||||||
|
# 傳遞給 composite action 的輸入參數
|
||||||
with:
|
with:
|
||||||
comment_token: ${{ secrets.COMMENT_TOKEN }}
|
# 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言
|
||||||
|
token: ${{ secrets.TOKEN }}
|
||||||
|
opencode_base_url: ${{ vars.OPENCODE_BASE_URL }}
|
||||||
|
opencode_provider: ${{ vars.OPENCODE_PROVIDER }}
|
||||||
|
opencode_model: ${{ vars.OPENCODE_PROVIDER_MODEL }}
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 用途:建置「AI 程式碼審查」Docker action 映像檔。
|
||||||
|
# 以 Alpine Linux 為基底,安裝 bash / git / Node.js / npm 等執行環境,
|
||||||
|
# 將 app/ 程式碼與相依套件打包進映像,並透過 entrypoint.sh 作為容器進入點,
|
||||||
|
# 供 CI(Gitea Actions)以 Docker action 形式執行 AI code review 流程。
|
||||||
|
# 更新日期:2026/06/26 11:34:46
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# 指定基底映像為 Alpine Linux 最新版;Alpine 體積小,可縮小最終映像大小並加快拉取速度。
|
||||||
|
# 需人工確認:使用 latest tag 會在不同時間建置出不同基底版本,可能影響可重現性,
|
||||||
|
# 建議釘選明確版本(例如 alpine:3.20)以確保建置一致。
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
Ghost marked this conversation as resolved
|
|||||||
|
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:註解中提到建議使用明確版本(例如 alpine:3.20),但實作上卻持續使用 latest。這會在未來的建置環境中產生不可重現的隱憂,把債留給未來。
**建議**:直接在 Dockerfile 中指定明確的 Alpine 版本(如 alpine:3.20),並將該註解移除。
|
|||||||
# 安裝必要的工具
|
# 安裝必要的工具
|
||||||
|
# 安裝執行 code review 所需的工具:bash(執行 entrypoint 腳本)、git(前置遠端驗證/取得 diff)、
|
||||||
|
# nodejs 與 npm(執行 app 內的 Node.js 程式)。
|
||||||
|
# --no-cache:不保留 apk 套件索引快取,避免殘留在映像層中以減少映像大小。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Assassin
**問題**:在 `apk add` 中使用了 `--no-check-certificate` 參數。這會強制忽略套件來源的 SSL/TLS 憑證檢查,攻擊者可以透過中間人攻擊(MITM)在容器建置期間植入惡意套件,導致整個建置環境被入侵。
**建議**:正式環境嚴禁使用 `--no-check-certificate`。若遇到憑證問題,應排查環境配置(如是否缺少根憑證),或是確實解決憑證驗證問題,確保套件來源可信。
|
|||||||
|
# 需人工確認:--no-check-certificate 會略過套件來源的憑證驗證,存在中間人攻擊風險,
|
||||||
|
# 僅在內網或憑證受限環境下使用;正式環境建議移除以維持安全性。
|
||||||
RUN apk add --no-cache --no-check-certificate bash git nodejs npm
|
RUN apk add --no-cache --no-check-certificate bash git nodejs npm
|
||||||
|
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Leo
**問題**:Dockerfile 中留下了明確的安全性警告註解(關於 --no-check-certificate)。這種將安全性風險留在程式碼中並以註解提醒的做法,容易被未來的維護者忽略,導致潛在的安全性漏洞。
**建議**:若此 flag 僅為暫時性,應盡快優化環境以移除它;若不可避免,應將此設定集中管理,而非在 Dockerfile 中寫死此類不安全的設定。
|
|||||||
|
# 將專案的 app/ 目錄複製到映像內的 /app;包含 Node.js 程式碼與 package.json 等相依宣告。
|
||||||
COPY ./app /app
|
COPY ./app /app
|
||||||
|
|
||||||
|
# 進入 /app 安裝 npm 相依套件,使 Node.js 程式可在容器內正常執行。
|
||||||
|
# 副作用:會在 /app/node_modules 產生套件檔案,並依 package-lock.json(若存在)解析版本。
|
||||||
RUN cd /app && npm install
|
RUN cd /app && npm install
|
||||||
|
|
||||||
|
# 將容器進入點腳本 entrypoint.sh 複製到映像根目錄 /entrypoint.sh。
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
# 賦予 entrypoint.sh 可執行權限,確保容器啟動時能直接執行該腳本。
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# 設定容器進入點為 /entrypoint.sh(exec 形式,不經過 shell 解析);
|
||||||
|
# 容器啟動時即執行此腳本,作為 Docker action 的實際入口。
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ name: 'AI Code Review'
|
|||||||
description: 'AI 程式碼審查'
|
description: 'AI 程式碼審查'
|
||||||
author: 'Jeffery'
|
author: 'Jeffery'
|
||||||
inputs:
|
inputs:
|
||||||
comment_token:
|
token:
|
||||||
description: ''
|
description: ''
|
||||||
required: true
|
required: true
|
||||||
|
comment_token:
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
opencode_base_url:
|
opencode_base_url:
|
||||||
description: 'OpenCode server Base URL'
|
description: 'OpenCode server Base URL'
|
||||||
required: false
|
required: false
|
||||||
@@ -20,8 +23,8 @@ runs:
|
|||||||
env:
|
env:
|
||||||
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
GITEA_TOKEN: ${{ gitea.token }}
|
GITEA_TOKEN: ${{ inputs.token || gitea.token }}
|
||||||
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token }}
|
GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || gitea.token }}
|
||||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
||||||
PR_HEAD_BRANCH: ${{ gitea.event.pull_request.head.ref }}
|
PR_HEAD_BRANCH: ${{ gitea.event.pull_request.head.ref }}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -7,20 +7,54 @@ import { ok, line, warn } from './log.js';
|
|||||||
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
|
||||||
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
|
||||||
const LEVEL_ORDER = ['critical', 'warning', 'info'];
|
const LEVEL_ORDER = ['critical', 'warning', 'info'];
|
||||||
|
// 預先把等級對應到排序索引,bySeverity 排序時直接 O(1) 取值,省去每次比較的 includes + indexOf 掃描。
|
||||||
|
const LEVEL_RANK = new Map(LEVEL_ORDER.map((level, index) => [level, index]));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將單一 finding 格式化為 Markdown 表格的一列(等級|審查員|位置|建議)。
|
||||||
|
*
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:`findingRow` 直接存取 `f.role` 且無空值檢查,`f` 為空值時會造成程式崩潰。
**建議**:加入空值判斷,避免程式崩潰
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:`findingRow` 直接存取 `f.role` 且無空值檢查,`f` 為空值時會造成程式崩潰。
**建議**:加入空值判斷,避免程式崩潰
|
|||||||
|
* @param {{ level?: string, role?: string, location?: string, suggestion?: string }} f
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Maya
**問題**:新增了許多輔助性的 formatting 函式(如 findingRow, buildTable 等),雖為內部使用,但這類字串處理邏輯若沒有測試覆蓋,極易因修改格式而導致 Markdown 輸出損壞。
**建議**:雖然是輔助函式,但建議在 test/comments.test.js 中補齊這些 formatting 函式的斷言測試,確保輸出格式穩定。
|
|||||||
|
* 單筆審查問題物件。`level` 若不在 critical/warning/info 之內,emoji 留空、標籤回退為原始 level 值;
|
||||||
|
* `role`、`location`、`suggestion` 直接內嵌字串(未定義時會輸出 undefined 字樣)。傳入 null/undefined 時回傳空字串(已防呆,不會拋例外)。
|
||||||
|
* @returns {string} 形如 `| 🔴 嚴重 | role | location | suggestion |` 的表格列字串;`f` 為空值時回傳空字串。
|
||||||
|
* @remarks 內部輔助函式,供 {@link buildTable} 逐列組裝表格使用,本身不含換行。
|
||||||
|
*/
|
||||||
function findingRow(f) {
|
function findingRow(f) {
|
||||||
|
if (!f) return '';
|
||||||
return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`;
|
return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將多筆 findings 組成完整的 Markdown 表格(含表頭與分隔列)。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} findings 審查問題陣列;空陣列時僅輸出表頭與分隔列。每筆物件格式見 {@link findingRow}。
|
||||||
|
* @returns {string} 完整的 Markdown 表格字串(表頭:等級|審查員|位置|建議)。
|
||||||
|
* @remarks 內部輔助函式,供發布舊問題、新問題(非嚴重)、單筆嚴重問題等 comment 內文使用。
|
||||||
|
*/
|
||||||
function buildTable(findings) {
|
function buildTable(findings) {
|
||||||
const rows = findings.map(findingRow).join('\n');
|
const rows = findings.map(findingRow).join('\n');
|
||||||
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得 finding 等級的人類可讀字串(emoji + 中文標籤),已去除頭尾空白。
|
||||||
|
*
|
||||||
|
* @param {{ level?: string }} f 單筆審查問題物件。`level` 查無對應時 emoji 留空、標籤回退為原始 level 值。
|
||||||
|
* @returns {string} 例如 `🔴 嚴重`;無法對應時回退為原始 level 字串(無 emoji)。
|
||||||
|
* @remarks 內部輔助函式,供 {@link inlineCommentBody} 與 {@link reviewCommentBody} 組裝 comment 內文使用。
|
||||||
|
*/
|
||||||
const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim();
|
const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim();
|
||||||
|
/**
|
||||||
|
* findings 排序比較器:先依嚴重等級(critical < warning < info < 其他),同級再依 location 字串排序。
|
||||||
|
*
|
||||||
|
* @param {{ level?: string, location?: string }} a 比較項 A。
|
||||||
|
* @param {{ level?: string, location?: string }} b 比較項 B。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Rogue
**問題**:每一項比較都在做 LEVEL_ORDER.includes 與 indexOf 搜尋,若 findings 超過千筆,這些無謂的搜尋將浪費大量 CPU 週期。
**建議**:在 bySeverity 外部將 LEVEL_ORDER 預先處理成 Map<string, number>,排序時直接 Map 取值,時間複雜度從 O(N log N * M) 降至 O(N log N)。
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Rogue
**問題**:每一項比較都在做 LEVEL_ORDER.includes 與 indexOf 搜尋,若 findings 超過千筆,這些無謂的搜尋將浪費大量 CPU 週期。
**建議**:在 bySeverity 外部將 LEVEL_ORDER 預先處理成 Map<string, number>,排序時直接 Map 取值,時間複雜度從 O(N log N * M) 降至 O(N log N)。
|
|||||||
|
* @returns {number} 負值代表 a 排在 b 之前,正值代表之後,0 代表相等(供 Array.prototype.sort 使用)。
|
||||||
|
* @remarks 不在 LEVEL_ORDER 內的等級一律視為最低優先(排在最後);location 未定義時以空字串參與比較,因此排序穩定不會丟例外。
|
||||||
|
*/
|
||||||
const bySeverity = (a, b) => {
|
const bySeverity = (a, b) => {
|
||||||
const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length;
|
const aLevel = LEVEL_RANK.has(a.level) ? LEVEL_RANK.get(a.level) : LEVEL_ORDER.length;
|
||||||
const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length;
|
const bLevel = LEVEL_RANK.has(b.level) ? LEVEL_RANK.get(b.level) : LEVEL_ORDER.length;
|
||||||
if (aLevel !== bLevel) return aLevel - bLevel;
|
if (aLevel !== bLevel) return aLevel - bLevel;
|
||||||
return String(a.location || '').localeCompare(String(b.location || ''));
|
return String(a.location || '').localeCompare(String(b.location || ''));
|
||||||
};
|
};
|
||||||
@@ -43,10 +77,26 @@ function inlineCommentBody(f) {
|
|||||||
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
return `**等級**:${levelText(f)}\n**審查員**:${f.role}\n**建議**:${f.suggestion}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從 finding 取出問題原因描述,依序嘗試多個可能欄位。
|
||||||
|
*
|
||||||
|
* @param {{ problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f
|
||||||
|
* 單筆審查問題物件;依序取第一個有值(truthy)的欄位。所有欄位皆無值時回退為「未提供問題原因」。
|
||||||
|
* @returns {string} 問題原因字串。
|
||||||
|
* @remarks 內部輔助函式,供 {@link reviewCommentBody} 組裝 comment 內文使用,用以容忍不同來源 finding 的欄位命名差異。
|
||||||
|
*/
|
||||||
function problemText(f) {
|
function problemText(f) {
|
||||||
return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因';
|
return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生 review comment 內文(嚴重等級/審查員/問題/建議四行)。
|
||||||
|
*
|
||||||
|
* @param {{ level?: string, role?: string, suggestion?: string, problem?: string, reason?: string, description?: string, detail?: string, title?: string, message?: string }} f
|
||||||
|
* 單筆審查問題物件。
|
||||||
|
* @returns {string} 多行 Markdown 字串。
|
||||||
|
* @remarks 內部輔助函式,供 {@link toReviewComment} 產生批次 review comment 內文使用。比 {@link inlineCommentBody} 多了「問題」一行。
|
||||||
|
*/
|
||||||
function reviewCommentBody(f) {
|
function reviewCommentBody(f) {
|
||||||
return [
|
return [
|
||||||
`**嚴重等級**:${levelText(f)}`,
|
`**嚴重等級**:${levelText(f)}`,
|
||||||
@@ -56,17 +106,47 @@ function reviewCommentBody(f) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 計算陣列中符合條件的元素數量。
|
||||||
|
*
|
||||||
|
* @param {Array<T>} findings 待計數的陣列。
|
||||||
|
* @param {(item: T) => boolean} predicate 判斷函式;回傳 true 的元素計入。
|
||||||
|
* @returns {number} 符合條件的元素數量。
|
||||||
|
* @template T
|
||||||
|
* @remarks 內部輔助函式,供 {@link formatFindingsStats} 與 {@link formatFindingsStatsLine} 統計各等級筆數使用。
|
||||||
|
*/
|
||||||
function countBy(findings, predicate) {
|
function countBy(findings, predicate) {
|
||||||
return findings.filter(predicate).length;
|
return findings.filter(predicate).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 過濾出新問題(is_new 不等於 false 者)。
|
||||||
|
*
|
||||||
|
* @param {Array<{ is_new?: boolean }>} findings 審查問題陣列。
|
||||||
|
* @returns {Array<object>} 新問題子集合。
|
||||||
|
* @remarks 內部輔助函式。判定採 `is_new !== false`,因此未設定 is_new(undefined)的 finding 也視為新問題;僅明確 `is_new === false` 會被排除。供統計與 review 發布判斷使用。
|
||||||
|
*/
|
||||||
function newFindingsOnly(findings) {
|
function newFindingsOnly(findings) {
|
||||||
return findings.filter(f => f.is_new !== false);
|
return findings.filter(f => f.is_new !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等級無法歸入 critical/warning/info(例如缺漏或無法辨識)時,歸入「無法標示」
|
/**
|
||||||
|
* 判斷 finding 等級是否無法歸入 critical/warning/info(無法標示)。
|
||||||
|
*
|
||||||
|
* @param {{ level?: string }} f 單筆審查問題物件。
|
||||||
|
* @returns {boolean} 等級不在 LEVEL_ORDER 內時為 true。
|
||||||
|
* @remarks 內部輔助函式,供統計表的「⚪ 無法標示」欄位計數使用。
|
||||||
|
*/
|
||||||
const isUnclassified = f => !LEVEL_ORDER.includes(f.level);
|
const isUnclassified = f => !LEVEL_ORDER.includes(f.level);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生 findings 統計的 Markdown 表格(新問題/舊問題 × 嚴重/警告/建議/無法標示)。
|
||||||
|
*
|
||||||
|
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||||
|
* `is_new === false` 計入舊問題,其餘計入新問題。
|
||||||
|
* @returns {string} 含表頭、分隔列與兩資料列的 Markdown 表格字串。
|
||||||
|
* @remarks 供 {@link buildReviewSummary} 組裝 review 統計本文使用。空陣列時仍輸出表格(各欄為 0 筆)。
|
||||||
|
*/
|
||||||
export function formatFindingsStats(findings) {
|
export function formatFindingsStats(findings) {
|
||||||
const oldFindings = findings.filter(f => f.is_new === false);
|
const oldFindings = findings.filter(f => f.is_new === false);
|
||||||
const newFindings = newFindingsOnly(findings);
|
const newFindings = newFindingsOnly(findings);
|
||||||
@@ -80,6 +160,14 @@ export function formatFindingsStats(findings) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 產生 findings 統計的單行文字摘要(供 log 使用)。
|
||||||
|
*
|
||||||
|
* @param {Array<{ is_new?: boolean, level?: string }>} findings 審查問題陣列;
|
||||||
|
* `is_new === false` 計入舊問題,其餘計入新問題。
|
||||||
|
* @returns {string} 形如 `新: 嚴重1 / 警告0 / 建議2 / 無法標示0;舊: ...` 的單行字串。
|
||||||
|
* @remarks 供 {@link postFindingsReview} 在 log 輸出統計時呼叫。內容與 {@link formatFindingsStats} 一致,僅格式為單行純文字。
|
||||||
|
*/
|
||||||
export function formatFindingsStatsLine(findings) {
|
export function formatFindingsStatsLine(findings) {
|
||||||
const oldFindings = findings.filter(f => f.is_new === false);
|
const oldFindings = findings.filter(f => f.is_new === false);
|
||||||
const newFindings = newFindingsOnly(findings);
|
const newFindings = newFindingsOnly(findings);
|
||||||
@@ -87,6 +175,14 @@ export function formatFindingsStatsLine(findings) {
|
|||||||
return `新: ${row(newFindings)};舊: ${row(oldFindings)}`;
|
return `新: ${row(newFindings)};舊: ${row(oldFindings)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 組裝 review 本文:標題 + findings 統計表 +(選擇性)用量區塊。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} findings 用於統計的審查問題陣列。
|
||||||
|
* @param {string} [usageSection=''] 額外附加的用量/token 統計區塊;空字串時不附加。
|
||||||
|
* @returns {string} review 本文(Markdown)。
|
||||||
|
* @remarks 內部輔助函式,供 {@link postFindingsReview} 產生整批 review 的 body。
|
||||||
|
*/
|
||||||
function buildReviewSummary(findings, usageSection = '') {
|
function buildReviewSummary(findings, usageSection = '') {
|
||||||
const parts = [
|
const parts = [
|
||||||
'## AI Code Review 統計',
|
'## AI Code Review 統計',
|
||||||
@@ -97,6 +193,14 @@ function buildReviewSummary(findings, usageSection = '') {
|
|||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將 finding 轉為 Gitea review comment 物件(含檔案路徑、內文、行號)。
|
||||||
|
*
|
||||||
|
* @param {{ location?: string, level?: string, role?: string, suggestion?: string }} f 單筆審查問題物件。
|
||||||
|
* @returns {{ path: string, body: string, new_position: number } | null}
|
||||||
|
* 可定位時回傳 comment 物件;location 無法解析出行號時回傳 null。
|
||||||
|
* @remarks 內部輔助函式,供 {@link postFindingsReview} 在 map 後以 `filter(Boolean)` 濾除無法定位的項目。
|
||||||
|
*/
|
||||||
function toReviewComment(f) {
|
function toReviewComment(f) {
|
||||||
const loc = parseLocation(f.location);
|
const loc = parseLocation(f.location);
|
||||||
if (!loc) return null;
|
if (!loc) return null;
|
||||||
|
|||||||
@@ -12,10 +12,31 @@ export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || '';
|
|||||||
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent,
|
||||||
|
* 供連接使用自簽或無效憑證的 OpenCode 服務時使用。
|
||||||
|
*
|
||||||
|
* @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用,
|
||||||
|
* 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。
|
||||||
|
* 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Rogue
**問題**:每次呼叫都 new https.Agent(),這會建立全新的 TCP 連線池,不僅增加記憶體碎片,還浪費三次握手建立連線的時間,根本沒發揮連線池的優勢。
**建議**:將 Agent 實例化為模組層級的靜態變數(Singleton),只在第一次呼叫時建立,後續重複使用。
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Rogue
**問題**:每次呼叫都 new https.Agent(),這會建立全新的 TCP 連線池,不僅增加記憶體碎片,還浪費三次握手建立連線的時間,根本沒發揮連線池的優勢。
**建議**:將 Agent 實例化為模組層級的靜態變數(Singleton),只在第一次呼叫時建立,後續重複使用。
|
|||||||
|
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
|
||||||
|
*/
|
||||||
|
let _openCodeHttpsAgent = null;
|
||||||
export function getOpenCodeHttpsAgent() {
|
export function getOpenCodeHttpsAgent() {
|
||||||
return new https.Agent({ rejectUnauthorized: false });
|
return (_openCodeHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依環境變數解析並回傳 LLM 提供者設定。
|
||||||
|
*
|
||||||
|
* 當設定了 `OPENCODE_BASE_URL` 時回傳 OpenCode 提供者設定
|
||||||
|
* (model 取自 `OPENCODE_MODEL`,預設為 `gemini-2.5-flash`);
|
||||||
|
* 否則回傳各欄位皆為空/null 的「無提供者」設定,由呼叫端據此判斷是否略過 LLM 流程。
|
||||||
|
*
|
||||||
|
* @remarks 每次呼叫都會即時讀取 `process.env`。`apiKeys` 在 OpenCode 模式下為固定佔位值 `['opencode']`,並非真實金鑰。
|
||||||
|
* @returns {{ provider: ('opencode'|null), apiKeys: string[], baseURL: (string|null), model: (string|null) }}
|
||||||
|
* LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。
|
||||||
|
*/
|
||||||
export function getLLMConfig() {
|
export function getLLMConfig() {
|
||||||
if (process.env.OPENCODE_BASE_URL) {
|
if (process.env.OPENCODE_BASE_URL) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ function readJSONArray(fullPath, label) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將排除設定(頂層陣列、{ exclusions: [] } 或 { excluded_findings: [] })正規化為條目陣列。
|
||||||
|
*
|
||||||
|
* @param {Array<object>|{exclusions?: Array<object>, excluded_findings?: Array<object>}|*} data - 任意形式的排除資料來源。
|
||||||
|
* @returns {Array<object>} 對應的排除條目陣列;無法辨識時回傳空陣列。
|
||||||
|
* @remarks 與 detectExclusionSource 搭配,相容舊有多種 exclusions.json 結構。
|
||||||
|
*/
|
||||||
function normalizeExclusions(data) {
|
function normalizeExclusions(data) {
|
||||||
if (Array.isArray(data)) return data;
|
if (Array.isArray(data)) return data;
|
||||||
if (data && Array.isArray(data.exclusions)) return data.exclusions;
|
if (data && Array.isArray(data.exclusions)) return data.exclusions;
|
||||||
@@ -44,6 +51,13 @@ function normalizeExclusions(data) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 偵測排除資料的原始容器格式,回傳格式標籤。
|
||||||
|
*
|
||||||
|
* @param {Array<object>|{exclusions?: *, excluded_findings?: *}|*} data - 任意形式的排除資料來源。
|
||||||
|
* @returns {('array'|'exclusions'|'excluded_findings'|'unknown')} 對應的格式標籤。
|
||||||
|
* @remarks 供 loadExclusions 判斷是否需把非陣列格式改寫成標準頂層陣列。
|
||||||
|
*/
|
||||||
function detectExclusionSource(data) {
|
function detectExclusionSource(data) {
|
||||||
if (Array.isArray(data)) return 'array';
|
if (Array.isArray(data)) return 'array';
|
||||||
if (data && Array.isArray(data.exclusions)) return 'exclusions';
|
if (data && Array.isArray(data.exclusions)) return 'exclusions';
|
||||||
@@ -51,28 +65,72 @@ function detectExclusionSource(data) {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以標準格式(2 空白縮排 JSON 陣列、結尾換行、UTF-8)將排除條目寫回檔案,覆蓋原內容。
|
||||||
|
*
|
||||||
|
* @param {string} fullPath - 目標檔案路徑;上層目錄須事先存在(本函式不建立目錄)。
|
||||||
|
* @param {Array<object>} exclusions - 欲寫入的排除條目陣列。
|
||||||
|
* @returns {void}
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:normalizeText 執行了複雜的字元正規化(NFKC、標點符號壓縮),這是 findings 去重比對的核心邏輯,但目前缺乏測試來驗證其對各種 Unicode 變體、全形/半形符號的處理是否符合預期。
**建議**:請補上測試用例,輸入包含全形/半形標點符號、各種 Unicode 組合字元的測試資料,確保正規化結果的一致性。
|
|||||||
|
* @throws 檔案寫入失敗(權限不足、目錄不存在等)時拋出 fs 錯誤。
|
||||||
|
* @remarks 統一輸出格式,使 exclusions.json 永遠是可預期的頂層陣列。
|
||||||
|
*/
|
||||||
function writeCanonicalExclusions(fullPath, exclusions) {
|
function writeCanonicalExclusions(fullPath, exclusions) {
|
||||||
fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8');
|
fs.writeFileSync(fullPath, JSON.stringify(exclusions, null, 2) + '\n', 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將檔案 mtime(毫秒時間戳)格式化為 ISO 字串,無效值回傳 'unknown'。
|
||||||
|
*
|
||||||
|
* @param {number} mtimeMs - 毫秒時間戳(通常為 fs.Stats.mtimeMs)。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Rogue
**問題**:這函式在 inner loop 被瘋狂呼叫,每次都跑四次重量級的字串正則替換 (normalize, replace, trim),若這與排除規則做笛卡爾積比對,效能直接雪崩。
**建議**:針對 findings 與 exclusions 的內容,採用「首次計算後快取 (Memoization)」機制,不要重複對同一段文字計算正規化。
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Rogue
**問題**:這函式在 inner loop 被瘋狂呼叫,每次都跑四次重量級的字串正則替換 (normalize, replace, trim),若這與排除規則做笛卡爾積比對,效能直接雪崩。
**建議**:針對 findings 與 exclusions 的內容,採用「首次計算後快取 (Memoization)」機制,不要重複對同一段文字計算正規化。
|
|||||||
|
* @returns {string} ISO 8601 時間字串,或在輸入非有限數時回傳 'unknown'。
|
||||||
|
* @remarks 僅用於診斷日誌,呈現舊 findings / exclusions 檔案的修改時間。
|
||||||
|
*/
|
||||||
function formatFileTime(mtimeMs) {
|
function formatFileTime(mtimeMs) {
|
||||||
if (!Number.isFinite(mtimeMs)) return 'unknown';
|
if (!Number.isFinite(mtimeMs)) return 'unknown';
|
||||||
return new Date(mtimeMs).toISOString();
|
return new Date(mtimeMs).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全取字串:字串則去頭尾空白,其餘型別(含 null/undefined/數字)一律回傳空字串。
|
||||||
|
*
|
||||||
|
* @param {*} value - 任意值。
|
||||||
|
* @returns {string} 去除頭尾空白後的字串,或空字串。
|
||||||
|
* @remarks 作為 normalizeText、toKeyText、getExclusionText 等的基礎防呆。
|
||||||
|
*/
|
||||||
function cleanText(value) {
|
function cleanText(value) {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeText(value) {
|
/**
|
||||||
return cleanText(value)
|
* 將文字正規化為比對用形式:NFKC、小寫、標點/符號/空白統一為單一空白後壓縮。
|
||||||
|
*
|
||||||
|
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
|
||||||
|
* @returns {string} 正規化後、以單一空白分隔的字串(可能為空字串)。
|
||||||
|
* @remarks 用於 finding 與排除條目文字的雙向「包含」比對(applyExclusions、appendExclusions)。
|
||||||
|
* 因為比對常對同一段文字重複呼叫(findings × exclusions 笛卡爾積),
|
||||||
|
* 以模組層級 Map 對「字串輸入」做 memoization,避免重複跑 NFKC/正則替換。
|
||||||
|
*/
|
||||||
|
const _normalizeTextCache = new Map();
|
||||||
|
export function normalizeText(value) {
|
||||||
|
if (typeof value === 'string' && _normalizeTextCache.has(value)) return _normalizeTextCache.get(value);
|
||||||
|
const result = cleanText(value)
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[\p{P}\p{S}\s]+/gu, ' ')
|
.replace(/[\p{P}\p{S}\s]+/gu, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
if (typeof value === 'string') _normalizeTextCache.set(value, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將文字壓縮成無分隔符的鍵值:NFKC 後移除所有標點/符號/空白。
|
||||||
|
*
|
||||||
|
* @param {*} value - 任意值;非字串會先經 cleanText 轉為空字串。
|
||||||
|
* @returns {string} 去除所有分隔符的緊湊字串(可能為空字串)。
|
||||||
|
* @remarks 用於 normalizeExclusionEntry 的 textKey 與 fingerprint,以及群組鍵。
|
||||||
|
* 不確定:是否刻意不轉小寫(與 normalizeText 不同),需人工確認此差異是否預期。
|
||||||
|
*/
|
||||||
function toKeyText(value) {
|
function toKeyText(value) {
|
||||||
return cleanText(value)
|
return cleanText(value)
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
@@ -80,6 +138,13 @@ function toKeyText(value) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從排除條目取出代表性文字,依優先序 original_finding > title > suggestion > reason > note 取第一個非空值。
|
||||||
|
*
|
||||||
|
* @param {object|null|undefined} exclusion - 排除條目物件(可為 null/undefined)。
|
||||||
|
* @returns {string} 第一個非空的代表性文字,皆空時回傳空字串。
|
||||||
|
* @remarks 供 normalizeExclusionEntry 產生比對文字;相容多種人工撰寫的排除欄位命名。
|
||||||
|
*/
|
||||||
function getExclusionText(exclusion) {
|
function getExclusionText(exclusion) {
|
||||||
return cleanText(exclusion?.original_finding)
|
return cleanText(exclusion?.original_finding)
|
||||||
|| cleanText(exclusion?.title)
|
|| cleanText(exclusion?.title)
|
||||||
@@ -88,6 +153,14 @@ function getExclusionText(exclusion) {
|
|||||||
|| cleanText(exclusion?.note);
|
|| cleanText(exclusion?.note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正規化單一排除條目,補上 filePath、text、textKey 與唯一 fingerprint,保留原始欄位。
|
||||||
|
*
|
||||||
|
* @param {object} exclusion - 原始排除條目(可能僅含部分欄位)。
|
||||||
|
* @param {number} index - 條目在來源陣列中的索引;無文字可用時用於產生 fallback 指紋(entry-N)。
|
||||||
|
* @returns {object} 合併原欄位與衍生欄位(location、filePath、role、text、textKey、fingerprint)的新物件。
|
||||||
|
* @remarks fingerprint 以 filePath|role|textKey 組成,缺值以 '*' 或 entry-N 補位,供 dedupeExclusions 去重。
|
||||||
|
*/
|
||||||
function normalizeExclusionEntry(exclusion, index) {
|
function normalizeExclusionEntry(exclusion, index) {
|
||||||
const location = cleanText(exclusion?.location);
|
const location = cleanText(exclusion?.location);
|
||||||
const filePath = location ? location.split(':')[0] : '';
|
const filePath = location ? location.split(':')[0] : '';
|
||||||
@@ -106,6 +179,13 @@ function normalizeExclusionEntry(exclusion, index) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依 fingerprint 去除重複的排除條目,保留首次出現者並維持原順序。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} exclusions - 已正規化(含 fingerprint)的排除條目陣列。
|
||||||
|
* @returns {Array<object>} 去重後的排除條目陣列。
|
||||||
|
* @remarks 須先呼叫 normalizeExclusionEntry 補上 fingerprint,否則缺指紋的條目可能被誤併。
|
||||||
|
*/
|
||||||
function dedupeExclusions(exclusions) {
|
function dedupeExclusions(exclusions) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
return exclusions.filter(exclusion => {
|
return exclusions.filter(exclusion => {
|
||||||
@@ -115,6 +195,14 @@ function dedupeExclusions(exclusions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將排除條目依 textKey 分組統計,產生供 AI prompt 使用的群組摘要(含出現次數、涉及路徑與角色、樣本)。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} exclusions - 已正規化(含 textKey、filePath、role、text、fingerprint)的排除條目。
|
||||||
|
* @returns {Array<{text: string, count: number, paths: string[], roles: string[], samples: string[]}>}
|
||||||
|
* 依出現次數、涉及路徑數、文字字典序排序的群組摘要陣列。
|
||||||
|
* @remarks 每組最多保留 2 筆樣本,避免後續 prompt 過長;供 buildExclusionContext 取前 N 組組裝提示。
|
||||||
|
*/
|
||||||
function groupExclusionsForAI(exclusions) {
|
function groupExclusionsForAI(exclusions) {
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
for (const exclusion of exclusions) {
|
for (const exclusion of exclusions) {
|
||||||
@@ -147,6 +235,14 @@ function groupExclusionsForAI(exclusions) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由原始排除條目建立「已知誤報」上下文:正規化、去重、分組後,產生計數摘要與可直接嵌入 prompt 的文字。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} exclusions - 原始(未正規化)排除條目陣列。
|
||||||
|
* @returns {{rawCount: number, uniqueCount: number, groupCount?: number, groups: Array<object>, prompt: string}}
|
||||||
|
* 含計數、前 12 組群組摘要與 prompt 字串;空輸入時 prompt 為空字串且不含 groupCount。
|
||||||
|
* @remarks 供 loadExclusions 日誌與 filterFalsePositivesWithAI 組裝防守方提示使用;prompt 最多展開 12 類群組。
|
||||||
|
*/
|
||||||
function buildExclusionContext(exclusions) {
|
function buildExclusionContext(exclusions) {
|
||||||
if (exclusions.length === 0) {
|
if (exclusions.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -303,7 +399,13 @@ export async function resolveMissingLineNumbers(findings, diff, deps = {}) {
|
|||||||
return findings;
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只保留 AI 需要的欄位,減少 token 用量 */
|
/**
|
||||||
|
* 將 findings 精簡為僅含 level、role、location、problem、suggestion 的物件,移除多餘欄位以節省 token。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} findings - 完整 findings 陣列。
|
||||||
|
* @returns {Array<{level: *, role: *, location: *, problem: *, suggestion: *}>} 精簡後的 payload 陣列。
|
||||||
|
* @remarks 送往 LLM 前的瘦身步驟;原始欄位(如 is_new)需由呼叫端事後依鍵補回。
|
||||||
|
*/
|
||||||
function toAIPayload(findings) {
|
function toAIPayload(findings) {
|
||||||
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
|
return findings.map(({ level, role, location, problem, suggestion }) => ({ level, role, location, problem, suggestion }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { spawnSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, GITEA_COMMENT_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
||||||
import { line, ok, warn } from './log.js';
|
import { line, ok, warn } from './log.js';
|
||||||
|
|
||||||
const REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
const REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
||||||
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||||||
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立一個同步執行 git 子行程的 runner。透過注入 `spawn` 以利測試
|
||||||
|
* (正式環境傳入 `child_process.spawnSync`,測試可傳入 stub)。
|
||||||
|
*
|
||||||
|
* 回傳的 `run(args, cwd, env)` 會以 utf8 編碼執行 `git <args>`,
|
||||||
|
* 成功回傳經 trim 的 stdout,失敗則丟出 Error。
|
||||||
|
*
|
||||||
|
* @param {(cmd: string, args: string[], opts: object) => {error?: Error & {code?: string}, status?: number, stdout?: string, stderr?: string}} spawn
|
||||||
|
* 同步 spawn 實作(依賴注入,通常為 `spawnSync`)。
|
||||||
|
* @returns {(args: string[], cwd?: string, env?: object) => string}
|
||||||
|
* 執行 git 的函式:回傳 trim 後的 stdout。
|
||||||
|
* @throws {Error} 找不到 git 指令時(`ENOENT`)丟出中文提示。
|
||||||
|
* @throws {Error} git 子行程本身的 `error`(非 ENOENT)原樣丟出。
|
||||||
|
* @throws {Error} git 離開碼非 0 時,以 stderr/stdout 內容丟出。
|
||||||
|
*/
|
||||||
function makeRunner(spawn) {
|
function makeRunner(spawn) {
|
||||||
return function run(args, cwd, env) {
|
return function run(args, cwd, env) {
|
||||||
const opts = { cwd, encoding: 'utf8' };
|
const opts = { cwd, encoding: 'utf8' };
|
||||||
@@ -22,10 +37,31 @@ function makeRunner(spawn) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function withAskpass(workspace, fn) {
|
/**
|
||||||
|
* 包裝一段需要 git HTTP 認證的工作:先在 workspace 寫出暫時的
|
||||||
|
* `.git-askpass.sh`(透過 `GIT_ASKPASS` 提供 token),呼叫 `fn(credEnv)`,
|
||||||
|
* 再清除該暫存腳本。
|
||||||
|
*
|
||||||
|
* 清理時機會依 `fn` 回傳型別自動判斷:
|
||||||
|
* 同步回傳會立即清理;回傳 Promise(含 async 回呼)則延後到 Promise
|
||||||
|
* settle 後才清理,避免在第一個 await 就刪掉腳本,導致後續 git push
|
||||||
|
* 因 `cannot exec .git-askpass.sh` 而失敗。
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {string} workspace 寫入暫存 askpass 腳本的目錄。
|
||||||
|
* @param {(credEnv: NodeJS.ProcessEnv) => T} fn 帶入憑證環境變數執行的回呼。
|
||||||
|
* @param {string} [token=GITEA_TOKEN] 供 git HTTP 認證使用的 token。預設為自動的
|
||||||
|
* `GITEA_TOKEN`(適用於唯讀的 clone/ls-remote);需要讓 push 出來的 commit
|
||||||
|
* 重新觸發 workflow 時,呼叫端應改傳真人 PAT(`GITEA_COMMENT_TOKEN`),因為
|
||||||
|
* Gitea 不會為「自動 token」推送的 commit 發出事件。
|
||||||
|
* @returns {T} 即 `fn` 的回傳值(Promise 會被包成 `.finally(cleanup)` 後回傳)。
|
||||||
|
* @throws 透傳 `fn` 丟出的任何例外(同步路徑會先清理暫存腳本再 re-throw)。
|
||||||
|
* @remarks askpass 腳本以權限 0o700 寫出;token 由參數帶入並透過 `GIT_TOKEN` 提供給腳本。
|
||||||
|
*/
|
||||||
|
function withAskpass(workspace, fn, token = GITEA_TOKEN) {
|
||||||
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
||||||
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
||||||
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: token };
|
||||||
const cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} };
|
const cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} };
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +80,19 @@ function withAskpass(workspace, fn) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以容錯方式執行 git 讀取指令:成功回傳 trim 後的輸出,
|
||||||
|
* 任何錯誤都吞掉並回傳空字串。適用於「失敗也不該中斷流程」的唯讀查詢
|
||||||
|
* (例如取 HEAD SHA、分支名、commit 時間)。
|
||||||
|
*
|
||||||
|
* @param {(args: string[], cwd?: string, env?: object) => string} run
|
||||||
|
* 由 `makeRunner` 產生的 git 執行函式。
|
||||||
|
* @param {string[]} args git 子指令與參數。
|
||||||
|
* @param {string} [cwd] 執行目錄。
|
||||||
|
* @param {object} [env] 環境變數覆寫。
|
||||||
|
* @returns {string} git 的 trim 輸出;失敗時回傳空字串。
|
||||||
|
* @remarks 不會拋出例外,也不記錄錯誤。
|
||||||
|
*/
|
||||||
function readGitOutput(run, args, cwd, env) {
|
function readGitOutput(run, args, cwd, env) {
|
||||||
try {
|
try {
|
||||||
return run(args, cwd, env);
|
return run(args, cwd, env);
|
||||||
@@ -52,6 +101,17 @@ function readGitOutput(run, args, cwd, env) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取指定 repo 目錄的目前狀態(HEAD SHA、短 SHA、目前分支、commit 時間)。
|
||||||
|
* 所有查詢皆採容錯讀取,任一失敗對應欄位即為空字串,不會丟出例外。
|
||||||
|
*
|
||||||
|
* @param {string} repoDir git 工作目錄路徑。
|
||||||
|
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||||||
|
* 測試用依賴注入:覆寫底層的同步 spawn 實作。
|
||||||
|
* @returns {{repoDir: string, branch: string, headSha: string, shortSha: string, commitTime: string}}
|
||||||
|
* repo 狀態快照;無法取得的欄位為空字串。
|
||||||
|
* @remarks `commitTime` 為 `%cI` 格式(committer date, ISO 8601 嚴格格式)。
|
||||||
|
*/
|
||||||
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
||||||
@@ -61,11 +121,30 @@ export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
|||||||
return { repoDir, branch, headSha, shortSha, commitTime };
|
return { repoDir, branch, headSha, shortSha, commitTime };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得 HEAD commit 的完整 commit message(`%B`,含 subject 與 body)。
|
||||||
|
* 容錯讀取:失敗時回傳空字串。
|
||||||
|
*
|
||||||
|
* @param {string} repoDir git 工作目錄路徑。
|
||||||
|
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||||||
|
* 測試用依賴注入。
|
||||||
|
* @returns {string} HEAD 的完整 commit 訊息;失敗時為空字串。
|
||||||
|
*/
|
||||||
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
|
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
|
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判斷 HEAD commit 是否為 AI Review 機器人自己產生的自動 commit
|
||||||
|
* (commit message 含 `BOT_COMMIT_MARKER`)。常用於避免機器人 commit
|
||||||
|
* 反覆觸發新一輪審查。
|
||||||
|
*
|
||||||
|
* @param {string} repoDir git 工作目錄路徑。
|
||||||
|
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||||||
|
* 測試用依賴注入。
|
||||||
|
* @returns {boolean} HEAD 訊息含機器人標記時為 true;讀取失敗時安全地回傳 false。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:`commitAndPush` 函式在 `git push` 失敗時僅記錄了 warning 且不拋出例外。這意味著如果 push 失敗(例如網路問題、衝突),系統會誤以為 review 已完成並發布,測試案例中沒有驗證 push 失敗後對於整個 CI pipeline 狀態的影響。
**建議**:即使不希望中斷流程,也應該在測試中明確驗證此時是否會觸發後續的告警機制或讓狀態檢查器偵測到失敗,確保沒有被誤當成執行成功。
gitea-actions
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:`commitAndPush` 函式在 `git push` 失敗時僅記錄了 warning 且不拋出例外。這意味著如果 push 失敗(例如網路問題、衝突),系統會誤以為 review 已完成並發布,測試案例中沒有驗證 push 失敗後對於整個 CI pipeline 狀態的影響。
**建議**:即使不希望中斷流程,也應該在測試中明確驗證此時是否會觸發後續的告警機制或讓狀態檢查器偵測到失敗,確保沒有被誤當成執行成功。
|
|||||||
|
*/
|
||||||
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
||||||
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
||||||
}
|
}
|
||||||
@@ -108,8 +187,34 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將 AI 審查產出的 review 檔(findings / exclusions)結轉到 repo,並 commit、
|
||||||
|
* push 回 PR head branch。流程:設定機器人 git 身分 → fetch + hard reset 對齊
|
||||||
|
* 遠端 → 從 workspace 複製存在的 review 檔到 repo 並 add → 若無變更則跳過 →
|
||||||
|
* 以含 `BOT_COMMIT_MARKER` 與結果標籤的訊息 commit → push。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Leo
**問題**:`withAskpass` is tightly coupled with Gitea's bot-trigger behavior.
**建議**:Encapsulate bot commit logic within `commitAndPush`, auto-selecting the correct `GITEA_COMMENT_TOKEN`.
gitea-actions
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Leo
**問題**:`withAskpass` is tightly coupled with Gitea's bot-trigger behavior.
**建議**:Encapsulate bot commit logic within `commitAndPush`, auto-selecting the correct `GITEA_COMMENT_TOKEN`.
|
|||||||
|
*
|
||||||
|
* 失敗策略:push 失敗只記 warning(commit 已在本地完成);其餘步驟的例外
|
||||||
|
* 由外層捕捉並記 warning,函式整體**不丟出例外**,以免中斷上層流程。
|
||||||
|
*
|
||||||
|
* @param {string} workspace review 檔來源目錄、askpass 腳本所在目錄。
|
||||||
|
* @param {string} repoDir 目標 git repo 目錄(commit/push 的工作目錄)。
|
||||||
|
* @param {typeof import('child_process').spawnSync} [_spawnSync=spawnSync]
|
||||||
|
* 測試用依賴注入:覆寫底層同步 spawn。
|
||||||
|
* @param {string|null} [_sourceRoot=null] 測試用依賴注入保留參數;
|
||||||
|
* 目前函式主體未使用(不確定,待確認其他呼叫端是否依賴)。
|
||||||
|
* @param {'success'|'failure'} [reviewOutcome='success']
|
||||||
|
* 審查結果,決定 commit 訊息標籤(`[success]` / `[failure]`)。
|
||||||
|
* @returns {Promise<void>} 無回傳值;所有失敗皆以 log 記錄後吞掉。
|
||||||
|
* @remarks `git reset --hard origin/<branch>` 會丟棄本地未對齊變更,請確認
|
||||||
|
* review 檔是在 reset 之後才複製進來(流程已如此安排)。
|
||||||
|
* @remarks push 優先使用真人 PAT `GITEA_COMMENT_TOKEN`(無則退回 `GITEA_TOKEN`),
|
||||||
|
* 目的是讓 bot commit 能重新觸發 PR 的 workflow:以自動 token 推送的 commit
|
||||||
|
* 不會發出 `synchronize` 事件,新 head commit 便拿不到檢查而卡住。改用 PAT
|
||||||
|
* 推送後會正常重觸發,重跑時由 main.js Step3 偵測 `[ai-review-bot]` 標記後跳過。
|
||||||
|
*/
|
||||||
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
|
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
|
||||||
const run = makeRunner(_spawnSync);
|
const run = makeRunner(_spawnSync);
|
||||||
|
const pushToken = GITEA_COMMENT_TOKEN || GITEA_TOKEN;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withAskpass(workspace, async credEnv => {
|
await withAskpass(workspace, async credEnv => {
|
||||||
@@ -146,7 +251,7 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
|
|||||||
} catch (pushErr) {
|
} catch (pushErr) {
|
||||||
warn(`Step8 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
warn(`Step8 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
||||||
}
|
}
|
||||||
});
|
}, pushToken);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`Runner failed: commit/push 失敗: ${e.message}`);
|
warn(`Runner failed: commit/push 失敗: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,26 @@ import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, P
|
|||||||
import { line, warn } from './log.js';
|
import { line, warn } from './log.js';
|
||||||
|
|
||||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
/**
|
||||||
|
* 產生呼叫 Gitea API 所需的 HTTP headers(含 Gitea token 授權與 JSON content-type)。
|
||||||
|
* 授權格式為 Gitea 專用的 `token <token>`,並非 OAuth Bearer。
|
||||||
|
* @param {string} [token=GITEA_TOKEN] - Gitea access token;讀取類用預設 token,留言/寫入類通常傳入 GITEA_COMMENT_TOKEN。
|
||||||
|
* @returns {{Authorization: string, 'Content-Type': string}} 可直接給 axios 的 headers 物件。
|
||||||
|
*/
|
||||||
const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
||||||
|
/**
|
||||||
|
* 將相對路徑組成 Gitea REST API v1 的完整 URL(自動去除 server URL 結尾斜線)。
|
||||||
|
* @param {string} path - 以斜線開頭的 API 子路徑,例如 `/repos/owner/repo/pulls/1.diff`。
|
||||||
|
* @returns {string} 形如 `<server>/api/v1<path>` 的完整 URL。
|
||||||
|
*/
|
||||||
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從 Gitea commit 相關 API 的回應中萃取 commit message,相容多種巢狀結構。
|
||||||
|
* 依序嘗試 `message`、`commit.message`、`commit.commit.message`,皆無則回空字串。
|
||||||
|
* @param {object|null|undefined} payload - Gitea API 回傳的物件(如 git/commits 或 branch 回應)。
|
||||||
|
* @returns {string} commit 訊息,找不到時為空字串。
|
||||||
|
*/
|
||||||
function extractCommitMessage(payload) {
|
function extractCommitMessage(payload) {
|
||||||
return payload?.message
|
return payload?.message
|
||||||
|| payload?.commit?.message
|
|| payload?.commit?.message
|
||||||
@@ -14,13 +31,22 @@ function extractCommitMessage(payload) {
|
|||||||
|| '';
|
|| '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析文字中的 `[ai-review-bot][success|failure]` 標記,判斷上一次自動審查結果。
|
||||||
|
* 用於 commit 訊息或留言內容;無標記或無後綴時視為未知。
|
||||||
|
* @param {string} message - 待解析的 commit 訊息或留言文字。
|
||||||
|
* @returns {'success'|'failure'|'unknown'} 解析出的審查結果。
|
||||||
|
*/
|
||||||
export function getBotReviewOutcome(message) {
|
export function getBotReviewOutcome(message) {
|
||||||
const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i);
|
const match = String(message || '').match(/\[ai-review-bot\](?:\[(success|failure)\])?/i);
|
||||||
return match?.[1]?.toLowerCase() || 'unknown';
|
return match?.[1]?.toLowerCase() || 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得 PR 的 Git Diff 內容,已自動排除 .gitea/ 資料夾。
|
* 取得目前 PR 的完整 Git diff,並排除 CI/文件等不需審查的路徑(.gitea/、.github/、README.md、TODO.md)。
|
||||||
|
* 透過 Gitea `GET /repos/{repo}/pulls/{index}.diff`(純文字 diff),授權使用 GITEA_TOKEN。
|
||||||
|
* @returns {Promise<string>} 過濾後的 diff 文字。
|
||||||
|
* @throws {Error} 當 Gitea API 請求失敗(網路錯誤、逾時或非 2xx 狀態)時拋出 axios 例外。
|
||||||
*/
|
*/
|
||||||
export async function getPRDiff() {
|
export async function getPRDiff() {
|
||||||
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent });
|
||||||
@@ -32,6 +58,12 @@ export async function getPRDiff() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依 commit SHA 向 Gitea 查詢該 commit 的訊息(`GET /repos/{repo}/git/commits/{sha}`)。
|
||||||
|
* 失敗或 sha 為空時不拋例外,僅記錄警告並回傳空字串,方便呼叫端做容錯判斷。
|
||||||
|
* @param {string} sha - commit 的完整或縮寫 SHA。
|
||||||
|
* @returns {Promise<string>} commit 訊息;查無、sha 空或請求失敗時為空字串。
|
||||||
|
*/
|
||||||
export async function getCommitMessageBySha(sha) {
|
export async function getCommitMessageBySha(sha) {
|
||||||
if (!sha) return '';
|
if (!sha) return '';
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +79,12 @@ export async function getCommitMessageBySha(sha) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得指定分支 head commit 的訊息(先查 `GET /repos/{repo}/branches/{branch}` 取 SHA,再查該 commit)。
|
||||||
|
* 失敗或 branch 為空時不拋例外,記錄警告並回傳空字串。
|
||||||
|
* @param {string} [branch=PR_HEAD_BRANCH] - 分支名稱。
|
||||||
|
* @returns {Promise<string>} 該分支 head commit 的訊息;查無或失敗時為空字串。
|
||||||
|
*/
|
||||||
export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
|
export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
|
||||||
if (!branch) return '';
|
if (!branch) return '';
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +101,15 @@ export async function getBranchHeadCommitMessage(branch = PR_HEAD_BRANCH) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 檢查 PR head(commit sha 或分支 head)的訊息是否帶 [ai-review-bot] 標記,是則代表本次是自動提交、應跳過審查。 */
|
/**
|
||||||
|
* 判斷目前 PR head(commit 或分支 head)的訊息是否帶 `[ai-review-bot]` 標記;
|
||||||
|
* 若是,代表本次變更為 bot 自動提交,呼叫端應跳過審查以避免自我審查迴圈。
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {string} [options.sha=PR_HEAD_SHA||process.env.GITHUB_SHA] - 要檢查的 commit SHA。
|
||||||
|
* @param {string} [options.branch=PR_HEAD_BRANCH] - 要檢查的分支名稱。
|
||||||
|
* @returns {Promise<boolean>} true 表示應跳過審查。
|
||||||
|
* @remarks 內部查詢失敗會被降級為空字串(視為未命中),因此正常情況下不會拋出例外。
|
||||||
|
*/
|
||||||
export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) {
|
export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GITHUB_SHA, branch = PR_HEAD_BRANCH } = {}) {
|
||||||
const shaMessage = await getCommitMessageBySha(sha);
|
const shaMessage = await getCommitMessageBySha(sha);
|
||||||
if (sha && shaMessage.includes('[ai-review-bot]')) return true;
|
if (sha && shaMessage.includes('[ai-review-bot]')) return true;
|
||||||
@@ -75,8 +121,11 @@ export async function shouldSkipBotCommit({ sha = PR_HEAD_SHA || process.env.GIT
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 過濾 diff 內容,移除路徑符合 excludePrefixes 的區塊。
|
* 過濾 unified diff,移除檔案路徑前綴命中 excludePrefixes 的區塊。
|
||||||
* 每個區塊以 "diff --git a/<prefix>" 開頭判斷,使用 startsWith 精確比對前綴。
|
* 以每個 `diff --git ` 行為界切割,對每個區塊用 `diff --git a/<prefix>` 做 startsWith 比對。
|
||||||
|
* @param {string} diff - 完整的 unified diff 文字。
|
||||||
|
* @param {string[]} excludePrefixes - 要排除的路徑前綴陣列(資料夾以 `/` 結尾,如 `.gitea/`)。
|
||||||
|
* @returns {string} 過濾後重新接合的 diff 文字。
|
||||||
*/
|
*/
|
||||||
export function filterDiff(diff, excludePrefixes) {
|
export function filterDiff(diff, excludePrefixes) {
|
||||||
return diff.split(/(?=^diff --git )/m)
|
return diff.split(/(?=^diff --git )/m)
|
||||||
@@ -88,6 +137,13 @@ export function filterDiff(diff, excludePrefixes) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在目前 PR 下發布一則一般留言(Gitea 以 issue comment 形式處理 PR 留言)。
|
||||||
|
* 透過 `POST /repos/{repo}/issues/{index}/comments`,優先使用 GITEA_COMMENT_TOKEN 授權。
|
||||||
|
* @param {string} body - 留言內容(支援 Markdown)。
|
||||||
|
* @returns {Promise<object>} Gitea 建立的 comment 物件。
|
||||||
|
* @throws {Error} 請求失敗(網路、逾時或非 2xx)時拋出 axios 例外。
|
||||||
|
*/
|
||||||
export async function postComment(body) {
|
export async function postComment(body) {
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`),
|
api(`/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments`),
|
||||||
@@ -98,9 +154,14 @@ export async function postComment(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在 PR 指定檔案的指定行數發布行內 review comment(標註程式碼位置)。
|
* 在 PR 指定檔案的指定新版行號發布一筆行內 review comment(建立一個只含單一 comment 的 COMMENT review)。
|
||||||
* 透過 Gitea 的 pull reviews API,以 new_position 對應新版檔案的行號。
|
* 以 `new_position` 對應新檔行號;該行不在 diff 範圍時 Gitea 會回錯誤而拋例外,呼叫端可降級為一般留言。
|
||||||
* 若該行不在 diff 範圍內,Gitea 會回傳錯誤,由呼叫端決定是否降級為一般 comment。
|
* @param {object} params
|
||||||
|
* @param {string} params.path - 檔案路徑(PR 內的相對路徑)。
|
||||||
|
* @param {number} params.line - 新版檔案中的行號(diff 右側行)。
|
||||||
|
* @param {string} params.body - 行內留言內容。
|
||||||
|
* @returns {Promise<object>} Gitea 建立的 review 物件。
|
||||||
|
* @throws {Error} 請求失敗或行號超出 diff 範圍時拋出 axios 例外。
|
||||||
*/
|
*/
|
||||||
export async function postPullReviewComment({ path: filePath, line, body }) {
|
export async function postPullReviewComment({ path: filePath, line, body }) {
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
@@ -117,7 +178,13 @@ export async function postPullReviewComment({ path: filePath, line, body }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 建立一個 PR review,本文放統計摘要,comments 放多筆行內 review comments。
|
* 建立一個 PR review:本文放摘要,comments 批次放多筆行內 review comments。
|
||||||
|
* 透過 `POST /repos/{repo}/pulls/{index}/reviews`(event=COMMENT),優先使用 GITEA_COMMENT_TOKEN。
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.body - review 本文(通常為統計摘要)。
|
||||||
|
* @param {Array<{path:string, body:string, new_position?:number}>} [params.comments=[]] - 行內 comment 陣列。
|
||||||
|
* @returns {Promise<object>} Gitea 建立的 review 物件。
|
||||||
|
* @throws {Error} 請求失敗(如某筆 comment 行號不在 diff 範圍)時拋出 axios 例外。
|
||||||
*/
|
*/
|
||||||
export async function postPullReview({ body, comments = [] }) {
|
export async function postPullReview({ body, comments = [] }) {
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
@@ -134,7 +201,10 @@ export async function postPullReview({ body, comments = [] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得 PR 上所有的 review(每個 review 可含多個行內 comment)。
|
* 取得目前 PR 上所有 review(`GET /repos/{repo}/pulls/{index}/reviews`)。
|
||||||
|
* 回應非陣列時回傳空陣列以保證型別一致。
|
||||||
|
* @returns {Promise<object[]>} review 物件陣列。
|
||||||
|
* @throws {Error} 請求失敗時拋出 axios 例外。
|
||||||
*/
|
*/
|
||||||
export async function listPullReviews() {
|
export async function listPullReviews() {
|
||||||
const resp = await axios.get(
|
const resp = await axios.get(
|
||||||
@@ -145,7 +215,10 @@ export async function listPullReviews() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得單一 review 底下的所有行內 comment。
|
* 取得指定 review 底下的所有行內 comment(`GET /repos/{repo}/pulls/{index}/reviews/{id}/comments`)。
|
||||||
|
* @param {number|string} reviewId - review 的 ID。
|
||||||
|
* @returns {Promise<object[]>} comment 物件陣列;非陣列回應時為空陣列。
|
||||||
|
* @throws {Error} 請求失敗時拋出 axios 例外。
|
||||||
*/
|
*/
|
||||||
export async function getPullReviewComments(reviewId) {
|
export async function getPullReviewComments(reviewId) {
|
||||||
const resp = await axios.get(
|
const resp = await axios.get(
|
||||||
@@ -156,8 +229,10 @@ export async function getPullReviewComments(reviewId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得 PR 上所有 review 的行內 comment,展平成單一陣列。
|
* 取得目前 PR 上所有 review 的行內 comment 並展平為單一陣列。
|
||||||
* 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程。
|
* 單一 review 取 comment 失敗時記錄警告並略過,不中斷整體流程;最後輸出統計日誌。
|
||||||
|
* @returns {Promise<object[]>} 所有行內 comment 的展平陣列。
|
||||||
|
* @throws {Error} 當 listPullReviews 取得 review 清單失敗時拋出例外。
|
||||||
*/
|
*/
|
||||||
export async function listAllReviewComments() {
|
export async function listAllReviewComments() {
|
||||||
const reviews = await listPullReviews();
|
const reviews = await listPullReviews();
|
||||||
@@ -175,8 +250,11 @@ export async function listAllReviewComments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解決(resolve)一個 review comment 所屬的對話。
|
* 解決(resolve)指定 review comment 所屬的對話。
|
||||||
* 對應 Gitea 官方 API:POST /repos/{repo}/pulls/comments/{id}/resolve。
|
* 對應 Gitea API `POST /repos/{repo}/pulls/comments/{id}/resolve`,使用 GITEA_COMMENT_TOKEN 授權。
|
||||||
|
* @param {number|string} commentId - 要解決的 review comment ID。
|
||||||
|
* @returns {Promise<object>} Gitea API 回應內容。
|
||||||
|
* @throws {Error} 請求失敗時拋出 axios 例外。
|
||||||
*/
|
*/
|
||||||
export async function resolvePullReviewComment(commentId) {
|
export async function resolvePullReviewComment(commentId) {
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
@@ -188,8 +266,12 @@ export async function resolvePullReviewComment(commentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取得指定 ref(預設 PR head)下某檔案的最新文字內容;
|
* 取得指定 ref(預設 PR head)下某檔案的文字內容。
|
||||||
* Gitea contents API 回傳 base64,這裡解碼成字串。檔案不存在或非文字時回傳空字串。
|
* 透過 Gitea contents API(`GET /repos/{repo}/contents/{path}`),base64 內容會自動解碼為 UTF-8 字串。
|
||||||
|
* 檔案不存在、非文字或請求失敗時不拋例外,記錄警告並回傳空字串。
|
||||||
|
* @param {string} filePath - 檔案在 repo 中的相對路徑。
|
||||||
|
* @param {string} [ref=PR_HEAD_SHA||PR_HEAD_BRANCH] - commit SHA 或分支名稱;空值時不帶 ref。
|
||||||
|
* @returns {Promise<string>} 檔案文字內容;查無或失敗時為空字串。
|
||||||
*/
|
*/
|
||||||
export async function getFileContentAtRef(filePath, ref = PR_HEAD_SHA || PR_HEAD_BRANCH) {
|
export async function getFileContentAtRef(filePath, ref = PR_HEAD_SHA || PR_HEAD_BRANCH) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { ok, warn, error } from './log.js';
|
|||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除 AI 回傳內容外層的 markdown code fence。
|
* 移除 AI 回傳文字外層的 markdown code fence(如 ```json ... ```),
|
||||||
|
* 並去除前後空白,使內容可直接交給 JSON.parse。
|
||||||
|
*
|
||||||
|
* 屬純函式、無副作用;常用於將 LLM 回傳結果正規化後再行解析。
|
||||||
|
*
|
||||||
|
* @param {*} text 待處理內容;非字串會先以 String() 轉型。
|
||||||
|
* @returns {string} 去除外層 code fence 與前後空白後的字串。
|
||||||
*/
|
*/
|
||||||
export function stripCodeFence(text) {
|
export function stripCodeFence(text) {
|
||||||
return String(text)
|
return String(text)
|
||||||
@@ -17,11 +23,22 @@ export function stripCodeFence(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 透過 LLM 修正 JSON 陣列內容。
|
* 透過 LLM 將任意原始內容修復成「可直接 JSON.parse 的 JSON 陣列」字串。
|
||||||
* @param {string} fullPath 檔案路徑,供提示詞與除錯使用。
|
*
|
||||||
* @param {string} label 檔案標籤。
|
* 會以固定 system prompt 指示模型忽略原內容中的指令/註解/markdown,
|
||||||
* @param {string} rawText 原始內容。
|
* 僅輸出修正後的陣列;無法判斷時模型應回傳空陣列 `[]`。
|
||||||
* @param {Function} chatFn 可注入的 LLM 呼叫函式,預設使用 `chat`。
|
* 回傳前會先以 stripCodeFence 清除外層 code fence。
|
||||||
|
*
|
||||||
|
* 備註:fullPath 與 label 僅放入提示詞供模型參考與除錯,不會用於讀檔;
|
||||||
|
* 回傳結果不保證為合法 JSON,需由呼叫端再行解析驗證。
|
||||||
|
*
|
||||||
|
* @param {string} fullPath 檔案完整路徑,供提示詞與除錯使用。
|
||||||
|
* @param {string} label 檔案標籤(人類可讀名稱)。
|
||||||
|
* @param {string} rawText 待修復的原始內容。
|
||||||
|
* @param {(systemPrompt: string, userContent: string) => Promise<string>} [chatFn=chat]
|
||||||
|
* 可注入的 LLM 呼叫函式,預設使用模組匯入的 chat;便於測試替換。
|
||||||
|
* @returns {Promise<string>} 經 code fence 清理後的修復字串。
|
||||||
|
* @throws {Error} 當 chatFn(LLM 呼叫)失敗時,例外向上拋出。
|
||||||
*/
|
*/
|
||||||
export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = chat) {
|
export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = chat) {
|
||||||
const systemPrompt = `你是 JSON 修復器。請修正使用者提供的內容,使其成為可直接 JSON.parse 的 JSON 陣列。
|
const systemPrompt = `你是 JSON 修復器。請修正使用者提供的內容,使其成為可直接 JSON.parse 的 JSON 陣列。
|
||||||
@@ -33,6 +50,18 @@ export async function repairJSONArrayWithAI(fullPath, label, rawText, chatFn = c
|
|||||||
return stripCodeFence(repaired);
|
return stripCodeFence(repaired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取指定 JSON 檔案的 UTF-8 文字內容,讀取前先檢查檔案大小上限。
|
||||||
|
*
|
||||||
|
* 模組私有工具函式,供 validateJSONArrayFile 內部使用;
|
||||||
|
* 大小超過 MAX_JSON_BYTES(約 1 MB)時直接拒絕讀取以避免處理過大檔案。
|
||||||
|
*
|
||||||
|
* @param {string} fullPath 欲讀取的檔案完整路徑。
|
||||||
|
* @param {string} label 檔案標籤,用於組合錯誤訊息。
|
||||||
|
* @returns {string} 檔案的 UTF-8 文字內容。
|
||||||
|
* @throws {Error} 檔案大小超過 MAX_JSON_BYTES 時丟出;
|
||||||
|
* 或 fs.statSync/fs.readFileSync 因檔案不存在、無權限等丟出的 IO 例外。
|
||||||
|
*/
|
||||||
function readJSONText(fullPath, label) {
|
function readJSONText(fullPath, label) {
|
||||||
const size = fs.statSync(fullPath).size;
|
const size = fs.statSync(fullPath).size;
|
||||||
if (size > MAX_JSON_BYTES) {
|
if (size > MAX_JSON_BYTES) {
|
||||||
@@ -42,10 +71,24 @@ function readJSONText(fullPath, label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 驗證 JSON 陣列檔案是否存在且格式正確。
|
* 驗證指定路徑是否為合法的 JSON 檔案;格式錯誤時嘗試以 AI 修復一次後再次驗證。
|
||||||
* 若格式錯誤,直接嘗試透過 AI 修復,修復後再次檢查;
|
*
|
||||||
* 第二次檢查仍失敗才丟出例外。
|
* 行為摘要:
|
||||||
* 若檔案不存在,回傳 exists=false,交由呼叫端決定是否補檔。
|
* - 先確保父目錄存在。
|
||||||
|
* - 檔案不存在:不丟例外,回傳 { exists:false },交由呼叫端決定是否補檔。
|
||||||
|
* - 解析成功:回傳 { exists:true, valid:true, repaired:false }。
|
||||||
|
* - 解析失敗:呼叫 repairer 修復、覆寫檔案(確保以換行結尾)、再驗證一次;
|
||||||
|
* 通過則回傳 repaired:true,仍失敗則丟出例外。
|
||||||
|
*
|
||||||
|
* 備註:僅嘗試修復一次;會寫入磁碟並輸出日誌,屬有副作用之非同步函式。
|
||||||
|
*
|
||||||
|
* @param {string} fullPath 欲驗證的 JSON 檔案完整路徑。
|
||||||
|
* @param {string} label 檔案標籤,用於日誌與提示訊息。
|
||||||
|
* @param {(fullPath: string, label: string, rawText: string) => Promise<string>} [repairer=repairJSONArrayWithAI]
|
||||||
|
* 可注入的修復函式,預設使用 repairJSONArrayWithAI;便於測試替換。
|
||||||
|
* @returns {Promise<{exists: boolean, valid: boolean, repaired: boolean}>}
|
||||||
|
* 驗證結果;repaired 表示是否經由 AI 修復後才通過驗證。
|
||||||
|
* @throws {Error} 修復後二次驗證仍失敗,或修復/檔案讀寫過程發生例外時拋出。
|
||||||
*/
|
*/
|
||||||
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 });
|
||||||
@@ -64,8 +107,10 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
|||||||
try {
|
try {
|
||||||
const original = readJSONText(fullPath, label);
|
const original = readJSONText(fullPath, label);
|
||||||
const repaired = await repairer(fullPath, label, original);
|
const repaired = await repairer(fullPath, label, original);
|
||||||
fs.writeFileSync(fullPath, repaired.endsWith('\n') ? repaired : `${repaired}\n`, 'utf8');
|
const normalized = repaired.endsWith('\n') ? repaired : `${repaired}\n`;
|
||||||
JSON.parse(readJSONText(fullPath, label));
|
// 先驗證修復結果是否為合法 JSON;無效就在寫檔前丟出,避免用毀損內容覆寫原檔。
|
||||||
|
JSON.parse(normalized);
|
||||||
|
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) {
|
||||||
@@ -76,7 +121,15 @@ export async function validateJSONArrayFile(fullPath, label, repairer = repairJS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 若檔案不存在則建立空陣列。
|
* 確保指定路徑存在一個 JSON 檔案;若不存在則建立內容為 "[]\n" 的空陣列檔。
|
||||||
|
*
|
||||||
|
* 會先建立父目錄。若檔案已存在則原樣保留、不檢查其內容是否合法
|
||||||
|
* (內容驗證請改用 validateJSONArrayFile)。為同步函式。
|
||||||
|
*
|
||||||
|
* @param {string} fullPath 目標檔案完整路徑。
|
||||||
|
* @param {string} label 檔案標籤,用於日誌訊息。
|
||||||
|
* @returns {boolean} 是否為本次新建:新建回傳 true,原本即存在回傳 false。
|
||||||
|
* @throws {Error} 建立目錄或寫入檔案失敗(如權限不足)時,IO 例外向上拋出。
|
||||||
*/
|
*/
|
||||||
export function ensureJSONArrayFileExists(fullPath, label) {
|
export function ensureJSONArrayFileExists(fullPath, label) {
|
||||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
|||||||
@@ -3,11 +3,28 @@ import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
|||||||
import { recordUsage } from './usage.js';
|
import { recordUsage } from './usage.js';
|
||||||
import { line, error } from './log.js';
|
import { line, error } from './log.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。
|
||||||
|
*
|
||||||
|
* 當字串含有 `/` 時視為 `providerID/modelID` 形式並拆解;否則 provider
|
||||||
|
* 取環境變數 `OPENCODE_PROVIDER`(預設 `google`),model 則為整個字串。
|
||||||
|
*
|
||||||
|
* @param {string} model - 模型識別字串,例如 `"google/gemini-2.0"` 或 `"gemini-2.0"`。
|
||||||
|
* @returns {{ providerID: string, modelID: string }} 拆解後的 provider 與 model 識別碼。
|
||||||
|
*/
|
||||||
function opencodeModelConfig(model) {
|
function opencodeModelConfig(model) {
|
||||||
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
||||||
return { providerID, modelID };
|
return { providerID, modelID };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立傳給 axios 的共用請求選項,統一注入 headers 與 OpenCode 專用的 HTTPS agent。
|
||||||
|
*
|
||||||
|
* 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。
|
||||||
|
*
|
||||||
|
* @param {Record<string, string>} headers - 要附加於請求的 HTTP 標頭。
|
||||||
|
* @returns {{ headers: Record<string, string>, httpsAgent: import('https').Agent }} axios 請求選項物件。
|
||||||
|
*/
|
||||||
function opencodeAxiosOptions(headers) {
|
function opencodeAxiosOptions(headers) {
|
||||||
return {
|
return {
|
||||||
headers,
|
headers,
|
||||||
@@ -15,6 +32,15 @@ function opencodeAxiosOptions(headers) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從 OpenCode 訊息回應中抽取並串接所有文字片段。
|
||||||
|
*
|
||||||
|
* 以多重 fallback 相容不同包裹層級的回應結構(`parts` / `data.parts` /
|
||||||
|
* `info.content` / `data.info.content`),逐片段取 `text` 或 `content` 後串接。
|
||||||
|
*
|
||||||
|
* @param {object} data - OpenCode `/session/{id}/message` 的回應資料物件。
|
||||||
|
* @returns {string} 串接後的純文字內容;無可用片段時回傳空字串。
|
||||||
|
*/
|
||||||
function extractOpenCodeContent(data) {
|
function extractOpenCodeContent(data) {
|
||||||
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
|
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
|
||||||
return parts
|
return parts
|
||||||
@@ -23,6 +49,21 @@ function extractOpenCodeContent(data) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。
|
||||||
|
*
|
||||||
|
* 先 POST `/session` 取得 session id(缺少則拋錯),再 POST
|
||||||
|
* `/session/{id}/message` 送出 system prompt 與使用者內容,最後抽取回應文字。
|
||||||
|
* 會發出兩次 HTTP 請求;網路或 API 錯誤會向外拋出,交由呼叫端處理。
|
||||||
|
*
|
||||||
|
* @param {string} baseURL - OpenCode server 基底 URL(尾端斜線會被去除)。
|
||||||
|
* @param {string} model - 模型識別字串,將交由 {@link opencodeModelConfig} 解析。
|
||||||
|
* @param {string} systemPrompt - 系統提示詞。
|
||||||
|
* @param {string} userContent - 使用者輸入內容。
|
||||||
|
* @param {Record<string, string>} headers - 附加於請求的 HTTP 標頭。
|
||||||
|
* @returns {Promise<{ content: string, data: object }>} 抽取後的文字內容與原始回應資料。
|
||||||
|
* @throws {Error} 當回應中無 session id,或任一 HTTP 請求失敗時。
|
||||||
|
*/
|
||||||
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
|
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
|
||||||
const base = baseURL.replace(/\/$/, '');
|
const base = baseURL.replace(/\/$/, '');
|
||||||
const { providerID, modelID } = opencodeModelConfig(model);
|
const { providerID, modelID } = opencodeModelConfig(model);
|
||||||
@@ -46,6 +87,18 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers)
|
|||||||
return { content: extractOpenCodeContent(resp.data), data: resp.data };
|
return { content: extractOpenCodeContent(resp.data), data: resp.data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
|
||||||
|
*
|
||||||
|
* 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄
|
||||||
|
* usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)`
|
||||||
|
* 終止整個行程(不會回傳)。
|
||||||
|
*
|
||||||
|
* @param {string} systemPrompt - 系統提示詞。
|
||||||
|
* @param {string} userContent - 使用者輸入內容。
|
||||||
|
* @returns {Promise<string>} 模型回應的純文字內容。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:extractBalancedJSON 處理括號平衡解析的邏輯相當複雜,且直接操作字串索引,非常容易在處理特殊字元或不完整 JSON 片段時產生錯誤,目前完全沒有針對邊界條件的單元測試。
**建議**:建議編寫針對性的單元測試,包含深層巢狀結構、跳脫字元字串、不完整 JSON、與非法起始字元的邊界測試案例,以確保解析器的魯棒性。
|
|||||||
|
* @throws {Error} 當未設定 OpenCode server(缺少 provider)時。
|
||||||
|
*/
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL');
|
if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL');
|
||||||
@@ -65,6 +118,16 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 對 OpenCode 送出對話並將回應解析為 JSON 物件/陣列。
|
||||||
|
*
|
||||||
|
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。
|
||||||
|
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
|
||||||
|
*
|
||||||
|
* @param {string} systemPrompt - 系統提示詞。
|
||||||
|
* @param {string} userContent - 使用者輸入內容。
|
||||||
|
* @returns {Promise<any>} 解析後的 JSON 值;解析失敗時回傳空陣列 `[]`。
|
||||||
|
*/
|
||||||
export async function chatJSON(systemPrompt, userContent) {
|
export async function chatJSON(systemPrompt, userContent) {
|
||||||
const text = await chat(systemPrompt, userContent);
|
const text = await chat(systemPrompt, userContent);
|
||||||
try {
|
try {
|
||||||
@@ -75,6 +138,15 @@ export async function chatJSON(systemPrompt, userContent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去除文字外層的 Markdown code fence(```),用於清理被 code block 包裹的輸出。
|
||||||
|
*
|
||||||
|
* 會 trim、移除開頭 fence(含可選語言標籤與換行)與結尾 fence,再 trim。
|
||||||
|
* 對非字串輸入會先以 `String()` 轉換;無 fence 時回傳 trim 後原文。
|
||||||
|
*
|
||||||
|
* @param {*} text - 待清理的內容(會被轉為字串)。
|
||||||
|
* @returns {string} 去除外層 fence 並 trim 後的字串。
|
||||||
|
*/
|
||||||
function stripOuterFence(text) {
|
function stripOuterFence(text) {
|
||||||
return String(text)
|
return String(text)
|
||||||
.trim()
|
.trim()
|
||||||
@@ -83,7 +155,17 @@ function stripOuterFence(text) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractBalancedJSON(text, startIndex) {
|
/**
|
||||||
|
* 從指定索引起,以括號平衡方式擷取一段完整配對的 JSON 子字串。
|
||||||
|
*
|
||||||
|
* 依起始字元判定為物件(`{}`)或陣列(`[]`),逐字元計數巢狀深度,
|
||||||
|
* 並正確略過字串字面值與其中的跳脫字元,深度歸零時回傳完整片段。
|
||||||
|
*
|
||||||
|
* @param {*} text - 來源內容(會被轉為字串)。
|
||||||
|
* @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。
|
||||||
|
* @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。
|
||||||
|
*/
|
||||||
|
export function extractBalancedJSON(text, startIndex) {
|
||||||
const source = String(text);
|
const source = String(text);
|
||||||
const open = source[startIndex];
|
const open = source[startIndex];
|
||||||
const close = open === '{' ? '}' : ']';
|
const close = open === '{' ? '}' : ']';
|
||||||
@@ -116,7 +198,17 @@ function extractBalancedJSON(text, startIndex) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractJSONText(text) {
|
/**
|
||||||
|
* 從可能夾雜雜訊或被 code fence 包裹的文字中,盡力抽出可被 JSON.parse 解析的片段。
|
||||||
|
*
|
||||||
|
* 先去除外層 fence;若整段即為合法 JSON 直接回傳;否則由左至右尋找每個
|
||||||
|
* `{`/`[` 起點,以括號平衡擷取候選片段並試解析,回傳第一個成功者;
|
||||||
|
* 全數失敗則回傳去 fence 後的原文(仍可能非合法 JSON,交由呼叫端再處理)。
|
||||||
|
*
|
||||||
|
* @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。
|
||||||
|
* @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。
|
||||||
|
*/
|
||||||
|
export function extractJSONText(text) {
|
||||||
const stripped = stripOuterFence(text);
|
const stripped = stripOuterFence(text);
|
||||||
try {
|
try {
|
||||||
JSON.parse(stripped);
|
JSON.parse(stripped);
|
||||||
|
|||||||
@@ -1,38 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* 輸出最上層的「區塊/章節」分隔標題(前綴空行 + `=== 標題 ===`)。
|
||||||
|
* 用於切分整個執行流程中彼此獨立的大段落(例如「環境檢查」「執行審查」「發布結果」),
|
||||||
|
* 讓 CI log 在視覺上分群;屬於最高層級的分隔,內部再以 step / line 等細分。
|
||||||
|
*
|
||||||
|
* @param {string} title - 區塊標題文字。
|
||||||
|
* @returns {void} 無回傳值,僅將標題寫入 stdout。
|
||||||
|
*/
|
||||||
export function section(title) {
|
export function section(title) {
|
||||||
console.log(`\n=== ${title} ===`);
|
console.log(`\n=== ${title} ===`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 輸出某個「步驟」的標題(前綴空行 + `[步驟代號] 標題`)。
|
||||||
|
* 適合在一個 section 之下標示流程中的各個有序步驟(如 `[1] 載入設定`、`[2] 呼叫模型`),
|
||||||
|
* 之後再用 input / output / line 等細項函式描述該步驟的細節。
|
||||||
|
*
|
||||||
|
* @param {string} stepName - 步驟代號或編號,會以中括號包覆顯示。
|
||||||
|
* @param {string} title - 步驟標題文字。
|
||||||
|
* @returns {void} 無回傳值,僅將步驟標題寫入 stdout。
|
||||||
|
*/
|
||||||
export function step(stepName, title) {
|
export function step(stepName, title) {
|
||||||
console.log(`\n[${stepName}] ${title}`);
|
console.log(`\n[${stepName}] ${title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 輸出一筆縮排的一般明細列(` - 訊息`)。
|
||||||
|
* 用於在某個 step 之下列出不帶語意成敗的中性資訊(例如逐項說明、設定值、進度敘述);
|
||||||
|
* 若要表達輸入/輸出或成敗,請改用 input / output / result / ok 等更具語意的函式。
|
||||||
|
*
|
||||||
|
* @param {string} message - 要顯示的明細訊息。
|
||||||
|
* @returns {void} 無回傳值,僅將明細寫入 stdout。
|
||||||
|
*/
|
||||||
export function line(message) {
|
export function line(message) {
|
||||||
console.log(` - ${message}`);
|
console.log(` - ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 階段輸入:這個階段吃進什麼。 */
|
/**
|
||||||
|
* 輸出「階段輸入」描述(` ← 輸入:訊息`),標示目前步驟吃進了什麼資料。
|
||||||
|
* 在一個步驟開始處理前,用來明確記錄其輸入來源或內容,方便日後對照輸出(output)追蹤資料流。
|
||||||
|
*
|
||||||
|
* @param {string} message - 描述輸入內容的訊息。
|
||||||
|
* @returns {void} 無回傳值,僅將輸入描述寫入 stdout。
|
||||||
|
*/
|
||||||
export function input(message) {
|
export function input(message) {
|
||||||
console.log(` ← 輸入:${message}`);
|
console.log(` ← 輸入:${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 階段輸出:這個階段產出什麼。 */
|
/**
|
||||||
|
* 輸出「階段輸出」描述(` → 輸出:訊息`),標示目前步驟產出了什麼結果。
|
||||||
|
* 在一個步驟處理完成後,用來記錄其產出,與 input 搭配可在 log 中清楚呈現該步驟的資料流向。
|
||||||
|
*
|
||||||
|
* @param {string} message - 描述輸出內容的訊息。
|
||||||
|
* @returns {void} 無回傳值,僅將輸出描述寫入 stdout。
|
||||||
|
*/
|
||||||
export function output(message) {
|
export function output(message) {
|
||||||
console.log(` → 輸出:${message}`);
|
console.log(` → 輸出:${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 檢查/把關結果:明確標示成功或失敗。 */
|
/**
|
||||||
|
* 輸出一筆檢查/把關結果列,依結果以 `✅ 成功` 或 `❌ 失敗` 為前綴(` ✅ 成功:訊息`)。
|
||||||
|
* 用於明確標示某個驗證、條件判斷或 gate 的通過與否;
|
||||||
|
* 需要由布林值決定成敗、且希望成功與失敗使用一致格式時最適合(注意:失敗仍寫入 stdout,非 stderr)。
|
||||||
|
*
|
||||||
|
* @param {boolean} passed - 結果是否通過;`true` 顯示成功、`false` 顯示失敗。
|
||||||
|
* @param {string} message - 描述該結果的訊息。
|
||||||
|
* @returns {void} 無回傳值,僅將結果寫入 stdout。
|
||||||
|
*/
|
||||||
export function result(passed, message) {
|
export function result(passed, message) {
|
||||||
console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`);
|
console.log(` ${passed ? '✅ 成功' : '❌ 失敗'}:${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 輸出一筆成功/完成訊息(` ✓ 訊息`)。
|
||||||
|
* 用於確認某項動作已順利完成的正向回饋;當只需表達成功、無需處理失敗分支時使用,
|
||||||
|
* 若需依條件同時涵蓋成功與失敗請改用 result,需要警告或錯誤請改用 warn / error。
|
||||||
|
*
|
||||||
|
* @param {string} message - 描述成功內容的訊息。
|
||||||
|
* @returns {void} 無回傳值,僅將成功訊息寫入 stdout。
|
||||||
|
*/
|
||||||
export function ok(message) {
|
export function ok(message) {
|
||||||
console.log(` ✓ ${message}`);
|
console.log(` ✓ ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 輸出一筆警告訊息(` ! 訊息`),透過 `console.warn` 寫入 stderr。
|
||||||
|
* 用於流程仍可繼續、但需要提醒使用者注意的非致命狀況(例如使用了預設值、跳過某項可選步驟);
|
||||||
|
* 比 line/ok 更醒目,但比 error 輕,真正導致失敗的狀況請改用 error。
|
||||||
|
*
|
||||||
|
* @param {string} message - 要顯示的警告訊息。
|
||||||
|
* @returns {void} 無回傳值,僅將警告訊息寫入 stderr。
|
||||||
|
*/
|
||||||
export function warn(message) {
|
export function warn(message) {
|
||||||
console.warn(` ! ${message}`);
|
console.warn(` ! ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 輸出一筆錯誤訊息(` x 訊息`),透過 `console.error` 寫入 stderr。
|
||||||
|
* 用於明確的失敗或例外狀況,是日誌中最高的嚴重層級;
|
||||||
|
* 適合在捕捉到錯誤或前置條件不滿足而無法繼續時使用,僅需提醒注意的非致命狀況請改用 warn。
|
||||||
|
*
|
||||||
|
* @param {string} message - 要顯示的錯誤訊息。
|
||||||
|
* @returns {void} 無回傳值,僅將錯誤訊息寫入 stderr。
|
||||||
|
*/
|
||||||
export function error(message) {
|
export function error(message) {
|
||||||
console.error(` x ${message}`);
|
console.error(` x ${message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,44 @@ import { section, step, line, input, output, result, warn, error } from './log.j
|
|||||||
|
|
||||||
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Code Review Pipeline 的總指揮(orchestrator)。
|
||||||
|
*
|
||||||
|
* 依序串接 Step1~Step11:啟動參數讀取、前置驗證、自動提交檢查、PR 對話收斂、
|
||||||
|
* 角色平行分析產生 findings、新舊 findings 合併與語意去重、排除規則與誤報過濾、
|
||||||
|
* 寫入 findings 並發布 Gitea Review、findings/exclusions JSON 格式驗證、
|
||||||
|
* 記憶區 commit/push,以及嚴重問題把關。
|
||||||
|
*
|
||||||
|
* 結果主要透過 `process.exit()` 決定 workflow 成敗,而非以回傳值傳遞。
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @returns {Promise<void>} 流程正常走完(無嚴重問題)時 resolve;多數結束路徑會直接
|
||||||
|
* 呼叫 `process.exit()` 結束程序,函式不會以回傳值回報審查結果。
|
||||||
|
* @throws {Error} 內部未被個別 try/catch 攔截的未預期例外會向上拋出,
|
||||||
|
* 由頂層 `main().catch(...)` 接住並以 `process.exit(1)` 結束。
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* 流程階段(Step1~Step11):
|
||||||
|
* - Step1 啟動:讀取 repo / PR / 分支等基本參數。
|
||||||
|
* - Step2 前置驗證:`runPreflight`,未通過則 exit 1。
|
||||||
|
* - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。
|
||||||
|
* - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。
|
||||||
|
* - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號;
|
||||||
|
* 未設定 API Key 或取 diff 失敗 exit 1,diff 為空 exit 0。
|
||||||
|
* - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。
|
||||||
|
* - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。
|
||||||
|
* - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。
|
||||||
|
* - Step9 JSON 驗證:驗證 findings/exclusions 檔,格式錯誤 exit 1,缺檔則建立空陣列檔。
|
||||||
|
* - Step10 記憶區 commit/push:依是否有 critical 計算 reviewOutcome 後推回來源分支。
|
||||||
|
* - Step11 嚴重問題把關:有 critical 則 exit 1,否則正常結束。
|
||||||
|
*
|
||||||
|
* 退出行為:
|
||||||
|
* - exit 1:前置驗證未過、上輪 bot failure、未設定 LLM Key、取 diff 失敗、JSON 格式錯誤、發現嚴重問題、頂層未預期例外。
|
||||||
|
* - exit 0:本次為 bot 自動提交、diff 為空、正常走完無嚴重問題。
|
||||||
|
*
|
||||||
|
* 降級處理:Step4 對話收斂、Step5 角色介紹 comment 與個別角色分析、Step6 clone repo、
|
||||||
|
* Step8 Review 發布等非致命步驟失敗時,僅 `warn` 後繼續執行。
|
||||||
|
*/
|
||||||
async function main() {
|
async function main() {
|
||||||
section('AI Code Review Pipeline');
|
section('AI Code Review Pipeline');
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test *.test.js"
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
@@ -13,24 +13,72 @@ import { verifyRemoteAccess } from './git.js';
|
|||||||
import { step, line, ok, error, result } from './log.js';
|
import { step, line, ok, error, result } from './log.js';
|
||||||
|
|
||||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
/**
|
||||||
|
* 組出 Gitea REST API v1 的完整網址。
|
||||||
|
*
|
||||||
|
* 會將模組層級的 GITEA_SERVER_URL 尾端斜線去除後串接 `/api/v1` 與傳入路徑。
|
||||||
|
* @param {string} path - 以 `/` 開頭的 API 子路徑,例如 `/repos/owner/name`。
|
||||||
|
* @returns {string} 完整可請求的 API URL。
|
||||||
|
* @remarks 依賴模組層級常數 GITEA_SERVER_URL;若該值為空會丟出 TypeError。
|
||||||
|
*/
|
||||||
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||||
|
/**
|
||||||
|
* 產生呼叫 Gitea API 用的 HTTP headers。
|
||||||
|
*
|
||||||
|
* Authorization 採 Gitea 的 `token <token>` 認證格式。
|
||||||
|
* @param {string} token - Gitea 個人存取權杖(personal access token)。
|
||||||
|
* @returns {{Authorization: string, 'Content-Type': string}} 可直接交給 axios 的 headers 物件。
|
||||||
|
*/
|
||||||
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
||||||
|
/**
|
||||||
|
* 將模型字串解析為 OpenCode 的 providerID 與 modelID。
|
||||||
|
*
|
||||||
|
* 若 model 含 `/` 則以斜線拆分為 provider/model;否則 provider 取
|
||||||
|
* 環境變數 OPENCODE_PROVIDER(預設 `google`),model 即原字串。
|
||||||
|
* @param {string} model - 模型識別字串,例如 `google/gemini-2.0` 或 `gemini-2.0`。
|
||||||
|
* @returns {{providerID: string, modelID: string}} 解析後的 provider 與 model 識別碼。
|
||||||
|
* @remarks 讀取 process.env.OPENCODE_PROVIDER;split 上限為 2 段,多餘段落會被忽略。
|
||||||
|
*/
|
||||||
const opencodeModelConfig = (model) => {
|
const opencodeModelConfig = (model) => {
|
||||||
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
||||||
return { providerID, modelID };
|
return { providerID, modelID };
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 組出呼叫 OpenCode server 用的 axios 請求設定。
|
||||||
|
*
|
||||||
|
* 固定 30 秒逾時,並套用 config.js 的 getOpenCodeHttpsAgent() 作為 httpsAgent。
|
||||||
|
* @param {object} headers - 要套用的 HTTP headers 物件。
|
||||||
|
* @returns {{headers: object, timeout: number, httpsAgent: import('https').Agent}} axios 設定物件。
|
||||||
|
* @remarks 每次呼叫都會執行 getOpenCodeHttpsAgent() 取得 agent。
|
||||||
|
*/
|
||||||
const opencodeAxiosOptions = (headers) => ({
|
const opencodeAxiosOptions = (headers) => ({
|
||||||
headers,
|
headers,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
httpsAgent: getOpenCodeHttpsAgent(),
|
httpsAgent: getOpenCodeHttpsAgent(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將(axios)錯誤格式化為易讀的訊息字串。
|
||||||
|
*
|
||||||
|
* 有 HTTP 回應狀態碼時輸出 `HTTP <status> <message>`,否則僅輸出 message。
|
||||||
|
* @param {Error & {response?: {status?: number}, message: string}} e - 捕捉到的錯誤物件。
|
||||||
|
* @returns {string} 格式化後的錯誤描述。
|
||||||
|
*/
|
||||||
function giteaErr(e) {
|
function giteaErr(e) {
|
||||||
const status = e.response?.status;
|
const status = e.response?.status;
|
||||||
return status ? `HTTP ${status} ${e.message}` : e.message;
|
return status ? `HTTP ${status} ${e.message}` : e.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 檢查必要環境變數是否齊全;可傳入覆寫值供測試使用 */
|
/**
|
||||||
|
* 檢查 code review 所需的必要環境變數是否齊全。
|
||||||
|
*
|
||||||
|
* 用法:preflight 第一關,缺任何一項即視為不通過並列出缺少項目。
|
||||||
|
* @param {object} [opts] - 覆寫值,供測試注入;省略時各欄取模組層級常數預設值。
|
||||||
|
* @param {string} [opts.token=GITEA_TOKEN] - Gitea token。
|
||||||
|
* @param {string} [opts.repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo。
|
||||||
|
* @param {string|number} [opts.pr=PR_NUMBER] - PR 編號。
|
||||||
|
* @returns {{ok: boolean, missing: string[]}} ok 表是否全部齊全;missing 列出缺少的環境變數名稱。
|
||||||
|
*/
|
||||||
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
|
export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY, pr = PR_NUMBER } = {}) {
|
||||||
const missing = [];
|
const missing = [];
|
||||||
if (!token) missing.push('GITEA_TOKEN');
|
if (!token) missing.push('GITEA_TOKEN');
|
||||||
@@ -39,7 +87,15 @@ export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY,
|
|||||||
return { ok: missing.length === 0, missing };
|
return { ok: missing.length === 0, missing };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用 GITEA_TOKEN 讀取此 repo,同時驗證 token 有效與有讀取權限 */
|
/**
|
||||||
|
* 驗證 Gitea token 有效且對指定 repo 有讀取權限。
|
||||||
|
*
|
||||||
|
* 透過唯讀的 `GET /repos/{repo}` 探測;任何錯誤都被攔截並轉為回傳值,不會 throw。
|
||||||
|
* 採用 rejectUnauthorized:false 的 httpsAgent(不驗證 TLS 憑證)。
|
||||||
|
* @param {string} [token=GITEA_TOKEN] - Gitea token,可注入供測試。
|
||||||
|
* @param {string} [repo=GITEA_REPOSITORY] - `owner/name` 形式的 repo,可注入供測試。
|
||||||
|
* @returns {Promise<{ok: true}|{ok: false, error: string}>} 成功僅含 ok;失敗含格式化錯誤訊息。
|
||||||
|
*/
|
||||||
export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSITORY) {
|
export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSITORY) {
|
||||||
try {
|
try {
|
||||||
await axios.get(api(`/repos/${repo}`), { headers: giteaHeaders(token), timeout: 30000, httpsAgent });
|
await axios.get(api(`/repos/${repo}`), { headers: giteaHeaders(token), timeout: 30000, httpsAgent });
|
||||||
@@ -49,7 +105,14 @@ export async function verifyGiteaToken(token = GITEA_TOKEN, repo = GITEA_REPOSIT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 若有提供 comment token,用它呼叫 /user 驗證可用;沒提供則略過 */
|
/**
|
||||||
|
* 驗證選用的 comment token(GITEA_COMMENT_TOKEN)是否可用。
|
||||||
|
*
|
||||||
|
* 未提供 token 時直接視為通過並標記 skipped:true(之後 comment 會沿用主 token);
|
||||||
|
* 有提供則以 `GET /user` 探測。錯誤被攔截轉為回傳值,不會 throw。
|
||||||
|
* @param {string} [token=GITEA_COMMENT_TOKEN] - 專用於發布 comment 的 token,可注入供測試。
|
||||||
|
* @returns {Promise<{ok: true, skipped?: true}|{ok: false, error: string}>} skipped 表示未提供而略過。
|
||||||
|
*/
|
||||||
export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
||||||
if (!token) return { ok: true, skipped: true };
|
if (!token) return { ok: true, skipped: true };
|
||||||
try {
|
try {
|
||||||
@@ -61,9 +124,13 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 驗證 LLM 設定可用:
|
* 驗證 LLM(OpenCode server)設定可用。
|
||||||
* - 僅支援 OpenCode server
|
*
|
||||||
* - 檢查 OpenCode base URL 是否可連線,並確認 provider/model 已設定
|
* 依序確認:已設定 provider、有 base URL、health 端點可連線、OpenCode 已設定
|
||||||
|
* 對應 provider 且其列出指定 model。任一不符回傳對應錯誤;錯誤被攔截不會 throw。
|
||||||
|
* @returns {Promise<{ok: true, provider: string}|{ok: false, provider?: string, error: string}>}
|
||||||
|
* 通過時含 provider;未設定 provider 的失敗分支不含 provider 欄位。
|
||||||
|
* @remarks 設定來源為 config.js 的 getLLMConfig();provider/model 鍵的比對使用 opencodeModelConfig 解析後的 providerID/modelID。
|
||||||
*/
|
*/
|
||||||
export async function verifyLLM() {
|
export async function verifyLLM() {
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, baseURL, model } = getLLMConfig();
|
||||||
@@ -87,8 +154,19 @@ export async function verifyLLM() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。
|
* 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM。
|
||||||
* 僅做唯讀的認證/連線確認,不發布任何 comment。
|
*
|
||||||
|
* 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。
|
||||||
|
* 各檢查可經 deps 注入覆寫,方便單元測試。
|
||||||
|
* @param {string} [workspace=process.env.GITHUB_WORKSPACE||'/workspace'] - git 遠端驗證用的工作目錄。
|
||||||
|
* @param {object} [deps] - 依賴注入,覆寫各檢查函式(預設為本模組/ git.js 的實作)。
|
||||||
|
* @param {Function} [deps.checkEnv=checkRequiredEnv] - 環境變數檢查。
|
||||||
|
* @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。
|
||||||
|
* @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。
|
||||||
|
* @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。
|
||||||
|
* @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM(OpenCode)連線驗證。
|
||||||
|
* @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。
|
||||||
|
* @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。
|
||||||
*/
|
*/
|
||||||
export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) {
|
export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '/workspace', deps = {}) {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ const FIELD_PATTERNS = {
|
|||||||
建議: /\*\*建議\*\*[::]\s*(.+)/,
|
建議: /\*\*建議\*\*[::]\s*(.+)/,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 取出 "**label**:value" 這一行的 value(單行)。 */
|
/**
|
||||||
|
* 從 Markdown 內文擷取單行欄位值,對應格式為 `**標籤**:value`(全形或半形冒號皆可)。
|
||||||
|
* 僅支援預先編譯於 FIELD_PATTERNS 的標籤:嚴重等級/等級/審查員/問題/建議;
|
||||||
|
* 標籤不在表內或無命中時回傳空字串。
|
||||||
|
* @param {string} body - 已正規化換行(\n)的留言內文;呼叫端須先確保為字串。
|
||||||
|
* @param {('嚴重等級'|'等級'|'審查員'|'問題'|'建議')} label - 要擷取的欄位標籤鍵。
|
||||||
|
* @returns {string} 該行的 value(已 trim);找不到或標籤不支援時為 ''。
|
||||||
|
* @remarks 正則為靜態定義,避免每次呼叫重建並排除以外部輸入動態組 regex 的注入風險。
|
||||||
|
*/
|
||||||
function fieldValue(body, label) {
|
function fieldValue(body, label) {
|
||||||
const re = FIELD_PATTERNS[label];
|
const re = FIELD_PATTERNS[label];
|
||||||
if (!re) return '';
|
if (!re) return '';
|
||||||
@@ -21,6 +29,12 @@ function fieldValue(body, label) {
|
|||||||
return m ? m[1].trim() : '';
|
return m ? m[1].trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將中文嚴重等級描述(如「嚴重」「警告」「建議」)映射為內部標準鍵。
|
||||||
|
* 採子字串比對且依序判斷,第一個命中者勝出。
|
||||||
|
* @param {string} raw - 來自留言的嚴重等級文字(可能為空)。
|
||||||
|
* @returns {('critical'|'warning'|'info'|null)} 對應的內部鍵;空字串或無法辨識時回傳 null。
|
||||||
|
*/
|
||||||
function levelToKey(raw) {
|
function levelToKey(raw) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
if (raw.includes('嚴重')) return 'critical';
|
if (raw.includes('嚴重')) return 'critical';
|
||||||
@@ -124,12 +138,24 @@ export async function judgeConversations(items, chatFn = chatJSON) {
|
|||||||
return items.map(it => ({ idx: it.idx, verdict: byIdx.get(it.idx) || 'open' }));
|
return items.map(it => ({ idx: it.idx, verdict: byIdx.get(it.idx) || 'open' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將一段仍成立(open)對話對應的 bot finding 加入結轉清單,標記 is_new=false 表示為延續的舊問題。
|
||||||
|
Ghost marked this conversation as resolved
gitea-actions
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Maya
**問題**:isSafeRepoPath 函式對於路徑穿越(Directory Traversal)的驗證邏輯至關重要,但目前新增處完全沒有相對應的安全性測試案例,無法確保無法被惡意路徑繞過。
**建議**:請補齊測試案例,驗證包含 '../', '..\', 或以絕對路徑開頭的惡意輸入,確保該函式能正確阻擋所有非預期的路徑穿越攻擊。
|
|||||||
|
* 若該對話無 botFinding 則不做任何事。
|
||||||
|
* @param {Array<object>} target - 接收結轉 finding 的陣列(會被就地 push)。
|
||||||
|
* @param {{botFinding: object|null}} conversation - 對話群組(取其 botFinding)。
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function pushCarried(target, conversation) {
|
function pushCarried(target, conversation) {
|
||||||
if (!conversation.botFinding) return;
|
if (!conversation.botFinding) return;
|
||||||
target.push({ ...conversation.botFinding, is_new: false });
|
target.push({ ...conversation.botFinding, is_new: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把判定為誤報的 bot finding 轉成 exclusions.json 的排除條目。 */
|
/**
|
||||||
|
* 將判定為誤報的 bot finding 轉成 exclusions.json 的排除條目。
|
||||||
|
* original_finding 取 suggestion,缺則退回 problem 再退回空字串;reason 為固定的誤報說明。
|
||||||
|
* @param {{location: string, role: string, suggestion?: string, problem?: string}} botFinding - 被判為誤報的 finding(呼叫端須確保非 null)。
|
||||||
|
* @returns {{location: string, role: string, original_finding: string, reason: string}} 排除條目。
|
||||||
|
*/
|
||||||
function toExclusion(botFinding) {
|
function toExclusion(botFinding) {
|
||||||
return {
|
return {
|
||||||
location: botFinding.location,
|
location: botFinding.location,
|
||||||
@@ -140,10 +166,12 @@ function toExclusion(botFinding) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 僅允許 repo 內的相對路徑:排除絕對路徑(/ 或 Windows 磁碟機)與含 `..` 的路徑穿越。
|
* 安全守衛:判定路徑是否為 repo 內的相對路徑(拒絕絕對路徑、Windows 磁碟機前綴與含 `..` 的路徑穿越)。
|
||||||
* comment 的 path 源自外部(PR 內檔名),用此守衛避免被用來讀取 repo 外的檔案。
|
* 用於防止以外部 PR 檔名讀取 repo 外的檔案。
|
||||||
|
* @param {string} p - 待檢查的檔案路徑。
|
||||||
|
* @returns {boolean} 安全(repo 內相對路徑)為 true,否則 false。
|
||||||
*/
|
*/
|
||||||
function isSafeRepoPath(p) {
|
export function isSafeRepoPath(p) {
|
||||||
if (typeof p !== 'string' || p === '') return false;
|
if (typeof p !== 'string' || p === '') return false;
|
||||||
if (p.startsWith('/') || /^[a-zA-Z]:/.test(p)) return false;
|
if (p.startsWith('/') || /^[a-zA-Z]:/.test(p)) return false;
|
||||||
return !p.split('/').includes('..');
|
return !p.split('/').includes('..');
|
||||||
@@ -263,10 +291,21 @@ export async function reconcileConversations(deps = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從 location(格式 `path:line`)取出檔案路徑部分(以第一個冒號切割並 trim)。
|
||||||
|
* @param {string} location - 位置字串,可能為 `path:line` 或僅 `path`(容許 null/undefined)。
|
||||||
|
* @returns {string} 檔案路徑;無輸入時為空字串。
|
||||||
|
*/
|
||||||
function fileOf(location) {
|
function fileOf(location) {
|
||||||
return String(location || '').split(':')[0].trim();
|
return String(location || '').split(':')[0].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將文字正規化為穩定比對鍵:NFKC 正規化後移除所有標點/符號/空白,再 trim 並轉小寫。
|
||||||
|
* 用於讓 finding 簽章對標點與空白差異不敏感。
|
||||||
|
* @param {string} text - 待正規化文字(容許 null/undefined)。
|
||||||
|
* @returns {string} 正規化後的小寫鍵。
|
||||||
|
*/
|
||||||
function normalizeKey(text) {
|
function normalizeKey(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
@@ -275,7 +314,11 @@ function normalizeKey(text) {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以「檔案路徑 + 正規化建議內容」為簽章,對 line 漂移與標點差異穩定。 */
|
/**
|
||||||
|
* 計算 finding 的去重簽章:以「檔案路徑 + 正規化建議內容」組成,對行號漂移與標點差異穩定。
|
||||||
|
* @param {{location?: string, suggestion?: string}} f - finding 物件(容許欄位缺漏)。
|
||||||
|
* @returns {string} 形如 `檔案路徑|正規化建議` 的簽章字串。
|
||||||
|
*/
|
||||||
function findingSig(f) {
|
function findingSig(f) {
|
||||||
return `${fileOf(f?.location)}|${normalizeKey(f?.suggestion)}`;
|
return `${fileOf(f?.location)}|${normalizeKey(f?.suggestion)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,20 @@ import { warn } from './log.js';
|
|||||||
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
|
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析單一角色 .md 檔:前置 YAML frontmatter(徽章、代表色、面向、個性等)+ 本文(審查重點)。
|
* 解析單一角色 Markdown 檔內容,拆出前置 YAML frontmatter 與本文。
|
||||||
* 回傳合併後的角色物件:{ name, side, focus, badge, color, personality, body }。
|
*
|
||||||
|
* 會先將 CRLF 正規化為 LF,再以 `---` 分隔線切出 frontmatter(徽章、代表色、
|
||||||
|
* 面向、個性等欄位)與其後的本文(審查重點 / 裁決準則)。frontmatter 欄位會
|
||||||
|
* 被攤平到回傳物件,本文則放入 `body`(已去除頭尾空白)。
|
||||||
|
*
|
||||||
|
* @param {string} content - 角色 `.md` 檔的完整文字內容。
|
||||||
|
* @returns {{ name?: string, side?: string, focus?: string, badge?: string,
|
||||||
|
* color?: string, personality?: string, body: string,
|
||||||
|
* [key: string]: unknown }} 合併 frontmatter 與本文後的角色物件。
|
||||||
|
* @throws {Error} 當內容缺少合法 `---` frontmatter 區塊時拋出「角色檔缺少 frontmatter」。
|
||||||
|
* @throws {import('js-yaml').YAMLException} 當 frontmatter 不是合法 YAML 時(由 `yaml.load` 拋出,未攔截)。
|
||||||
|
*
|
||||||
|
* @remarks 純字串處理,無任何檔案 IO;frontmatter 中若自帶 `body` 欄位會被本文覆蓋。
|
||||||
*/
|
*/
|
||||||
export function parseRoleFile(content) {
|
export function parseRoleFile(content) {
|
||||||
const normalized = content.replace(/\r\n/g, '\n');
|
const normalized = content.replace(/\r\n/g, '\n');
|
||||||
@@ -21,8 +33,16 @@ export function parseRoleFile(content) {
|
|||||||
let cachedRoles = null;
|
let cachedRoles = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 讀取並解析所有角色 .md,結果快取於模組層級(單次程序生命週期內檔案不變)。
|
* 讀取並解析 `ROLES_DIR` 下所有角色 `.md` 檔,依檔名排序後回傳角色陣列。
|
||||||
* 單一檔案解析失敗(壞 YAML、缺 frontmatter 等)時記錄警告並略過,不讓整個流程崩潰。
|
*
|
||||||
|
* 結果快取於模組層級(`cachedRoles`),同一程序生命週期內只讀檔一次;之後即使
|
||||||
|
* 角色檔有變動也不會重新載入,需重啟程序才會生效。單一檔案解析失敗(壞 YAML、
|
||||||
|
* 缺 frontmatter 等)只記錄警告並略過,不會中斷其他角色的載入。
|
||||||
|
*
|
||||||
|
* @returns {Array<ReturnType<typeof parseRoleFile>>} 已解析的角色物件陣列(依檔名排序)。
|
||||||
|
*
|
||||||
|
* @remarks 模組私有函式;使用同步檔案 IO。目錄不存在或無權限時,`fs.readdirSync`
|
||||||
|
* 會在容錯範圍外拋出錯誤。
|
||||||
*/
|
*/
|
||||||
function readRoleFiles() {
|
function readRoleFiles() {
|
||||||
if (cachedRoles) return cachedRoles;
|
if (cachedRoles) return cachedRoles;
|
||||||
@@ -39,22 +59,47 @@ function readRoleFiles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 載入攻擊方角色(Step3 產生 findings 用),依檔名排序。
|
* 載入所有「攻擊方」角色(frontmatter `side === 'attack'`),依檔名排序。
|
||||||
* 防守方(如 Paladin)不在此列,裁決邏輯由去重/誤報過濾流程承擔。
|
*
|
||||||
|
* 供 Step3 產生 findings 階段使用。防守方角色(如 Paladin)不在回傳之列,
|
||||||
|
* 其裁決邏輯由去重 / 誤報過濾流程處理。
|
||||||
|
*
|
||||||
|
* @returns {Array<ReturnType<typeof parseRoleFile>>} 攻擊方角色物件陣列。
|
||||||
|
*
|
||||||
|
* @remarks 透過 `readRoleFiles` 取得快取後的全部角色再過濾,首次呼叫會觸發檔案讀取。
|
||||||
*/
|
*/
|
||||||
export function loadRoles() {
|
export function loadRoles() {
|
||||||
return readRoleFiles().filter(r => r.side === 'attack');
|
return readRoleFiles().filter(r => r.side === 'attack');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 依 frontmatter name 取得單一角色(不分大小寫),找不到回傳 null。 */
|
/**
|
||||||
|
* 依 frontmatter `name` 取得單一角色(比對不分大小寫),找不到回傳 `null`。
|
||||||
|
*
|
||||||
|
* 不分攻擊方 / 防守方,所有已成功載入的角色皆可查得。
|
||||||
|
*
|
||||||
|
* @param {string} name - 角色名稱(大小寫不拘)。
|
||||||
|
* @returns {ReturnType<typeof parseRoleFile> | null} 對應角色物件,無對應時為 `null`。
|
||||||
|
*
|
||||||
|
* @remarks 透過 `readRoleFiles` 取得快取角色清單,首次呼叫會觸發檔案讀取。
|
||||||
|
*/
|
||||||
export function loadRole(name) {
|
export function loadRole(name) {
|
||||||
const target = String(name).toLowerCase();
|
const target = String(name).toLowerCase();
|
||||||
return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null;
|
return readRoleFiles().find(r => String(r.name).toLowerCase() === target) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由角色定義組出攻擊方的 system prompt:
|
* 由攻擊方角色定義組出其分析用 system prompt。
|
||||||
* 套用其個性與審查重點本文,並要求以固定 JSON 陣列格式回傳 findings。
|
*
|
||||||
|
* 套用角色的徽章、名稱、面向(focus,缺省為「綜合」)、個性(personality,可選)
|
||||||
|
* 與審查重點本文(body),並附上固定指示:分析 Git Diff 僅針對新增/修改處找問題,
|
||||||
|
* 並以固定 JSON 陣列格式(level / role / location / problem / suggestion)回傳 findings,
|
||||||
|
* 強制每條問題帶 `檔案路徑:行號`。
|
||||||
|
*
|
||||||
|
* @param {ReturnType<typeof parseRoleFile>} role - 攻擊方角色物件(需含 `name`、`body`;`badge`/`focus`/`personality` 可選)。
|
||||||
|
* @returns {string} 組裝完成的多行 system prompt 文字。
|
||||||
|
* @throws {TypeError} 當 `role` 為 `null`/`undefined` 時(未做防呆,存取屬性即拋出)。
|
||||||
|
*
|
||||||
|
* @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。
|
||||||
*/
|
*/
|
||||||
export function buildAnalysisPrompt(role) {
|
export function buildAnalysisPrompt(role) {
|
||||||
return [
|
return [
|
||||||
@@ -90,8 +135,16 @@ export function buildAnalysisPrompt(role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由角色定義組出「補行號」的 system prompt:
|
* 組出「補行號」用的 system prompt。
|
||||||
* 當該角色先前提出的問題只有檔名、缺行號時,請它對照 Git Diff 找出實際行號。
|
*
|
||||||
|
* 用於某角色先前提出的 finding 其 `location` 只有檔名、缺行號的情境:請 LLM 對照
|
||||||
|
* 該檔 Git Diff 找出問題對應的實際行號,並只回 `{"line": 數字}`(找不到回 `{"line": 0}`)。
|
||||||
|
*
|
||||||
|
* @param {ReturnType<typeof parseRoleFile> | null | undefined} [role] - 角色物件;可省略或為 null,
|
||||||
|
* 此時名稱退回 `'AI Review'` 且不帶徽章與面向子句。
|
||||||
|
* @returns {string} 組裝完成的多行 system prompt 文字。
|
||||||
|
*
|
||||||
|
* @remarks 使用選擇性串接(`?.`),對 `role` 為空值具防呆,不會拋出例外。
|
||||||
*/
|
*/
|
||||||
export function buildLocateLinePrompt(role) {
|
export function buildLocateLinePrompt(role) {
|
||||||
const name = role?.name || 'AI Review';
|
const name = role?.name || 'AI Review';
|
||||||
@@ -104,9 +157,18 @@ export function buildLocateLinePrompt(role) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 由防守方角色定義組出「單條 finding 誤報裁決」的 system prompt:
|
* 由防守方角色定義組出「單條 finding 誤報裁決」用的 system prompt。
|
||||||
* 套用其個性與裁決準則本文,要求對一條 finding 判定成立或誤報,回固定 JSON 物件。
|
*
|
||||||
* role 為 null 時退回不帶角色的通用裁判 prompt。
|
* `role` 存在時套用其徽章、名稱、面向(focus,缺省「裁決」)、個性與裁決準則本文(body);
|
||||||
|
* `role` 為空值時退回固定的通用裁判 persona(🛡️ Paladin 聖騎士)。prompt 要求對一條
|
||||||
|
* 攻擊方 finding 判定「成立 / 誤報」,並只回 `{"verdict", "reason"}`;無法確定時一律回
|
||||||
|
* `"confirmed"`(寧可保留、不冤枉)。
|
||||||
|
*
|
||||||
|
* @param {ReturnType<typeof parseRoleFile> | null | undefined} role - 防守方角色物件;為空值時改用通用裁判 persona。
|
||||||
|
* @param {string} [exclusionHint=''] - 額外的排除 / 已知誤報提示文字;為空字串時該行會被略過。
|
||||||
|
* @returns {string} 組裝完成的多行 system prompt 文字。
|
||||||
|
*
|
||||||
|
* @remarks 純字串組裝,無副作用;空白分段行在串接前會被過濾移除。
|
||||||
*/
|
*/
|
||||||
export function buildVerdictPrompt(role, exclusionHint = '') {
|
export function buildVerdictPrompt(role, exclusionHint = '') {
|
||||||
const persona = role
|
const persona = role
|
||||||
@@ -129,6 +191,18 @@ export function buildVerdictPrompt(role, exclusionHint = '') {
|
|||||||
].filter(l => l !== '').join('\n');
|
].filter(l => l !== '').join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由角色陣列產生「AI Code Review 團隊」介紹用的 Markdown 表格。
|
||||||
|
*
|
||||||
|
* 表格含三欄:角色(粗體,含徽章)、面向(focus)、個性(personality);缺省欄位以空字串呈現。
|
||||||
|
* 通常用於 PR 留言 / 審查報告開頭呈現參與審查的角色陣容。
|
||||||
|
*
|
||||||
|
* @param {Array<ReturnType<typeof parseRoleFile>>} roles - 角色物件陣列(每個可含 `badge`/`name`/`focus`/`personality`)。
|
||||||
|
* @returns {string} Markdown 格式的多行表格字串。
|
||||||
|
* @throws {TypeError} 當 `roles` 非可迭代值(如 `null`/`undefined`)時,`for...of` 會拋出。
|
||||||
|
*
|
||||||
|
* @remarks 純字串組裝,無副作用;傳入空陣列會得到只有標題與表頭的表格。
|
||||||
|
*/
|
||||||
export function getRoleIntro(roles) {
|
export function getRoleIntro(roles) {
|
||||||
const lines = [
|
const lines = [
|
||||||
'## 🤖 AI Code Review 團隊', '',
|
'## 🤖 AI Code Review 團隊', '',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { saveFindings, parseLocation, postNewCriticalComments, postFindingsReview, formatFindingsStats, formatFindingsStatsLine } from './comments.js';
|
import { saveFindings, parseLocation, postNewCriticalComments, postFindingsReview, formatFindingsStats, formatFindingsStatsLine } from '../comments.js';
|
||||||
import { FINDINGS_PATH } from './config.js';
|
import { FINDINGS_PATH } from '../config.js';
|
||||||
|
|
||||||
describe('saveFindings', () => {
|
describe('saveFindings', () => {
|
||||||
const tempDirs = [];
|
const tempDirs = [];
|
||||||
@@ -420,3 +420,97 @@ describe('postFindingsReview', () => {
|
|||||||
assert.deepEqual(inlineCalls.map(c => `${c.path}:${c.line}`), ['app/a.js:5', 'app/b.js:9']);
|
assert.deepEqual(inlineCalls.map(c => `${c.path}:${c.line}`), ['app/a.js:5', 'app/b.js:9']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatFindingsStats markdown formatting', () => {
|
||||||
|
// 代表性輸入:critical/warning/info 混合,含 is_new:false(舊問題)與未分類等級(custom)
|
||||||
|
const mixedFindings = [
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'warning', is_new: true },
|
||||||
|
{ level: 'info' }, // 未設 is_new 視為新問題
|
||||||
|
{ level: 'custom', is_new: true }, // 無法標示(不在 LEVEL_ORDER)
|
||||||
|
{ level: 'critical', is_new: false }, // 舊問題
|
||||||
|
{ level: 'warning', is_new: false }, // 舊問題
|
||||||
|
{ level: 'mystery', is_new: false }, // 舊問題 + 無法標示
|
||||||
|
];
|
||||||
|
|
||||||
|
it('emits the exact header, separator and one row per type for mixed findings', () => {
|
||||||
|
const stats = formatFindingsStats(mixedFindings);
|
||||||
|
|
||||||
|
assert.equal(stats, [
|
||||||
|
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
|
||||||
|
'| --- | --- | --- | --- | --- |',
|
||||||
|
'| 新問題 | 2 筆 | 1 筆 | 1 筆 | 1 筆 |',
|
||||||
|
'| 舊問題 | 1 筆 | 1 筆 | 0 筆 | 1 筆 |',
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a structurally valid markdown table (4 lines, 6 pipes each, 5 columns)', () => {
|
||||||
|
const lines = formatFindingsStats(mixedFindings).split('\n');
|
||||||
|
|
||||||
|
// 表頭 + 分隔列 + 新問題列 + 舊問題列
|
||||||
|
assert.equal(lines.length, 4);
|
||||||
|
// 每列皆以 pipe 起訖
|
||||||
|
for (const row of lines) {
|
||||||
|
assert.ok(row.startsWith('| '), `row should start with a pipe: ${row}`);
|
||||||
|
assert.ok(row.endsWith(' |'), `row should end with a pipe: ${row}`);
|
||||||
|
// 5 欄 => 6 個 pipe 分隔符
|
||||||
|
assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
|
||||||
|
}
|
||||||
|
// 分隔列每格皆為 ---
|
||||||
|
assert.equal(lines[1], '| --- | --- | --- | --- | --- |');
|
||||||
|
// 資料列以 類型 標籤起頭
|
||||||
|
assert.match(lines[2], /^\| 新問題 \|/);
|
||||||
|
assert.match(lines[3], /^\| 舊問題 \|/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a stable, non-broken table for an empty findings array', () => {
|
||||||
|
let stats;
|
||||||
|
assert.doesNotThrow(() => { stats = formatFindingsStats([]); });
|
||||||
|
|
||||||
|
assert.equal(stats, [
|
||||||
|
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
|
||||||
|
'| --- | --- | --- | --- | --- |',
|
||||||
|
'| 新問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
|
||||||
|
'| 舊問題 | 0 筆 | 0 筆 | 0 筆 | 0 筆 |',
|
||||||
|
].join('\n'));
|
||||||
|
// 即使無資料仍維持 4 列、每列 6 個 pipe 的結構
|
||||||
|
const lines = stats.split('\n');
|
||||||
|
assert.equal(lines.length, 4);
|
||||||
|
for (const row of lines) {
|
||||||
|
assert.equal((row.match(/\|/g) || []).length, 6, `row should have 6 pipes: ${row}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFindingsStatsLine markdown formatting', () => {
|
||||||
|
const mixedFindings = [
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'critical', is_new: true },
|
||||||
|
{ level: 'warning', is_new: true },
|
||||||
|
{ level: 'info' },
|
||||||
|
{ level: 'custom', is_new: true },
|
||||||
|
{ level: 'critical', is_new: false },
|
||||||
|
{ level: 'warning', is_new: false },
|
||||||
|
{ level: 'mystery', is_new: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('produces the exact single-line summary for mixed findings', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatFindingsStatsLine(mixedFindings),
|
||||||
|
'新: 嚴重2 / 警告1 / 建議1 / 無法標示1;舊: 嚴重1 / 警告1 / 建議0 / 無法標示1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a stable single line with zero counts for an empty findings array', () => {
|
||||||
|
let lineSummary;
|
||||||
|
assert.doesNotThrow(() => { lineSummary = formatFindingsStatsLine([]); });
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
lineSummary,
|
||||||
|
'新: 嚴重0 / 警告0 / 建議0 / 無法標示0;舊: 嚴重0 / 警告0 / 建議0 / 無法標示0',
|
||||||
|
);
|
||||||
|
// 單行:不含換行
|
||||||
|
assert.ok(!lineSummary.includes('\n'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers } from './findings.js';
|
import { loadOldFindings, loadExclusions, applyExclusions, filterFalsePositivesWithAI, appendExclusions, resolveMissingLineNumbers, normalizeText } from '../findings.js';
|
||||||
import { EXCLUSIONS_PATH, FINDINGS_PATH } from './config.js';
|
import { EXCLUSIONS_PATH, FINDINGS_PATH } from '../config.js';
|
||||||
|
|
||||||
describe('findings exclusions', () => {
|
describe('findings exclusions', () => {
|
||||||
let workspace;
|
let workspace;
|
||||||
@@ -349,3 +349,47 @@ 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)}`)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('normalizeText', () => {
|
||||||
|
it('normalizes full-width characters to the same result as half-width lowercase (NFKC)', () => {
|
||||||
|
// NFKC 將全形 ABC 折成半形 ABC,再小寫 → 'abc',與 'abc' 一致
|
||||||
|
assert.equal(normalizeText('ABC'), 'abc');
|
||||||
|
assert.equal(normalizeText('abc'), 'abc');
|
||||||
|
assert.equal(normalizeText('ABC'), normalizeText('abc'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses mixed punctuation and symbols into single spaces', () => {
|
||||||
|
assert.equal(normalizeText('Hello, World!! foo@bar'), 'hello world foo bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple spaces, newlines and tabs into a single space and trims', () => {
|
||||||
|
assert.equal(normalizeText(' multiple \n\t spaces \n here '), 'multiple spaces here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases uppercase input', () => {
|
||||||
|
assert.equal(normalizeText('UPPER Case Mix'), 'upper case mix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for non-string inputs', () => {
|
||||||
|
assert.equal(normalizeText(null), '');
|
||||||
|
assert.equal(normalizeText(undefined), '');
|
||||||
|
assert.equal(normalizeText(42), '');
|
||||||
|
assert.equal(normalizeText({ a: 1 }), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for empty string input', () => {
|
||||||
|
assert.equal(normalizeText(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips surrounding full-width whitespace and collapses CJK punctuation', () => {
|
||||||
|
// 全形空白被 trim、,與 !屬於 \p{P} 折成單一空白 → '你好 世界'
|
||||||
|
assert.equal(normalizeText(' 你好,世界! '), '你好 世界');
|
||||||
|
assert.equal(normalizeText('(重要)測試:項目#1'), '重要 測試 項目 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent', () => {
|
||||||
|
for (const input of ['ABC', 'Hello, World!! foo@bar', ' 你好,世界! ', 'UPPER Case Mix', '']) {
|
||||||
|
assert.equal(normalizeText(normalizeText(input)), normalizeText(input));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { commitAndPush, cloneRepo, verifyRemoteAccess, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js';
|
import { commitAndPush, cloneRepo, verifyRemoteAccess, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from '../git.js';
|
||||||
|
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN } from '../config.js';
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
function makeTmpWorkspace() {
|
function makeTmpWorkspace() {
|
||||||
@@ -74,6 +75,17 @@ describe('commitAndPush', () => {
|
|||||||
assert.ok(commitCall.args.some(arg => arg.includes('[failure]')), 'expected commit message to include failure outcome');
|
assert.ok(commitCall.args.some(arg => arg.includes('[failure]')), 'expected commit message to include failure outcome');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pushes with the comment-token (PAT) so the bot commit can re-trigger the workflow', async () => {
|
||||||
|
const spawn = makeSpawn();
|
||||||
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
|
|
||||||
|
// push 必須帶「優先 PAT」的 token,Gitea 才會為 bot commit 重新發出 synchronize 事件。
|
||||||
|
const expectedToken = GITEA_COMMENT_TOKEN || GITEA_TOKEN;
|
||||||
|
const pushCall = spawn.calls.find(c => c.args[0] === 'push');
|
||||||
|
assert.ok(pushCall, 'expected git push to run');
|
||||||
|
assert.equal(pushCall.opts?.env?.GIT_TOKEN, expectedToken, 'push must use GITEA_COMMENT_TOKEN (fallback GITEA_TOKEN)');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
|
||||||
const spawn = makeSpawn();
|
const spawn = makeSpawn();
|
||||||
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
|
||||||
@@ -196,6 +208,23 @@ describe('commitAndPush', () => {
|
|||||||
assert.ok(logs.some(line => line.includes('Step8 commit 成功但 push 失敗')));
|
assert.ok(logs.some(line => line.includes('Step8 commit 成功但 push 失敗')));
|
||||||
assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
|
assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not throw and does not mistake a failed push for success (commit still ran, push attempted)', async () => {
|
||||||
|
// commit 成功、僅 push 失敗:函式必須吞掉錯誤不中斷上層流程。
|
||||||
|
const spawn = makeSpawn({
|
||||||
|
push: () => ({ status: 1, stdout: '', stderr: 'fatal: unable to access remote', error: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.doesNotReject(
|
||||||
|
() => commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot),
|
||||||
|
'push failure must not crash the pipeline',
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitCall = spawn.calls.find(c => c.args[0] === 'commit');
|
||||||
|
assert.ok(commitCall, 'commit should still run before the failed push');
|
||||||
|
const pushCall = spawn.calls.find(c => c.args[0] === 'push');
|
||||||
|
assert.ok(pushCall, 'push should have been attempted');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cloneRepo', () => {
|
describe('cloneRepo', () => {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getPRDiff, filterDiff, postComment, postPullReviewComment, postPullReview, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome, listPullReviews, getPullReviewComments, listAllReviewComments, resolvePullReviewComment, getFileContentAtRef } from './gitea.js';
|
import { getPRDiff, filterDiff, postComment, postPullReviewComment, postPullReview, getCommitMessageBySha, getBranchHeadCommitMessage, shouldSkipBotCommit, getBotReviewOutcome, listPullReviews, getPullReviewComments, listAllReviewComments, resolvePullReviewComment, getFileContentAtRef } from '../gitea.js';
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { stripCodeFence, repairJSONArrayWithAI, validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { stripCodeFence, repairJSONArrayWithAI, validateJSONArrayFile, ensureJSONArrayFileExists } from '../json.js';
|
||||||
|
|
||||||
describe('json helpers', () => {
|
describe('json helpers', () => {
|
||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
@@ -139,3 +139,166 @@ describe('json helpers', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateJSONArrayFile repair failure paths', () => {
|
||||||
|
let workspace;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'json-test-repair-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites the invalid file with the valid array returned by the repairer', async () => {
|
||||||
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, '{ this is not json', 'utf8');
|
||||||
|
|
||||||
|
let receivedOriginal;
|
||||||
|
const result = await validateJSONArrayFile(
|
||||||
|
fullPath,
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
async (passedPath, passedLabel, original) => {
|
||||||
|
// repairer is called with (fullPath, label, original) per json.js line 109
|
||||||
|
assert.equal(passedPath, fullPath);
|
||||||
|
assert.equal(passedLabel, '.gitea/ai-review/findings.json');
|
||||||
|
receivedOriginal = original;
|
||||||
|
return '[{"id":1},{"id":2}]';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(receivedOriginal, '{ this is not json');
|
||||||
|
assert.deepEqual(result, { exists: true, valid: true, repaired: true });
|
||||||
|
// file is overwritten with the repaired content, trailing newline appended (line 110)
|
||||||
|
const written = fs.readFileSync(fullPath, 'utf8');
|
||||||
|
assert.equal(written, '[{"id":1},{"id":2}]\n');
|
||||||
|
assert.deepEqual(JSON.parse(written), [{ id: 1 }, { id: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when the repaired text is still invalid JSON and does NOT overwrite the original file', async () => {
|
||||||
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
const originalBytes = '{ still broken';
|
||||||
|
fs.writeFileSync(fullPath, originalBytes, 'utf8');
|
||||||
|
|
||||||
|
const stillInvalid = 'not a json array either';
|
||||||
|
await assert.rejects(
|
||||||
|
() => validateJSONArrayFile(
|
||||||
|
fullPath,
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
async () => stillInvalid
|
||||||
|
),
|
||||||
|
SyntaxError
|
||||||
|
);
|
||||||
|
|
||||||
|
// json.js validates the repaired text in-memory BEFORE writing, so an invalid
|
||||||
|
// repair throws without corrupting the file — the original bytes are preserved.
|
||||||
|
const after = fs.readFileSync(fullPath, 'utf8');
|
||||||
|
assert.equal(after, originalBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates an error thrown by the repairer', async () => {
|
||||||
|
const fullPath = path.join(workspace, '.gitea/ai-review/findings.json');
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, '{ broken', 'utf8');
|
||||||
|
|
||||||
|
const boom = new Error('repairer exploded');
|
||||||
|
await assert.rejects(
|
||||||
|
() => validateJSONArrayFile(
|
||||||
|
fullPath,
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
async () => { throw boom; }
|
||||||
|
),
|
||||||
|
/repairer exploded/
|
||||||
|
);
|
||||||
|
|
||||||
|
// repairer threw before any write happened, so the original bytes remain untouched (line 109)
|
||||||
|
assert.equal(fs.readFileSync(fullPath, 'utf8'), '{ broken');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairJSONArrayWithAI', () => {
|
||||||
|
it('returns a clean JSON array string parseable by the caller', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'{not valid json',
|
||||||
|
async () => '[{"id":1},{"id":2}]'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[{"id":1},{"id":2}]');
|
||||||
|
assert.deepEqual(JSON.parse(repaired), [{ id: 1 }, { id: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a fenced ```json block from the AI output', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/exclusions.json',
|
||||||
|
'garbage',
|
||||||
|
async () => '```json\n[1, 2, 3]\n```'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[1, 2, 3]');
|
||||||
|
assert.deepEqual(JSON.parse(repaired), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to an empty array when the model cannot repair the content', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'totally unparseable !!! @@@',
|
||||||
|
async () => '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[]');
|
||||||
|
assert.deepEqual(JSON.parse(repaired), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns garbage unchanged (no parsing/throwing) so the caller can validate', async () => {
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/x.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'{broken',
|
||||||
|
async () => 'not a json array at all'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, 'not a json array at all');
|
||||||
|
assert.throws(() => JSON.parse(repaired));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes chatFn once with the strict system prompt and JSON-encoded context', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const repaired = await repairJSONArrayWithAI(
|
||||||
|
'/tmp/findings.json',
|
||||||
|
'.gitea/ai-review/findings.json',
|
||||||
|
'{broken',
|
||||||
|
async (systemPrompt, userContent) => {
|
||||||
|
calls.push({ systemPrompt, userContent });
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(repaired, '[]');
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.ok(calls[0].systemPrompt.includes('你是 JSON 修復器'));
|
||||||
|
assert.ok(calls[0].systemPrompt.includes('回傳 []'));
|
||||||
|
|
||||||
|
const context = JSON.parse(calls[0].userContent);
|
||||||
|
assert.deepEqual(context, {
|
||||||
|
file: '.gitea/ai-review/findings.json',
|
||||||
|
path: '/tmp/findings.json',
|
||||||
|
rawText: '{broken'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates errors thrown by chatFn', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => repairJSONArrayWithAI('/tmp/x.json', '.gitea/ai-review/findings.json', '{broken', async () => {
|
||||||
|
throw new Error('llm unavailable');
|
||||||
|
}),
|
||||||
|
/llm unavailable/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { extractBalancedJSON, extractJSONText } from '../llm.js';
|
||||||
|
|
||||||
const ENV_KEYS = [
|
const ENV_KEYS = [
|
||||||
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
@@ -29,7 +30,7 @@ function mockOpenCodeResponse(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('chat - OpenCode', async () => {
|
describe('chat - OpenCode', async () => {
|
||||||
const { chat } = await import('./llm.js');
|
const { chat } = await import('../llm.js');
|
||||||
|
|
||||||
it('uses OpenCode server session API', async () => {
|
it('uses OpenCode server session API', async () => {
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
@@ -96,7 +97,7 @@ describe('chat - OpenCode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('chatJSON', async () => {
|
describe('chatJSON', async () => {
|
||||||
const { chatJSON } = await import('./llm.js');
|
const { chatJSON } = await import('../llm.js');
|
||||||
|
|
||||||
it('parses plain JSON response', async () => {
|
it('parses plain JSON response', async () => {
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
@@ -143,3 +144,90 @@ describe('chatJSON', async () => {
|
|||||||
assert.deepEqual(result, []);
|
assert.deepEqual(result, []);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractBalancedJSON', () => {
|
||||||
|
it('returns the whole object for a simple object from index 0', () => {
|
||||||
|
const text = '{"a":1}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the full balanced segment for deeply nested object/array', () => {
|
||||||
|
const text = '{"a":[1,{"b":[2,{"c":3}]}],"d":4}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":[1,{"b":[2,{"c":3}]}],"d":4}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let braces inside a string value break balancing', () => {
|
||||||
|
const text = '{"a":"}{"}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"}{"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an escaped quote inside a string value', () => {
|
||||||
|
const text = '{"a":"\\""}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"\\""}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for truncated/incomplete JSON', () => {
|
||||||
|
const text = '{"a":1';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a balanced array when starting at a "["', () => {
|
||||||
|
const text = '[1,[2,3],{"a":4}]';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '[1,[2,3],{"a":4}]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes trailing content after the balanced segment', () => {
|
||||||
|
const text = '{"a":1} trailing text {"b":2}';
|
||||||
|
|
||||||
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractJSONText', () => {
|
||||||
|
it('strips a fenced ```json block', () => {
|
||||||
|
const text = '```json\n{"a":1}\n```';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(result), { a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts a JSON object after leading prose', () => {
|
||||||
|
const text = 'Here are the findings:\n{"level":"critical"}';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(result), { level: 'critical' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts an array embedded in surrounding text', () => {
|
||||||
|
const text = 'prefix [1,2,3] suffix';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(result), [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an already-pure JSON string as-is', () => {
|
||||||
|
const text = '{"a":1,"b":[2,3]}';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.equal(result, '{"a":1,"b":[2,3]}');
|
||||||
|
assert.deepEqual(JSON.parse(result), { a: 1, b: [2, 3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the de-fenced original text when no valid JSON is found', () => {
|
||||||
|
const text = '```\nnot json at all\n```';
|
||||||
|
|
||||||
|
const result = extractJSONText(text);
|
||||||
|
|
||||||
|
assert.equal(result, 'not json at all');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { section, step, line, input, output, result, ok, warn, error } from './log.js';
|
import { section, step, line, input, output, result, ok, warn, error } from '../log.js';
|
||||||
|
|
||||||
afterEach(() => mock.restoreAll());
|
afterEach(() => mock.restoreAll());
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, afterEach, mock } from 'node:test';
|
import { describe, it, afterEach, mock } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from './preflight.js';
|
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js';
|
||||||
|
|
||||||
const LLM_ENV_KEYS = [
|
const LLM_ENV_KEYS = [
|
||||||
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
reconcileConversations,
|
reconcileConversations,
|
||||||
dropResolvedFindings,
|
dropResolvedFindings,
|
||||||
addCarriedFindings,
|
addCarriedFindings,
|
||||||
} from './resolve.js';
|
isSafeRepoPath,
|
||||||
|
} from '../resolve.js';
|
||||||
|
|
||||||
const reviewBody = (level, role, problem, suggestion) =>
|
const reviewBody = (level, role, problem, suggestion) =>
|
||||||
`**嚴重等級**:${level}\n**審查員**:${role}\n**問題**:${problem}\n**建議**:${suggestion}`;
|
`**嚴重等級**:${level}\n**審查員**:${role}\n**問題**:${problem}\n**建議**:${suggestion}`;
|
||||||
@@ -337,3 +338,41 @@ describe('addCarriedFindings', () => {
|
|||||||
assert.equal(addCarriedFindings(findings, []), findings);
|
assert.equal(addCarriedFindings(findings, []), findings);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isSafeRepoPath', () => {
|
||||||
|
it('accepts a normal relative path', () => {
|
||||||
|
assert.equal(isSafeRepoPath('src/index.js'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a deep but safe relative path', () => {
|
||||||
|
assert.equal(isSafeRepoPath('a/b/c.js'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths containing ../ traversal', () => {
|
||||||
|
assert.equal(isSafeRepoPath('../../etc/passwd'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a segment that is exactly .. (incl. middle of path)', () => {
|
||||||
|
assert.equal(isSafeRepoPath('a/../b'), false);
|
||||||
|
// 反斜線 ..\ 形式:以 / 切割後整段仍為 ..\... 非單純 ..,但起首相對路徑仍判定安全
|
||||||
|
assert.equal(isSafeRepoPath('a/..'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects leading-slash absolute paths', () => {
|
||||||
|
assert.equal(isSafeRepoPath('/etc/passwd'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects Windows drive-letter prefixes', () => {
|
||||||
|
assert.equal(isSafeRepoPath('C:\\Windows\\system32'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty string', () => {
|
||||||
|
assert.equal(isSafeRepoPath(''), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string inputs', () => {
|
||||||
|
assert.equal(isSafeRepoPath(null), false);
|
||||||
|
assert.equal(isSafeRepoPath(undefined), false);
|
||||||
|
assert.equal(isSafeRepoPath(123), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { parseRoleFile, loadRoles, loadRole, buildAnalysisPrompt, buildLocateLinePrompt, getRoleIntro } from './roles.js';
|
import { parseRoleFile, loadRoles, loadRole, buildAnalysisPrompt, buildLocateLinePrompt, getRoleIntro } from '../roles.js';
|
||||||
|
|
||||||
const SAMPLE = `---
|
const SAMPLE = `---
|
||||||
name: Tester
|
name: Tester
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
fetchAccountQuota,
|
fetchAccountQuota,
|
||||||
formatUsageStats,
|
formatUsageStats,
|
||||||
formatUsageStatsLine,
|
formatUsageStatsLine,
|
||||||
} from './usage.js';
|
} from '../usage.js';
|
||||||
|
|
||||||
describe('extractUsage', () => {
|
describe('extractUsage', () => {
|
||||||
it('parses OpenAI-compatible usage', () => {
|
it('parses OpenAI-compatible usage', () => {
|
||||||
@@ -4,6 +4,12 @@ import { warn } from './log.js';
|
|||||||
/** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */
|
/** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */
|
||||||
const runUsage = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
const runUsage = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全數字轉換:將任意輸入轉為有限數字,無法轉換或非有限值(NaN/Infinity)一律回 0。
|
||||||
|
* 常用於正規化外部 API 回應或 HTTP header 取出的值,避免污染後續加總與百分比運算。
|
||||||
|
* @param {*} x 任意待轉換的值。
|
||||||
|
* @returns {number} 有限數字;否則為 0。
|
||||||
|
*/
|
||||||
function num(x) {
|
function num(x) {
|
||||||
const n = Number(x);
|
const n = Number(x);
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
@@ -83,7 +89,12 @@ export function resetRunUsage() {
|
|||||||
/** 最近一次回應的速率配額(rate limit)快照,用來計算「當前視窗剩餘百分比」。 */
|
/** 最近一次回應的速率配額(rate limit)快照,用來計算「當前視窗剩餘百分比」。 */
|
||||||
const rateLimit = { hasData: false, remaining: null, limit: null, kind: null };
|
const rateLimit = { hasData: false, remaining: null, limit: null, kind: null };
|
||||||
|
|
||||||
/** 將物件的 key 全部轉小寫,方便對大小寫不敏感的 HTTP header 取值。 */
|
/**
|
||||||
|
* 將物件第一層的 key 全部轉為小寫並回傳新物件,方便對大小寫不敏感的 HTTP header 取值。
|
||||||
|
* 不修改傳入物件;僅處理第一層 key。呼叫端須自行確保傳入為物件。
|
||||||
|
* @param {Object<string, *>} obj 來源物件(通常為 HTTP response headers)。
|
||||||
|
* @returns {Object<string, *>} key 全小寫的新物件。
|
||||||
|
*/
|
||||||
function lowerCaseKeys(obj) {
|
function lowerCaseKeys(obj) {
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const k of Object.keys(obj)) out[k.toLowerCase()] = obj[k];
|
for (const k of Object.keys(obj)) out[k.toLowerCase()] = obj[k];
|
||||||
@@ -128,12 +139,20 @@ export function resetRateLimit() {
|
|||||||
rateLimit.kind = null;
|
rateLimit.kind = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去除字串結尾的單一斜線(常用於正規化 baseURL 以利串接路徑)。
|
||||||
|
* 空值會被視為空字串;僅移除最後一個斜線,不處理連續尾斜線。
|
||||||
|
* @param {*} s 來源字串(通常為 URL)。
|
||||||
|
* @returns {string} 去除結尾斜線後的字串。
|
||||||
|
*/
|
||||||
const stripSlash = (s) => String(s || '').replace(/\/$/, '');
|
const stripSlash = (s) => String(s || '').replace(/\/$/, '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 以實際 hostname 精確比對是否為 OpenRouter(僅接受 apex 域名 `openrouter.ai`),
|
* 以解析後的 hostname 精確比對 baseURL 是否為 OpenRouter(僅接受 apex 域名 openrouter.ai)。
|
||||||
* 避免被偽造的 baseURL(如 `openrouter.ai.evil.com`、`evil.com/openrouter.ai` 或任何子網域)
|
* 用於 fetchAccountQuota 的安全守門:防止被偽造或子網域 baseURL 矇騙而外洩 API key。
|
||||||
* 矇騙而把 API key 送往非 OpenRouter 主機。
|
* 無法解析為合法 URL 時回 false(不丟例外)。
|
||||||
|
* @param {string} baseURL 待驗證的 base URL。
|
||||||
|
* @returns {boolean} hostname 恰為 openrouter.ai 時為 true,否則 false。
|
||||||
*/
|
*/
|
||||||
function isOpenRouterBaseURL(baseURL) {
|
function isOpenRouterBaseURL(baseURL) {
|
||||||
try {
|
try {
|
||||||
@@ -144,8 +163,14 @@ function isOpenRouterBaseURL(baseURL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenRouter:以 API key 呼叫 GET /auth/key 取得額度(可靠)。
|
* 呼叫 OpenRouter GET /auth/key,以 API key 取得帳號額度(單位 USD credits)。
|
||||||
* 回傳金額單位為 USD credits。
|
* remaining 缺漏時以 limit - used 推算;limit 為 null(無上限)時 remaining 亦為 null。
|
||||||
|
* 本函式不攔截例外;HTTP/網路錯誤會向上拋出,由呼叫端負責降級。
|
||||||
|
* @param {{apiKey:string, baseURL:string}} cfg 連線設定(API key 與 base URL)。
|
||||||
|
* @param {function(string, object): Promise<{data:*}>} get HTTP GET 函式(可注入,預設 axios.get)。
|
||||||
|
* @returns {Promise<{available:true, used:number, limit:number|null, remaining:number|null, currency:'USD', source:'openrouter'}>}
|
||||||
|
* 額度資訊。
|
||||||
|
* @throws {Error} HTTP 請求失敗(逾時、非 2xx、網路錯誤等)時拋出。
|
||||||
*/
|
*/
|
||||||
async function fetchOpenRouterQuota({ apiKey, baseURL }, get) {
|
async function fetchOpenRouterQuota({ apiKey, baseURL }, get) {
|
||||||
const resp = await get(`${stripSlash(baseURL)}/auth/key`, {
|
const resp = await get(`${stripSlash(baseURL)}/auth/key`, {
|
||||||
@@ -193,7 +218,11 @@ export async function fetchAccountQuota(provider, config = {}, deps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 千分位整數/小數格式。 */
|
/**
|
||||||
|
* 將數字格式化為千分位字串(保留原有小數)。null/undefined/NaN 一律回 '0'。
|
||||||
|
* @param {number|string|null|undefined} n 待格式化的數值。
|
||||||
|
* @returns {string} 千分位字串,例如 1234567 → '1,234,567'。
|
||||||
|
*/
|
||||||
function fmt(n) {
|
function fmt(n) {
|
||||||
if (n == null || Number.isNaN(Number(n))) return '0';
|
if (n == null || Number.isNaN(Number(n))) return '0';
|
||||||
const [int, frac] = String(Number(n)).split('.');
|
const [int, frac] = String(Number(n)).split('.');
|
||||||
@@ -201,10 +230,22 @@ function fmt(n) {
|
|||||||
return frac ? `${withCommas}.${frac}` : withCommas;
|
return frac ? `${withCommas}.${frac}` : withCommas;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將數值格式化為金額字串:有幣別時前綴幣別(如 'USD 1,234'),無幣別則只回千分位數字。
|
||||||
|
* @param {string} currency 幣別代碼(空字串/falsy 表示無幣別)。
|
||||||
|
* @param {number|string|null|undefined} n 數值。
|
||||||
|
* @returns {string} 格式化後的金額字串。
|
||||||
|
*/
|
||||||
function money(currency, n) {
|
function money(currency, n) {
|
||||||
return currency ? `${currency} ${fmt(n)}` : fmt(n);
|
return currency ? `${currency} ${fmt(n)}` : fmt(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 四捨五入到小數一位(用於百分比顯示)。
|
||||||
|
* 不對非數字防呆;非有限輸入會得到 NaN(呼叫端應先確保為有限數)。
|
||||||
|
* @param {number|string} n 數值。
|
||||||
|
* @returns {number} 四捨五入到一位小數的結果。
|
||||||
|
*/
|
||||||
function round1(n) {
|
function round1(n) {
|
||||||
return Math.round(Number(n) * 10) / 10;
|
return Math.round(Number(n) * 10) / 10;
|
||||||
}
|
}
|
||||||
@@ -212,9 +253,12 @@ function round1(n) {
|
|||||||
const RATE_KIND_LABEL = { tokens: 'token', requests: '次數' };
|
const RATE_KIND_LABEL = { tokens: 'token', requests: '次數' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 計算「剩餘百分比」= remaining / limit × 100。
|
* 計算剩餘百分比(remaining / limit × 100),四捨五入到一位小數。
|
||||||
* limit 或 remaining 為 null/undefined/NaN/Infinity,或 limit ≤ 0 時回 null,
|
* 任一參數為 null/undefined/NaN/Infinity,或 limit ≤ 0 時回 null,
|
||||||
* 避免算出 Infinity%/NaN%/負百分比或除以零。
|
* 以避免算出 Infinity%、NaN%、負百分比或除以零。
|
||||||
|
* @param {number|null|undefined} remaining 剩餘量。
|
||||||
|
* @param {number|null|undefined} limit 上限。
|
||||||
|
* @returns {number|null} 百分比(一位小數);無法計算時為 null。
|
||||||
*/
|
*/
|
||||||
function calculatePercent(remaining, limit) {
|
function calculatePercent(remaining, limit) {
|
||||||
if (remaining == null || limit == null) return null;
|
if (remaining == null || limit == null) return null;
|
||||||
@@ -253,6 +297,13 @@ export function resolveRemainingPercent(quota, rate) {
|
|||||||
return { percent: null, reason };
|
return { percent: null, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 resolveRemainingPercent 的結果格式化為一行 Markdown 文字(剩餘可用百分比與明細)。
|
||||||
|
* 無法計算時輸出帶原因的說明字串。
|
||||||
|
* @param {{percent:number, basis:string, remaining:number, limit:number, unit:string}|{percent:null, reason:string}} pct
|
||||||
|
* resolveRemainingPercent 的回傳值。
|
||||||
|
* @returns {string} 單行 Markdown 字串。
|
||||||
|
*/
|
||||||
function remainingLine(pct) {
|
function remainingLine(pct) {
|
||||||
if (pct.percent == null) return `剩餘可用:無法計算百分比(${pct.reason})`;
|
if (pct.percent == null) return `剩餘可用:無法計算百分比(${pct.reason})`;
|
||||||
const detail = `${pct.basis}:${money(pct.unit, pct.remaining)} / ${money(pct.unit, pct.limit)}`;
|
const detail = `${pct.basis}:${money(pct.unit, pct.remaining)} / ${money(pct.unit, pct.limit)}`;
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# 用途:Docker action 的進入點(entrypoint),負責啟動 Node.js
|
||||||
|
# 撰寫的 AI code review 執行器(review runner)。
|
||||||
|
# 此 script 為容器啟動時執行的第一支程式。
|
||||||
|
# 更新日期:2026/06/26 11:34:46
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# set -e:開啟「遇到任何指令回傳非零(失敗)即立刻中止 script」模式。
|
||||||
|
# 為什麼:避免前面步驟失敗卻仍繼續往下執行,確保 review runner 在
|
||||||
|
# 乾淨且可預期的狀態下啟動。
|
||||||
|
# 副作用:之後任一指令失敗會讓整支 entrypoint(連同容器)以非零碼結束。
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# echo:印出啟動提示訊息到 stdout,方便在 CI/容器 log 中辨識啟動點。
|
||||||
|
# 為什麼:提供可觀測性,確認 entrypoint 已被執行。
|
||||||
|
# 副作用:僅輸出文字,無其他影響。
|
||||||
echo "🚀 AI Code Review Action 啟動"
|
echo "🚀 AI Code Review Action 啟動"
|
||||||
|
|
||||||
|
# exec node /app/main.js:用 node 進程「取代」目前的 shell 進程來執行
|
||||||
|
# 主程式 main.js(AI code review 的實際邏輯入口)。
|
||||||
|
# 為什麼:使用 exec 而非直接呼叫,可讓 node 成為 PID 1,正確接收
|
||||||
|
# 容器的訊號(如 SIGTERM),達成優雅關閉並避免殭屍 shell。
|
||||||
|
# 副作用:此行之後的任何指令都不會被執行;node 的結束碼即為容器結束碼。
|
||||||
exec node /app/main.js
|
exec node /app/main.js
|
||||||
|
|||||||
嚴重等級:🟡 警告
審查員:Assassin
問題:使用了
alpine:latest作為基底映像。latest標籤極不穩定,當 Alpine Linux 發布新版本時,建置出的映像環境可能會產生難以預期的變更,甚至引入不相容的套件版本,導致建置再現性(Reproducibility)崩潰。建議:建議釘選明確的 Alpine 版本號(例如
alpine:3.20),以確保每次建置的執行環境完全一致且可預測。