From 097b6fb7219a12ae91f20a7f23f7b26ab8c6f378 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 21 May 2026 11:29:41 +0800 Subject: [PATCH] feat: implement Git integration for automated repository instruction syncing and commit management --- README.md | 2 +- TODO.md | 4 +- app/git.js | 197 +++++++++++++++++++++++++++++++++++++++++------- app/git.test.js | 85 +++++++++++++++------ 4 files changed, 236 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5598381..6e31f81 100644 --- a/README.md +++ b/README.md @@ -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 與目標專案內容脫節。 diff --git a/TODO.md b/TODO.md index 05dba85..f71e908 100644 --- a/TODO.md +++ b/TODO.md @@ -38,8 +38,8 @@ - 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json` 與 `.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`。 ## 階段八:記憶區 commit/push 與錯誤處理 -- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。 -- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。 +- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;`AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md` 在目標專案已存在時會先做規則化合併,並在可用 LLM 時再做 AI 輔助檢查以避免遺失 skill、command 或規則;其餘同步檔則以來源覆蓋;workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。 +- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出四個入口檔會先規則合併、再由 AI 輔助檢查,其他同步檔會被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案不會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。 - 已驗收:commit/push 成功時會出現 `persisted findings commit=... push=... review_outcome=...`,且同步規則與缺檔不刪除的行為都有單元測試覆蓋。 ## 階段九:阻擋嚴重問題 PR(第 8 點) diff --git a/app/git.js b/app/git.js index bc04deb..ba95084 100644 --- a/app/git.js +++ b/app/git.js @@ -2,8 +2,8 @@ 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'; -import { line, ok, warn } from './log.js'; +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']; @@ -31,6 +31,13 @@ const FORCE_SYNC_FILE_PATHS = [ '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', @@ -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); if (!fs.existsSync(srcDir)) return []; const copied = []; for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { const relPath = path.join(relDir, entry.name); - const src = path.join(sourceRoot, relPath); - const dest = path.join(repoDir, relPath); if (entry.isDirectory()) { - copied.push(...copyTree(sourceRoot, repoDir, relPath)); + copied.push(...syncTree(sourceRoot, repoDir, relPath)); continue; } - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.copyFileSync(src, dest); - copied.push(relPath); + const synced = syncFileOverwrite(sourceRoot, repoDir, relPath); + if (synced) copied.push(synced); } 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) { const run = makeRunner(_spawnSync); 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 aiMergeAssistant = await getInstructionMergeAssistant(); - // Copy action skill trees into the target repo. Existing files are overwritten; - // missing source files are ignored so we do not delete target repo content. + // 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 copyTree(sourceRoot, repoDir, relDir)) { + for (const relPath of syncTree(sourceRoot, repoDir, relDir)) { existingSyncPaths.add(relPath); } } - // Force overwrite the direct instruction files first so the target repo always - // receives the action-owned versions even if the repo has drifted. + // 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 = 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); } - // 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) { 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); } diff --git a/app/git.test.js b/app/git.test.js index f63674d..6bd6dbb 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'fs'; import os from 'os'; 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 --- function makeTmpWorkspace() { @@ -159,40 +159,79 @@ describe('commitAndPush', () => { 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'); - fs.writeFileSync(path.join(repoDir, '.agents/skills/triage-findings/SKILL.md'), 'stale'); - fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale'); - fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'stale'); - fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'stale'); - 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'); + fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), 'repo agents doc'); + fs.writeFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'repo antigravity doc'); + fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'repo claude doc'); + fs.writeFileSync(path.join(repoDir, 'GEMINI.md'), 'repo gemini doc'); 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'); - assert.equal(fs.readFileSync(path.join(repoDir, '.agents/skills/triage-findings/SKILL.md'), 'utf8'), '.agents/skills/triage-findings/SKILL.md'); - assert.equal(fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'), 'AGENTS.md'); - assert.equal(fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8'), 'ANTIGRAVITY.md'); - 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.equal(fs.readFileSync(path.join(repoDir, '.github/copilot-instructions.md'), 'utf8'), '.github/copilot-instructions.md'); + const agentsDoc = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); + const antigravityDoc = fs.readFileSync(path.join(repoDir, 'ANTIGRAVITY.md'), 'utf8'); + const claudeDoc = fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'); + const geminiDoc = fs.readFileSync(path.join(repoDir, 'GEMINI.md'), 'utf8'); + + assert.ok(agentsDoc.includes('repo agents doc')); + 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 nestedRelPath = '.codex/skills/triage-findings/assets/example.txt'; - const sourceNestedPath = path.join(sourceRoot, nestedRelPath); - const repoNestedPath = path.join(repoDir, nestedRelPath); + const sourceSkillPath = path.join(sourceRoot, '.github/skills/triage-findings/SKILL.md'); + const repoSkillPath = path.join(repoDir, '.github/skills/triage-findings/SKILL.md'); + 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.writeFileSync(sourceNestedPath, 'fresh'); + fs.writeFileSync(sourceNestedPath, 'fresh nested'); 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); - 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 () => {