40ebfe99a8
git push 走 askpass + HTTP 認證,與 Gitea REST API 是兩套機制,API token 有效不代表 push 能用(曾出現 askpass 無法執行、could not read Username 而 push 失敗)。新增 git.js verifyRemoteAccess() 以相同 askpass + remote URL 跑唯讀 git ls-remote,preflight 呼叫並在失敗時 exit 1,提前攔下設定問題。 新增 git.test.js 對 verifyRemoteAccess 的測試(成功、失敗不丟例外、token 不外洩、askpass 清理)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
373 lines
13 KiB
JavaScript
373 lines
13 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, 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);
|
||
}
|
||
|
||
/**
|
||
* 用與 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 = 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}`);
|
||
}
|
||
}
|