196 lines
6.7 KiB
JavaScript
196 lines
6.7 KiB
JavaScript
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, maskSecrets } 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}` : '';
|
||
// 截斷主體長度,避免 target/source 過長使分支名稱超出 Git 限制
|
||
const stem = `${safe(target)}-into-${safe(source)}`.slice(0, 180);
|
||
return `resolve-conflict/${stem}${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) => {
|
||
const detail = err?.stack || err?.message || String(err);
|
||
// 錯誤訊息/stack 可能夾帶 token,輸出到 CI 日誌前先遮蔽
|
||
log.error(maskSecrets(detail, [process.env.GITEA_TOKEN]));
|
||
process.exit(1);
|
||
});
|