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