236 lines
6.8 KiB
JavaScript
236 lines
6.8 KiB
JavaScript
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) {
|
||
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;
|
||
} 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;
|
||
}
|