From 08a72a8c7fda46e14da2d92b711b714a8725e8fd Mon Sep 17 00:00:00 2001 From: Jeffery Date: Sat, 27 Jun 2026 13:30:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai-code-review):=20=E6=94=B9=E7=94=A8=20AI?= =?UTF-8?q?=20=E5=8A=A9=E7=90=86=20CLI=20=E5=9F=B7=E8=A1=8C=E5=AF=A9?= =?UTF-8?q?=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;