151 lines
5.8 KiB
JavaScript
151 lines
5.8 KiB
JavaScript
import { spawnSync } from 'child_process';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
|
||
import { line, ok, warn } from './log.js';
|
||
|
||
const REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
|
||
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
|
||
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
|
||
|
||
function makeRunner(spawn) {
|
||
return function run(args, cwd, env) {
|
||
const opts = { cwd, encoding: 'utf8' };
|
||
if (env) opts.env = env;
|
||
const result = spawn('git', args, opts);
|
||
if (result.error) throw result.error;
|
||
if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
|
||
return (result.stdout || '').trim();
|
||
};
|
||
}
|
||
|
||
function withAskpass(workspace, fn) {
|
||
const askpassScript = path.join(workspace, '.git-askpass.sh');
|
||
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
|
||
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
|
||
const cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} };
|
||
let result;
|
||
try {
|
||
result = fn(credEnv);
|
||
} catch (e) {
|
||
cleanup();
|
||
throw e;
|
||
}
|
||
// Defer cleanup until an async callback settles, otherwise the askpass script
|
||
// is deleted at the first `await` and later network ops (e.g. git push) fail
|
||
// with "cannot exec .git-askpass.sh". Sync callbacks clean up immediately.
|
||
if (result && typeof result.then === 'function') {
|
||
return result.finally(cleanup);
|
||
}
|
||
cleanup();
|
||
return result;
|
||
}
|
||
|
||
function readGitOutput(run, args, cwd, env) {
|
||
try {
|
||
return run(args, cwd, env);
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
export function getRepoState(repoDir, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
|
||
const shortSha = readGitOutput(run, ['rev-parse', '--short', 'HEAD'], repoDir);
|
||
const branch = readGitOutput(run, ['branch', '--show-current'], repoDir);
|
||
const commitTime = readGitOutput(run, ['show', '-s', '--format=%cI', 'HEAD'], repoDir);
|
||
return { repoDir, branch, headSha, shortSha, commitTime };
|
||
}
|
||
|
||
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
|
||
}
|
||
|
||
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
|
||
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
|
||
}
|
||
|
||
/**
|
||
* 用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`,
|
||
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
|
||
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
|
||
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
|
||
*/
|
||
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
try {
|
||
return withAskpass(workspace, credEnv => {
|
||
run(['ls-remote', remoteUrl, PR_HEAD_BRANCH || 'HEAD'], workspace, credEnv);
|
||
return { ok: true };
|
||
});
|
||
} catch (e) {
|
||
return { ok: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clone PR head branch to workspace/repo (idempotent)
|
||
*/
|
||
export function cloneRepo(workspace, _spawnSync = spawnSync) {
|
||
const run = makeRunner(_spawnSync);
|
||
const repoDir = path.join(workspace, 'repo');
|
||
|
||
return withAskpass(workspace, credEnv => {
|
||
if (!fs.existsSync(repoDir)) {
|
||
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
|
||
ok(`repo cloned to ${repoDir}`);
|
||
} else {
|
||
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
||
run(['checkout', PR_HEAD_BRANCH], repoDir);
|
||
ok('repo already exists, fetched latest');
|
||
}
|
||
return repoDir;
|
||
});
|
||
}
|
||
|
||
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
|
||
const run = makeRunner(_spawnSync);
|
||
|
||
try {
|
||
await withAskpass(workspace, async credEnv => {
|
||
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
|
||
run(['config', 'user.name', 'AI Review Bot'], repoDir);
|
||
if (PR_HEAD_BRANCH) {
|
||
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
|
||
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
|
||
}
|
||
|
||
const reviewFilePaths = REVIEW_FILE_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
|
||
if (reviewFilePaths.length > 0) {
|
||
for (const relPath of reviewFilePaths) {
|
||
const src = path.join(workspace, relPath);
|
||
const dest = path.join(repoDir, relPath);
|
||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||
fs.copyFileSync(src, dest);
|
||
}
|
||
run(['add', ...reviewFilePaths], repoDir);
|
||
}
|
||
|
||
const status = run(['status', '--porcelain'], repoDir);
|
||
if (!status) {
|
||
line('review files 無變更,跳過 commit');
|
||
return;
|
||
}
|
||
|
||
const outcomeTag = reviewOutcome === 'failure' ? '[failure]' : '[success]';
|
||
const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}${outcomeTag}`], repoDir);
|
||
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
|
||
try {
|
||
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
|
||
ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
|
||
} catch (pushErr) {
|
||
warn(`Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
warn(`Runner failed: commit/push 失敗: ${e.message}`);
|
||
}
|
||
}
|