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'; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; const SYNC_PATHS = [ FINDINGS_PATH, '.amazonq/rules/triage-findings.md', '.claude/skills/triage-findings/SKILL.md', '.gemini/skills/triage-findings/SKILL.md', '.github/copilot-instructions.md', '.github/skills/triage-findings/SKILL.md', 'CLAUDE.md', 'GEMINI.md', ]; 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 }; try { return fn(credEnv); } finally { try { fs.unlinkSync(askpassScript); } catch {} } } /** * 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); console.log(` ✅ repo cloned to ${repoDir}`); } else { run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv); run(['checkout', PR_HEAD_BRANCH], repoDir); console.log(` ✅ repo already exists, fetched latest`); } return repoDir; }); } export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync) { 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); // Always copy source files over the repo copy so skill files stay in sync. for (const relPath of SYNC_PATHS) { const src = path.join(workspace, relPath); const dest = path.join(repoDir, relPath); if (!fs.existsSync(src)) continue; fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); } run(['add', ...SYNC_PATHS], repoDir); const status = run(['status', '--porcelain'], repoDir); if (!status) { console.log(' sync files 無變更,跳過 commit'); return; } const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`); }); } catch (e) { console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); } }