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;