import { spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; export const SYNC_PATHS = [ '.amazonq/rules/triage-findings.md', '.codex/skills/triage-findings/SKILL.md', '.codex/skills/triage-findings/agents/openai.yaml', '.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 {} } } 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 }; } /** * 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, sourceRoot = ACTION_ROOT) { 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 existingSyncPaths = []; // Copy action skill files into the target repo. Existing files are overwritten; // missing source files are ignored so we do not delete target repo content. for (const relPath of SYNC_PATHS) { const src = path.join(sourceRoot, relPath); const dest = path.join(repoDir, relPath); if (fs.existsSync(src)) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); existingSyncPaths.push(relPath); } } if (existingSyncPaths.length > 0) { run(['add', ...existingSyncPaths], repoDir); } const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath))); if (generatedSyncPaths.length > 0) { for (const relPath of generatedSyncPaths) { 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', ...generatedSyncPaths], 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'; try { run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`); } catch (pushErr) { console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} error=${pushErr.message}`); } }); } catch (e) { console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); } }