feat(ai-pull-request): 以 opencode 分析 diff 自動產生並建立 Pull Request
This commit is contained in:
+185
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user