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); });