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
+163
View File
@@ -0,0 +1,163 @@
import { run, runOrThrow, log, maskSecrets } from './util.js';
/**
* 封裝這個 action 需要的 git 操作。所有對遠端的操作都透過
* http.extraheader 帶上 Gitea token,避免 token 寫進 remote URL。
*/
export class Git {
/**
* @param {object} opts
* @param {string} opts.cwd 工作目錄(已 checkout 的 repo
* @param {string} opts.remoteUrl 不含認證資訊的 repo HTTPS URL
* @param {string} opts.token Gitea token
*/
constructor({ cwd, remoteUrl, token }) {
this.cwd = cwd;
this.remoteUrl = remoteUrl;
this.token = token;
// Gitea 接受 "Authorization: token <token>"
this.authArgs = ['-c', `http.extraheader=Authorization: token ${token}`];
}
/** 帶 token 的 git 執行(用於遠端操作),不會把 args 印進日誌。 */
_authGit(args, { throwOnError = true } = {}) {
const full = [...this.authArgs, ...args];
const result = run('git', full, { cwd: this.cwd });
if (throwOnError && result.status !== 0) {
const detail = maskSecrets(result.stderr || result.stdout, [this.token]).trim();
throw new Error(`git ${args.join(' ')} 失敗 (${result.status}):\n${detail}`);
}
return result;
}
/** 不帶 token 的本地 git 執行。 */
_git(args, opts = {}) {
return run('git', args, { cwd: this.cwd, ...opts });
}
/** 初始化必要的 git 設定(safe.directory、user.name/email)。 */
configure() {
run('git', ['config', '--global', '--add', 'safe.directory', this.cwd]);
run('git', ['config', '--global', '--add', 'safe.directory', '*']);
// 解衝突分支需要建立 merge commit,必須有身份
this._git(['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'ai-pull-request[bot]']);
this._git(['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'ai-pull-request@users.noreply.gitea']);
}
/**
* 從遠端抓取 source 與 target 分支到本地追蹤分支 refs/remotes/pr/<branch>。
*
* @param {string[]} branches
*/
fetchBranches(branches) {
const refspecs = branches.map((b) => `+refs/heads/${b}:refs/remotes/pr/${b}`);
log.info(`抓取分支: ${branches.join(', ')}`);
this._authGit(['fetch', '--no-tags', this.remoteUrl, ...refspecs]);
}
/** 取得分支的 commit 數量差異(source 比 target 多幾個 commit)。 */
countAheadCommits(target, source) {
const result = this._git(['rev-list', '--count', `refs/remotes/pr/${target}..refs/remotes/pr/${source}`]);
return result.status === 0 ? parseInt(result.stdout.trim(), 10) || 0 : 0;
}
/** 取得 source 相對 target 的 commit 訊息清單。 */
getCommitMessages(target, source, limit = 50) {
const result = this._git([
'log',
`--max-count=${limit}`,
'--pretty=format:- %s',
`refs/remotes/pr/${target}..refs/remotes/pr/${source}`,
]);
return result.status === 0 ? result.stdout.trim() : '';
}
/** 取得 diff 統計(--stat)。 */
getDiffStat(target, source) {
const result = this._git([
'diff',
'--stat',
`refs/remotes/pr/${target}...refs/remotes/pr/${source}`,
]);
return result.status === 0 ? result.stdout.trim() : '';
}
/** 取得完整 diffthree-dot,等同 PR 在 merge base 之後的變更)。 */
getDiff(target, source) {
const result = this._git([
'diff',
`refs/remotes/pr/${target}...refs/remotes/pr/${source}`,
]);
return result.status === 0 ? result.stdout : '';
}
/**
* 偵測 source 合併進 target 是否會衝突(不會留下任何變更)。
*
* @returns {{ hasConflict: boolean, files: string[] }}
*/
detectConflict(target, source) {
// 建立暫時的本地 target 分支,嘗試以 --no-commit 合併 source
const tmp = `__conflict_check_${target}`;
this._git(['checkout', '-B', tmp, `refs/remotes/pr/${target}`]);
const merge = this._git(['merge', '--no-commit', '--no-ff', `refs/remotes/pr/${source}`]);
let hasConflict = merge.status !== 0;
let files = [];
if (hasConflict) {
const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']);
files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
}
// 還原工作區
this._git(['merge', '--abort']);
this._git(['checkout', '--detach']);
this._git(['branch', '-D', tmp]);
return { hasConflict, files };
}
/**
* 建立解衝突分支:以 target 為基礎,合併 source(保留衝突標記後 commit),
* 再推送到遠端。
*
* @param {object} opts
* @param {string} opts.target 目標分支
* @param {string} opts.source 來源分支
* @param {string} opts.resolveBranch 解衝突分支名稱
* @returns {{ files: string[] }} 衝突檔案清單
*/
createResolveBranch({ target, source, resolveBranch }) {
log.info(`${target} 為基礎建立解衝突分支 ${resolveBranch}`);
this._git(['checkout', '-B', resolveBranch, `refs/remotes/pr/${target}`]);
const merge = this._git([
'merge',
'--no-ff',
'-m',
`Merge branch '${source}' into ${resolveBranch} (待人工解衝突)`,
`refs/remotes/pr/${source}`,
]);
let files = [];
if (merge.status !== 0) {
// 合併產生衝突:將含有衝突標記的檔案標記為已解決後 commit,
// 讓開發者可以在 PR 中看到並解決衝突。
const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']);
files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
runOrThrow('git', ['add', '-A'], { cwd: this.cwd });
runOrThrow(
'git',
['commit', '--no-verify', '-m', `Merge branch '${source}' into ${resolveBranch}(含衝突標記,待人工解衝突)`],
{ cwd: this.cwd },
);
}
log.info(`推送解衝突分支 ${resolveBranch}`);
this._authGit(['push', '--force', this.remoteUrl, `HEAD:refs/heads/${resolveBranch}`]);
return { files };
}
}
+98
View File
@@ -0,0 +1,98 @@
import { log } from './util.js';
/**
* 極簡的 Gitea API client,只實作這個 action 需要的 PR 相關操作。
*/
export class GiteaClient {
/**
* @param {object} opts
* @param {string} opts.serverUrl Gitea base URL(不含結尾斜線)
* @param {string} opts.owner
* @param {string} opts.repo
* @param {string} opts.token
*/
constructor({ serverUrl, owner, repo, token }) {
this.apiBase = `${serverUrl}/api/v1`;
this.owner = owner;
this.repo = repo;
this.token = token;
}
async _request(method, path, body) {
const url = `${this.apiBase}${path}`;
const res = await fetch(url, {
method,
headers: {
Authorization: `token ${this.token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json;
try {
json = text ? JSON.parse(text) : {};
} catch {
json = { message: text };
}
return { ok: res.ok, status: res.status, json };
}
/**
* 查詢 head -> base 是否已存在開啟中的 PR。
*
* @param {string} head 來源分支
* @param {string} base 目標分支
* @returns {Promise<object|null>}
*/
async findOpenPull(head, base) {
// Gitea pulls 不直接支援 head/base 過濾,這裡撈開啟中的 PR 自行比對
const { ok, json } = await this._request(
'GET',
`/repos/${this.owner}/${this.repo}/pulls?state=open&limit=50`,
);
if (!ok || !Array.isArray(json)) return null;
return (
json.find(
(pr) => pr?.head?.ref === head && pr?.base?.ref === base,
) || null
);
}
/**
* 建立 Pull Request。若已存在相同 head/base 的 PR 則回傳既有 PR。
*
* @param {object} opts
* @param {string} opts.head 來源分支
* @param {string} opts.base 目標分支
* @param {string} opts.title
* @param {string} opts.body
* @returns {Promise<{ pull: object, created: boolean }>}
*/
async createPull({ head, base, title, body }) {
log.info(`建立 PR: ${head}${base}`);
const { ok, status, json } = await this._request(
'POST',
`/repos/${this.owner}/${this.repo}/pulls`,
{ head, base, title, body },
);
if (ok) {
return { pull: json, created: true };
}
// 422 通常代表 PR 已存在
if (status === 422 || status === 409) {
const existing = await this.findOpenPull(head, base);
if (existing) {
log.warn(`PR 已存在: #${existing.number}`);
return { pull: existing, created: false };
}
}
const message = json?.message || JSON.stringify(json);
throw new Error(`建立 PR 失敗 (${status}): ${message}`);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { log } from './util.js';
/**
* 從環境變數讀取並驗證所有輸入參數。
*
* @returns {{
* serverUrl: string,
* repository: string,
* owner: string,
* repo: string,
* token: string,
* sourceBranch: string,
* targetBranch: string,
* opencode: { baseUrl: string, model: string, provider: string },
* language: string,
* maxDiffChars: number,
* workspace: string,
* }}
*/
export function loadInputs() {
const serverUrl = trimSlash(required('GITEA_SERVER_URL'));
const repository = required('GITEA_REPOSITORY'); // owner/repo
const token = required('GITEA_TOKEN');
const sourceBranch = required('SOURCE_BRANCH');
const targetBranch = required('TARGET_BRANCH');
const [owner, repo] = repository.split('/');
if (!owner || !repo) {
throw new Error(`GITEA_REPOSITORY 格式應為 owner/repo,收到: ${repository}`);
}
if (sourceBranch === targetBranch) {
throw new Error(`來源分支與目標分支不可相同: ${sourceBranch}`);
}
const opencode = {
baseUrl: trimSlash(process.env.OPENCODE_BASE_URL || ''),
model: process.env.OPENCODE_MODEL || '',
provider: process.env.OPENCODE_PROVIDER || '',
};
// PR 標題/描述固定使用繁體中文,diff 截斷上限固定,皆不透過參數控制
const language = 'Traditional Chinese (繁體中文)';
const maxDiffChars = 60000;
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
return {
serverUrl,
repository,
owner,
repo,
token,
sourceBranch,
targetBranch,
opencode,
language,
maxDiffChars,
workspace,
};
}
function required(name) {
const value = process.env[name];
if (!value || !value.trim()) {
throw new Error(`缺少必要的環境變數: ${name}`);
}
return value.trim();
}
function trimSlash(url) {
return url.replace(/\/+$/, '');
}
/** 印出輸入摘要(遮蔽敏感資訊)。 */
export function logInputs(inputs) {
log.info(`Gitea Server : ${inputs.serverUrl}`);
log.info(`Repository : ${inputs.repository}`);
log.info(`來源分支 : ${inputs.sourceBranch}`);
log.info(`目標分支 : ${inputs.targetBranch}`);
log.info(`opencode : provider=${inputs.opencode.provider || '(未設定)'} model=${inputs.opencode.model || '(未設定)'} baseUrl=${inputs.opencode.baseUrl || '(未設定)'}`);
}
+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;
}
+72
View File
@@ -0,0 +1,72 @@
import { spawnSync } from 'node:child_process';
/**
* 執行外部指令並回傳結果(不會因為非零結束碼而 throw)。
*
* @param {string} command 要執行的指令
* @param {string[]} args 指令參數
* @param {object} [options] spawnSync 額外設定(cwd、env、input、maxBuffer...
* @returns {{ status: number, stdout: string, stderr: string }}
*/
export function run(command, args = [], options = {}) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024, // 64MB,避免大型 diff 被截斷
...options,
});
if (result.error) {
return { status: 1, stdout: '', stderr: String(result.error.message || result.error) };
}
return {
status: typeof result.status === 'number' ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
/**
* 執行外部指令,若結束碼非零則 throw。
*
* @param {string} command
* @param {string[]} args
* @param {object} [options]
* @returns {string} stdout(已 trim
*/
export function runOrThrow(command, args = [], options = {}) {
const result = run(command, args, options);
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || '').trim();
throw new Error(`指令失敗 (${result.status}): ${command} ${args.join(' ')}\n${detail}`);
}
return result.stdout.trim();
}
const ICONS = { info: '️', warn: '⚠️', error: '❌', success: '✅', step: '▶️' };
/** 簡單的分級日誌輸出。 */
export const log = {
info: (msg) => console.log(`${ICONS.info} ${msg}`),
warn: (msg) => console.log(`${ICONS.warn} ${msg}`),
error: (msg) => console.error(`${ICONS.error} ${msg}`),
success: (msg) => console.log(`${ICONS.success} ${msg}`),
step: (msg) => console.log(`\n${ICONS.step} ${msg}`),
};
/**
* 將敏感字串(如 token)從文字中遮蔽,避免寫入日誌。
*
* @param {string} text
* @param {string[]} secrets
* @returns {string}
*/
export function maskSecrets(text, secrets = []) {
let masked = String(text ?? '');
for (const secret of secrets) {
if (secret && secret.length >= 4) {
masked = masked.split(secret).join('***');
}
}
return masked;
}