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, getLLMConfig } from './config.js'; import { line, ok, warn, error } from './log.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 BOT_COMMIT_MARKER = '[ai-review-bot]'; export const SYNC_PATHS = [ '.amazonq/rules/triage-findings.md', '.agents/skills/triage-findings/SKILL.md', '.antigravity/skills/triage-findings/SKILL.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', 'AGENTS.md', 'ANTIGRAVITY.md', 'CLAUDE.md', 'GEMINI.md', ]; const FORCE_SYNC_FILE_PATHS = [ '.github/copilot-instructions.md', 'AGENTS.md', 'ANTIGRAVITY.md', 'CLAUDE.md', 'GEMINI.md', ]; const MERGE_SYNC_FILE_PATHS = new Set([ 'AGENTS.md', 'ANTIGRAVITY.md', 'CLAUDE.md', 'GEMINI.md', ]); let instructionMergeAssistantPromise = null; const SYNC_TREE_PATHS = [ '.agents/skills/triage-findings', '.antigravity/skills/triage-findings', '.codex/skills/triage-findings', '.claude/skills/triage-findings', '.gemini/skills/triage-findings', '.github/skills/triage-findings', ]; 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 ''; } } function normalizeText(text) { return text.replace(/\r\n/g, '\n'); } function splitTextBlocks(text) { const normalized = normalizeText(text).replace(/\n+$/, ''); if (!normalized) return []; return normalized.split(/\n{2,}/).map(block => block.trimEnd()).filter(Boolean); } function mergeText(existingText, sourceText) { const existing = normalizeText(existingText); const source = normalizeText(sourceText); if (existing === source) return existing; const mergedBlocks = splitTextBlocks(existing); const seenBlocks = new Set(mergedBlocks.map(block => block.trim())); let changed = false; for (const block of splitTextBlocks(source)) { const key = block.trim(); if (seenBlocks.has(key)) continue; seenBlocks.add(key); mergedBlocks.push(block); changed = true; } if (!changed) return existing; return `${mergedBlocks.join('\n\n')}\n`; } function uniqueBlocksFromTexts(...texts) { const seen = new Set(); const blocks = []; for (const text of texts) { for (const block of splitTextBlocks(text)) { const key = block.trim(); if (!key || seen.has(key)) continue; seen.add(key); blocks.push(block); } } return blocks; } function validateMergedInstructionText(mergedText, requiredBlocks) { const candidate = normalizeText(mergedText); return requiredBlocks.every(block => candidate.includes(normalizeText(block).trim())); } class InstructionMergeError extends Error { constructor(message, options) { super(message, options); this.name = 'InstructionMergeError'; } } function abortInstructionMerge(message) { error(message); process.exit(1); throw new InstructionMergeError(message); } function syncFileOverwrite(sourceRoot, repoDir, relPath) { const src = path.join(sourceRoot, relPath); if (!fs.existsSync(src)) return null; const dest = path.join(repoDir, relPath); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); return relPath; } async function getInstructionMergeAssistant() { const { provider } = getLLMConfig(); if (!provider) return null; if (instructionMergeAssistantPromise) return instructionMergeAssistantPromise; instructionMergeAssistantPromise = (async () => { try { const { chatJSON } = await import('./llm.js'); return async ({ relPath, existingText, sourceText, deterministicText }) => { const systemPrompt = [ 'You merge repository instruction files without losing any skill, command, or rule.', 'Never delete unique content from either input.', 'You may only remove exact duplicates or improve ordering/formatting.', 'Return JSON with a single field: merged_text.', ].join(' '); const userContent = JSON.stringify({ path: relPath, existing_text: existingText, source_text: sourceText, deterministic_candidate: deterministicText, }); const result = await chatJSON(systemPrompt, userContent); if (typeof result === 'string') return result; if (result && typeof result.merged_text === 'string') return result.merged_text; return null; }; } catch (e) { warn(`[merge] AI instruction merge unavailable: ${e.message}`); return null; } })(); return instructionMergeAssistantPromise; } export async function mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant = null) { const deterministic = mergeText(existingText, sourceText); const requiredBlocks = uniqueBlocksFromTexts(existingText, sourceText); if (!aiMergeAssistant || requiredBlocks.length === 0) return deterministic; try { const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks }); if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) { return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged; } abortInstructionMerge(`[merge] ${relPath} AI result rejected; refusing fallback`); } catch (e) { if (e instanceof InstructionMergeError) throw e; abortInstructionMerge(`[merge] ${relPath} AI merge failed: ${e.message}`); } } async function syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant = null) { const src = path.join(sourceRoot, relPath); if (!fs.existsSync(src)) return null; const dest = path.join(repoDir, relPath); fs.mkdirSync(path.dirname(dest), { recursive: true }); if (!fs.existsSync(dest)) { fs.copyFileSync(src, dest); return relPath; } const existingText = fs.readFileSync(dest, 'utf8'); const sourceText = fs.readFileSync(src, 'utf8'); const merged = await mergeInstructionText(existingText, sourceText, relPath, aiMergeAssistant); if (merged !== existingText) { fs.writeFileSync(dest, merged, 'utf8'); } return relPath; } function syncTree(sourceRoot, repoDir, relDir) { const srcDir = path.join(sourceRoot, relDir); if (!fs.existsSync(srcDir)) return []; const copied = []; for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { const relPath = path.join(relDir, entry.name); if (entry.isDirectory()) { copied.push(...syncTree(sourceRoot, repoDir, relPath)); continue; } const synced = syncFileOverwrite(sourceRoot, repoDir, relPath); if (synced) copied.push(synced); } return copied; } 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); } /** * 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 = ACTION_ROOT, 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 existingSyncPaths = new Set(); const aiMergeAssistant = await getInstructionMergeAssistant(); // Copy action skill trees into the target repo. Existing files are merged with // the action source; missing source files are ignored so we do not delete // target repo content. for (const relDir of SYNC_TREE_PATHS) { for (const relPath of syncTree(sourceRoot, repoDir, relDir)) { existingSyncPaths.add(relPath); } } // Merge only the direct instruction files that must preserve repository-specific // skills, commands, and rules. Everything else keeps the source copy. for (const relPath of FORCE_SYNC_FILE_PATHS) { const copied = MERGE_SYNC_FILE_PATHS.has(relPath) ? await syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant) : syncFileOverwrite(sourceRoot, repoDir, relPath); if (copied) existingSyncPaths.add(copied); } // Merge standalone action files into the target repo. for (const relPath of SYNC_PATHS) { if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue; const copied = syncFileOverwrite(sourceRoot, repoDir, relPath); if (copied) existingSyncPaths.add(copied); } if (existingSyncPaths.size > 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) { line('sync 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}`); } }