From 4431f2fec5f12bdeb4d71f6de462b8032f07f6e6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 09:20:57 +0000 Subject: [PATCH 01/19] =?UTF-8?q?fix(Dockerfile):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=B8=8D=E5=AE=89=E5=85=A8=E5=A5=97=E4=BB=B6=E6=86=91=E8=AD=89?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=E4=B8=A6=E9=87=98=E9=81=B8=20Alpine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index d708574..911b6b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,18 +6,15 @@ # 更新日期:2026/06/26 11:34:46 # ============================================================================= -# 指定基底映像為 Alpine Linux 最新版;Alpine 體積小,可縮小最終映像大小並加快拉取速度。 -# 需人工確認:使用 latest tag 會在不同時間建置出不同基底版本,可能影響可重現性, -# 建議釘選明確版本(例如 alpine:3.20)以確保建置一致。 -FROM alpine:latest +# 指定支援中的 Alpine Linux 固定版本;Alpine 體積小,可縮小最終映像大小並加快拉取速度。 +# 釘選明確版本可避免 latest tag 隨時間變動,提升建置可重現性。 +FROM alpine:3.24.1 # 安裝必要的工具 # 安裝執行 code review 所需的工具:bash(執行 entrypoint 腳本)、git(前置遠端驗證/取得 diff)、 # nodejs 與 npm(執行 app 內的 Node.js 程式)。 # --no-cache:不保留 apk 套件索引快取,避免殘留在映像層中以減少映像大小。 -# 需人工確認:--no-check-certificate 會略過套件來源的憑證驗證,存在中間人攻擊風險, -# 僅在內網或憑證受限環境下使用;正式環境建議移除以維持安全性。 -RUN apk add --no-cache --no-check-certificate bash git nodejs npm +RUN apk add --no-cache bash git nodejs npm # 將專案的 app/ 目錄複製到映像內的 /app;包含 Node.js 程式碼與 package.json 等相依宣告。 COPY ./app /app -- 2.53.0 From bc0df064457ead64223a4d4f3570f2cd56b92d96 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 09:20:57 +0000 Subject: [PATCH 02/19] =?UTF-8?q?fix(TLS=20=E9=A9=97=E8=AD=89):=20?= =?UTF-8?q?=E5=A5=97=E7=94=A8=E5=85=A8=E5=9F=9F=E6=86=91=E8=AD=89=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.js | 12 ++++++++---- app/git.js | 8 +++++++- app/gitea.js | 5 ++--- app/preflight.js | 4 ++-- app/usage.js | 2 ++ 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/config.js b/app/config.js index 3f205d1..c92f2b1 100644 --- a/app/config.js +++ b/app/config.js @@ -1,5 +1,7 @@ import https from 'https'; +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + export const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; export const GITEA_COMMENT_TOKEN = process.env.GITEA_COMMENT_TOKEN || ''; export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.com'; @@ -14,18 +16,20 @@ export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; /** * 建立一個停用 TLS 憑證驗證(`rejectUnauthorized: false`)的 HTTPS Agent, - * 供連接使用自簽或無效憑證的 OpenCode 服務時使用。 + * 供連接使用自簽或無效憑證的內部服務時使用。 * * @remarks 首次呼叫時建立,之後快取為模組層級單例(singleton)重複使用, * 避免每次都新建 Agent 與連線池、浪費 TCP 三次握手。 * 停用憑證驗證有中間人攻擊風險,僅限受信任的內部環境使用。 * @returns {import('https').Agent} 已關閉憑證驗證的 HTTPS Agent 單例。 */ -let _openCodeHttpsAgent = null; -export function getOpenCodeHttpsAgent() { - return (_openCodeHttpsAgent ??= new https.Agent({ rejectUnauthorized: false })); +let _insecureHttpsAgent = null; +export function getInsecureHttpsAgent() { + return (_insecureHttpsAgent ??= new https.Agent({ rejectUnauthorized: false })); } +export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; + /** * 依環境變數解析並回傳 LLM 提供者設定。 * diff --git a/app/git.js b/app/git.js index 8fc9423..ea8146e 100644 --- a/app/git.js +++ b/app/git.js @@ -61,7 +61,13 @@ function makeRunner(spawn) { function withAskpass(workspace, fn, token = GITEA_TOKEN) { const askpassScript = path.join(workspace, '.git-askpass.sh'); 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 {} }; let result; try { diff --git a/app/gitea.js b/app/gitea.js index ccd6b49..3f06b0a 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -1,9 +1,8 @@ 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 } from './config.js'; +import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH, getInsecureHttpsAgent } from './config.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 `,並非 OAuth Bearer。 diff --git a/app/preflight.js b/app/preflight.js index 3f94fb6..90b8814 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -1,18 +1,18 @@ import axios from 'axios'; -import https from 'https'; import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER, + getInsecureHttpsAgent, getOpenCodeHttpsAgent, getLLMConfig, } from './config.js'; import { verifyRemoteAccess } from './git.js'; import { step, line, ok, error, result } from './log.js'; -const httpsAgent = new https.Agent({ rejectUnauthorized: false }); +const httpsAgent = getInsecureHttpsAgent(); /** * 組出 Gitea REST API v1 的完整網址。 * diff --git a/app/usage.js b/app/usage.js index 9376b56..97eb2df 100644 --- a/app/usage.js +++ b/app/usage.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { getInsecureHttpsAgent } from './config.js'; import { warn } from './log.js'; /** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */ @@ -176,6 +177,7 @@ async function fetchOpenRouterQuota({ apiKey, baseURL }, get) { const resp = await get(`${stripSlash(baseURL)}/auth/key`, { headers: { Authorization: `Bearer ${apiKey}` }, timeout: 30000, + httpsAgent: getInsecureHttpsAgent(), }); const d = resp.data?.data || {}; const used = num(d.usage); -- 2.53.0 From 2fe03e94f03fb06925ca0846ee4887f7f0295c88 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 09:20:57 +0000 Subject: [PATCH 03/19] =?UTF-8?q?test(TLS=20=E9=A9=97=E8=AD=89):=20?= =?UTF-8?q?=E8=A3=9C=E4=B8=8A=E6=86=91=E8=AD=89=E5=BF=BD=E7=95=A5=E8=A1=8C?= =?UTF-8?q?=E7=82=BA=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/test/config.test.js | 4 ++++ app/test/git.test.js | 3 +++ app/test/usage.test.js | 1 + 3 files changed, 8 insertions(+) diff --git a/app/test/config.test.js b/app/test/config.test.js index c6aa636..7139dae 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -48,4 +48,8 @@ describe('getLLMConfig', () => { assert.equal(agent.options.rejectUnauthorized, false); }); + it('disables Node TLS certificate verification globally', () => { + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '0'); + }); + }); diff --git a/app/test/git.test.js b/app/test/git.test.js index 9d9b151..9676d06 100644 --- a/app/test/git.test.js +++ b/app/test/git.test.js @@ -96,6 +96,7 @@ describe('commitAndPush', () => { for (const { args, opts } of networkCalls) { 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'); for (const { args, opts } of networkCalls) { 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'); assert.ok(lsRemote, 'expected git ls-remote to run'); 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', () => { diff --git a/app/test/usage.test.js b/app/test/usage.test.js index 9c42bf0..d6b5f4e 100644 --- a/app/test/usage.test.js +++ b/app/test/usage.test.js @@ -82,6 +82,7 @@ describe('fetchAccountQuota', () => { const get = async (url, opts) => { assert.match(url, /openrouter\.ai\/api\/v1\/auth\/key$/); 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 } } }; }; const q = await fetchAccountQuota('openai', { apiKeys: ['sk-or-xxx'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); -- 2.53.0 From a62bfbd4b8f67641f75e58fdfda2c761c59d4bdb Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 09:20:57 +0000 Subject: [PATCH 04/19] =?UTF-8?q?chore(ai-review=20=E7=8B=80=E6=85=8B):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=B7=B2=E8=A7=A3=E6=B1=BA=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/ai-review/findings.json | 35 +--------------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 1320606..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,34 +1 @@ -[ - { - "level": "critical", - "role": "Assassin", - "location": "Dockerfile:17", - "problem": "在 `apk add` 中使用了 `--no-check-certificate` 參數。這會強制忽略套件來源的 SSL/TLS 憑證檢查,攻擊者可以透過中間人攻擊(MITM)在容器建置期間植入惡意套件,導致整個建置環境被入侵。", - "suggestion": "正式環境嚴禁使用 `--no-check-certificate`。若遇到憑證問題,應排查環境配置(如是否缺少根憑證),或是確實解決憑證驗證問題,確保套件來源可信。", - "is_new": false - }, - { - "level": "warning", - "role": "Assassin", - "location": "Dockerfile:12", - "problem": "使用了 `alpine:latest` 作為基底映像。`latest` 標籤極不穩定,當 Alpine Linux 發布新版本時,建置出的映像環境可能會產生難以預期的變更,甚至引入不相容的套件版本,導致建置再現性(Reproducibility)崩潰。", - "suggestion": "建議釘選明確的 Alpine 版本號(例如 `alpine:3.20`),以確保每次建置的執行環境完全一致且可預測。", - "is_new": false - }, - { - "level": "warning", - "role": "Leo", - "location": "Dockerfile:21", - "problem": "Dockerfile 中留下了明確的安全性警告註解(關於 --no-check-certificate)。這種將安全性風險留在程式碼中並以註解提醒的做法,容易被未來的維護者忽略,導致潛在的安全性漏洞。", - "suggestion": "若此 flag 僅為暫時性,應盡快優化環境以移除它;若不可避免,應將此設定集中管理,而非在 Dockerfile 中寫死此類不安全的設定。", - "is_new": false - }, - { - "level": "info", - "role": "Leo", - "location": "app/git.js:194", - "problem": "`withAskpass` is tightly coupled with Gitea's bot-trigger behavior.", - "suggestion": "Encapsulate bot commit logic within `commitAndPush`, auto-selecting the correct `GITEA_COMMENT_TOKEN`.", - "is_new": false - } -] +[] -- 2.53.0 From 6c3e7b9d375728701f0a5bbb73a40781e8916103 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 09:26:12 +0000 Subject: [PATCH 05/19] =?UTF-8?q?fix(LLM=20=E5=AF=A9=E6=9F=A5=E6=B5=81?= =?UTF-8?q?=E7=A8=8B):=20=E9=81=BF=E5=85=8D=E5=96=AE=E4=B8=80=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=A4=B1=E6=95=97=E4=B8=AD=E6=AD=A2=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/llm.js | 29 +++++++++++++++++++++++------ app/main.js | 10 +++++++++- app/test/llm.test.js | 14 +++++++++----- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/app/llm.js b/app/llm.js index bd63588..71f06cb 100644 --- a/app/llm.js +++ b/app/llm.js @@ -1,7 +1,7 @@ import axios from 'axios'; import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js'; import { recordUsage } from './usage.js'; -import { line, error } from './log.js'; +import { line } from './log.js'; /** * 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。 @@ -49,6 +49,23 @@ function extractOpenCodeContent(data) { .join(''); } +function summarizeErrorResponse(data) { + if (data == null) return ''; + if (typeof data === 'string') return data.slice(0, 500); + try { + return JSON.stringify(data).slice(0, 500); + } catch { + return String(data).slice(0, 500); + } +} + +function formatOpenCodeError(e) { + const status = e.response?.status; + const response = summarizeErrorResponse(e.response?.data); + const statusText = status ? `HTTP ${status}` : e.message; + return response ? `${statusText}: ${response}` : statusText; +} + /** * 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。 * @@ -91,8 +108,8 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) * 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。 * * 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄 - * usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並以 `process.exit(1)` - * 終止整個行程(不會回傳)。 + * usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並向外拋出,讓呼叫端 + * 決定是否降級、略過單一角色或終止整體流程。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 @@ -112,10 +129,10 @@ export async function chat(systemPrompt, userContent) { recordUsage(data); return content; } catch (e) { - line(`[LLM] OpenCode 呼叫失敗: ${e.message}`); + const message = formatOpenCodeError(e); + line(`[LLM] OpenCode 呼叫失敗: ${message}`); + throw new Error(message); } - error('[LLM] OpenCode 呼叫失敗,終止流程'); - process.exit(1); } /** diff --git a/app/main.js b/app/main.js index ec3974e..ca1b967 100644 --- a/app/main.js +++ b/app/main.js @@ -122,10 +122,18 @@ async function main() { } const analyses = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const newFindings = []; + let fulfilledAnalyses = 0; for (let i = 0; i < analyses.length; i++) { - if (analyses[i].status === 'fulfilled') newFindings.push(...analyses[i].value); + if (analyses[i].status === 'fulfilled') { + fulfilledAnalyses += 1; + newFindings.push(...analyses[i].value); + } else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`); } + if (fulfilledAnalyses === 0) { + result(false, '所有角色分析皆失敗,終止流程以避免誤判為審查通過'); + process.exit(1); + } // 對只有檔名、缺行號的問題,反問原角色補上行號(最多重試數次),確保後續能行內標註 await resolveMissingLineNumbers(newFindings, diff); output(`新 findings ${newFindings.length} 筆(${formatFindingsStatsLine(newFindings)})`); diff --git a/app/test/llm.test.js b/app/test/llm.test.js index b99dd94..a23122f 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -85,14 +85,18 @@ describe('chat - OpenCode', async () => { assert.equal(result, 'hello world'); }); - it('calls process.exit(1) when OpenCode fails', async () => { + it('throws an error when OpenCode fails instead of exiting the process', 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'); }); + mock.method(axios, 'post', async () => { + const err = new Error('Request failed with status code 500'); + err.response = { status: 500, data: { error: 'provider overloaded' } }; + throw err; + }); + const exitMock = mock.method(process, 'exit', () => { throw new Error('exit should not be called'); }); - await assert.rejects(() => chat('sys', 'user'), /exit:1/); + await assert.rejects(() => chat('sys', 'user'), /HTTP 500.*provider overloaded/); - assert.equal(exitMock.mock.calls[0].arguments[0], 1); + assert.equal(exitMock.mock.calls.length, 0); }); }); -- 2.53.0 From 93be261b909299c3fb87163992cab29c6fb57a96 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 09:36:33 +0000 Subject: [PATCH 06/19] =?UTF-8?q?fix(OpenCode=20=E5=91=BC=E5=8F=AB):=20?= =?UTF-8?q?=E9=99=8D=E4=BD=8E=E4=B8=A6=E8=A1=8C=E8=AB=8B=E6=B1=82=E4=B8=A6?= =?UTF-8?q?=E9=87=8D=E8=A9=A6=E6=9A=AB=E6=99=82=E6=80=A7=E9=8C=AF=E8=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/llm.js | 28 +++++++++++++++++++++++++++- app/main.js | 11 ++++++----- app/test/llm.test.js | 24 +++++++++++++++++++++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/app/llm.js b/app/llm.js index 71f06cb..eaf77e4 100644 --- a/app/llm.js +++ b/app/llm.js @@ -32,6 +32,10 @@ function opencodeAxiosOptions(headers) { }; } +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + /** * 從 OpenCode 訊息回應中抽取並串接所有文字片段。 * @@ -66,6 +70,11 @@ function formatOpenCodeError(e) { return response ? `${statusText}: ${response}` : statusText; } +function isTransientOpenCodeError(e) { + const status = e.response?.status; + return status === 500 || status === 502 || status === 503 || status === 504 || status === 429; +} + /** * 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。 * @@ -104,6 +113,23 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) return { content: extractOpenCodeContent(resp.data), data: resp.data }; } +async function chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers) { + const maxAttempts = Number(process.env.OPENCODE_RETRY_ATTEMPTS || 3); + let lastError; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); + } catch (e) { + lastError = e; + if (!isTransientOpenCodeError(e) || attempt === maxAttempts) throw e; + const delay = Math.min(1000 * 2 ** (attempt - 1), 8000); + line(`[LLM] OpenCode 暫時性錯誤,${delay}ms 後重試 (${attempt}/${maxAttempts}): ${formatOpenCodeError(e)}`); + await sleep(delay); + } + } + throw lastError; +} + /** * 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。 * @@ -125,7 +151,7 @@ export async function chat(systemPrompt, userContent) { const headers = { 'Content-Type': 'application/json' }; try { - const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); + const { content, data } = await chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers); recordUsage(data); return content; } catch (e) { diff --git a/app/main.js b/app/main.js index ca1b967..cc7e68f 100644 --- a/app/main.js +++ b/app/main.js @@ -120,15 +120,16 @@ async function main() { } catch (e) { warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); } - const analyses = await Promise.allSettled(roles.map(role => analyzeWithRole(role, diff))); const newFindings = []; let fulfilledAnalyses = 0; - for (let i = 0; i < analyses.length; i++) { - if (analyses[i].status === 'fulfilled') { + for (const role of roles) { + try { + const findings = await analyzeWithRole(role, diff); fulfilledAnalyses += 1; - newFindings.push(...analyses[i].value); + newFindings.push(...findings); + } catch (e) { + warn(`[${role.name}] 分析失敗(跳過): ${e.message}`); } - else warn(`[${roles[i].name}] 分析失敗(跳過): ${analyses[i].reason?.message}`); } if (fulfilledAnalyses === 0) { result(false, '所有角色分析皆失敗,終止流程以避免誤判為審查通過'); diff --git a/app/test/llm.test.js b/app/test/llm.test.js index a23122f..ffb3e1e 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -4,7 +4,7 @@ import axios from 'axios'; import { extractBalancedJSON, extractJSONText } from '../llm.js'; const ENV_KEYS = [ - 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', + 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_RETRY_ATTEMPTS', ]; let saved = {}; @@ -87,6 +87,7 @@ describe('chat - OpenCode', async () => { it('throws an error when OpenCode fails instead of exiting the process', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + process.env.OPENCODE_RETRY_ATTEMPTS = '1'; mock.method(axios, 'post', async () => { const err = new Error('Request failed with status code 500'); err.response = { status: 500, data: { error: 'provider overloaded' } }; @@ -98,6 +99,27 @@ describe('chat - OpenCode', async () => { assert.equal(exitMock.mock.calls.length, 0); }); + + it('retries transient OpenCode failures before returning content', async () => { + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + process.env.OPENCODE_RETRY_ATTEMPTS = '2'; + let messageAttempts = 0; + mock.method(axios, 'post', async (url) => { + if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; + messageAttempts += 1; + if (messageAttempts === 1) { + const err = new Error('Request failed with status code 500'); + err.response = { status: 500, data: { error: 'temporary failure' } }; + throw err; + } + return { data: { parts: [{ type: 'text', text: 'ok after retry' }] } }; + }); + + const result = await chat('sys', 'user'); + + assert.equal(result, 'ok after retry'); + assert.equal(messageAttempts, 2); + }); }); describe('chatJSON', async () => { -- 2.53.0 From 08a72a8c7fda46e14da2d92b711b714a8725e8fd Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:30:40 +0000 Subject: [PATCH 07/19] =?UTF-8?q?feat(ai-code-review):=20=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20AI=20=E5=8A=A9=E7=90=86=20CLI=20=E5=9F=B7=E8=A1=8C?= =?UTF-8?q?=E5=AF=A9=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- action.yaml | 49 ++++++----- app/config.js | 62 +++++++++---- app/llm.js | 225 ++++++++++++++++++++--------------------------- app/preflight.js | 64 +++----------- 4 files changed, 177 insertions(+), 223 deletions(-) diff --git a/action.yaml b/action.yaml index 31e568a..d8f86a7 100644 --- a/action.yaml +++ b/action.yaml @@ -8,27 +8,30 @@ inputs: comment_token: description: '' required: false - 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 + model: + description: '' + required: true runs: - using: 'docker' - image: 'Dockerfile' - env: - GITEA_SERVER_URL: ${{ gitea.server_url }} - GITEA_REPOSITORY: ${{ gitea.repository }} - GITEA_TOKEN: ${{ inputs.token || gitea.token }} - GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || gitea.token }} - PR_NUMBER: ${{ gitea.event.pull_request.number }} - PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }} - PR_HEAD_BRANCH: ${{ gitea.event.pull_request.head.ref }} - PR_BASE_BRANCH: ${{ gitea.event.pull_request.base.ref }} - OPENCODE_BASE_URL: ${{ inputs.opencode_base_url }} - OPENCODE_MODEL: ${{ inputs.opencode_model }} - OPENCODE_PROVIDER: ${{ inputs.opencode_provider }} + using: 'composite' + steps: + - name: 安裝 Node.js 相依套件 + shell: bash + run: | + cd "${GITHUB_ACTION_PATH:-${GITEA_ACTION_PATH:-.}}/app" + npm ci + - name: 執行 AI 程式碼審查 + shell: bash + env: + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ inputs.token || gitea.token }} + GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || gitea.token }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }} + PR_HEAD_BRANCH: ${{ gitea.event.pull_request.head.ref }} + PR_BASE_BRANCH: ${{ gitea.event.pull_request.base.ref }} + MODEL: ${{ inputs.model }} + run: | + echo "🚀 AI Code Review Action 啟動" + cd "${GITHUB_ACTION_PATH:-${GITEA_ACTION_PATH:-.}}/app" + node main.js diff --git a/app/config.js b/app/config.js index c92f2b1..e76389c 100644 --- a/app/config.js +++ b/app/config.js @@ -1,4 +1,5 @@ import https from 'https'; +import { execFileSync } from 'child_process'; process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; @@ -30,25 +31,56 @@ export function getInsecureHttpsAgent() { export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; +const CLI_CANDIDATES = [ + { + provider: 'codex', + command: 'codex', + defaultModel: 'gpt-5', + }, + { + provider: 'claude', + command: 'claude', + defaultModel: 'sonnet', + }, + { + provider: 'opencode', + command: 'opencode', + defaultModel: 'google/gemini-2.5-flash', + }, +]; + +function commandExists(command) { + try { + execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + /** * 依環境變數解析並回傳 LLM 提供者設定。 * - * 當設定了 `OPENCODE_BASE_URL` 時回傳 OpenCode 提供者設定 - * (model 取自 `OPENCODE_MODEL`,預設為 `gemini-2.5-flash`); - * 否則回傳各欄位皆為空/null 的「無提供者」設定,由呼叫端據此判斷是否略過 LLM 流程。 + * 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、opencode。 + * model 優先取 `MODEL`,再相容舊的 `OPENCODE_MODEL`,最後使用各 CLI 預設值。 * - * @remarks 每次呼叫都會即時讀取 `process.env`。`apiKeys` 在 OpenCode 模式下為固定佔位值 `['opencode']`,並非真實金鑰。 - * @returns {{ provider: ('opencode'|null), apiKeys: string[], baseURL: (string|null), model: (string|null) }} + * @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 CLI 偵測函式,供測試使用。 + * @returns {{ provider: ('codex'|'claude'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }} * LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。 */ -export function getLLMConfig() { - if (process.env.OPENCODE_BASE_URL) { - return { - provider: 'opencode', - apiKeys: ['opencode'], - baseURL: process.env.OPENCODE_BASE_URL, - model: process.env.OPENCODE_MODEL || 'gemini-2.5-flash', - }; - } - return { provider: null, apiKeys: [], baseURL: null, model: 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, + }; } diff --git a/app/llm.js b/app/llm.js index eaf77e4..7a27866 100644 --- a/app/llm.js +++ b/app/llm.js @@ -1,168 +1,129 @@ -import axios from 'axios'; -import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js'; +import * as childProcess from 'child_process'; +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 { line } from './log.js'; /** - * 將模型識別字串解析為 OpenCode API 所需的 provider 與 model 識別碼。 - * - * 當字串含有 `/` 時視為 `providerID/modelID` 形式並拆解;否則 provider - * 取環境變數 `OPENCODE_PROVIDER`(預設 `google`),model 則為整個字串。 - * - * @param {string} model - 模型識別字串,例如 `"google/gemini-2.0"` 或 `"gemini-2.0"`。 - * @returns {{ providerID: string, modelID: string }} 拆解後的 provider 與 model 識別碼。 + * 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。 */ -function opencodeModelConfig(model) { - const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; - return { providerID, modelID }; +function buildPrompt(systemPrompt, userContent) { + return [ + '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。', + '', + '', + systemPrompt, + '', + '', + '', + userContent, + '', + ].join('\n'); } -/** - * 建立傳給 axios 的共用請求選項,統一注入 headers 與 OpenCode 專用的 HTTPS agent。 - * - * 供本模組所有 OpenCode HTTP 呼叫共用,集中管理連線設定。 - * - * @param {Record} headers - 要附加於請求的 HTTP 標頭。 - * @returns {{ headers: Record, httpsAgent: import('https').Agent }} axios 請求選項物件。 - */ -function opencodeAxiosOptions(headers) { - return { - headers, - httpsAgent: getOpenCodeHttpsAgent(), - }; +function cliArgs(provider, model, promptFile = null) { + if (provider === 'codex') { + return ['exec', '--model', model, '--sandbox', 'read-only', '--ask-for-approval', 'never', '--skip-git-repo-check', '-']; + } + if (provider === 'claude') { + return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence']; + } + if (provider === 'opencode') { + return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。']; + } + throw new Error(`不支援的 AI 助理 CLI: ${provider}`); } -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); +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); } -/** - * 從 OpenCode 訊息回應中抽取並串接所有文字片段。 - * - * 以多重 fallback 相容不同包裹層級的回應結構(`parts` / `data.parts` / - * `info.content` / `data.info.content`),逐片段取 `text` 或 `content` 後串接。 - * - * @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(''); -} - -function summarizeErrorResponse(data) { - if (data == null) return ''; - if (typeof data === 'string') return data.slice(0, 500); +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); + 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 JSON.stringify(data).slice(0, 500); - } catch { - return String(data).slice(0, 500); + 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' ? '' : prompt); + }); + } finally { + if (tempDir) await rm(tempDir, { recursive: true, force: true }); } } -function formatOpenCodeError(e) { - const status = e.response?.status; - const response = summarizeErrorResponse(e.response?.data); - const statusText = status ? `HTTP ${status}` : e.message; - return response ? `${statusText}: ${response}` : statusText; -} - -function isTransientOpenCodeError(e) { - const status = e.response?.status; - return status === 500 || status === 502 || status === 503 || status === 504 || status === 429; -} - /** - * 對 OpenCode server 執行一次完整對話:建立 session 後送出訊息並回傳結果。 + * 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應。 * - * 先 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} 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 }; -} - -async function chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers) { - const maxAttempts = Number(process.env.OPENCODE_RETRY_ATTEMPTS || 3); - let lastError; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); - } catch (e) { - lastError = e; - if (!isTransientOpenCodeError(e) || attempt === maxAttempts) throw e; - const delay = Math.min(1000 * 2 ** (attempt - 1), 8000); - line(`[LLM] OpenCode 暫時性錯誤,${delay}ms 後重試 (${attempt}/${maxAttempts}): ${formatOpenCodeError(e)}`); - await sleep(delay); - } - } - throw lastError; -} - -/** - * 對 OpenCode server 送出一次對話請求並回傳模型純文字回應。 - * - * 從設定取得 provider/baseURL/model;未設定 provider 時拋錯。成功時記錄 - * usage 並回傳內容。OpenCode 呼叫失敗時會記錄錯誤並向外拋出,讓呼叫端 - * 決定是否降級、略過單一角色或終止整體流程。 + * 從設定取得 provider/command/model;未偵測到 CLI 時拋錯。成功時記錄一次 + * usage 呼叫(CLI 通常不回傳 token 明細,因此 token 可能為 0)並回傳內容。 * * @param {string} systemPrompt - 系統提示詞。 * @param {string} userContent - 使用者輸入內容。 * @returns {Promise} 模型回應的純文字內容。 - * @throws {Error} 當未設定 OpenCode server(缺少 provider)時。 + * @throws {Error} 當未偵測到可用 AI 助理 CLI,或 CLI 呼叫失敗時。 */ export async function chat(systemPrompt, userContent) { - const { provider, baseURL, model } = getLLMConfig(); - if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL'); + const cfg = getLLMConfig(); + const { provider, command, model } = cfg; + if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude 或 opencode'); - line(`[LLM] provider=${provider} model=${model}`); - - const headers = { 'Content-Type': 'application/json' }; + line(`[LLM] provider=${provider} command=${command} model=${model}`); try { - const { content, data } = await chatOpenCodeWithRetry(baseURL, model, systemPrompt, userContent, headers); - recordUsage(data); + const content = await runAssistantCLI(cfg, buildPrompt(systemPrompt, userContent)); + recordUsage(null); return content; } catch (e) { - const message = formatOpenCodeError(e); - line(`[LLM] OpenCode 呼叫失敗: ${message}`); + const message = summarizeCliError(e); + line(`[LLM] ${provider} CLI 呼叫失敗: ${message}`); throw new Error(message); } } /** - * 對 OpenCode 送出對話並將回應解析為 JSON 物件/陣列。 + * 對 AI 助理 CLI 送出對話並將回應解析為 JSON 物件/陣列。 * * 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。 * 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。 diff --git a/app/preflight.js b/app/preflight.js index 90b8814..ca7586d 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -6,7 +6,6 @@ import { GITEA_REPOSITORY, PR_NUMBER, getInsecureHttpsAgent, - getOpenCodeHttpsAgent, getLLMConfig, } from './config.js'; import { verifyRemoteAccess } from './git.js'; @@ -30,33 +29,6 @@ const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; * @returns {{Authorization: string, 'Content-Type': string}} 可直接交給 axios 的 headers 物件。 */ const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); -/** - * 將模型字串解析為 OpenCode 的 providerID 與 modelID。 - * - * 若 model 含 `/` 則以斜線拆分為 provider/model;否則 provider 取 - * 環境變數 OPENCODE_PROVIDER(預設 `google`),model 即原字串。 - * @param {string} model - 模型識別字串,例如 `google/gemini-2.0` 或 `gemini-2.0`。 - * @returns {{providerID: string, modelID: string}} 解析後的 provider 與 model 識別碼。 - * @remarks 讀取 process.env.OPENCODE_PROVIDER;split 上限為 2 段,多餘段落會被忽略。 - */ -const opencodeModelConfig = (model) => { - const [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)錯誤格式化為易讀的訊息字串。 * @@ -124,37 +96,23 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { } /** - * 驗證 LLM(OpenCode server)設定可用。 + * 驗證 LLM(AI 助理 CLI)設定可用。 * - * 依序確認:已設定 provider、有 base URL、health 端點可連線、OpenCode 已設定 - * 對應 provider 且其列出指定 model。任一不符回傳對應錯誤;錯誤被攔截不會 throw。 + * 確認目前環境可偵測到支援的 CLI,且已解析出 model。實際模型可用性由 CLI + * 在正式呼叫時回報;preflight 不主動送 prompt,避免額外消耗額度。 * @returns {Promise<{ok: true, provider: string}|{ok: false, provider?: string, error: string}>} * 通過時含 provider;未設定 provider 的失敗分支不含 provider 欄位。 - * @remarks 設定來源為 config.js 的 getLLMConfig();provider/model 鍵的比對使用 opencodeModelConfig 解析後的 providerID/modelID。 + * @remarks 設定來源為 config.js 的 getLLMConfig()。 */ export async function verifyLLM() { - const { provider, baseURL, model } = getLLMConfig(); - if (!provider) return { ok: false, error: '未設定 OpenCode server,請設定 OPENCODE_BASE_URL' }; - if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` }; - - 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}` }; - } + const { provider, command, model } = getLLMConfig(); + if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude 或 opencode' }; + if (!model) return { ok: false, provider, error: '未設定 MODEL' }; + return { ok: true, provider, command, model }; } /** - * 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM。 + * 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM CLI。 * * 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。 * 各檢查可經 deps 注入覆寫,方便單元測試。 @@ -164,7 +122,7 @@ export async function verifyLLM() { * @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。 * @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。 * @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。 - * @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM(OpenCode)連線驗證。 + * @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM(AI 助理 CLI)驗證。 * @returns {Promise} 全部通過為 true,任一失敗為 false。 * @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}`); return false; } - ok(`LLM provider=${llm.provider} 連線正常`); + ok(`LLM provider=${llm.provider} CLI 可用`); result(true, '前置驗證通過'); return true; -- 2.53.0 From 4fcb240208d5003d2e708fc90a0b062d0859df83 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:30:45 +0000 Subject: [PATCH 08/19] =?UTF-8?q?test(ai-code-review):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20CLI=20=E5=91=BC=E5=8F=AB=E8=88=87=E5=89=8D=E7=BD=AE?= =?UTF-8?q?=E9=A9=97=E8=AD=89=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/test/config.test.js | 35 +++++---- app/test/llm.test.js | 151 ++++++++++++++----------------------- app/test/preflight.test.js | 96 +++++++++-------------- 3 files changed, 115 insertions(+), 167 deletions(-) diff --git a/app/test/config.test.js b/app/test/config.test.js index 7139dae..942acc3 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; const ENV_KEYS = [ - 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', + 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', ]; let saved = {}; @@ -20,26 +20,35 @@ afterEach(() => { describe('getLLMConfig', () => { it('returns null provider when no env vars set', () => { - const cfg = getLLMConfig(); + const cfg = getLLMConfig({ commandExistsFn: () => false }); assert.equal(cfg.provider, null); assert.deepEqual(cfg.apiKeys, []); + assert.equal(cfg.command, null); }); - 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 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('detects opencode server with custom model', () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + 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('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(); + const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' || command === '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'); }); diff --git a/app/test/llm.test.js b/app/test/llm.test.js index ffb3e1e..5d7bd5c 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -1,133 +1,96 @@ import { describe, it, beforeEach, afterEach, mock } from 'node:test'; 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'; const ENV_KEYS = [ - 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', 'OPENCODE_RETRY_ATTEMPTS', + '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(() => { +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(); }); -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 }] } }; - }); +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 cat > "$FAKE_AI_STDIN_PATH"; else 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}:${saved.PATH || ''}`; + 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'); - 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' }] } }; - }); + 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, '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); + 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, /\nsys\n<\/system>/); + assert.match(prompt, /\nuser\n<\/user>/); }); - 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' }] } }; - }); + 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, 'hello world'); + assert.equal(result, 'ok'); + assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/); }); - it('throws an error when OpenCode fails instead of exiting the process', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_RETRY_ATTEMPTS = '1'; - mock.method(axios, 'post', async () => { - const err = new Error('Request failed with status code 500'); - err.response = { status: 500, data: { error: 'provider overloaded' } }; - throw err; - }); + 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'), /HTTP 500.*provider overloaded/); + await assert.rejects(() => chat('sys', 'user'), /provider overloaded/); assert.equal(exitMock.mock.calls.length, 0); }); - - it('retries transient OpenCode failures before returning content', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_RETRY_ATTEMPTS = '2'; - let messageAttempts = 0; - mock.method(axios, 'post', async (url) => { - if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; - messageAttempts += 1; - if (messageAttempts === 1) { - const err = new Error('Request failed with status code 500'); - err.response = { status: 500, data: { error: 'temporary failure' } }; - throw err; - } - return { data: { parts: [{ type: 'text', text: 'ok after retry' }] } }; - }); - - const result = await chat('sys', 'user'); - - assert.equal(result, 'ok after retry'); - assert.equal(messageAttempts, 2); - }); }); 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"}]'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '[{"level":"critical"}]'; const result = await chatJSON('sys', 'user'); @@ -135,8 +98,8 @@ describe('chatJSON', async () => { }); it('strips markdown code block before parsing', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - mockOpenCodeResponse('```json\n[{"level":"info"}]\n```'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '```json\n[{"level":"info"}]\n```'; const result = await chatJSON('sys', 'user'); @@ -144,8 +107,8 @@ describe('chatJSON', async () => { }); 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.'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.'; const result = await chatJSON('sys', 'user'); @@ -153,8 +116,8 @@ describe('chatJSON', async () => { }); 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"}'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}'; const result = await chatJSON('sys', 'user'); @@ -162,8 +125,8 @@ describe('chatJSON', async () => { }); it('returns [] when JSON is invalid', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - mockOpenCodeResponse('not json'); + await installFakeCLI('codex'); + process.env.FAKE_AI_STDOUT = 'not json'; const result = await chatJSON('sys', 'user'); diff --git a/app/test/preflight.test.js b/app/test/preflight.test.js index 8d02e5e..4f6e4c6 100644 --- a/app/test/preflight.test.js +++ b/app/test/preflight.test.js @@ -1,21 +1,38 @@ import { describe, it, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; 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'; 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() { for (const k of LLM_ENV_KEYS) delete process.env[k]; } -afterEach(() => { +let tempDir; + +afterEach(async () => { mock.restoreAll(); 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', () => { it('reports all three missing when nothing provided', () => { const result = checkRequiredEnv({ token: '', repo: '', pr: '' }); @@ -102,81 +119,40 @@ describe('verifyCommentToken', () => { }); describe('verifyLLM', () => { - it('fails when OpenCode is not configured', async () => { + it('fails when no supported assistant CLI is detected', async () => { clearLLMEnv(); + process.env.AI_ASSISTANT_CLI = 'no-such-ai-cli'; + process.env.PATH = ''; const result = await verifyLLM(); 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(); - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_PROVIDER = 'google'; - process.env.OPENCODE_MODEL = 'gemini-2.5-flash'; - 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' } } }] } }; - }); + await installFakeCLI('codex'); + process.env.AI_ASSISTANT_CLI = 'codex'; + process.env.MODEL = 'gpt-5-mini'; const result = await verifyLLM(); assert.equal(result.ok, true); - assert.equal(result.provider, 'opencode'); - assert.deepEqual(urls, ['http://opencode.local:4096/global/health', 'http://opencode.local:4096/config/providers']); + assert.equal(result.provider, 'codex'); + 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(); - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_PROVIDER = 'google'; - mock.method(axios, 'get', async (url) => { - if (url.endsWith('/global/health')) return { data: { healthy: true } }; - return { data: { providers: [{ id: 'anthropic', models: {} }] } }; - }); + process.env.AI_ASSISTANT_CLI = 'missing-cli'; + process.env.PATH = ''; const result = await verifyLLM(); assert.equal(result.ok, false); - assert.match(result.error, /未設定 provider=google/); - }); - - 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); + assert.match(result.error, /AI 助理 CLI/); }); }); @@ -188,7 +164,7 @@ describe('runPreflight', () => { verifyToken: async () => ({ ok: true }), verifyComment: async () => ({ ok: true }), verifyRemote: () => ({ ok: true }), - verifyLLMFn: async () => ({ ok: true, provider: 'opencode' }), + verifyLLMFn: async () => ({ ok: true, provider: 'codex' }), ...overrides, }; } @@ -239,7 +215,7 @@ describe('runPreflight', () => { it('returns false when LLM verification fails', async () => { const result = await runPreflight('/ws', makeDeps({ - verifyLLMFn: async () => ({ ok: false, error: 'OpenCode server 驗證失敗' }), + verifyLLMFn: async () => ({ ok: false, error: 'AI 助理 CLI 驗證失敗' }), })); assert.equal(result, false); }); -- 2.53.0 From e17c25ce390e83df2af4615b450f45d36cce2e2d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:30:50 +0000 Subject: [PATCH 09/19] =?UTF-8?q?chore(ci):=20=E8=AA=BF=E6=95=B4=20AI=20?= =?UTF-8?q?=E5=AF=A9=E6=9F=A5=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=A8=8B=E8=88=87?= =?UTF-8?q?=E6=A0=B9=E7=9B=AE=E9=8C=84=E6=B8=AC=E8=A9=A6=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 21 ++++++++++++++++----- package.json | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 package.json diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0377269..fd2171e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -16,10 +16,22 @@ on: types: [opened, synchronize] # 定義此 workflow 的 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 ai-code-review: # job 顯示名稱 name: AI Code Review + needs: release-tag-version # 指定執行環境的 runner 標籤:ubuntu runs-on: ubuntu # 此 job 所需的權限設定 @@ -34,15 +46,14 @@ jobs: steps: - name: 取得程式碼 uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - # 步驟名稱:呼叫 OpenCode 進行 AI 程式碼審查 + - name: 安裝 AI 工具 + uses: https://gitea.jsc.idv.tw/docker-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} - name: AI 程式碼審查 # 使用外部 composite action 執行審查邏輯 # 版本由 repository variable ACTION_OPENCODE_CODE_REVIEW_VERSION 決定,便於集中管理版本 - uses: ./ + uses: https://gitea.jsc.idv.tw/docker-actions/ai-code-review@v${{ needs.release-tag-version.outputs.version }} # 傳遞給 composite action 的輸入參數 with: # 留言用 token:取自 secret COMMENT_TOKEN,供 action 在 PR 上發布審查留言 token: ${{ secrets.TOKEN }} - opencode_base_url: ${{ vars.OPENCODE_BASE_URL }} - opencode_provider: ${{ vars.OPENCODE_PROVIDER }} - opencode_model: ${{ vars.OPENCODE_PROVIDER_MODEL }} + model: ${{ vars.AI_CODE_REVIEW_MODEL }} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2272d1b --- /dev/null +++ b/package.json @@ -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" +} -- 2.53.0 From df34376d9558941d87ac5a5c17378936d1886c76 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:38:31 +0000 Subject: [PATCH 10/19] =?UTF-8?q?chore(ci):=20=E5=A2=9E=E5=8A=A0=20AI=20?= =?UTF-8?q?=E5=8A=A9=E7=90=86=20CLI=20=E5=B7=A5=E5=85=B7=E6=AA=A2=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index fd2171e..8f63b1f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -48,9 +48,25 @@ jobs: uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - name: 安裝 AI 工具 uses: https://gitea.jsc.idv.tw/docker-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} + - name: 檢查 AI 助理 CLI + shell: bash + run: | + set -e + found="" + for tool in codex claude opencode; 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(需要 codex、claude 或 opencode 其中之一)" >&2 + exit 1 + fi + echo "可用 AI 助理 CLI:${found}" - name: AI 程式碼審查 - # 使用外部 composite action 執行審查邏輯 - # 版本由 repository variable ACTION_OPENCODE_CODE_REVIEW_VERSION 決定,便於集中管理版本 + # 使用外部 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 }} # 傳遞給 composite action 的輸入參數 with: -- 2.53.0 From 4818e82d765fc94913688316b7952a4cca24c4dd Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:39:04 +0000 Subject: [PATCH 11/19] =?UTF-8?q?chore(ci):=20=E6=94=AF=E6=8F=B4=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=20AI=20=E5=8A=A9=E7=90=86=20CLI=20=E5=80=99=E9=81=B8?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8f63b1f..0d925bb 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -50,17 +50,20 @@ jobs: uses: https://gitea.jsc.idv.tw/docker-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} - name: 檢查 AI 助理 CLI shell: bash + env: + AI_ASSISTANT_CLI_TOOLS: ${{ vars.AI_ASSISTANT_CLI_TOOLS }} run: | set -e + tools="${AI_ASSISTANT_CLI_TOOLS:-codex claude opencode}" found="" - for tool in codex claude opencode; do + 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(需要 codex、claude 或 opencode 其中之一)" >&2 + echo "找不到可用的 AI 助理 CLI(候選工具:${tools})" >&2 exit 1 fi echo "可用 AI 助理 CLI:${found}" -- 2.53.0 From 5457e680652c73be8b475947e5c471b58423ac56 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:41:15 +0000 Subject: [PATCH 12/19] =?UTF-8?q?chore(ci):=20=E6=94=B9=E5=9B=9E=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E5=81=B5=E6=B8=AC=E5=B7=B2=E5=AE=89=E8=A3=9D=20AI=20?= =?UTF-8?q?=E5=8A=A9=E7=90=86=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0d925bb..8f63b1f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -50,20 +50,17 @@ jobs: uses: https://gitea.jsc.idv.tw/docker-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} - name: 檢查 AI 助理 CLI shell: bash - env: - AI_ASSISTANT_CLI_TOOLS: ${{ vars.AI_ASSISTANT_CLI_TOOLS }} run: | set -e - tools="${AI_ASSISTANT_CLI_TOOLS:-codex claude opencode}" found="" - for tool in $tools; do + for tool in codex claude opencode; 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 + echo "找不到可用的 AI 助理 CLI(需要 codex、claude 或 opencode 其中之一)" >&2 exit 1 fi echo "可用 AI 助理 CLI:${found}" -- 2.53.0 From 6f997083efd02c8366131334cde952d5e8690aa3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:47:44 +0000 Subject: [PATCH 13/19] =?UTF-8?q?feat(ai-code-review):=20=E6=94=AF?= =?UTF-8?q?=E6=8F=B4=20Antigravity=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 5 +++-- app/config.js | 18 ++++++++++++++++-- app/llm.js | 11 +++++++---- app/preflight.js | 2 +- app/test/config.test.js | 13 ++++++++++++- app/test/llm.test.js | 21 +++++++++++++++++++-- app/test/usage.test.js | 1 + app/usage.js | 1 + 8 files changed, 60 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8f63b1f..c5155fc 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -52,15 +52,16 @@ jobs: 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 codex claude opencode; do + 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(需要 codex、claude 或 opencode 其中之一)" >&2 + echo "找不到可用的 AI 助理 CLI(需要 ${tools} 其中之一)" >&2 exit 1 fi echo "可用 AI 助理 CLI:${found}" diff --git a/app/config.js b/app/config.js index e76389c..5efc5b4 100644 --- a/app/config.js +++ b/app/config.js @@ -42,6 +42,16 @@ const CLI_CANDIDATES = [ 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', @@ -49,6 +59,10 @@ const CLI_CANDIDATES = [ }, ]; +export function getLLMCLICommands() { + return CLI_CANDIDATES.map(c => c.command); +} + function commandExists(command) { try { execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' }); @@ -61,11 +75,11 @@ function commandExists(command) { /** * 依環境變數解析並回傳 LLM 提供者設定。 * - * 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、opencode。 + * 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、antigravity、opencode。 * model 優先取 `MODEL`,再相容舊的 `OPENCODE_MODEL`,最後使用各 CLI 預設值。 * * @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 CLI 偵測函式,供測試使用。 - * @returns {{ provider: ('codex'|'claude'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }} + * @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 } = {}) { diff --git a/app/llm.js b/app/llm.js index 7a27866..470439f 100644 --- a/app/llm.js +++ b/app/llm.js @@ -23,13 +23,16 @@ function buildPrompt(systemPrompt, userContent) { ].join('\n'); } -function cliArgs(provider, model, promptFile = null) { +function cliArgs(provider, model, promptFile = null, prompt = null) { if (provider === 'codex') { return ['exec', '--model', model, '--sandbox', 'read-only', '--ask-for-approval', 'never', '--skip-git-repo-check', '-']; } 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 的完整內容執行,並只輸出要求的最終結果。']; } @@ -50,7 +53,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) { promptFile = join(tempDir, 'prompt.md'); await writeFile(promptFile, prompt); } - const args = cliArgs(provider, model, promptFile); + 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 { @@ -86,7 +89,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) { 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' ? '' : prompt); + child.stdin.end(provider === 'opencode' || provider === 'antigravity' ? '' : prompt); }); } finally { if (tempDir) await rm(tempDir, { recursive: true, force: true }); @@ -107,7 +110,7 @@ async function runAssistantCLI({ provider, command, model }, prompt) { export async function chat(systemPrompt, userContent) { const cfg = getLLMConfig(); const { provider, command, model } = cfg; - if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude 或 opencode'); + if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode'); line(`[LLM] provider=${provider} command=${command} model=${model}`); diff --git a/app/preflight.js b/app/preflight.js index ca7586d..e707238 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -106,7 +106,7 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { */ export async function verifyLLM() { const { provider, command, model } = getLLMConfig(); - if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude 或 opencode' }; + if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' }; if (!model) return { ok: false, provider, error: '未設定 MODEL' }; return { ok: true, provider, command, model }; } diff --git a/app/test/config.test.js b/app/test/config.test.js index 942acc3..986b349 100644 --- a/app/test/config.test.js +++ b/app/test/config.test.js @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; +import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; const ENV_KEYS = [ 'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', @@ -19,6 +19,10 @@ afterEach(() => { }); 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); @@ -43,6 +47,13 @@ describe('getLLMConfig', () => { 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'; diff --git a/app/test/llm.test.js b/app/test/llm.test.js index 5d7bd5c..0864089 100644 --- a/app/test/llm.test.js +++ b/app/test/llm.test.js @@ -31,13 +31,13 @@ async function installFakeCLI(command = 'codex') { 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 cat > "$FAKE_AI_STDIN_PATH"; else cat >/dev/null; 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}:${saved.PATH || ''}`; + process.env.PATH = tempDir; return { stdinPath: join(tempDir, 'stdin.txt'), argsPath: join(tempDir, 'args.txt') }; } @@ -73,6 +73,23 @@ describe('chat - assistant CLI', async () => { 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, /\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'; diff --git a/app/test/usage.test.js b/app/test/usage.test.js index d6b5f4e..6ce0c95 100644 --- a/app/test/usage.test.js +++ b/app/test/usage.test.js @@ -120,6 +120,7 @@ describe('fetchAccountQuota', () => { it('reports 不適用 for local platforms', async () => { assert.equal((await fetchAccountQuota('ollama', {})).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 () => { diff --git a/app/usage.js b/app/usage.js index 97eb2df..3e37821 100644 --- a/app/usage.js +++ b/app/usage.js @@ -197,6 +197,7 @@ const QUOTA_STRATEGIES = { return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,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 無法直接查詢' }), amazonq: async () => ({ available: false, reason: 'Amazon Q 額度由 AWS 帳務管理,需 AWS 憑證查詢' }), ollama: async () => ({ available: false, reason: '本地服務,無帳號額度概念' }), -- 2.53.0 From d0c1bb0c202616294d2d48e00d36944ed1a50889 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 15:35:03 +0000 Subject: [PATCH 14/19] =?UTF-8?q?chore(ci):=20=E6=94=B9=E7=94=A8=20composi?= =?UTF-8?q?te-actions=20=E7=9A=84=20antigravity=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c5155fc..229121e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -47,7 +47,7 @@ jobs: - name: 取得程式碼 uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - name: 安裝 AI 工具 - uses: https://gitea.jsc.idv.tw/docker-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} + uses: https://gitea.jsc.idv.tw/composite-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} - name: 檢查 AI 助理 CLI shell: bash run: | -- 2.53.0 From ea2ac46d6c73629f78a8191c7753c47c1e52630f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 15:56:12 +0000 Subject: [PATCH 15/19] =?UTF-8?q?chore(ci):=20=E5=82=B3=E5=85=A5=20Antigra?= =?UTF-8?q?vity=20OAuth=20secret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 229121e..11b61af 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -48,6 +48,8 @@ jobs: uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - name: 安裝 AI 工具 uses: https://gitea.jsc.idv.tw/composite-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} + with: + oauth: ${{ secrets.ANTIGRAVITY_OAUTH }} - name: 檢查 AI 助理 CLI shell: bash run: | -- 2.53.0 From 811f56d715db1b0d9889e0cc51858486019c218d Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 29 Jun 2026 15:24:05 +0800 Subject: [PATCH 16/19] =?UTF-8?q?chore(ci):=20=E6=94=B9=E7=94=A8=20codex?= =?UTF-8?q?=20composite=20action=20=E5=AE=89=E8=A3=9D=20AI=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 11b61af..8b937d2 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -47,9 +47,9 @@ jobs: - name: 取得程式碼 uses: actions/checkout@${{ vars.ACTION_CHECKOUT_VERSION }} - name: 安裝 AI 工具 - uses: https://gitea.jsc.idv.tw/composite-actions/antigravity@${{ vars.ACTION_ANTIGRAVITY_VERSION }} + uses: https://gitea.jsc.idv.tw/composite-actions/codex@${{ vars.ACTION_CODEX_VERSION }} with: - oauth: ${{ secrets.ANTIGRAVITY_OAUTH }} + oauth: ${{ secrets.CODEX_OAUTH }} - name: 檢查 AI 助理 CLI shell: bash run: | -- 2.53.0 From 34a9eb6d4e414a37d763b35cf6cb126c8a74f1f6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 29 Jun 2026 16:01:35 +0800 Subject: [PATCH 17/19] =?UTF-8?q?fix(llm):=20=E7=A7=BB=E9=99=A4=20codex=20?= =?UTF-8?q?exec=20=E5=B7=B2=E4=B8=8D=E6=94=AF=E6=8F=B4=E7=9A=84=20--ask-fo?= =?UTF-8?q?r-approval=20=E5=8F=83=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/llm.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/llm.js b/app/llm.js index 470439f..0226b0a 100644 --- a/app/llm.js +++ b/app/llm.js @@ -25,7 +25,7 @@ function buildPrompt(systemPrompt, userContent) { function cliArgs(provider, model, promptFile = null, prompt = null) { if (provider === 'codex') { - return ['exec', '--model', model, '--sandbox', 'read-only', '--ask-for-approval', 'never', '--skip-git-repo-check', '-']; + return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-']; } if (provider === 'claude') { return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence']; -- 2.53.0 From 9ec30e1abbf43029519dafc712b17cfdf008a48b Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 29 Jun 2026 16:01:38 +0800 Subject: [PATCH 18/19] =?UTF-8?q?chore(config):=20=E5=B0=87=20codex=20?= =?UTF-8?q?=E9=A0=90=E8=A8=AD=E6=A8=A1=E5=9E=8B=E6=9B=B4=E6=96=B0=E7=82=BA?= =?UTF-8?q?=20gpt-5.4-mini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/config.js b/app/config.js index 5efc5b4..4c8db14 100644 --- a/app/config.js +++ b/app/config.js @@ -35,7 +35,7 @@ const CLI_CANDIDATES = [ { provider: 'codex', command: 'codex', - defaultModel: 'gpt-5', + defaultModel: 'gpt-5.4-mini', }, { provider: 'claude', -- 2.53.0 From d0b22e8444061ff19c4355606554e7189d81286a Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Mon, 29 Jun 2026 08:07:59 +0000 Subject: [PATCH 19/19] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 67 +++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..92d2571 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,66 @@ -[] +[ + { + "level": "warning", + "role": "Assassin", + "location": "app/git.js:67", + "problem": "這裡對所有 git 網路操作強制關閉 SSL 驗證。攻擊者若能攔截遠端回應,就能偽造 `ls-remote`、污染抓到的內容,甚至在有認證的情況下竊取 GITEA_TOKEN,讓後續審查建立在被污染的資料上。", + "suggestion": "移除 `GIT_SSL_NO_VERIFY=true`。若是內部憑證問題,改用正確的 CA bundle 或指定受信任憑證檔,不要整體關閉 git 的憑證驗證。", + "is_new": true + }, + { + "level": "warning", + "role": "Assassin", + "location": "app/usage.js:180", + "problem": "這裡對 OpenRouter 額度查詢也明確套用不驗證憑證的 agent。攻擊者只要攔截這條 HTTPS 請求,就能讀到 `Authorization: Bearer` 的 API key,還能回傳假 quota 資料誤導流程判斷。", + "suggestion": "改回預設的 HTTPS 憑證驗證。若必須支援自簽憑證,僅對特定內部主機單獨配置受信任 CA,且不要對外部 API 一律關閉驗證。", + "is_new": true + }, + { + "level": "warning", + "role": "Bard", + "location": "action.yaml:8", + "problem": "這個新加入的 `model` 輸入沒有任何描述,讓 action 的公開介面像是少了說明書的樂譜,讀者只能靠猜來理解它要吃什麼值。", + "suggestion": "補上一句簡短但明確的描述,例如說明這個欄位是「要使用的模型名稱」或「AI 助理 CLI 的模型識別字串」;讓使用者一眼看懂用途。", + "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", + "role": "Bard", + "location": "app/preflight.js:170", + "problem": "成功訊息仍寫成 `LLM provider=... CLI 可用`,語氣還停在 provider 中心,但整個流程已改成 CLI 中心,術語有些半新半舊,節奏不夠一致。", + "suggestion": "把成功訊息統一成 CLI 觀點,例如直接報 `command=...` 或改成更中性的「LLM CLI 可用」,讓文字系統保持同一把調性。", + "is_new": true + } +] -- 2.53.0