feat: implement Git integration for automated repository instruction syncing and commit management

This commit is contained in:
Jeffery
2026-05-21 11:29:41 +08:00
parent adf37520cb
commit 097b6fb721
4 changed files with 236 additions and 52 deletions
+1 -1
View File
@@ -243,4 +243,4 @@ Antigravity:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
### 版本包含 ### 版本包含
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json``exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。 提交時一併包含 `triage-findings` skill 與各平台入口檔;其中 `AGENTS.md``ANTIGRAVITY.md``CLAUDE.md``GEMINI.md` 會在目標專案已存在時先做規則化合併,並在可用 LLM 時再用 AI 輔助檢查是否有遺失任何 skill、command 或規則;其餘同步檔則以來源覆蓋;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json``exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
+2 -2
View File
@@ -38,8 +38,8 @@
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確` - 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`
## 階段八:記憶區 commit/push 與錯誤處理 ## 階段八:記憶區 commit/push 與錯誤處理
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。 - 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;`AGENTS.md``ANTIGRAVITY.md``CLAUDE.md``GEMINI.md` 在目標專案已存在時會先做規則化合併,並在可用 LLM 時再做 AI 輔助檢查以避免遺失 skill、command 或規則;其餘同步檔則以來源覆蓋workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。 - 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出四個入口檔會先規則合併、再由 AI 輔助檢查,其他同步檔會被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
- 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。 - 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。
## 階段九:阻擋嚴重問題 PR(第 8 點) ## 階段九:阻擋嚴重問題 PR(第 8 點)
+171 -26
View File
@@ -2,8 +2,8 @@ import { spawnSync } from 'child_process';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH, getLLMConfig } from './config.js';
import { line, ok, warn } from './log.js'; import { line, ok, warn, error } from './log.js';
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json']; const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
@@ -31,6 +31,13 @@ const FORCE_SYNC_FILE_PATHS = [
'CLAUDE.md', 'CLAUDE.md',
'GEMINI.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 = [ const SYNC_TREE_PATHS = [
'.agents/skills/triage-findings', '.agents/skills/triage-findings',
'.antigravity/skills/triage-findings', '.antigravity/skills/triage-findings',
@@ -70,35 +77,169 @@ function readGitOutput(run, args, cwd, env) {
} }
} }
function copyTree(sourceRoot, repoDir, relDir) { 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); const srcDir = path.join(sourceRoot, relDir);
if (!fs.existsSync(srcDir)) return []; if (!fs.existsSync(srcDir)) return [];
const copied = []; const copied = [];
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const relPath = path.join(relDir, entry.name); const relPath = path.join(relDir, entry.name);
const src = path.join(sourceRoot, relPath);
const dest = path.join(repoDir, relPath);
if (entry.isDirectory()) { if (entry.isDirectory()) {
copied.push(...copyTree(sourceRoot, repoDir, relPath)); copied.push(...syncTree(sourceRoot, repoDir, relPath));
continue; continue;
} }
fs.mkdirSync(path.dirname(dest), { recursive: true }); const synced = syncFileOverwrite(sourceRoot, repoDir, relPath);
fs.copyFileSync(src, dest); if (synced) copied.push(synced);
copied.push(relPath);
} }
return copied; return copied;
} }
function copyFileOverwrite(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;
}
export function getRepoState(repoDir, _spawnSync = spawnSync) { export function getRepoState(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync); const run = makeRunner(_spawnSync);
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir); const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
@@ -150,26 +291,30 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
} }
const existingSyncPaths = new Set(); const existingSyncPaths = new Set();
const aiMergeAssistant = await getInstructionMergeAssistant();
// Copy action skill trees into the target repo. Existing files are overwritten; // Copy action skill trees into the target repo. Existing files are merged with
// missing source files are ignored so we do not delete target repo content. // 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 relDir of SYNC_TREE_PATHS) {
for (const relPath of copyTree(sourceRoot, repoDir, relDir)) { for (const relPath of syncTree(sourceRoot, repoDir, relDir)) {
existingSyncPaths.add(relPath); existingSyncPaths.add(relPath);
} }
} }
// Force overwrite the direct instruction files first so the target repo always // Merge only the direct instruction files that must preserve repository-specific
// receives the action-owned versions even if the repo has drifted. // skills, commands, and rules. Everything else keeps the source copy.
for (const relPath of FORCE_SYNC_FILE_PATHS) { for (const relPath of FORCE_SYNC_FILE_PATHS) {
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath); const copied = MERGE_SYNC_FILE_PATHS.has(relPath)
? await syncInstructionFile(sourceRoot, repoDir, relPath, aiMergeAssistant)
: syncFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied); if (copied) existingSyncPaths.add(copied);
} }
// Copy standalone action files into the target repo. Existing files are overwritten. // Merge standalone action files into the target repo.
for (const relPath of SYNC_PATHS) { for (const relPath of SYNC_PATHS) {
if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue; if (FORCE_SYNC_FILE_PATHS.includes(relPath)) continue;
const copied = copyFileOverwrite(sourceRoot, repoDir, relPath); const copied = syncFileOverwrite(sourceRoot, repoDir, relPath);
if (copied) existingSyncPaths.add(copied); if (copied) existingSyncPaths.add(copied);
} }
+62 -23
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit } from './git.js'; import { commitAndPush, cloneRepo, SYNC_PATHS, BOT_COMMIT_MARKER, getHeadCommitMessage, isBotAutoCommit, mergeInstructionText } from './git.js';
// --- helpers --- // --- helpers ---
function makeTmpWorkspace() { function makeTmpWorkspace() {
@@ -159,40 +159,79 @@ describe('commitAndPush', () => {
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale'); assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
}); });
it('overwrites existing repo copies with workspace files', async () => { it('merges existing repo copies with workspace files', async () => {
const repoDir = path.join(workspace, 'repo'); const repoDir = path.join(workspace, 'repo');
fs.writeFileSync(path.join(repoDir, '.agents/skills/triage-findings/SKILL.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'repo agents doc');
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'repo antigravity doc');
fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'repo claude doc');
fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'stale'); fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'repo gemini doc');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'stale');
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot); await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'utf8'), '.github/skills/triage-findings/SKILL.md'); const agentsDoc = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, '.agents/skills/triage-findings/SKILL.md'), 'utf8'), '.agents/skills/triage-findings/SKILL.md'); const antigravityDoc = fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'), 'AGENTS.md'); const claudeDoc = fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8'), 'ANTIGRAVITY.md'); const geminiDoc = fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8');
assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
assert.equal(fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8'), 'GEMINI.md'); assert.ok(agentsDoc.includes('repo agents doc'));
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md'); assert.ok(agentsDoc.includes('AGENTS.md'));
assert.ok(antigravityDoc.includes('repo antigravity doc'));
assert.ok(antigravityDoc.includes('ANTIGRAVITY.md'));
assert.ok(claudeDoc.includes('repo claude doc'));
assert.ok(claudeDoc.includes('CLAUDE.md'));
assert.ok(geminiDoc.includes('repo gemini doc'));
assert.ok(geminiDoc.includes('GEMINI.md'));
assert.ok(agentsDoc.includes('repo agents doc'));
}); });
it('recursively overwrites skill tree files from the action source', async () => { it('accepts AI merged instruction text when all unique blocks are preserved', async () => {
const calls = [];
const aiMergeAssistant = async payload => {
calls.push(payload);
return ['repo block', 'source block', 'extra block'].join('\n\n');
};
const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant);
assert.equal(calls.length, 1);
assert.ok(result.includes('repo block'));
assert.ok(result.includes('source block'));
assert.ok(result.includes('extra block'));
});
it('exits when AI output drops a block', async () => {
const originalExit = process.exit;
let exitCode = null;
process.exit = code => { exitCode = code; };
try {
const aiMergeAssistant = async () => 'source block only';
await assert.rejects(() => mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant));
assert.equal(exitCode, 1);
} finally {
process.exit = originalExit;
}
});
it('overwrites non-merge sync files with workspace files', async () => {
const repoDir = path.join(workspace, 'repo'); const repoDir = path.join(workspace, 'repo');
const nestedRelPath = '.codex/skills/triage-findings/assets/example.txt'; const sourceSkillPath = path.join(sourceRoot, '.github/skills/triage-findings/SKILL.md');
const sourceNestedPath = path.join(sourceRoot, nestedRelPath); const repoSkillPath = path.join(repoDir, '.github/skills/triage-findings/SKILL.md');
const repoNestedPath = path.join(repoDir, nestedRelPath); const sourceNestedPath = path.join(sourceRoot, '.codex/skills/triage-findings/assets/example.txt');
const repoNestedPath = path.join(repoDir, '.codex/skills/triage-findings/assets/example.txt');
fs.writeFileSync(sourceSkillPath, 'fresh github skill');
fs.writeFileSync(repoSkillPath, 'stale github skill');
fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true }); fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true });
fs.writeFileSync(sourceNestedPath, 'fresh'); fs.writeFileSync(sourceNestedPath, 'fresh nested');
fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true }); fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true });
fs.writeFileSync(repoNestedPath, 'stale'); fs.writeFileSync(repoNestedPath, 'stale nested');
fs.writeFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'stale copilot');
await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot); await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot);
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh'); assert.equal(fs.readFileSync(repoSkillPath, 'utf8'), 'fresh github skill');
assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh nested');
assert.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md');
}); });
it('does not throw when git command fails', async () => { it('does not throw when git command fails', async () => {