feat(ai-pull-request): 以 opencode 分析 diff 自動產生並建立 Pull Request

This commit is contained in:
Jeffery
2026-06-26 11:42:05 +08:00
parent 5db3b328a2
commit d42ccd8b08
10 changed files with 887 additions and 27 deletions
+163
View File
@@ -0,0 +1,163 @@
import { run, runOrThrow, log, maskSecrets } from './util.js';
/**
* 封裝這個 action 需要的 git 操作。所有對遠端的操作都透過
* http.extraheader 帶上 Gitea token,避免 token 寫進 remote URL。
*/
export class Git {
/**
* @param {object} opts
* @param {string} opts.cwd 工作目錄(已 checkout 的 repo
* @param {string} opts.remoteUrl 不含認證資訊的 repo HTTPS URL
* @param {string} opts.token Gitea token
*/
constructor({ cwd, remoteUrl, token }) {
this.cwd = cwd;
this.remoteUrl = remoteUrl;
this.token = token;
// Gitea 接受 "Authorization: token <token>"
this.authArgs = ['-c', `http.extraheader=Authorization: token ${token}`];
}
/** 帶 token 的 git 執行(用於遠端操作),不會把 args 印進日誌。 */
_authGit(args, { throwOnError = true } = {}) {
const full = [...this.authArgs, ...args];
const result = run('git', full, { cwd: this.cwd });
if (throwOnError && result.status !== 0) {
const detail = maskSecrets(result.stderr || result.stdout, [this.token]).trim();
throw new Error(`git ${args.join(' ')} 失敗 (${result.status}):\n${detail}`);
}
return result;
}
/** 不帶 token 的本地 git 執行。 */
_git(args, opts = {}) {
return run('git', args, { cwd: this.cwd, ...opts });
}
/** 初始化必要的 git 設定(safe.directory、user.name/email)。 */
configure() {
run('git', ['config', '--global', '--add', 'safe.directory', this.cwd]);
run('git', ['config', '--global', '--add', 'safe.directory', '*']);
// 解衝突分支需要建立 merge commit,必須有身份
this._git(['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'ai-pull-request[bot]']);
this._git(['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'ai-pull-request@users.noreply.gitea']);
}
/**
* 從遠端抓取 source 與 target 分支到本地追蹤分支 refs/remotes/pr/<branch>。
*
* @param {string[]} branches
*/
fetchBranches(branches) {
const refspecs = branches.map((b) => `+refs/heads/${b}:refs/remotes/pr/${b}`);
log.info(`抓取分支: ${branches.join(', ')}`);
this._authGit(['fetch', '--no-tags', this.remoteUrl, ...refspecs]);
}
/** 取得分支的 commit 數量差異(source 比 target 多幾個 commit)。 */
countAheadCommits(target, source) {
const result = this._git(['rev-list', '--count', `refs/remotes/pr/${target}..refs/remotes/pr/${source}`]);
return result.status === 0 ? parseInt(result.stdout.trim(), 10) || 0 : 0;
}
/** 取得 source 相對 target 的 commit 訊息清單。 */
getCommitMessages(target, source, limit = 50) {
const result = this._git([
'log',
`--max-count=${limit}`,
'--pretty=format:- %s',
`refs/remotes/pr/${target}..refs/remotes/pr/${source}`,
]);
return result.status === 0 ? result.stdout.trim() : '';
}
/** 取得 diff 統計(--stat)。 */
getDiffStat(target, source) {
const result = this._git([
'diff',
'--stat',
`refs/remotes/pr/${target}...refs/remotes/pr/${source}`,
]);
return result.status === 0 ? result.stdout.trim() : '';
}
/** 取得完整 diffthree-dot,等同 PR 在 merge base 之後的變更)。 */
getDiff(target, source) {
const result = this._git([
'diff',
`refs/remotes/pr/${target}...refs/remotes/pr/${source}`,
]);
return result.status === 0 ? result.stdout : '';
}
/**
* 偵測 source 合併進 target 是否會衝突(不會留下任何變更)。
*
* @returns {{ hasConflict: boolean, files: string[] }}
*/
detectConflict(target, source) {
// 建立暫時的本地 target 分支,嘗試以 --no-commit 合併 source
const tmp = `__conflict_check_${target}`;
this._git(['checkout', '-B', tmp, `refs/remotes/pr/${target}`]);
const merge = this._git(['merge', '--no-commit', '--no-ff', `refs/remotes/pr/${source}`]);
let hasConflict = merge.status !== 0;
let files = [];
if (hasConflict) {
const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']);
files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
}
// 還原工作區
this._git(['merge', '--abort']);
this._git(['checkout', '--detach']);
this._git(['branch', '-D', tmp]);
return { hasConflict, files };
}
/**
* 建立解衝突分支:以 target 為基礎,合併 source(保留衝突標記後 commit),
* 再推送到遠端。
*
* @param {object} opts
* @param {string} opts.target 目標分支
* @param {string} opts.source 來源分支
* @param {string} opts.resolveBranch 解衝突分支名稱
* @returns {{ files: string[] }} 衝突檔案清單
*/
createResolveBranch({ target, source, resolveBranch }) {
log.info(`${target} 為基礎建立解衝突分支 ${resolveBranch}`);
this._git(['checkout', '-B', resolveBranch, `refs/remotes/pr/${target}`]);
const merge = this._git([
'merge',
'--no-ff',
'-m',
`Merge branch '${source}' into ${resolveBranch} (待人工解衝突)`,
`refs/remotes/pr/${source}`,
]);
let files = [];
if (merge.status !== 0) {
// 合併產生衝突:將含有衝突標記的檔案標記為已解決後 commit,
// 讓開發者可以在 PR 中看到並解決衝突。
const unmerged = this._git(['diff', '--name-only', '--diff-filter=U']);
files = unmerged.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
runOrThrow('git', ['add', '-A'], { cwd: this.cwd });
runOrThrow(
'git',
['commit', '--no-verify', '-m', `Merge branch '${source}' into ${resolveBranch}(含衝突標記,待人工解衝突)`],
{ cwd: this.cwd },
);
}
log.info(`推送解衝突分支 ${resolveBranch}`);
this._authGit(['push', '--force', this.remoteUrl, `HEAD:refs/heads/${resolveBranch}`]);
return { files };
}
}