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
+185
View File
@@ -0,0 +1,185 @@
import { loadInputs, logInputs } from './lib/inputs.js';
import { Git } from './lib/git.js';
import { GiteaClient } from './lib/gitea.js';
import { OpenCode } from './lib/opencode.js';
import { log } from './lib/util.js';
async function main() {
const inputs = loadInputs();
logInputs(inputs);
const remoteUrl = `${inputs.serverUrl}/${inputs.owner}/${inputs.repo}.git`;
const git = new Git({ cwd: inputs.workspace, remoteUrl, token: inputs.token });
const gitea = new GiteaClient({
serverUrl: inputs.serverUrl,
owner: inputs.owner,
repo: inputs.repo,
token: inputs.token,
});
const opencode = new OpenCode({ ...inputs.opencode, language: inputs.language });
// 1. 準備 git 環境並抓取兩個分支
log.step('準備 git 環境');
git.configure();
git.fetchBranches([inputs.sourceBranch, inputs.targetBranch]);
// 2. 確認來源分支相對目標分支有變更
const ahead = git.countAheadCommits(inputs.targetBranch, inputs.sourceBranch);
if (ahead === 0) {
log.warn(`來源分支 ${inputs.sourceBranch} 相對 ${inputs.targetBranch} 沒有新的 commit,無需建立 PR`);
return;
}
log.info(`來源分支領先 ${ahead} 個 commit`);
// 3. 蒐集 diff 內容
log.step('蒐集 git diff');
const commitMessages = git.getCommitMessages(inputs.targetBranch, inputs.sourceBranch);
const diffStat = git.getDiffStat(inputs.targetBranch, inputs.sourceBranch);
const fullDiff = git.getDiff(inputs.targetBranch, inputs.sourceBranch);
const { diff, truncated } = truncateDiff(fullDiff, inputs.maxDiffChars);
if (truncated) log.warn(`diff 過大,已截斷至 ${inputs.maxDiffChars} 字元`);
// 4. 使用 opencode 產生標題與描述(失敗則 fallback)
log.step('使用 opencode 產生 PR 標題與描述');
let summary = await opencode.summarize({
sourceBranch: inputs.sourceBranch,
targetBranch: inputs.targetBranch,
commitMessages,
diffStat,
diff,
});
if (!summary) {
log.warn('改用 commit/stat 自動產生標題與描述');
summary = fallbackSummary({
source: inputs.sourceBranch,
target: inputs.targetBranch,
commitMessages,
diffStat,
});
}
log.success(`標題: ${summary.title}`);
// 5. 偵測合併衝突
log.step('偵測合併衝突');
const { hasConflict, files } = git.detectConflict(inputs.targetBranch, inputs.sourceBranch);
if (!hasConflict) {
// 5a. 無衝突:直接建立 來源 → 目標 的 PR
log.success('無衝突,建立來源分支 → 目標分支的 PR');
const { pull, created } = await gitea.createPull({
head: inputs.sourceBranch,
base: inputs.targetBranch,
title: summary.title,
body: summary.description,
});
reportPull(pull, created);
return;
}
// 5b. 有衝突:從目標分支建立解衝突分支,合併來源分支後 PR 回來源分支
log.warn(`偵測到衝突檔案 (${files.length}): ${files.join(', ')}`);
const resolveBranch = buildResolveBranchName(inputs.targetBranch, inputs.sourceBranch);
log.step('建立解衝突分支並合併來源分支');
const { files: conflictFiles } = git.createResolveBranch({
target: inputs.targetBranch,
source: inputs.sourceBranch,
resolveBranch,
});
const body = buildResolveBody({
source: inputs.sourceBranch,
target: inputs.targetBranch,
resolveBranch,
files: conflictFiles.length ? conflictFiles : files,
summary,
});
log.step('建立解衝突分支 → 來源分支的 PR');
const { pull, created } = await gitea.createPull({
head: resolveBranch,
base: inputs.sourceBranch,
title: `解衝突: 將 ${inputs.targetBranch} 合併回 ${inputs.sourceBranch}`,
body,
});
reportPull(pull, created);
}
/** 把 diff 截斷至上限字元數。 */
function truncateDiff(diff, maxChars) {
if (!diff || diff.length <= maxChars) return { diff: diff || '', truncated: false };
return {
diff: `${diff.slice(0, maxChars)}\n\n... (diff 已截斷,僅顯示前 ${maxChars} 字元) ...`,
truncated: true,
};
}
/** opencode 不可用時,用 commit 訊息與 stat 產生簡單摘要。 */
function fallbackSummary({ source, target, commitMessages, diffStat }) {
const firstCommit = (commitMessages || '')
.split('\n')
.map((l) => l.replace(/^- /, '').trim())
.find(Boolean);
const title = firstCommit || `Merge ${source} into ${target}`;
const description = [
`## 變更摘要`,
``,
`\`${source}\` 合併到 \`${target}\``,
``,
`### Commits`,
commitMessages || '(無)',
``,
`### 變更檔案`,
'```',
diffStat || '(無)',
'```',
].join('\n');
return { title, description };
}
/** 解衝突分支名稱。 */
function buildResolveBranchName(target, source) {
const runId = process.env.GITHUB_RUN_NUMBER || process.env.GITHUB_RUN_ID || '';
const safe = (s) => s.replace(/[^a-zA-Z0-9._/-]/g, '-');
const suffix = runId ? `-${runId}` : '';
return `resolve-conflict/${safe(target)}-into-${safe(source)}${suffix}`;
}
/** 解衝突 PR 的描述。 */
function buildResolveBody({ source, target, resolveBranch, files, summary }) {
return [
`## ⚠️ 自動解衝突 PR`,
``,
`來源分支 \`${source}\` 合併到目標分支 \`${target}\` 時偵測到衝突,`,
`已自動從 \`${target}\` 建立解衝突分支 \`${resolveBranch}\` 並合併 \`${source}\``,
``,
`**此 PR 會將 \`${resolveBranch}\` 合併回 \`${source}\`,請在合併前手動解決下列檔案的衝突標記(\`<<<<<<<\`\`=======\`\`>>>>>>>\`):**`,
``,
...files.map((f) => `- \`${f}\``),
``,
`解決並合併此 PR 後,\`${source}\` 即可順利合併進 \`${target}\``,
``,
`---`,
``,
`### AI 變更摘要`,
``,
summary.description || '(無)',
].join('\n');
}
/** 印出 PR 結果。 */
function reportPull(pull, created) {
const url = pull?.html_url || pull?.url || '';
const number = pull?.number || '';
if (created) {
log.success(`已建立 PR #${number}: ${url}`);
} else {
log.info(`PR 已存在 #${number}: ${url}`);
}
}
main().catch((err) => {
log.error(err?.stack || err?.message || String(err));
process.exit(1);
});