Files
ai-pull-request/app/lib/opencode.js
T

240 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join, dirname } 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 });
try {
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 || homedir(),
},
timeout: 5 * 60 * 1000,
},
);
if (result.status !== 0) {
const stderr = maskSecrets(result.stderr);
const shown = stderr.length > 2000
? `${stderr.slice(0, 2000)}\n…(錯誤訊息過長,已截斷,僅顯示前 2000 字元)`
: stderr;
log.warn(`opencode 執行失敗 (${result.status})${shown}`);
return null;
}
const parsed = extractResult(result.stdout);
if (!parsed) {
log.warn('無法從 opencode 輸出解析出標題/描述');
return null;
}
return parsed;
} finally {
// 清理 _writeConfig 建立的暫存設定目錄,避免堆積
rmSync(dirname(configPath), { recursive: true, force: true });
}
}
}
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;
}