fix(ai-review 同步): 限制自動提交只包含問題檔

This commit is contained in:
2026-06-18 08:07:20 +00:00
parent c66a9aa025
commit 6f2bbab041
12 changed files with 9 additions and 596 deletions
+9 -246
View File
@@ -1,51 +1,12 @@
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';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
import { line, ok, warn } 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 REVIEW_FILE_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) {
@@ -88,173 +49,6 @@ function readGitOutput(run, args, cwd, env) {
}
}
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 (aiMerged == null) {
warn(`[merge] ${relPath} AI result unavailable; using deterministic merge`);
return deterministic;
}
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);
@@ -311,7 +105,7 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
});
}
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT, reviewOutcome = 'success') {
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
const run = makeRunner(_spawnSync);
try {
@@ -323,51 +117,20 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
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 reviewFilePaths = REVIEW_FILE_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
if (reviewFilePaths.length > 0) {
for (const relPath of reviewFilePaths) {
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);
run(['add', ...reviewFilePaths], repoDir);
}
const status = run(['status', '--porcelain'], repoDir);
if (!status) {
line('sync files 無變更,跳過 commit');
line('review files 無變更,跳過 commit');
return;
}