feat(ai-pull-request): 以 opencode 分析 diff 自動產生並建立 Pull Request
This commit is contained in:
+163
@@ -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() : '';
|
||||
}
|
||||
|
||||
/** 取得完整 diff(three-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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user