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 { 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 BOT_COMMIT_MARKER = '[ai-review-bot]';
|
|
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 };
|
|
}
|
|
|
|
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);
|
|
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, 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 = [];
|
|
|
|
// 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 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);
|
|
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
|
|
} catch (pushErr) {
|
|
console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
|
|
}
|
|
}
|