feat(ai-pull-request): 以 opencode 分析 diff 自動產生並建立 Pull Request

This commit is contained in:
Jeffery
2026-06-26 11:42:05 +08:00
parent 5db3b328a2
commit d42ccd8b08
10 changed files with 887 additions and 27 deletions
+230
View File
@@ -0,0 +1,230 @@
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;
}