190 lines
6.4 KiB
JavaScript
190 lines
6.4 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);
|
|
});
|