Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a904f9ede | ||
|
|
2f54271c38 | ||
|
|
b3878b9860 | ||
|
|
5a07f03abb | ||
|
|
2703840018 | ||
|
|
45b6abacc8 | ||
|
|
0b59ca6023 | ||
|
|
68ce92b0e5 | ||
|
|
f26e9deba7 | ||
|
|
d0b22e8444 | ||
|
|
9ec30e1abb | ||
|
|
34a9eb6d4e | ||
|
|
811f56d715 | ||
|
|
ea2ac46d6c | ||
|
|
d0c1bb0c20 | ||
|
|
6f997083ef | ||
|
|
5457e68065 | ||
|
|
4818e82d76 | ||
|
|
df34376d95 | ||
|
|
e17c25ce39 | ||
|
|
4fcb240208 | ||
|
|
08a72a8c7f | ||
|
|
93be261b90 | ||
|
|
6c3e7b9d37 | ||
|
|
a62bfbd4b8 | ||
|
|
2fe03e94f0 | ||
|
|
bc0df06445 | ||
|
|
4431f2fec5 | ||
|
|
11551f8f58 | ||
|
|
9ec221ebfd | ||
|
|
f0a42cd98d | ||
|
|
998b5ca5ac | ||
|
|
402630fa69 | ||
|
|
fbb74092cb | ||
|
|
bcb07511e7 | ||
|
|
b6a63563ca | ||
|
|
b8a15883ab | ||
|
|
f267d9e06b | ||
|
|
74086e5f23 | ||
|
|
55b5c6f68e | ||
|
|
775cc575ae | ||
|
|
fc10ab7266 | ||
|
|
ef8a0d61c8 | ||
|
|
0b3e974a8f | ||
|
|
3ced8fa688 | ||
|
|
1378f03595 | ||
|
|
303104bb20 |
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"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 修復。改成回 [] 或丟例外會破壞既有呼叫鏈,且型別不一致(回傳值為字串非陣列),故不採納。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"location": "app/config.js:25",
|
||||||
|
"role": "Bard",
|
||||||
|
"original_finding": "`getInsecureHttpsAgent` 與 `getOpenCodeHttpsAgent` 同時存在,卻指向同一個物件來源,等於替同一段旋律寫了兩個名字,容易讓後續維護者搞不清楚哪個才是正規稱呼。",
|
||||||
|
"reason": "相容性保留:`getOpenCodeHttpsAgent` 是既有公開匯出名稱,README 與測試仍引用此語意名稱;本次已補上過渡別名註解,說明新程式碼應優先使用 `getInsecureHttpsAgent`,避免破壞既有呼叫端。"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -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,57 @@
|
|||||||
|
# ============================================================
|
||||||
|
# 用途: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:
|
||||||
ai-code-review:
|
release-tag-version:
|
||||||
name: AI Code Review
|
name: Release Tag Version
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
permissions:
|
outputs:
|
||||||
contents: write
|
version: ${{ steps.release-tag-version.outputs.version }}
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: AI 程式碼審查 by OpenCode
|
- name: 計算版本號
|
||||||
uses: https://gitea.jsc.idv.tw/composite-actions/opencode-code-review@${{ vars.ACTION_OPENCODE_CODE_REVIEW_VERSION }}
|
id: release-tag-version
|
||||||
|
uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }}
|
||||||
with:
|
with:
|
||||||
comment_token: ${{ secrets.COMMENT_TOKEN }}
|
is_beta: true
|
||||||
|
# job 識別碼:ai-code-review
|
||||||
|
ai-code-review:
|
||||||
|
# job 顯示名稱
|
||||||
|
name: AI Code Review
|
||||||
|
needs: release-tag-version
|
||||||
|
# 指定執行環境的 runner 標籤:ubuntu
|
||||||
|
runs-on: ubuntu
|
||||||
|
# job 的執行步驟
|
||||||
|
steps:
|
||||||
|
- name: 取得程式碼
|
||||||
|
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
|
||||||
|
- name: AI 程式碼審查
|
||||||
|
# 使用外部 composite action 執行審查邏輯。
|
||||||
|
# 版本由 release-tag-version 計算,便於在 PR 中測試目前 action 版本。
|
||||||
|
uses: https://gitea.jsc.idv.tw/docker-actions/ai-code-review@v${{ needs.release-tag-version.outputs.version }}
|
||||||
|
# 此 job 所需的權限設定
|
||||||
|
permissions:
|
||||||
|
# 對 repository 內容的寫入權限(讀寫程式碼/檔案)
|
||||||
|
contents: write
|
||||||
|
# 對 pull request 的寫入權限(讓 AI 可在 PR 上留言/審查)
|
||||||
|
pull-requests: write
|
||||||
|
# 對 issues 的寫入權限(建立/更新 issue 留言所需)
|
||||||
|
issues: write
|
||||||
|
# 傳遞給 composite action 的輸入參數
|
||||||
|
with:
|
||||||
|
# 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言
|
||||||
|
token: ${{ secrets.TOKEN }}
|
||||||
|
model: ${{ vars.AI_CODE_REVIEW_MODEL }}
|
||||||
|
|||||||
+6
-4
@@ -1,12 +1,14 @@
|
|||||||
FROM alpine:latest
|
FROM gitea.jsc.idv.tw/images/codex:0.1.2
|
||||||
|
|
||||||
# 安裝必要的工具
|
RUN apt update \
|
||||||
RUN apk add --no-cache --no-check-certificate bash git nodejs npm
|
&& apt install -y --no-install-recommends git \
|
||||||
|
&& apt clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY ./app /app
|
COPY ./app /app
|
||||||
RUN cd /app && npm install
|
RUN cd /app && npm install
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY ./entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
+9
-14
@@ -2,30 +2,25 @@ name: 'AI Code Review'
|
|||||||
description: 'AI 程式碼審查'
|
description: 'AI 程式碼審查'
|
||||||
author: 'Jeffery'
|
author: 'Jeffery'
|
||||||
inputs:
|
inputs:
|
||||||
|
token:
|
||||||
|
description: ''
|
||||||
|
required: false
|
||||||
comment_token:
|
comment_token:
|
||||||
description: ''
|
description: ''
|
||||||
|
required: false
|
||||||
|
model:
|
||||||
|
description: '要使用的 AI 助理 CLI 模型識別字串'
|
||||||
required: true
|
required: true
|
||||||
opencode_base_url:
|
|
||||||
description: 'OpenCode server Base URL'
|
|
||||||
required: false
|
|
||||||
opencode_model:
|
|
||||||
description: 'OpenCode model id'
|
|
||||||
required: false
|
|
||||||
opencode_provider:
|
|
||||||
description: 'OpenCode server provider id'
|
|
||||||
required: false
|
|
||||||
runs:
|
runs:
|
||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'Dockerfile'
|
image: 'Dockerfile'
|
||||||
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 }}
|
||||||
PR_BASE_BRANCH: ${{ gitea.event.pull_request.base.ref }}
|
PR_BASE_BRANCH: ${{ gitea.event.pull_request.base.ref }}
|
||||||
OPENCODE_BASE_URL: ${{ inputs.opencode_base_url }}
|
MODEL: ${{ inputs.model }}
|
||||||
OPENCODE_MODEL: ${{ inputs.opencode_model }}
|
|
||||||
OPENCODE_PROVIDER: ${{ inputs.opencode_provider }}
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
+107
-3
@@ -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 表格的一列(等級|審查員|位置|建議)。
|
||||||
|
*
|
||||||
|
* @param {{ level?: string, role?: string, location?: string, suggestion?: string }} f
|
||||||
|
* 單筆審查問題物件。`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。
|
||||||
|
* @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;
|
||||||
|
|||||||
+84
-10
@@ -1,4 +1,9 @@
|
|||||||
import https from 'https';
|
import https from 'https';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
// 本 action 會連接自架 Gitea / OpenCode,部署環境可能使用內部 CA 或自簽憑證。
|
||||||
|
// 對外部服務請優先使用預設 TLS 驗證;需要內部服務相容時才使用 getInsecureHttpsAgent()。
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
export const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
export const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
|
||||||
export const GITEA_COMMENT_TOKEN = process.env.GITEA_COMMENT_TOKEN || '';
|
export const GITEA_COMMENT_TOKEN = process.env.GITEA_COMMENT_TOKEN || '';
|
||||||
@@ -12,18 +17,87 @@ 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';
|
||||||
|
|
||||||
export function getOpenCodeHttpsAgent() {
|
/**
|
||||||
return new https.Agent({ rejectUnauthorized: false });
|
* 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent,
|
||||||
|
* 供連接使用自簽或無效憑證的內部服務時使用。
|
||||||
|
*
|
||||||
|
* @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用,
|
||||||
|
* 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。
|
||||||
|
* 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。
|
||||||
|
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
|
||||||
|
*/
|
||||||
|
let _insecureHttpsAgent = null;
|
||||||
|
export function getInsecureHttpsAgent() {
|
||||||
|
return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLLMConfig() {
|
// 過渡別名:既有呼叫端仍可用 OpenCode 語意名稱;新程式碼請直接使用 getInsecureHttpsAgent。
|
||||||
if (process.env.OPENCODE_BASE_URL) {
|
export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
|
||||||
return {
|
|
||||||
|
const CLI_CANDIDATES = [
|
||||||
|
{
|
||||||
|
provider: 'codex',
|
||||||
|
command: 'codex',
|
||||||
|
defaultModel: 'gpt-5.4-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'claude',
|
||||||
|
command: 'claude',
|
||||||
|
defaultModel: 'sonnet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'antigravity',
|
||||||
|
command: 'agy',
|
||||||
|
defaultModel: 'gemini-2.5-flash',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'antigravity',
|
||||||
|
command: 'antigravity',
|
||||||
|
defaultModel: 'gemini-2.5-flash',
|
||||||
|
},
|
||||||
|
{
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
apiKeys: ['opencode'],
|
command: 'opencode',
|
||||||
baseURL: process.env.OPENCODE_BASE_URL,
|
defaultModel: 'google/gemini-2.5-flash',
|
||||||
model: process.env.OPENCODE_MODEL || 'gemini-2.5-flash',
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getLLMCLICommands() {
|
||||||
|
return CLI_CANDIDATES.map(c => c.command);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandExists(command) {
|
||||||
|
try {
|
||||||
|
execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依環境變數解析並回傳 LLM 提供者設定。
|
||||||
|
*
|
||||||
|
* 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、antigravity、opencode。
|
||||||
|
* model 優先取 `MODEL`,再相容舊的 `OPENCODE_MODEL`,最後使用各 CLI 預設值。
|
||||||
|
*
|
||||||
|
* @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 CLI 偵測函式,供測試使用。
|
||||||
|
* @returns {{ provider: ('codex'|'claude'|'antigravity'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }}
|
||||||
|
* LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。
|
||||||
|
*/
|
||||||
|
export function getLLMConfig({ commandExistsFn = commandExists } = {}) {
|
||||||
|
const requested = process.env.AI_ASSISTANT_CLI;
|
||||||
|
const candidates = requested
|
||||||
|
? CLI_CANDIDATES.filter(c => c.provider === requested || c.command === requested)
|
||||||
|
: CLI_CANDIDATES;
|
||||||
|
const cli = candidates.find(c => commandExistsFn(c.command));
|
||||||
|
if (!cli) return { provider: null, apiKeys: [], baseURL: null, model: null, command: null };
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: cli.provider,
|
||||||
|
apiKeys: [cli.provider],
|
||||||
|
baseURL: null,
|
||||||
|
model: process.env.MODEL || process.env.OPENCODE_MODEL || cli.defaultModel,
|
||||||
|
command: cli.command,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { provider: null, apiKeys: [], baseURL: null, model: null };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
|
||||||
|
|
||||||
const ENV_KEYS = [
|
|
||||||
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
|
||||||
];
|
|
||||||
|
|
||||||
let saved = {};
|
|
||||||
beforeEach(() => {
|
|
||||||
saved = {};
|
|
||||||
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
for (const k of ENV_KEYS) {
|
|
||||||
if (saved[k] === undefined) delete process.env[k];
|
|
||||||
else process.env[k] = saved[k];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getLLMConfig', () => {
|
|
||||||
it('returns null provider when no env vars set', () => {
|
|
||||||
const cfg = getLLMConfig();
|
|
||||||
assert.equal(cfg.provider, null);
|
|
||||||
assert.deepEqual(cfg.apiKeys, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects opencode server with defaults', () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
const cfg = getLLMConfig();
|
|
||||||
assert.equal(cfg.provider, 'opencode');
|
|
||||||
assert.deepEqual(cfg.apiKeys, ['opencode']);
|
|
||||||
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
|
|
||||||
assert.equal(cfg.model, 'gemini-2.5-flash');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects opencode server with custom model', () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
|
|
||||||
const cfg = getLLMConfig();
|
|
||||||
assert.equal(cfg.provider, 'opencode');
|
|
||||||
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
|
|
||||||
assert.equal(cfg.model, 'google/gemini-2.5-pro');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses an insecure HTTPS agent for OpenCode', () => {
|
|
||||||
const agent = getOpenCodeHttpsAgent();
|
|
||||||
assert.equal(agent.options.rejectUnauthorized, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
+105
-3
@@ -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}
|
||||||
|
* @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)。
|
||||||
|
* @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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-4
@@ -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,36 @@ 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 +85,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 +106,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 +126,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。
|
||||||
|
*/
|
||||||
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 +192,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。
|
||||||
|
*
|
||||||
|
* 失敗策略: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 +256,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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-19
@@ -1,12 +1,28 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import https from 'https';
|
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH, getInsecureHttpsAgent } from './config.js';
|
||||||
import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js';
|
|
||||||
import { line, warn } from './log.js';
|
import { line, warn } from './log.js';
|
||||||
|
|
||||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
const httpsAgent = getInsecureHttpsAgent();
|
||||||
|
/**
|
||||||
|
* 產生呼叫 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 +30,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 +57,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 +78,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 +100,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 +120,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 +136,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 +153,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 +177,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 +200,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 +214,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 +228,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 +249,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 +265,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 {
|
||||||
|
|||||||
+66
-13
@@ -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 });
|
||||||
|
|||||||
+152
-53
@@ -1,70 +1,140 @@
|
|||||||
import axios from 'axios';
|
import * as childProcess from 'child_process';
|
||||||
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
import { mkdtemp, writeFile, rm } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { getLLMConfig } from './config.js';
|
||||||
import { recordUsage } from './usage.js';
|
import { recordUsage } from './usage.js';
|
||||||
import { line, error } from './log.js';
|
import { line } from './log.js';
|
||||||
|
|
||||||
function opencodeModelConfig(model) {
|
/**
|
||||||
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
||||||
return { providerID, modelID };
|
*/
|
||||||
|
function buildPrompt(systemPrompt, userContent) {
|
||||||
|
return [
|
||||||
|
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
|
||||||
|
'',
|
||||||
|
'<system>',
|
||||||
|
systemPrompt,
|
||||||
|
'</system>',
|
||||||
|
'',
|
||||||
|
'<user>',
|
||||||
|
userContent,
|
||||||
|
'</user>',
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function opencodeAxiosOptions(headers) {
|
function cliArgs({ provider, model, promptFile = null, prompt = null }) {
|
||||||
return {
|
if (provider === 'codex') {
|
||||||
headers,
|
return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
|
||||||
httpsAgent: getOpenCodeHttpsAgent(),
|
}
|
||||||
|
if (provider === 'claude') {
|
||||||
|
return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
|
||||||
|
}
|
||||||
|
if (provider === 'antigravity') {
|
||||||
|
return ['-p', prompt, '--model', model];
|
||||||
|
}
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。'];
|
||||||
|
}
|
||||||
|
throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeCliError(e) {
|
||||||
|
const stderr = String(e.stderr || '').trim();
|
||||||
|
const stdout = String(e.stdout || '').trim();
|
||||||
|
return (stderr || stdout || e.message || String(e)).slice(0, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAssistantCLI({ provider, command, model }, prompt) {
|
||||||
|
let tempDir = null;
|
||||||
|
let promptFile = null;
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), 'ai-review-prompt-'));
|
||||||
|
promptFile = join(tempDir, 'prompt.md');
|
||||||
|
await writeFile(promptFile, prompt);
|
||||||
|
}
|
||||||
|
const args = cliArgs({ provider, model, promptFile, prompt });
|
||||||
|
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
|
||||||
|
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
|
||||||
|
try {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const child = childProcess.spawn(command, args, { env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
settled = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
const append = (kind, chunk) => {
|
||||||
|
if (kind === 'stdout') stdout += chunk;
|
||||||
|
else stderr += chunk;
|
||||||
|
if (stdout.length + stderr.length > maxBuffer) {
|
||||||
|
settled = true;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
reject(new Error(`${provider} CLI 輸出超過 ${maxBuffer} bytes`));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
child.stdout.setEncoding('utf8');
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stdout.on('data', chunk => append('stdout', chunk));
|
||||||
|
child.stderr.on('data', chunk => append('stderr', chunk));
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code, signal) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (settled) return;
|
||||||
|
if (code === 0) resolve(stdout.trim());
|
||||||
|
else reject(Object.assign(new Error(`${provider} CLI exited with ${code ?? signal}`), { stdout, stderr }));
|
||||||
|
});
|
||||||
|
child.stdin.end(provider === 'opencode' || provider === 'antigravity' ? '' : prompt);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractOpenCodeContent(data) {
|
/**
|
||||||
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
|
* 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應。
|
||||||
return parts
|
*
|
||||||
.map(part => part.text || part.content || '')
|
* 從設定取得 provider/command/model;未偵測到 CLI 時拋錯。成功時記錄一次
|
||||||
.filter(Boolean)
|
* usage 呼叫(CLI 通常不回傳 token 明細,因此 token 可能為 0)並回傳內容。
|
||||||
.join('');
|
*
|
||||||
}
|
* @param {string} systemPrompt - 系統提示詞。
|
||||||
|
* @param {string} userContent - 使用者輸入內容。
|
||||||
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
|
* @returns {Promise<string>} 模型回應的純文字內容。
|
||||||
const base = baseURL.replace(/\/$/, '');
|
* @throws {Error} 當未偵測到可用 AI 助理 CLI,或 CLI 呼叫失敗時。
|
||||||
const { providerID, modelID } = opencodeModelConfig(model);
|
*/
|
||||||
const session = await axios.post(
|
|
||||||
`${base}/session`,
|
|
||||||
{ title: 'AI Code Review', model: { providerID, id: modelID } },
|
|
||||||
opencodeAxiosOptions(headers)
|
|
||||||
);
|
|
||||||
const sessionID = session.data.id || session.data.data?.id;
|
|
||||||
if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id');
|
|
||||||
|
|
||||||
const resp = await axios.post(
|
|
||||||
`${base}/session/${sessionID}/message`,
|
|
||||||
{
|
|
||||||
model: { providerID, modelID },
|
|
||||||
system: systemPrompt,
|
|
||||||
parts: [{ type: 'text', text: userContent }],
|
|
||||||
},
|
|
||||||
opencodeAxiosOptions(headers)
|
|
||||||
);
|
|
||||||
return { content: extractOpenCodeContent(resp.data), data: resp.data };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL');
|
const { provider, command, model } = cfg;
|
||||||
|
if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode');
|
||||||
|
|
||||||
line(`[LLM] provider=${provider} model=${model}`);
|
line(`[LLM] provider=${provider} command=${command} model=${model}`);
|
||||||
|
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
|
const content = await runAssistantCLI(cfg, buildPrompt(systemPrompt, userContent));
|
||||||
recordUsage(data);
|
recordUsage(null);
|
||||||
return content;
|
return content;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[LLM] OpenCode 呼叫失敗: ${e.message}`);
|
const message = summarizeCliError(e);
|
||||||
|
line(`[LLM] ${provider} CLI 呼叫失敗: ${message}`);
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
error('[LLM] OpenCode 呼叫失敗,終止流程');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 對 AI 助理 CLI 送出對話並將回應解析為 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 +145,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 +162,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 +205,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);
|
||||||
|
|||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const ENV_KEYS = [
|
|
||||||
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
|
||||||
];
|
|
||||||
|
|
||||||
let saved = {};
|
|
||||||
beforeEach(() => {
|
|
||||||
saved = {};
|
|
||||||
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
for (const k of ENV_KEYS) {
|
|
||||||
if (saved[k] === undefined) delete process.env[k];
|
|
||||||
else process.env[k] = saved[k];
|
|
||||||
}
|
|
||||||
mock.restoreAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
function mockOpenCodeResponse(content) {
|
|
||||||
let calls = 0;
|
|
||||||
mock.method(axios, 'post', async () => {
|
|
||||||
calls += 1;
|
|
||||||
if (calls === 1) return { data: { id: 'ses_test' } };
|
|
||||||
return { data: { parts: [{ type: 'text', text: content }] } };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('chat - OpenCode', async () => {
|
|
||||||
const { chat } = await import('./llm.js');
|
|
||||||
|
|
||||||
it('uses OpenCode server session API', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
process.env.OPENCODE_PROVIDER = 'google';
|
|
||||||
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
|
|
||||||
const calls = [];
|
|
||||||
mock.method(axios, 'post', async (url, payload, opts) => {
|
|
||||||
calls.push({ url, payload, headers: opts.headers });
|
|
||||||
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
|
|
||||||
return { data: { parts: [{ type: 'text', text: 'opencode response' }] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await chat('sys', 'user');
|
|
||||||
|
|
||||||
assert.equal(result, 'opencode response');
|
|
||||||
assert.equal(calls[0].url, 'http://opencode.local:4096/session');
|
|
||||||
assert.deepEqual(calls[0].payload.model, { providerID: 'google', id: 'gemini-2.5-flash' });
|
|
||||||
assert.equal(calls[1].url, 'http://opencode.local:4096/session/ses_test/message');
|
|
||||||
assert.deepEqual(calls[1].payload.model, { providerID: 'google', modelID: 'gemini-2.5-flash' });
|
|
||||||
assert.equal(calls[1].payload.system, 'sys');
|
|
||||||
assert.deepEqual(calls[1].payload.parts, [{ type: 'text', text: 'user' }]);
|
|
||||||
assert.equal(calls[1].headers['Authorization'], undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes an insecure https agent to OpenCode by default', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
|
||||||
const agents = [];
|
|
||||||
mock.method(axios, 'post', async (url, _payload, opts) => {
|
|
||||||
agents.push(opts.httpsAgent);
|
|
||||||
if (url.endsWith('/session')) return { data: { id: 'ses_test' } };
|
|
||||||
return { data: { parts: [{ type: 'text', text: 'ok' }] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
await chat('sys', 'user');
|
|
||||||
|
|
||||||
assert.equal(agents.length, 2);
|
|
||||||
assert.equal(agents[0].options.rejectUnauthorized, false);
|
|
||||||
assert.equal(agents[1].options.rejectUnauthorized, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts text from OpenCode message parts', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
let calls = 0;
|
|
||||||
mock.method(axios, 'post', async () => {
|
|
||||||
calls += 1;
|
|
||||||
if (calls === 1) return { data: { id: 'ses_test' } };
|
|
||||||
return { data: { parts: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await chat('sys', 'user');
|
|
||||||
|
|
||||||
assert.equal(result, 'hello world');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls process.exit(1) when OpenCode fails', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
mock.method(axios, 'post', async () => { throw new Error('fail'); });
|
|
||||||
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); });
|
|
||||||
|
|
||||||
await assert.rejects(() => chat('sys', 'user'), /exit:1/);
|
|
||||||
|
|
||||||
assert.equal(exitMock.mock.calls[0].arguments[0], 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('chatJSON', async () => {
|
|
||||||
const { chatJSON } = await import('./llm.js');
|
|
||||||
|
|
||||||
it('parses plain JSON response', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
mockOpenCodeResponse('[{"level":"critical"}]');
|
|
||||||
|
|
||||||
const result = await chatJSON('sys', 'user');
|
|
||||||
|
|
||||||
assert.deepEqual(result, [{ level: 'critical' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('strips markdown code block before parsing', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
mockOpenCodeResponse('```json\n[{"level":"info"}]\n```');
|
|
||||||
|
|
||||||
const result = await chatJSON('sys', 'user');
|
|
||||||
|
|
||||||
assert.deepEqual(result, [{ level: 'info' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts JSON array from surrounding prose', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
mockOpenCodeResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.');
|
|
||||||
|
|
||||||
const result = await chatJSON('sys', 'user');
|
|
||||||
|
|
||||||
assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts JSON object from surrounding prose', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
mockOpenCodeResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}');
|
|
||||||
|
|
||||||
const result = await chatJSON('sys', 'user');
|
|
||||||
|
|
||||||
assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns [] when JSON is invalid', async () => {
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
mockOpenCodeResponse('not json');
|
|
||||||
|
|
||||||
const result = await chatJSON('sys', 'user');
|
|
||||||
|
|
||||||
assert.deepEqual(result, []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+72
-3
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-4
@@ -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');
|
||||||
|
|
||||||
@@ -82,11 +120,20 @@ async function main() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
const analyses = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff)));
|
|
||||||
const newFindings = [];
|
const newFindings = [];
|
||||||
for (let i = 0; i < analyses.length; i++) {
|
let fulfilledAnalyses = 0;
|
||||||
if (analyses[i].status === 'fulfilled') newFindings.push(...analyses[i].value);
|
for (const role of roles) {
|
||||||
else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`);
|
try {
|
||||||
|
const findings = await analyzeWithRole(role, diff);
|
||||||
|
fulfilledAnalyses += 1;
|
||||||
|
newFindings.push(...findings);
|
||||||
|
} catch (e) {
|
||||||
|
warn(`[${role.name}] 分析失敗(跳過): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fulfilledAnalyses === 0) {
|
||||||
|
result(false, '所有角色分析皆失敗,終止流程以避免誤判為審查通過');
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
// 對只有檔名、缺行號的問題,反問原角色補上行號(最多重試數次),確保後續能行內標註
|
// 對只有檔名、缺行號的問題,反問原角色補上行號(最多重試數次),確保後續能行內標註
|
||||||
await resolveMissingLineNumbers(newFindings, diff);
|
await resolveMissingLineNumbers(newFindings, diff);
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
|
|||||||
+79
-40
@@ -1,36 +1,56 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import https from 'https';
|
|
||||||
import {
|
import {
|
||||||
GITEA_TOKEN,
|
GITEA_TOKEN,
|
||||||
GITEA_COMMENT_TOKEN,
|
GITEA_COMMENT_TOKEN,
|
||||||
GITEA_SERVER_URL,
|
GITEA_SERVER_URL,
|
||||||
GITEA_REPOSITORY,
|
GITEA_REPOSITORY,
|
||||||
PR_NUMBER,
|
PR_NUMBER,
|
||||||
getOpenCodeHttpsAgent,
|
getInsecureHttpsAgent,
|
||||||
getLLMConfig,
|
getLLMConfig,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { verifyRemoteAccess } from './git.js';
|
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 = getInsecureHttpsAgent();
|
||||||
|
/**
|
||||||
|
* 組出 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' });
|
||||||
const opencodeModelConfig = (model) => {
|
/**
|
||||||
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
|
* 將(axios)錯誤格式化為易讀的訊息字串。
|
||||||
return { providerID, modelID };
|
*
|
||||||
};
|
* 有 HTTP 回應狀態碼時輸出 `HTTP <status> <message>`,否則僅輸出 message。
|
||||||
const opencodeAxiosOptions = (headers) => ({
|
* @param {Error & {response?: {status?: number}, message: string}} e - 捕捉到的錯誤物件。
|
||||||
headers,
|
* @returns {string} 格式化後的錯誤描述。
|
||||||
timeout: 30000,
|
*/
|
||||||
httpsAgent: getOpenCodeHttpsAgent(),
|
|
||||||
});
|
|
||||||
|
|
||||||
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 +59,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 +77,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,34 +96,38 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 驗證 LLM 設定可用:
|
* 驗證 LLM(AI 助理 CLI)設定可用。
|
||||||
* - 僅支援 OpenCode server
|
*
|
||||||
* - 檢查 OpenCode base URL 是否可連線,並確認 provider/model 已設定
|
* 確認目前環境可偵測到支援的 CLI,且已解析出 model。實際模型可用性由 CLI
|
||||||
|
* 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度。
|
||||||
|
* @returns {Promise<
|
||||||
|
* {ok: true, provider: string, command: string, model: string} |
|
||||||
|
* {ok: false, provider?: string, error: string}
|
||||||
|
* >}
|
||||||
|
* 通過時含 provider、command 與 model;未設定 provider 的失敗分支不含 provider 欄位。
|
||||||
|
* @remarks 設定來源為 config.js 的 getLLMConfig()。
|
||||||
*/
|
*/
|
||||||
export async function verifyLLM() {
|
export async function verifyLLM() {
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, command, model } = getLLMConfig();
|
||||||
if (!provider) return { ok: false, error: '未設定 OpenCode server,請設定 OPENCODE_BASE_URL' };
|
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' };
|
||||||
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
|
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
|
||||||
|
return { ok: true, provider, command, model };
|
||||||
const base = baseURL.replace(/\/$/, '');
|
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
|
||||||
|
|
||||||
const { providerID, modelID } = opencodeModelConfig(model);
|
|
||||||
try {
|
|
||||||
await axios.get(`${base}/global/health`, opencodeAxiosOptions(headers));
|
|
||||||
const providers = await axios.get(`${base}/config/providers`, opencodeAxiosOptions(headers));
|
|
||||||
const configuredProvider = providers.data.providers?.find(p => p.id === providerID);
|
|
||||||
if (!configuredProvider) return { ok: false, provider, error: `OpenCode server 未設定 provider=${providerID}` };
|
|
||||||
if (!configuredProvider.models?.[modelID]) return { ok: false, provider, error: `OpenCode server provider=${providerID} 未列出 model=${modelID}` };
|
|
||||||
return { ok: true, provider };
|
|
||||||
} catch (e) {
|
|
||||||
return { ok: false, provider, error: `OpenCode server 驗證失敗: ${e.message}` };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。
|
* 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM CLI。
|
||||||
* 僅做唯讀的認證/連線確認,不發布任何 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(AI 助理 CLI)驗證。
|
||||||
|
* @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 {
|
||||||
@@ -134,7 +173,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
|
|||||||
error(`LLM 驗證失敗: ${llm.error}`);
|
error(`LLM 驗證失敗: ${llm.error}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
ok(`LLM provider=${llm.provider} 連線正常`);
|
ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model})`);
|
||||||
|
|
||||||
result(true, '前置驗證通過');
|
result(true, '前置驗證通過');
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+49
-6
@@ -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 表示為延續的舊問題。
|
||||||
|
* 若該對話無 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)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-14
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
||||||
|
|
||||||
|
const ENV_KEYS = [
|
||||||
|
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
let saved = {};
|
||||||
|
beforeEach(() => {
|
||||||
|
saved = {};
|
||||||
|
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of ENV_KEYS) {
|
||||||
|
if (saved[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = saved[k];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLLMConfig', () => {
|
||||||
|
it('exports the supported assistant CLI commands', () => {
|
||||||
|
assert.deepEqual(getLLMCLICommands(), ['codex', 'claude', 'agy', 'antigravity', 'opencode']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null provider when no env vars set', () => {
|
||||||
|
const cfg = getLLMConfig({ commandExistsFn: () => false });
|
||||||
|
assert.equal(cfg.provider, null);
|
||||||
|
assert.deepEqual(cfg.apiKeys, []);
|
||||||
|
assert.equal(cfg.command, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects the first installed assistant CLI with defaults', () => {
|
||||||
|
const cfg = getLLMConfig({ commandExistsFn: command => command === 'claude' });
|
||||||
|
assert.equal(cfg.provider, 'claude');
|
||||||
|
assert.deepEqual(cfg.apiKeys, ['claude']);
|
||||||
|
assert.equal(cfg.baseURL, null);
|
||||||
|
assert.equal(cfg.command, 'claude');
|
||||||
|
assert.equal(cfg.model, 'sonnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses MODEL for the selected assistant CLI', () => {
|
||||||
|
process.env.MODEL = 'gpt-5-mini';
|
||||||
|
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' });
|
||||||
|
assert.equal(cfg.provider, 'codex');
|
||||||
|
assert.equal(cfg.command, 'codex');
|
||||||
|
assert.equal(cfg.model, 'gpt-5-mini');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Antigravity through the agy command', () => {
|
||||||
|
const cfg = getLLMConfig({ commandExistsFn: command => command === 'agy' });
|
||||||
|
assert.equal(cfg.provider, 'antigravity');
|
||||||
|
assert.equal(cfg.command, 'agy');
|
||||||
|
assert.equal(cfg.model, 'gemini-2.5-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can force a CLI with AI_ASSISTANT_CLI', () => {
|
||||||
|
process.env.AI_ASSISTANT_CLI = 'opencode';
|
||||||
|
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
|
||||||
|
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' || command === 'opencode' });
|
||||||
|
assert.equal(cfg.provider, 'opencode');
|
||||||
|
assert.equal(cfg.command, 'opencode');
|
||||||
|
assert.equal(cfg.model, 'google/gemini-2.5-pro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an insecure HTTPS agent for OpenCode', () => {
|
||||||
|
const agent = getOpenCodeHttpsAgent();
|
||||||
|
assert.equal(agent.options.rejectUnauthorized, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables Node TLS certificate verification globally', () => {
|
||||||
|
assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -84,6 +96,7 @@ describe('commitAndPush', () => {
|
|||||||
|
|
||||||
for (const { args, opts } of networkCalls) {
|
for (const { args, opts } of networkCalls) {
|
||||||
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
|
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
|
||||||
|
assert.equal(opts.env.GIT_SSL_NO_VERIFY, undefined, `GIT_SSL_NO_VERIFY must not be forced for git ${args[0]}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,6 +209,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', () => {
|
||||||
@@ -237,6 +267,7 @@ describe('cloneRepo', () => {
|
|||||||
assert.ok(networkCalls.length > 0, 'expected at least one network git call');
|
assert.ok(networkCalls.length > 0, 'expected at least one network git call');
|
||||||
for (const { args, opts } of networkCalls) {
|
for (const { args, opts } of networkCalls) {
|
||||||
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
|
assert.ok(opts?.env?.GIT_ASKPASS, `GIT_ASKPASS missing for git ${args[0]}`);
|
||||||
|
assert.equal(opts.env.GIT_SSL_NO_VERIFY, undefined, `GIT_SSL_NO_VERIFY must not be forced for git ${args[0]}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,6 +310,7 @@ describe('verifyRemoteAccess', () => {
|
|||||||
const lsRemote = calls.find(c => c.args[0] === 'ls-remote');
|
const lsRemote = calls.find(c => c.args[0] === 'ls-remote');
|
||||||
assert.ok(lsRemote, 'expected git ls-remote to run');
|
assert.ok(lsRemote, 'expected git ls-remote to run');
|
||||||
assert.ok(lsRemote.opts?.env?.GIT_ASKPASS, 'expected GIT_ASKPASS env for ls-remote');
|
assert.ok(lsRemote.opts?.env?.GIT_ASKPASS, 'expected GIT_ASKPASS env for ls-remote');
|
||||||
|
assert.equal(lsRemote.opts.env.GIT_SSL_NO_VERIFY, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not leak the token in ls-remote args', () => {
|
it('does not leak the token in ls-remote args', () => {
|
||||||
@@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { extractBalancedJSON, extractJSONText } from '../llm.js';
|
||||||
|
|
||||||
|
const ENV_KEYS = [
|
||||||
|
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
||||||
|
'FAKE_AI_STDOUT', 'FAKE_AI_STDERR', 'FAKE_AI_EXIT', 'FAKE_AI_STDIN_PATH', 'FAKE_AI_ARGS_PATH',
|
||||||
|
];
|
||||||
|
|
||||||
|
let saved = {};
|
||||||
|
let tempDir;
|
||||||
|
beforeEach(() => {
|
||||||
|
saved = {};
|
||||||
|
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||||
|
tempDir = null;
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const k of ENV_KEYS) {
|
||||||
|
if (saved[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = saved[k];
|
||||||
|
}
|
||||||
|
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||||||
|
mock.restoreAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function installFakeCLI(command = 'codex') {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), 'ai-cli-test-'));
|
||||||
|
const script = join(tempDir, command);
|
||||||
|
await writeFile(script, `#!/bin/sh
|
||||||
|
if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi
|
||||||
|
if [ -n "$FAKE_AI_STDIN_PATH" ]; then /bin/cat > "$FAKE_AI_STDIN_PATH"; else /bin/cat >/dev/null; fi
|
||||||
|
if [ -n "$FAKE_AI_STDERR" ]; then printf '%s' "$FAKE_AI_STDERR" >&2; fi
|
||||||
|
if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi
|
||||||
|
exit "\${FAKE_AI_EXIT:-0}"
|
||||||
|
`);
|
||||||
|
await chmod(script, 0o755);
|
||||||
|
process.env.PATH = tempDir;
|
||||||
|
return { stdinPath: join(tempDir, 'stdin.txt'), argsPath: join(tempDir, 'args.txt') };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('chat - assistant CLI', async () => {
|
||||||
|
const { chat } = await import('../llm.js');
|
||||||
|
|
||||||
|
it('runs the detected CLI with MODEL and sends the prompts through stdin', async () => {
|
||||||
|
const { stdinPath, argsPath } = await installFakeCLI('codex');
|
||||||
|
process.env.MODEL = 'gpt-5-mini';
|
||||||
|
process.env.FAKE_AI_STDOUT = 'cli response';
|
||||||
|
process.env.FAKE_AI_STDIN_PATH = stdinPath;
|
||||||
|
process.env.FAKE_AI_ARGS_PATH = argsPath;
|
||||||
|
|
||||||
|
const result = await chat('sys', 'user');
|
||||||
|
|
||||||
|
assert.equal(result, 'cli response');
|
||||||
|
assert.match(await readFile(argsPath, 'utf8'), /exec --model gpt-5-mini/);
|
||||||
|
const prompt = await readFile(stdinPath, 'utf8');
|
||||||
|
assert.match(prompt, /<system>\nsys\n<\/system>/);
|
||||||
|
assert.match(prompt, /<user>\nuser\n<\/user>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can force opencode with AI_ASSISTANT_CLI', async () => {
|
||||||
|
const { argsPath } = await installFakeCLI('opencode');
|
||||||
|
process.env.AI_ASSISTANT_CLI = 'opencode';
|
||||||
|
process.env.MODEL = 'google/gemini-2.5-pro';
|
||||||
|
process.env.FAKE_AI_STDOUT = 'ok';
|
||||||
|
process.env.FAKE_AI_ARGS_PATH = argsPath;
|
||||||
|
|
||||||
|
const result = await chat('sys', 'user');
|
||||||
|
|
||||||
|
assert.equal(result, 'ok');
|
||||||
|
assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs Antigravity through agy with MODEL and prompt argument', async () => {
|
||||||
|
const { stdinPath, argsPath } = await installFakeCLI('agy');
|
||||||
|
process.env.AI_ASSISTANT_CLI = 'agy';
|
||||||
|
process.env.MODEL = 'gemini-2.5-pro';
|
||||||
|
process.env.FAKE_AI_STDOUT = 'antigravity response';
|
||||||
|
process.env.FAKE_AI_STDIN_PATH = stdinPath;
|
||||||
|
process.env.FAKE_AI_ARGS_PATH = argsPath;
|
||||||
|
|
||||||
|
const result = await chat('sys', 'user');
|
||||||
|
|
||||||
|
assert.equal(result, 'antigravity response');
|
||||||
|
const args = await readFile(argsPath, 'utf8');
|
||||||
|
assert.match(args, /-p .*--model gemini-2.5-pro/s);
|
||||||
|
assert.match(args, /<system>\nsys\n<\/system>/);
|
||||||
|
assert.equal(await readFile(stdinPath, 'utf8'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws an error when the CLI fails instead of exiting the process', async () => {
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.FAKE_AI_EXIT = '2';
|
||||||
|
process.env.FAKE_AI_STDERR = 'provider overloaded';
|
||||||
|
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit should not be called'); });
|
||||||
|
|
||||||
|
await assert.rejects(() => chat('sys', 'user'), /provider overloaded/);
|
||||||
|
|
||||||
|
assert.equal(exitMock.mock.calls.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chatJSON', async () => {
|
||||||
|
const { chatJSON } = await import('../llm.js');
|
||||||
|
|
||||||
|
it('parses plain JSON response', async () => {
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.FAKE_AI_STDOUT = '[{"level":"critical"}]';
|
||||||
|
|
||||||
|
const result = await chatJSON('sys', 'user');
|
||||||
|
|
||||||
|
assert.deepEqual(result, [{ level: 'critical' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips markdown code block before parsing', async () => {
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.FAKE_AI_STDOUT = '```json\n[{"level":"info"}]\n```';
|
||||||
|
|
||||||
|
const result = await chatJSON('sys', 'user');
|
||||||
|
|
||||||
|
assert.deepEqual(result, [{ level: 'info' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts JSON array from surrounding prose', async () => {
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.FAKE_AI_STDOUT = '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.';
|
||||||
|
|
||||||
|
const result = await chatJSON('sys', 'user');
|
||||||
|
|
||||||
|
assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts JSON object from surrounding prose', async () => {
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.FAKE_AI_STDOUT = '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}';
|
||||||
|
|
||||||
|
const result = await chatJSON('sys', 'user');
|
||||||
|
|
||||||
|
assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] when JSON is invalid', async () => {
|
||||||
|
await installFakeCLI('codex');
|
||||||
|
process.env.FAKE_AI_STDOUT = 'not json';
|
||||||
|
|
||||||
|
const result = await chatJSON('sys', 'user');
|
||||||
|
|
||||||
|
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,21 +1,38 @@
|
|||||||
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 { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js';
|
||||||
|
|
||||||
const LLM_ENV_KEYS = [
|
const LLM_ENV_KEYS = [
|
||||||
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH',
|
||||||
];
|
];
|
||||||
|
const ORIGINAL_PATH = process.env.PATH;
|
||||||
|
|
||||||
function clearLLMEnv() {
|
function clearLLMEnv() {
|
||||||
for (const k of LLM_ENV_KEYS) delete process.env[k];
|
for (const k of LLM_ENV_KEYS) delete process.env[k];
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
let tempDir;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
mock.restoreAll();
|
mock.restoreAll();
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
|
process.env.PATH = ORIGINAL_PATH;
|
||||||
|
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||||||
|
tempDir = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function installFakeCLI(command = 'codex') {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), 'preflight-cli-test-'));
|
||||||
|
const script = join(tempDir, command);
|
||||||
|
await writeFile(script, '#!/bin/sh\nexit 0\n');
|
||||||
|
await chmod(script, 0o755);
|
||||||
|
process.env.PATH = tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
describe('checkRequiredEnv', () => {
|
describe('checkRequiredEnv', () => {
|
||||||
it('reports all three missing when nothing provided', () => {
|
it('reports all three missing when nothing provided', () => {
|
||||||
const result = checkRequiredEnv({ token: '', repo: '', pr: '' });
|
const result = checkRequiredEnv({ token: '', repo: '', pr: '' });
|
||||||
@@ -102,81 +119,40 @@ describe('verifyCommentToken', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('verifyLLM', () => {
|
describe('verifyLLM', () => {
|
||||||
it('fails when OpenCode is not configured', async () => {
|
it('fails when no supported assistant CLI is detected', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
|
process.env.AI_ASSISTANT_CLI = 'no-such-ai-cli';
|
||||||
|
process.env.PATH = '';
|
||||||
|
|
||||||
const result = await verifyLLM();
|
const result = await verifyLLM();
|
||||||
|
|
||||||
assert.equal(result.ok, false);
|
assert.equal(result.ok, false);
|
||||||
assert.match(result.error, /OPENCODE_BASE_URL/);
|
assert.match(result.error, /AI 助理 CLI/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('checks OpenCode server provider and model', async () => {
|
it('passes when a supported assistant CLI is detected', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
await installFakeCLI('codex');
|
||||||
process.env.OPENCODE_PROVIDER = 'google';
|
process.env.AI_ASSISTANT_CLI = 'codex';
|
||||||
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
|
process.env.MODEL = 'gpt-5-mini';
|
||||||
const urls = [];
|
|
||||||
mock.method(axios, 'get', async (url) => {
|
|
||||||
urls.push(url);
|
|
||||||
if (url.endsWith('/global/health')) return { data: { healthy: true, version: '1.17.7' } };
|
|
||||||
return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await verifyLLM();
|
const result = await verifyLLM();
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.provider, 'opencode');
|
assert.equal(result.provider, 'codex');
|
||||||
assert.deepEqual(urls, ['http://opencode.local:4096/global/health', 'http://opencode.local:4096/config/providers']);
|
assert.equal(result.command, 'codex');
|
||||||
|
assert.equal(result.model, 'gpt-5-mini');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails when configured provider is missing', async () => {
|
it('fails when a requested CLI is not installed', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
process.env.AI_ASSISTANT_CLI = 'missing-cli';
|
||||||
process.env.OPENCODE_PROVIDER = 'google';
|
process.env.PATH = '';
|
||||||
mock.method(axios, 'get', async (url) => {
|
|
||||||
if (url.endsWith('/global/health')) return { data: { healthy: true } };
|
|
||||||
return { data: { providers: [{ id: 'anthropic', models: {} }] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await verifyLLM();
|
const result = await verifyLLM();
|
||||||
|
|
||||||
assert.equal(result.ok, false);
|
assert.equal(result.ok, false);
|
||||||
assert.match(result.error, /未設定 provider=google/);
|
assert.match(result.error, /AI 助理 CLI/);
|
||||||
});
|
|
||||||
|
|
||||||
it('fails when configured model is missing', async () => {
|
|
||||||
clearLLMEnv();
|
|
||||||
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
|
||||||
process.env.OPENCODE_PROVIDER = 'google';
|
|
||||||
process.env.OPENCODE_MODEL = 'gemini-2.5-pro';
|
|
||||||
mock.method(axios, 'get', async (url) => {
|
|
||||||
if (url.endsWith('/global/health')) return { data: { healthy: true } };
|
|
||||||
return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await verifyLLM();
|
|
||||||
|
|
||||||
assert.equal(result.ok, false);
|
|
||||||
assert.match(result.error, /未列出 model=gemini-2.5-pro/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes an insecure https agent by default', async () => {
|
|
||||||
clearLLMEnv();
|
|
||||||
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096';
|
|
||||||
const agents = [];
|
|
||||||
mock.method(axios, 'get', async (url, opts) => {
|
|
||||||
agents.push(opts.httpsAgent);
|
|
||||||
if (url.endsWith('/global/health')) return { data: { healthy: true } };
|
|
||||||
return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } };
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await verifyLLM();
|
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
|
||||||
assert.equal(agents.length, 2);
|
|
||||||
assert.equal(agents[0].options.rejectUnauthorized, false);
|
|
||||||
assert.equal(agents[1].options.rejectUnauthorized, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -188,7 +164,7 @@ describe('runPreflight', () => {
|
|||||||
verifyToken: async () => ({ ok: true }),
|
verifyToken: async () => ({ ok: true }),
|
||||||
verifyComment: async () => ({ ok: true }),
|
verifyComment: async () => ({ ok: true }),
|
||||||
verifyRemote: () => ({ ok: true }),
|
verifyRemote: () => ({ ok: true }),
|
||||||
verifyLLMFn: async () => ({ ok: true, provider: 'opencode' }),
|
verifyLLMFn: async () => ({ ok: true, provider: 'codex' }),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -239,7 +215,7 @@ describe('runPreflight', () => {
|
|||||||
|
|
||||||
it('returns false when LLM verification fails', async () => {
|
it('returns false when LLM verification fails', async () => {
|
||||||
const result = await runPreflight('/ws', makeDeps({
|
const result = await runPreflight('/ws', makeDeps({
|
||||||
verifyLLMFn: async () => ({ ok: false, error: 'OpenCode server 驗證失敗' }),
|
verifyLLMFn: async () => ({ ok: false, error: 'AI 助理 CLI 驗證失敗' }),
|
||||||
}));
|
}));
|
||||||
assert.equal(result, false);
|
assert.equal(result, false);
|
||||||
});
|
});
|
||||||
@@ -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', () => {
|
||||||
@@ -82,6 +82,7 @@ describe('fetchAccountQuota', () => {
|
|||||||
const get = async (url, opts) => {
|
const get = async (url, opts) => {
|
||||||
assert.match(url, /openrouter\.ai\/api\/v1\/auth\/key$/);
|
assert.match(url, /openrouter\.ai\/api\/v1\/auth\/key$/);
|
||||||
assert.equal(opts.headers.Authorization, 'Bearer sk-or-xxx');
|
assert.equal(opts.headers.Authorization, 'Bearer sk-or-xxx');
|
||||||
|
assert.equal(opts.httpsAgent, undefined);
|
||||||
return { data: { data: { usage: 12.4, limit: 100, limit_remaining: 87.6 } } };
|
return { data: { data: { usage: 12.4, limit: 100, limit_remaining: 87.6 } } };
|
||||||
};
|
};
|
||||||
const q = await fetchAccountQuota('openai', { apiKeys: ['sk-or-xxx'], baseURL: 'https://openrouter.ai/api/v1' }, { get });
|
const q = await fetchAccountQuota('openai', { apiKeys: ['sk-or-xxx'], baseURL: 'https://openrouter.ai/api/v1' }, { get });
|
||||||
@@ -119,6 +120,7 @@ describe('fetchAccountQuota', () => {
|
|||||||
it('reports 不適用 for local platforms', async () => {
|
it('reports 不適用 for local platforms', async () => {
|
||||||
assert.equal((await fetchAccountQuota('ollama', {})).available, false);
|
assert.equal((await fetchAccountQuota('ollama', {})).available, false);
|
||||||
assert.equal((await fetchAccountQuota('opencode', {})).available, false);
|
assert.equal((await fetchAccountQuota('opencode', {})).available, false);
|
||||||
|
assert.equal((await fetchAccountQuota('antigravity', {})).available, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('degrades gracefully when the quota call throws', async () => {
|
it('degrades gracefully when the quota call throws', async () => {
|
||||||
+62
-10
@@ -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`, {
|
||||||
@@ -170,6 +195,7 @@ const QUOTA_STRATEGIES = {
|
|||||||
return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' };
|
return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' };
|
||||||
},
|
},
|
||||||
claude: async () => ({ available: false, reason: 'Anthropic 額度需 Admin API 權限,一般 API key 無法取得' }),
|
claude: async () => ({ available: false, reason: 'Anthropic 額度需 Admin API 權限,一般 API key 無法取得' }),
|
||||||
|
antigravity: async () => ({ available: false, reason: 'Antigravity 額度由 Google 帳務/方案管理,CLI 無法直接查詢' }),
|
||||||
gemini: async () => ({ available: false, reason: 'Gemini 額度由 Google Cloud quota 管理,API key 無法直接查詢' }),
|
gemini: async () => ({ available: false, reason: 'Gemini 額度由 Google Cloud quota 管理,API key 無法直接查詢' }),
|
||||||
amazonq: async () => ({ available: false, reason: 'Amazon Q 額度由 AWS 帳務管理,需 AWS 憑證查詢' }),
|
amazonq: async () => ({ available: false, reason: 'Amazon Q 額度由 AWS 帳務管理,需 AWS 憑證查詢' }),
|
||||||
ollama: async () => ({ available: false, reason: '本地服務,無帳號額度概念' }),
|
ollama: async () => ({ available: false, reason: '本地服務,無帳號額度概念' }),
|
||||||
@@ -193,7 +219,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 +231,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 +254,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 +298,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)}`;
|
||||||
|
|||||||
Regular → Executable
+19
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user