import { writeFileSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { run, log, maskSecrets } from './util.js'; /** * 透過 opencode CLI 分析 git diff,產生 PR 標題與描述。 */ export class OpenCode { /** * @param {object} opts * @param {string} opts.baseUrl * @param {string} opts.model * @param {string} opts.provider * @param {string} [opts.language] */ constructor({ baseUrl, model, provider, language = 'Traditional Chinese (繁體中文)' }) { this.baseUrl = baseUrl; this.model = model; this.provider = provider; this.language = language; } /** 是否有足夠設定可以呼叫 opencode。 */ isConfigured() { return Boolean(this.baseUrl && this.model && this.provider); } /** * 在暫存目錄寫出 opencode.json,將自訂 provider 設為 OpenAI 相容端點。 * * @returns {string} config 檔路徑 */ _writeConfig() { const dir = mkdtempSync(join(tmpdir(), 'opencode-')); const options = { baseURL: this.baseUrl }; const config = { $schema: 'https://opencode.ai/config.json', provider: { [this.provider]: { npm: '@ai-sdk/openai-compatible', name: this.provider, options, models: { [this.model]: { name: this.model }, }, }, }, }; const path = join(dir, 'opencode.json'); writeFileSync(path, JSON.stringify(config, null, 2)); return path; } /** * 呼叫 opencode 產生標題與描述。 * * @param {object} ctx * @param {string} ctx.sourceBranch * @param {string} ctx.targetBranch * @param {string} ctx.commitMessages * @param {string} ctx.diffStat * @param {string} ctx.diff 已截斷的 diff * @returns {Promise<{ title: string, description: string } | null>} */ async summarize(ctx) { if (!this.isConfigured()) { log.warn('opencode 參數不完整(需要 base_url / model / provider),略過 AI 摘要'); return null; } const configPath = this._writeConfig(); const prompt = buildPrompt({ ...ctx, language: this.language }); log.info(`呼叫 opencode(${this.provider}/${this.model})分析 diff...`); const result = run( 'opencode', ['run', '--model', `${this.provider}/${this.model}`, prompt], { cwd: tmpdir(), env: { ...process.env, OPENCODE_CONFIG: configPath, // 確保 opencode 有可寫的 HOME / 設定目錄 HOME: process.env.HOME || '/root', }, timeout: 5 * 60 * 1000, }, ); if (result.status !== 0) { log.warn(`opencode 執行失敗 (${result.status}):${maskSecrets(result.stderr).slice(0, 500)}`); return null; } const parsed = extractResult(result.stdout); if (!parsed) { log.warn('無法從 opencode 輸出解析出標題/描述'); return null; } return parsed; } } function buildPrompt({ sourceBranch, targetBranch, commitMessages, diffStat, diff, language }) { return [ `You are an assistant that writes high-quality Pull Request titles and descriptions.`, `Analyze the following git changes for a PR merging branch "${sourceBranch}" into "${targetBranch}".`, ``, `Write the title and description in ${language}.`, `The title should be a concise one-line summary (ideally following Conventional Commits style, e.g. "feat: ...").`, `The description should be Markdown and include: a short summary, a bullet list of key changes, and any notable impact or risk.`, ``, `Respond with ONLY a single JSON object, no code fences, no extra text:`, `{"title": "...", "description": "..."}`, ``, `=== Commits ===`, commitMessages || '(no commit messages)', ``, `=== Changed files (stat) ===`, diffStat || '(no stat)', ``, `=== Diff ===`, diff || '(no diff)', ].join('\n'); } /** 去除 ANSI 控制碼。 */ function stripAnsi(text) { // eslint-disable-next-line no-control-regex return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); } /** * 從 opencode 輸出中擷取含 title 的 JSON 物件並解析。 * * @param {string} stdout * @returns {{ title: string, description: string } | null} */ export function extractResult(stdout) { const text = stripAnsi(stdout || ''); // 掃描所有平衡的 {...} 區塊,挑出第一個能成功解析且含 title 的物件 for (const candidate of findJsonObjects(text)) { // LLM 常在字串值內輸出未跳脫的換行,先嘗試原始解析,失敗再嘗試修正 for (const variant of [candidate, escapeControlCharsInStrings(candidate)]) { try { const obj = JSON.parse(variant); if (obj && typeof obj === 'object' && obj.title) { return { title: String(obj.title).trim(), description: String(obj.description || '').trim(), }; } } catch { // 試下一個變體 / 候選 } } } return null; } /** 將字串值內未跳脫的控制字元(換行、tab 等)跳脫,修正 LLM 常見的無效 JSON。 */ function escapeControlCharsInStrings(text) { let out = ''; let inString = false; let escape = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (inString) { if (escape) { out += ch; escape = false; continue; } if (ch === '\\') { out += ch; escape = true; continue; } if (ch === '"') { out += ch; inString = false; continue; } if (ch === '\n') { out += '\\n'; continue; } if (ch === '\r') { out += '\\r'; continue; } if (ch === '\t') { out += '\\t'; continue; } out += ch; } else { out += ch; if (ch === '"') inString = true; } } return out; } /** 以括號平衡方式找出文字中所有最外層的 {...} 區塊。 */ function findJsonObjects(text) { const objects = []; let depth = 0; let start = -1; let inString = false; let escape = false; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (inString) { if (escape) escape = false; else if (ch === '\\') escape = true; else if (ch === '"') inString = false; continue; } if (ch === '"') { inString = true; } else if (ch === '{') { if (depth === 0) start = i; depth++; } else if (ch === '}') { depth--; if (depth === 0 && start !== -1) { objects.push(text.slice(start, i + 1)); start = -1; } } } return objects; }