Merge pull request '改用 AI 助理 CLI 並強化 AI Code Review 執行流程' (#13) from ai-review-resolve/develop-20260626-091820 into develop

Reviewed-on: docker-actions/ai-code-review#13
This commit was merged in pull request #13.
This commit is contained in:
2026-06-29 08:49:20 +00:00
17 changed files with 503 additions and 383 deletions
+51 -19
View File
@@ -1,34 +1,66 @@
[ [
{ {
"level": "critical", "level": "warning",
"role": "Assassin", "role": "Assassin",
"location": "Dockerfile:17", "location": "app/git.js:67",
"problem": "在 `apk add` 中使用了 `--no-check-certificate` 參數。這會強制忽略套件來源的 SSL/TLS 憑證檢查,攻擊者可以透過中間人攻擊(MITM)在容器建置期間植入惡意套件,導致整個建置環境被入侵。", "problem": "這裡對所有 git 網路操作強制關閉 SSL 驗證。攻擊者若能攔截遠端回應,就能偽造 `ls-remote`、污染抓到的內容,甚至在有認證的情況下竊取 GITEA_TOKEN,讓後續審查建立在被污染的資料上。",
"suggestion": "正式環境嚴禁使用 `--no-check-certificate`。若遇到憑證問題,應排查環境配置(如是否缺少根憑證),或是確實解決憑證驗證問題,確保套件來源可信。", "suggestion": "移除 `GIT_SSL_NO_VERIFY=true`。若是內部憑證問題,改用正確的 CA bundle 或指定受信任憑證檔,不要整體關閉 git 的憑證驗證。",
"is_new": false "is_new": true
}, },
{ {
"level": "warning", "level": "warning",
"role": "Assassin", "role": "Assassin",
"location": "Dockerfile:12", "location": "app/usage.js:180",
"problem": "使用了 `alpine:latest` 作為基底映像。`latest` 標籤極不穩定,當 Alpine Linux 發布新版本時,建置出的映像環境可能會產生難以預期的變更,甚至引入不相容的套件版本,導致建置再現性(Reproducibility)崩潰。", "problem": "這裡對 OpenRouter 額度查詢也明確套用不驗證憑證的 agent。攻擊者只要攔截這條 HTTPS 請求,就能讀到 `Authorization: Bearer` 的 API key,還能回傳假 quota 資料誤導流程判斷。",
"suggestion": "建議釘選明確的 Alpine 版本號(例如 `alpine:3.20`),以確保每次建置的執行環境完全一致且可預測。", "suggestion": "改回預設的 HTTPS 憑證驗證。若必須支援自簽憑證,僅對特定內部主機單獨配置受信任 CA,且不要對外部 API 一律關閉驗證。",
"is_new": false "is_new": true
}, },
{ {
"level": "warning", "level": "warning",
"role": "Leo", "role": "Bard",
"location": "Dockerfile:21", "location": "action.yaml:8",
"problem": "Dockerfile 中留下了明確的安全性警告註解(關於 --no-check-certificate)。這種將安全性風險留在程式碼中並以註解提醒的做法,容易被未來的維護者忽略,導致潛在的安全性漏洞。", "problem": "這個新加入的 `model` 輸入沒有任何描述,讓 action 的公開介面像是少了說明書的樂譜,讀者只能靠猜來理解它要吃什麼值。",
"suggestion": "若此 flag 僅為暫時性,應盡快優化環境以移除它;若不可避免,應將此設定集中管理,而非在 Dockerfile 中寫死此類不安全的設定。", "suggestion": "補上一句簡短但明確的描述,例如說明這個欄位是「要使用的模型名稱」或「AI 助理 CLI 的模型識別字串」;讓使用者一眼看懂用途。",
"is_new": false "is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "app/config.js:4",
"problem": "`process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'` 放在模組載入時就直接生效,這種全域副作用太早出聲,會讓讀者在還沒看到任何呼叫前,就先被迫接受一個隱晦的環境改寫。",
"suggestion": "把這種全域設定移到明確的啟動流程或初始化函式中,至少搭配更醒目的註解,讓副作用出現的位置和目的都更清楚。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "app/config.js:25",
"problem": "`getInsecureHttpsAgent` 與 `getOpenCodeHttpsAgent` 同時存在,卻指向同一個物件來源,等於替同一段旋律寫了兩個名字,容易讓後續維護者搞不清楚哪個才是正規稱呼。",
"suggestion": "保留一個主名稱即可;如果一定要兼容舊名稱,請加上明確的遷移註解,說明哪個是新名字、哪個只是過渡別名。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "app/llm.js:27",
"problem": "`cliArgs(provider, model, promptFile = null, prompt = null)` 這個簽名把不同 provider 的參數意義混在一起,光看位置很難分辨哪個值在某條分支才有用,讀起來像把三種節拍硬塞進同一小節。",
"suggestion": "改成物件參數或拆成各 provider 專用的組裝函式,讓每個欄位的用途能被名字直接說明,而不是靠呼叫順序猜測。",
"is_new": true
},
{
"level": "warning",
"role": "Bard",
"location": "app/preflight.js:103",
"problem": "`verifyLLM` 的 JSDoc 仍然只描述 `provider` 與 `error`,但實際回傳已經多了 `command` 和 `model`。註解和程式碼不同步,會讓人以為這段函式比實際更單薄。",
"suggestion": "把 `@returns` 的型別與欄位補完整,讓文件精準反映目前回傳結構,避免讀者依舊沿用舊版心智模型。",
"is_new": true
}, },
{ {
"level": "info", "level": "info",
"role": "Leo", "role": "Bard",
"location": "app/git.js:194", "location": "app/preflight.js:170",
"problem": "`withAskpass` is tightly coupled with Gitea's bot-trigger behavior.", "problem": "成功訊息仍寫成 `LLM provider=... CLI 可用`,語氣還停在 provider 中心,但整個流程已改成 CLI 中心,術語有些半新半舊,節奏不夠一致。",
"suggestion": "Encapsulate bot commit logic within `commitAndPush`, auto-selecting the correct `GITEA_COMMENT_TOKEN`.", "suggestion": "把成功訊息統一成 CLI 觀點,例如直接報 `command=...` 或改成更中性的「LLM CLI 可用」,讓文字系統保持同一把調性。",
"is_new": false "is_new": true
} }
] ]
+37 -7
View File
@@ -16,10 +16,22 @@ on:
types: [opened, synchronize] types: [opened, synchronize]
# 定義此 workflow 的 jobs # 定義此 workflow 的 jobs
jobs: jobs:
release-tag-version:
name: Release Tag Version
runs-on: ubuntu
outputs:
version: ${{ steps.release-tag-version.outputs.version }}
steps:
- name: 計算版本號
id: release-tag-version
uses: https://gitea.jsc.idv.tw/composite-actions/release-tag-version@${{ vars.ACTION_RELEASE_TAG_VERSION }}
with:
is_beta: 'true'
# job 識別碼:ai-code-review # job 識別碼:ai-code-review
ai-code-review: ai-code-review:
# job 顯示名稱 # job 顯示名稱
name: AI Code Review name: AI Code Review
needs: release-tag-version
# 指定執行環境的 runner 標籤:ubuntu # 指定執行環境的 runner 標籤:ubuntu
runs-on: ubuntu runs-on: ubuntu
# 此 job 所需的權限設定 # 此 job 所需的權限設定
@@ -34,15 +46,33 @@ jobs:
steps: steps:
- name: 取得程式碼 - name: 取得程式碼
uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }}
# 步驟名稱:呼叫 OpenCode 進行 AI 程式碼審查 - name: 安裝 AI 工具
uses: https://gitea.jsc.idv.tw/composite-actions/codex@${{ vars.ACTION_CODEX_VERSION }}
with:
oauth: ${{ secrets.CODEX_OAUTH }}
- name: 檢查 AI 助理 CLI
shell: bash
run: |
set -e
tools="$(node --input-type=module -e "import { getLLMCLICommands } from './app/config.js'; console.log(getLLMCLICommands().join(' '));")"
found=""
for tool in $tools; do
if command -v "$tool" >/dev/null 2>&1; then
found="${found} ${tool}"
"$tool" --version || true
fi
done
if [ -z "$found" ]; then
echo "找不到可用的 AI 助理 CLI(需要 ${tools} 其中之一)" >&2
exit 1
fi
echo "可用 AI 助理 CLI:${found}"
- name: AI 程式碼審查 - name: AI 程式碼審查
# 使用外部 composite action 執行審查邏輯 # 使用外部 composite action 執行審查邏輯
# 版本由 repository variable ACTION_OPENCODE_CODE_REVIEW_VERSION 決定,便於集中管理版本 # 版本由 release-tag-version 計算,便於在 PR 中測試目前 action 版本
uses: ./ uses: https://gitea.jsc.idv.tw/docker-actions/ai-code-review@v${{ needs.release-tag-version.outputs.version }}
# 傳遞給 composite action 的輸入參數 # 傳遞給 composite action 的輸入參數
with: with:
# 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言 # 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言
token: ${{ secrets.TOKEN }} token: ${{ secrets.TOKEN }}
opencode_base_url: ${{ vars.OPENCODE_BASE_URL }} model: ${{ vars.AI_CODE_REVIEW_MODEL }}
opencode_provider: ${{ vars.OPENCODE_PROVIDER }}
opencode_model: ${{ vars.OPENCODE_PROVIDER_MODEL }}
+4 -7
View File
@@ -6,18 +6,15 @@
# 更新日期:2026/06/26 11:34:46 # 更新日期:2026/06/26 11:34:46
# ============================================================================= # =============================================================================
# 指定基底映像為 Alpine Linux 最新版;Alpine 體積小,可縮小最終映像大小並加快拉取速度。 # 指定支援中的 Alpine Linux 固定版本;Alpine 體積小,可縮小最終映像大小並加快拉取速度。
# 需人工確認:使用 latest tag 會在不同時間建置出不同基底版本,可能影響可重現性 # 釘選明確版本可避免 latest tag 隨時間變動,提升建置可重現性
# 建議釘選明確版本(例如 alpine:3.20)以確保建置一致。 FROM alpine:3.24.1
FROM alpine:latest
# 安裝必要的工具 # 安裝必要的工具
# 安裝執行 code review 所需的工具:bash(執行 entrypoint 腳本)、git(前置遠端驗證/取得 diff)、 # 安裝執行 code review 所需的工具:bash(執行 entrypoint 腳本)、git(前置遠端驗證/取得 diff)、
# nodejs 與 npm(執行 app 內的 Node.js 程式)。 # nodejs 與 npm(執行 app 內的 Node.js 程式)。
# --no-cache:不保留 apk 套件索引快取,避免殘留在映像層中以減少映像大小。 # --no-cache:不保留 apk 套件索引快取,避免殘留在映像層中以減少映像大小。
# 需人工確認:--no-check-certificate 會略過套件來源的憑證驗證,存在中間人攻擊風險, RUN apk add --no-cache bash git nodejs npm
# 僅在內網或憑證受限環境下使用;正式環境建議移除以維持安全性。
RUN apk add --no-cache --no-check-certificate bash git nodejs npm
# 將專案的 app/ 目錄複製到映像內的 /app;包含 Node.js 程式碼與 package.json 等相依宣告。 # 將專案的 app/ 目錄複製到映像內的 /app;包含 Node.js 程式碼與 package.json 等相依宣告。
COPY ./app /app COPY ./app /app
+17 -14
View File
@@ -8,18 +8,19 @@ inputs:
comment_token: comment_token:
description: '' description: ''
required: false required: false
opencode_base_url: model:
description: 'OpenCode server Base URL' description: ''
required: false required: true
opencode_model:
description: 'OpenCode model id'
required: false
opencode_provider:
description: 'OpenCode server provider id'
required: false
runs: runs:
using: 'docker' using: 'composite'
image: 'Dockerfile' steps:
- name: 安裝 Node.js 相依套件
shell: bash
run: |
cd "${GITHUB_ACTION_PATH:-${GITEA_ACTION_PATH:-.}}/app"
npm ci
- name: 執行 AI 程式碼審查
shell: bash
env: env:
GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
@@ -29,6 +30,8 @@ runs:
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 }} run: |
OPENCODE_PROVIDER: ${{ inputs.opencode_provider }} echo "🚀 AI Code Review Action 啟動"
cd "${GITHUB_ACTION_PATH:-${GITEA_ACTION_PATH:-.}}/app"
node main.js
+67 -17
View File
@@ -1,4 +1,7 @@
import https from 'https'; import https from 'https';
import { execFileSync } from 'child_process';
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 || '';
@@ -14,37 +17,84 @@ export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
/** /**
* 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent * 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent
* 供連接使用自簽或無效憑證的 OpenCode 服務時使用。 * 供連接使用自簽或無效憑證的內部服務時使用。
* *
* @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用, * @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用,
* 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。 * 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。
* 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。 * 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。
* @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。 * @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。
*/ */
let _openCodeHttpsAgent = null; let _insecureHttpsAgent = null;
export function getOpenCodeHttpsAgent() { export function getInsecureHttpsAgent() {
return (_openCodeHttpsAgent ??= new https.Agent({ rejectUnauthorized: false })); return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false }));
}
export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
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',
command: 'opencode',
defaultModel: 'google/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 提供者設定。 * 依環境變數解析並回傳 LLM 提供者設定。
* *
* 當設定了 `OPENCODE_BASE_URL` 時回傳 OpenCode 提供者設定 * 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、antigravity、opencode
* model 取自 `OPENCODE_MODEL`預設為 `gemini-2.5-flash`); * model 優先取 `MODEL`,再相容舊的 `OPENCODE_MODEL`最後使用各 CLI 預設值。
* 否則回傳各欄位皆為空/null 的「無提供者」設定,由呼叫端據此判斷是否略過 LLM 流程。
* *
* @remarks 每次呼叫都會即時讀取 `process.env`。`apiKeys` 在 OpenCode 模式下為固定佔位值 `['opencode']`,並非真實金鑰 * @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 CLI 偵測函式,供測試使用
* @returns {{ provider: ('opencode'|null), apiKeys: string[], baseURL: (string|null), model: (string|null) }} * @returns {{ provider: ('codex'|'claude'|'antigravity'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }}
* LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。 * LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。
*/ */
export function getLLMConfig() { export function getLLMConfig({ commandExistsFn = commandExists } = {}) {
if (process.env.OPENCODE_BASE_URL) { 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 { return {
provider: 'opencode', provider: cli.provider,
apiKeys: ['opencode'], apiKeys: [cli.provider],
baseURL: process.env.OPENCODE_BASE_URL, baseURL: null,
model: process.env.OPENCODE_MODEL || 'gemini-2.5-flash', model: process.env.MODEL || process.env.OPENCODE_MODEL || cli.defaultModel,
command: cli.command,
}; };
}
return { provider: null, apiKeys: [], baseURL: null, model: null };
} }
+7 -1
View File
@@ -61,7 +61,13 @@ function makeRunner(spawn) {
function withAskpass(workspace, fn, token = GITEA_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: token }; const credEnv = {
...process.env,
GIT_ASKPASS: askpassScript,
GIT_SSL_NO_VERIFY: 'true',
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 {
+2 -3
View File
@@ -1,9 +1,8 @@
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 API 所需的 HTTP headers(含 Gitea token 授權與 JSON content-type)。
* 授權格式為 Gitea 專用的 `token <token>`,並非 OAuth Bearer。 * 授權格式為 Gitea 專用的 `token <token>`,並非 OAuth Bearer。
+103 -96
View File
@@ -1,125 +1,132 @@
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';
/** /**
* 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼 * 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入
*
* 當字串含有 `/` 時視為 `providerID/modelID` 形式並拆解;否則 provider
* 取環境變數 `OPENCODE_PROVIDER`(預設 `google`),model 則為整個字串。
*
* @param {string} model - 模型識別字串,例如 `"google/gemini-2.0"` 或 `"gemini-2.0"`。
* @returns {{ providerID: string, modelID: string }} 拆解後的 provider 與 model 識別碼。
*/ */
function opencodeModelConfig(model) { function buildPrompt(systemPrompt, userContent) {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; return [
return { providerID, modelID }; '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
'',
'<system>',
systemPrompt,
'</system>',
'',
'<user>',
userContent,
'</user>',
].join('\n');
} }
/** function cliArgs(provider, model, promptFile = null, prompt = null) {
* 建立傳給 axios 的共用請求選項,統一注入 headers 與 OpenCode 專用的 HTTPS agent。 if (provider === 'codex') {
* return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
* 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。 }
* if (provider === 'claude') {
* @param {Record<string, string>} headers - 要附加於請求的 HTTP 標頭。 return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
* @returns {{ headers: Record<string, string>, httpsAgent: import('https').Agent }} axios 請求選項物件。 }
*/ if (provider === 'antigravity') {
function opencodeAxiosOptions(headers) { return ['-p', prompt, '--model', model];
return { }
headers, if (provider === 'opencode') {
httpsAgent: getOpenCodeHttpsAgent(), 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 });
}
} }
/** /**
* 從 OpenCode 訊息回應中抽取並串接所有文字片段 * 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應
* *
* 以多重 fallback 相容不同包裹層級的回應結構(`parts` / `data.parts` / * 從設定取得 provider/command/model;未偵測到 CLI 時拋錯。成功時記錄一次
* `info.content` / `data.info.content`),逐片段取 `text` 或 `content` 後串接 * usage 呼叫(CLI 通常不回傳 token 明細,因此 token 可能為 0)並回傳內容
*
* @param {object} data - OpenCode `/session/{id}/message` 的回應資料物件。
* @returns {string} 串接後的純文字內容;無可用片段時回傳空字串。
*/
function extractOpenCodeContent(data) {
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
return parts
.map(part => part.text || part.content || '')
.filter(Boolean)
.join('');
}
/**
* 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。
*
* 先 POST `/session` 取得 session id(缺少則拋錯),再 POST
* `/session/{id}/message` 送出 system prompt 與使用者內容,最後抽取回應文字。
* 會發出兩次 HTTP 請求;網路或 API 錯誤會向外拋出,交由呼叫端處理。
*
* @param {string} baseURL - OpenCode server 基底 URL(尾端斜線會被去除)。
* @param {string} model - 模型識別字串,將交由 {@link opencodeModelConfig} 解析。
* @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。
* @param {Record<string, string>} headers - 附加於請求的 HTTP 標頭。
* @returns {Promise<{ content: string, data: object }>} 抽取後的文字內容與原始回應資料。
* @throws {Error} 當回應中無 session id,或任一 HTTP 請求失敗時。
*/
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
const base = baseURL.replace(/\/$/, '');
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 };
}
/**
* 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。
*
* 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄
* usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)`
* 終止整個行程(不會回傳)。
* *
* @param {string} systemPrompt - 系統提示詞。 * @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。 * @param {string} userContent - 使用者輸入內容。
* @returns {Promise<string>} 模型回應的純文字內容。 * @returns {Promise<string>} 模型回應的純文字內容。
* @throws {Error} 當未設定 OpenCode server(缺少 provider時。 * @throws {Error} 當未偵測到可用 AI 助理 CLI,或 CLI 呼叫失敗時。
*/ */
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);
} }
/** /**
* 對 OpenCode 送出對話並將回應解析為 JSON 物件/陣列。 * 對 AI 助理 CLI 送出對話並將回應解析為 JSON 物件/陣列。
* *
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。 * 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。 * 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
+13 -4
View File
@@ -120,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);
+13 -55
View File
@@ -1,18 +1,17 @@
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 REST API v1 的完整網址。
* *
@@ -30,33 +29,6 @@ const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
* @returns {{Authorization: string, 'Content-Type': string}} 可直接交給 axios 的 headers 物件。 * @returns {{Authorization: string, 'Content-Type': string}} 可直接交給 axios 的 headers 物件。
*/ */
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
/**
* 將模型字串解析為 OpenCode 的 providerID 與 modelID。
*
* 若 model 含 `/` 則以斜線拆分為 provider/model;否則 provider 取
* 環境變數 OPENCODE_PROVIDER(預設 `google`),model 即原字串。
* @param {string} model - 模型識別字串,例如 `google/gemini-2.0` 或 `gemini-2.0`。
* @returns {{providerID: string, modelID: string}} 解析後的 provider 與 model 識別碼。
* @remarks 讀取 process.env.OPENCODE_PROVIDERsplit 上限為 2 段,多餘段落會被忽略。
*/
const opencodeModelConfig = (model) => {
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model];
return { providerID, modelID };
};
/**
* 組出呼叫 OpenCode server 用的 axios 請求設定。
*
* 固定 30 秒逾時,並套用 config.js 的 getOpenCodeHttpsAgent() 作為 httpsAgent。
* @param {object} headers - 要套用的 HTTP headers 物件。
* @returns {{headers: object, timeout: number, httpsAgent: import('https').Agent}} axios 設定物件。
* @remarks 每次呼叫都會執行 getOpenCodeHttpsAgent() 取得 agent。
*/
const opencodeAxiosOptions = (headers) => ({
headers,
timeout: 30000,
httpsAgent: getOpenCodeHttpsAgent(),
});
/** /**
* 將(axios)錯誤格式化為易讀的訊息字串。 * 將(axios)錯誤格式化為易讀的訊息字串。
* *
@@ -124,37 +96,23 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
} }
/** /**
* 驗證 LLMOpenCode server)設定可用。 * 驗證 LLMAI 助理 CLI)設定可用。
* *
* 依序確認:已設定 provider、有 base URL、health 端點可連線、OpenCode 已設定 * 確認目前環境可偵測到支援的 CLI,且已解析出 model。實際模型可用性由 CLI
* 對應 provider 且其列出指定 model。任一不符回傳對應錯誤;錯誤被攔截不會 throw * 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度
* @returns {Promise<{ok: true, provider: string}|{ok: false, provider?: string, error: string}>} * @returns {Promise<{ok: true, provider: string}|{ok: false, provider?: string, error: string}>}
* 通過時含 provider;未設定 provider 的失敗分支不含 provider 欄位。 * 通過時含 provider;未設定 provider 的失敗分支不含 provider 欄位。
* @remarks 設定來源為 config.js 的 getLLMConfig()provider/model 鍵的比對使用 opencodeModelConfig 解析後的 providerID/modelID * @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}` };
}
} }
/** /**
* 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM。 * 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM CLI
* *
* 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。 * 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。
* 各檢查可經 deps 注入覆寫,方便單元測試。 * 各檢查可經 deps 注入覆寫,方便單元測試。
@@ -164,7 +122,7 @@ export async function verifyLLM() {
* @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。 * @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。
* @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。 * @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。
* @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。 * @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。
* @param {Function} [deps.verifyLLMFn=verifyLLM] - LLMOpenCode)連線驗證。 * @param {Function} [deps.verifyLLMFn=verifyLLM] - LLMAI 助理 CLI驗證。
* @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。 * @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。
* @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。 * @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。
*/ */
@@ -212,7 +170,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 provider=${llm.provider} CLI 可用`);
result(true, '前置驗證通過'); result(true, '前置驗證通過');
return true; return true;
+40 -16
View File
@@ -1,9 +1,9 @@
import { describe, it, beforeEach, afterEach } from 'node:test'; import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
const ENV_KEYS = [ const ENV_KEYS = [
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL',
]; ];
let saved = {}; let saved = {};
@@ -19,27 +19,47 @@ afterEach(() => {
}); });
describe('getLLMConfig', () => { describe('getLLMConfig', () => {
it('returns null provider when no env vars set', () => { it('exports the supported assistant CLI commands', () => {
const cfg = getLLMConfig(); assert.deepEqual(getLLMCLICommands(), ['codex', 'claude', 'agy', 'antigravity', 'opencode']);
assert.equal(cfg.provider, null);
assert.deepEqual(cfg.apiKeys, []);
}); });
it('detects opencode server with defaults', () => { it('returns null provider when no env vars set', () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; const cfg = getLLMConfig({ commandExistsFn: () => false });
const cfg = getLLMConfig(); assert.equal(cfg.provider, null);
assert.equal(cfg.provider, 'opencode'); assert.deepEqual(cfg.apiKeys, []);
assert.deepEqual(cfg.apiKeys, ['opencode']); assert.equal(cfg.command, null);
assert.equal(cfg.baseURL, 'http://opencode.local:4096'); });
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'); assert.equal(cfg.model, 'gemini-2.5-flash');
}); });
it('detects opencode server with custom model', () => { it('can force a CLI with AI_ASSISTANT_CLI', () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; process.env.AI_ASSISTANT_CLI = 'opencode';
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro'; process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
const cfg = getLLMConfig(); const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' || command === 'opencode' });
assert.equal(cfg.provider, 'opencode'); assert.equal(cfg.provider, 'opencode');
assert.equal(cfg.baseURL, 'http://opencode.local:4096'); assert.equal(cfg.command, 'opencode');
assert.equal(cfg.model, 'google/gemini-2.5-pro'); assert.equal(cfg.model, 'google/gemini-2.5-pro');
}); });
@@ -48,4 +68,8 @@ describe('getLLMConfig', () => {
assert.equal(agent.options.rejectUnauthorized, false); assert.equal(agent.options.rejectUnauthorized, false);
}); });
it('disables Node TLS certificate verification globally', () => {
assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '0');
});
}); });
+3
View File
@@ -96,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, 'true', `GIT_SSL_NO_VERIFY missing for git ${args[0]}`);
} }
}); });
@@ -266,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, 'true', `GIT_SSL_NO_VERIFY missing for git ${args[0]}`);
} }
}); });
@@ -308,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, 'true');
}); });
it('does not leak the token in ls-remote args', () => { it('does not leak the token in ls-remote args', () => {
+76 -70
View File
@@ -1,98 +1,104 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import axios from 'axios'; import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { extractBalancedJSON, extractJSONText } from '../llm.js'; import { extractBalancedJSON, extractJSONText } from '../llm.js';
const ENV_KEYS = [ const ENV_KEYS = [
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', '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 saved = {};
let tempDir;
beforeEach(() => { beforeEach(() => {
saved = {}; saved = {};
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; } for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
tempDir = null;
}); });
afterEach(() => { afterEach(async () => {
for (const k of ENV_KEYS) { for (const k of ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k]; if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k]; else process.env[k] = saved[k];
} }
if (tempDir) await rm(tempDir, { recursive: true, force: true });
mock.restoreAll(); mock.restoreAll();
}); });
function mockOpenCodeResponse(content) { async function installFakeCLI(command = 'codex') {
let calls = 0; tempDir = await mkdtemp(join(tmpdir(), 'ai-cli-test-'));
mock.method(axios, 'post', async () => { const script = join(tempDir, command);
calls += 1; await writeFile(script, `#!/bin/sh
if (calls === 1) return { data: { id: 'ses_test' } }; if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi
return { data: { parts: [{ type: 'text', text: content }] } }; 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 - OpenCode', async () => { describe('chat - assistant CLI', async () => {
const { chat } = await import('../llm.js'); const { chat } = await import('../llm.js');
it('uses OpenCode server session API', async () => { it('runs the detected CLI with MODEL and sends the prompts through stdin', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; const { stdinPath, argsPath } = await installFakeCLI('codex');
process.env.OPENCODE_PROVIDER = 'google'; process.env.MODEL = 'gpt-5-mini';
process.env.OPENCODE_MODEL = 'gemini-2.5-flash'; process.env.FAKE_AI_STDOUT = 'cli response';
const calls = []; process.env.FAKE_AI_STDIN_PATH = stdinPath;
mock.method(axios, 'post', async (url, payload, opts) => { process.env.FAKE_AI_ARGS_PATH = argsPath;
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'); const result = await chat('sys', 'user');
assert.equal(result, 'opencode response'); assert.equal(result, 'cli response');
assert.equal(calls[0].url, 'http://opencode.local:4096/session'); assert.match(await readFile(argsPath, 'utf8'), /exec --model gpt-5-mini/);
assert.deepEqual(calls[0].payload.model, { providerID: 'google', id: 'gemini-2.5-flash' }); const prompt = await readFile(stdinPath, 'utf8');
assert.equal(calls[1].url, 'http://opencode.local:4096/session/ses_test/message'); assert.match(prompt, /<system>\nsys\n<\/system>/);
assert.deepEqual(calls[1].payload.model, { providerID: 'google', modelID: 'gemini-2.5-flash' }); assert.match(prompt, /<user>\nuser\n<\/user>/);
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 () => { it('can force opencode with AI_ASSISTANT_CLI', async () => {
process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; const { argsPath } = await installFakeCLI('opencode');
const agents = []; process.env.AI_ASSISTANT_CLI = 'opencode';
mock.method(axios, 'post', async (url, _payload, opts) => { process.env.MODEL = 'google/gemini-2.5-pro';
agents.push(opts.httpsAgent); process.env.FAKE_AI_STDOUT = 'ok';
if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; process.env.FAKE_AI_ARGS_PATH = argsPath;
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'); const result = await chat('sys', 'user');
assert.equal(result, 'hello world'); assert.equal(result, 'ok');
assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/);
}); });
it('calls process.exit(1) when OpenCode fails', async () => { it('runs Antigravity through agy with MODEL and prompt argument', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; const { stdinPath, argsPath } = await installFakeCLI('agy');
mock.method(axios, 'post', async () => { throw new Error('fail'); }); process.env.AI_ASSISTANT_CLI = 'agy';
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); }); 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;
await assert.rejects(() => chat('sys', 'user'), /exit:1/); const result = await chat('sys', 'user');
assert.equal(exitMock.mock.calls[0].arguments[0], 1); 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);
}); });
}); });
@@ -100,8 +106,8 @@ describe('chatJSON', async () => {
const { chatJSON } = await import('../llm.js'); const { chatJSON } = await import('../llm.js');
it('parses plain JSON response', async () => { it('parses plain JSON response', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; await installFakeCLI('codex');
mockOpenCodeResponse('[{"level":"critical"}]'); process.env.FAKE_AI_STDOUT = '[{"level":"critical"}]';
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -109,8 +115,8 @@ describe('chatJSON', async () => {
}); });
it('strips markdown code block before parsing', async () => { it('strips markdown code block before parsing', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; await installFakeCLI('codex');
mockOpenCodeResponse('```json\n[{"level":"info"}]\n```'); process.env.FAKE_AI_STDOUT = '```json\n[{"level":"info"}]\n```';
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -118,8 +124,8 @@ describe('chatJSON', async () => {
}); });
it('extracts JSON array from surrounding prose', async () => { it('extracts JSON array from surrounding prose', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; await installFakeCLI('codex');
mockOpenCodeResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.'); process.env.FAKE_AI_STDOUT = '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.';
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -127,8 +133,8 @@ describe('chatJSON', async () => {
}); });
it('extracts JSON object from surrounding prose', async () => { it('extracts JSON object from surrounding prose', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; await installFakeCLI('codex');
mockOpenCodeResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}'); process.env.FAKE_AI_STDOUT = '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}';
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
@@ -136,8 +142,8 @@ describe('chatJSON', async () => {
}); });
it('returns [] when JSON is invalid', async () => { it('returns [] when JSON is invalid', async () => {
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; await installFakeCLI('codex');
mockOpenCodeResponse('not json'); process.env.FAKE_AI_STDOUT = 'not json';
const result = await chatJSON('sys', 'user'); const result = await chatJSON('sys', 'user');
+36 -60
View File
@@ -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 { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from '../preflight.js';
const LLM_ENV_KEYS = [ const LLM_ENV_KEYS = [
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', '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);
}); });
+2
View File
@@ -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.options.rejectUnauthorized, false);
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 () => {
+3
View File
@@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { getInsecureHttpsAgent } from './config.js';
import { warn } from './log.js'; import { warn } from './log.js';
/** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */ /** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */
@@ -176,6 +177,7 @@ async function fetchOpenRouterQuota({ apiKey, baseURL }, get) {
const resp = await get(`${stripSlash(baseURL)}/auth/key`, { const resp = await get(`${stripSlash(baseURL)}/auth/key`, {
headers: { Authorization: `Bearer ${apiKey}` }, headers: { Authorization: `Bearer ${apiKey}` },
timeout: 30000, timeout: 30000,
httpsAgent: getInsecureHttpsAgent(),
}); });
const d = resp.data?.data || {}; const d = resp.data?.data || {};
const used = num(d.usage); const used = num(d.usage);
@@ -195,6 +197,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: '本地服務,無帳號額度概念' }),
+15
View File
@@ -0,0 +1,15 @@
{
"name": "ai-code-review",
"version": "1.0.0",
"description": "AI code review Gitea action",
"private": true,
"scripts": {
"test": "npm --prefix app test"
},
"repository": {
"type": "git",
"url": "https://gitea.jsc.idv.tw/docker-actions/ai-code-review.git"
},
"author": "Jeffery",
"license": "ISC"
}