Files
ai-pull-request/app/index.js
T

207 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fileURLToPath } from 'node:url';
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 };
}
// 解衝突分支主體最大長度;連同前綴 `resolve-conflict/` 與 runId 後綴,
// 總長度仍遠低於 Git 對 ref 名稱的限制(約 255)。
const MAX_BRANCH_STEM_LENGTH = 180;
/** 解衝突分支名稱。 */
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, MAX_BRANCH_STEM_LENGTH);
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 流程)
export { truncateDiff, fallbackSummary, buildResolveBranchName, buildResolveBody };
// 僅在直接以 `node index.js` 執行時才跑主流程;被測試 import 時不執行
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
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);
});
}