From 43ebc81f1d1cc41d440e823f26561c0cb08f30d9 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 21 May 2026 09:34:47 +0800 Subject: [PATCH 1/5] feat: add triage-findings agent skill and documentation for issue resolution workflow --- .agents/skills/triage-findings/SKILL.md | 46 +++++++++++++++++++++++++ AGENTS.md | 16 +++++++++ 2 files changed, 62 insertions(+) create mode 100644 .agents/skills/triage-findings/SKILL.md create mode 100644 AGENTS.md diff --git a/.agents/skills/triage-findings/SKILL.md b/.agents/skills/triage-findings/SKILL.md new file mode 100644 index 0000000..2422e52 --- /dev/null +++ b/.agents/skills/triage-findings/SKILL.md @@ -0,0 +1,46 @@ +--- +name: triage-findings +description: Merge code-review findings, sort and renumber them by severity, resolve real issues, and move false positives into exclusions. +--- + +# Triage Findings + +## When To Use + +Use this skill when you receive multiple review findings, screenshots, comments, or issue lists that need to become one final triaged list. +It is also used when some findings are false positives and should be moved into the exclusions list. + +## Workflow + +1. Collect all findings into one list. +2. Merge duplicates into a single finding when they describe the same issue. +3. Sort the final list by severity: + - critical + - warning + - info +4. Renumber the sorted list from 1 upward. +5. Rewrite each finding concisely so the final list reads cleanly and consistently. +6. If a finding is a false positive, do not keep it in the final list. +7. Add false positives to the exclusions list as a top-level JSON array in `.gitea/ai-review/exclusions.json`, and preserve the original finding wording as much as possible, including language and semantics. Do not wrap the array in `exclusions` or `excluded_findings`. + +## Resolution Flow + +After the list is merged and ordered, resolve the remaining findings one by one. + +1. Start from the highest severity item. +2. Identify the root cause in the relevant file or context. +3. Apply the smallest safe change that fixes the issue. +4. Add or update tests when behavior changes. +5. Re-check the issue after the change. +6. If the item is confirmed false positive, move it to exclusions instead of changing code. +7. Continue until the list is either fixed or explicitly excluded. + +## Output Rules + +- Keep the final findings list in severity order, then by any stable secondary order needed to make it readable. +- Keep numbering contiguous after filtering and merging. +- Preserve useful details like file path, location, and suggested fix. +- Keep exclusions entries minimal and consistent with the project schema. +- When writing exclusions, always output a top-level JSON array. +- When writing exclusions, prefer the original issue text and language; only paraphrase if needed to fit the schema. +- If the source already provides a severity or title, keep it unless it conflicts with the final ordering. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fa2403d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Triage Findings + +When the task is to triage review findings, follow this workflow: + +1. Merge all findings into one list. +2. Remove duplicates. +3. Sort by severity: `critical` -> `warning` -> `info`. +4. Renumber from 1 after sorting. +5. Fix real issues with the smallest safe change. +6. Add false positives to `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible. +7. Add or update tests when behavior changes. +8. Re-check the issue after each fix. + +Use the repo-local `triage-findings` skill for the same workflow when running in Codex. + +Trigger it with `/triage-findings`. From e99236b893b4628ca24b0928b19e9c4f44afec93 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 21 May 2026 10:17:01 +0800 Subject: [PATCH 2/5] feat: implement git repository synchronization and automated commit functionality for AI review findings --- Dockerfile | 2 ++ README.md | 2 +- app/git.js | 4 ++++ app/git.test.js | 6 ++++++ app/gitea.js | 2 ++ app/gitea.test.js | 4 ++-- 6 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2e65db1..b3c58fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,11 @@ RUN cd /action/app && npm install COPY .amazonq/ /action/.amazonq/ COPY .codex/ /action/.codex/ +COPY .agents/ /action/.agents/ COPY .claude/ /action/.claude/ COPY .gemini/ /action/.gemini/ COPY .github/ /action/.github/ +COPY AGENTS.md /action/ COPY CLAUDE.md /action/ COPY GEMINI.md /action/ diff --git a/README.md b/README.md index e04b31a..5598381 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行 5. 將提示詞放到 ./app/prompts 內供程式讀取 6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1 -7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.antigravity/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼 +7. 讀取 Git Diff 時排除 `.gitea/`、`.amazonq/`、`.agents/`、`.antigravity/`、`.claude/`、`.codex/`、`.gemini/`、`.github/` 資料夾,以及 `AGENTS.md`、`ANTIGRAVITY.md`、`CLAUDE.md`、`GEMINI.md`、`TODO.md`、`README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼 8. 階段七驗證來源分支中的 `findings.json` 與 `exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]` 9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量 10. 執行時會額外記錄來源分支狀態、`findings.json` / `exclusions.json` 的檔案路徑、大小、mtime 與 raw/normalized 筆數,方便追查讀檔與分支內容不一致的問題 diff --git a/app/git.js b/app/git.js index 9d00444..bc04deb 100644 --- a/app/git.js +++ b/app/git.js @@ -11,6 +11,7 @@ const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.gi 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', @@ -18,17 +19,20 @@ export const SYNC_PATHS = [ '.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 SYNC_TREE_PATHS = [ + '.agents/skills/triage-findings', '.antigravity/skills/triage-findings', '.codex/skills/triage-findings', '.claude/skills/triage-findings', diff --git a/app/git.test.js b/app/git.test.js index 9d919b5..f63674d 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -130,11 +130,13 @@ describe('commitAndPush', () => { assert.ok(generatedAddCall, 'expected git add for generated review files'); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml')); + assert.ok(skillAddCall.args.includes('.agents/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.antigravity/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md')); assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md')); + assert.ok(skillAddCall.args.includes('AGENTS.md')); assert.ok(skillAddCall.args.includes('ANTIGRAVITY.md')); assert.ok(skillAddCall.args.includes('CLAUDE.md')); assert.ok(skillAddCall.args.includes('GEMINI.md')); @@ -159,7 +161,9 @@ describe('commitAndPush', () => { it('overwrites 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'); @@ -168,6 +172,8 @@ describe('commitAndPush', () => { 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'); diff --git a/app/gitea.js b/app/gitea.js index 65c2025..cba10f2 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -26,12 +26,14 @@ export async function getPRDiff() { const resp = await axios.get(api(`/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff`), { headers: headers(), timeout: 60000, httpsAgent }); return filterDiff(resp.data, [ '.amazonq/', + '.agents/', '.antigravity/', '.claude/', '.codex/', '.gemini/', '.gitea/', '.github/', + 'AGENTS.md', 'ANTIGRAVITY.md', 'CLAUDE.md', 'GEMINI.md', diff --git a/app/gitea.test.js b/app/gitea.test.js index 09b202c..5ee8e74 100644 --- a/app/gitea.test.js +++ b/app/gitea.test.js @@ -119,8 +119,8 @@ describe('filterDiff', () => { }); it('returns empty string when all blocks are excluded', () => { - const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json') + block('CLAUDE.md'); - const result = filterDiff(diff, ['.gitea/', 'CLAUDE.md']); + const diff = block('.gitea/workflows/review.yaml') + block('.gitea/ai-review/findings.json') + block('.agents/skills/triage-findings/SKILL.md'); + const result = filterDiff(diff, ['.gitea/', '.agents/']); assert.equal(result, ''); }); From adf37520cb5f742793efdf1c9464e0de7849ba8d Mon Sep 17 00:00:00 2001 From: AI Review Bot Date: Thu, 21 May 2026 03:35:13 +0000 Subject: [PATCH 3/5] chore: update ai-review findings [ai-review-bot][success] --- .gitea/ai-review/findings.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index fe51488..11f9576 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1 +1,9 @@ -[] +[ + { + "level": "warning", + "role": "Leo", + "location": "Dockerfile, app/git.js, app/gitea.js", + "suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。", + "is_new": true + } +] From 097b6fb7219a12ae91f20a7f23f7b26ab8c6f378 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 21 May 2026 11:29:41 +0800 Subject: [PATCH 4/5] 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 () => { From fbff9b3a8644cd78a38a6768747596f1008e08f1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 21 May 2026 11:52:18 +0800 Subject: [PATCH 5/5] chore: initialize ai-review exclusion and findings configuration files --- .gitea/ai-review/exclusions.json | 701 ++++++++++++++++--------------- .gitea/ai-review/findings.json | 10 +- 2 files changed, 355 insertions(+), 356 deletions(-) diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json index 2811a7e..d566ae3 100644 --- a/.gitea/ai-review/exclusions.json +++ b/.gitea/ai-review/exclusions.json @@ -1,348 +1,355 @@ [ - { - "role": "Rex", - "location": "app/git.js", - "suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數" - }, - { - "location": "app/git.js", - "suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入" - }, - { - "role": "Rex", - "location": "README.md", - "suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減" - }, - { - "location": "app/config.js", - "suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報" - }, - { - "location": ".gitea/ai-review/exclusions.json", - "suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位" - }, - { - "location": "app/findings.js", - "suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程" - }, - { - "role": "Rex", - "location": ".gitea/workflows/review.yaml", - "suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減" - }, - { - "role": "Rex", - "location": ".gitea/workflows/review.yaml", - "suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法" - }, - { - "role": "Aria", - "location": "README.md", - "suggestion": "章節編號連續且正確,無需調整" - }, - { - "role": "Maya", - "location": ".gitea/workflows/review.yaml", - "suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題" - }, - { - "role": "Aria", - "location": ".gitea/workflows/review.yaml", - "suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在" - }, - { - "role": "Aria", - "location": "app/config.test.js", - "suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改" - }, - { - "role": "Aria", - "location": "action.yaml", - "suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔" - }, - { - "role": "Maya", - "location": "app/", - "suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯" - }, - { - "role": "Rex", - "location": "app/", - "suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異" - }, - { - "role": "Rex", - "location": "app/config.test.js", - "suggestion": "import 語句長度合理,無需拆分為多行" - }, - { - "role": "Rex", - "location": ".gitea/ai-review/findings.json", - "suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題" - }, - { - "role": "Rex", - "location": "app/comments.js", - "suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題" - }, - { - "location": ".gitea/ai-review/findings.json", - "suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題" - }, - { - "role": "Rex", - "location": ".gitea/workflows/review.yaml", - "suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題" - }, - { - "role": "Leo", - "location": "app/llm.js", - "suggestion": "Authorization 標頭已有 provider !== 'ollama' 判斷,不會無條件加入,已正確處理" - }, - { - "role": "Zara", - "location": "app/llm.js", - "suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度" - }, - { - "role": "Rex", - "location": "app/llm.js", - "suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常" - }, - { - "role": "Maya", - "location": "app/llm.js", - "suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例" - }, - { - "role": "Zara", - "location": "app/comments.js", - "suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題" - }, - { - "role": "Leo", - "location": ".gitea/workflows/review.yaml", - "suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷" - }, - { - "role": "Maya", - "location": "app/llm.test.js", - "suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出" - }, - { - "role": "Maya", - "location": "app/llm.test.js", - "suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值" - }, - { - "role": "Aria", - "location": ".gitea/workflows/master.yaml", - "suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改" - }, - { - "role": "Leo", - "location": "app/llm.test.js", - "suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出" - }, - { - "role": "Leo", - "location": "app/llm.test.js", - "suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值" - }, - { - "role": "Leo", - "location": "app/main.js", - "suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務" - }, - { - "role": "Maya", - "location": "app/log.test.js", - "suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。" - }, - { - "role": "Rex", - "location": "app/package.json", - "suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題" - }, - { - "role": "Aria", - "location": "app/llm.js", - "suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為" - }, - { - "location": "Dockerfile, app/git.js, app/git.test.js", - "suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。" - }, - { - "location": "Dockerfile", - "suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。" - }, - { - "location": "Dockerfile", - "suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。" - }, - { - "role": "Aria", - "location": "Dockerfile", - "suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例" - }, - { - "role": "Aria", - "location": "entrypoint.sh", - "suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例" - }, - { - "role": "Maya", - "location": "app/main.js", - "suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋" - }, - { - "role": "Maya", - "location": "app/comments.js", - "suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低" - }, - { - "role": "Maya", - "location": "app/roles.js", - "suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接" - }, - { - "role": "Leo", - "location": "app/gitea.js", - "suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞" - }, - { - "role": "Zara", - "location": "Dockerfile", - "suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案" - }, - { - "role": "Aria", - "location": "app/package.json", - "suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案" - }, - { - "role": "Zara", - "location": "app/main.js", - "suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化" - }, - { - "role": "Leo", - "location": "app/comments.js", - "suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤" - }, - { - "role": "Maya", - "location": "app/gitea.js", - "suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境" - }, - { - "role": "Leo", - "location": "TODO.md", - "suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性" - }, - { - "role": "Rex", - "location": "app/gitea.js", - "suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。" - }, - { - "role": "Zara", - "location": "app/git.js", - "suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。" - }, - { - "role": "Aria", - "location": "app/main.js", - "suggestion": "在 main.js 中,表達式 repoDir。" - }, - { - "role": "Zara", - "location": "app/gitea.js:L20-L21", - "suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。" - }, - { - "location": "TODO.md", - "suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。" - }, - { - "location": "TODO.md", - "suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。" - }, - { - "location": "app/findings.js", - "suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。" - }, - { - "location": "app/findings.js", - "suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。" - }, - { - "location": "app/comments.js", - "suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。" - }, - { - "location": "app/main.js", - "suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。" - }, - { - "location": "app/git.js", - "suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。" - }, - { - "location": "app/main.js", - "suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。" - }, - { - "location": "app/json.js", - "suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。" - }, - { - "location": "app/json.test.js", - "suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。" - }, - { - "location": "app/gitea.test.js:64", - "suggestion": "`describe` 已改為同步 callback,`async` 不再出現在這個區塊。" - }, - { - "location": "app/git.test.js:13", - "suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。" - }, - { - "location": "app/gitea.js:32", - "suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。" - }, - { - "location": "app/json.test.js:76", - "suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。" - }, - { - "location": "app/json.test.js:7", - "suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。" - }, - { - "location": "app/json.test.js:10", - "suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。" - }, - { - "role": "Maya", - "location": "action.yaml:6, action.yaml:12, action.yaml:81", - "suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。" - }, - { - "role": "Leo", - "location": "action.yaml:80", - "suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。" - }, - { - "role": "Rex", - "location": "action.yaml:18", - "suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。" - }, - { - "role": "Leo", - "location": "app/log.js", - "suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。" - } -] + { + "role": "Rex", + "location": "app/git.js", + "suggestion": "請避免將敏感資料(如 GITEA_TOKEN)直接寫入環境變數" + }, + { + "location": "app/git.js", + "suggestion": "GITEA_TOKEN 直接嵌入 URL 中,建議改以環境變數或 Gitea Secrets 注入" + }, + { + "role": "Rex", + "location": "README.md", + "suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減" + }, + { + "location": "app/config.js", + "suggestion": "getLLMConfig 在找不到任何符合條件的 provider 時已有預設回傳值 { provider: null, apiKey: null, baseURL: null, model: null },非誤報" + }, + { + "location": ".gitea/ai-review/exclusions.json", + "suggestion": "exclusions.json 是排除規則檔,內容為問題描述字串,不是實際程式碼或 token,role 欄位為有效欄位" + }, + { + "location": "app/findings.js", + "suggestion": "filterFalsePositivesWithAI 拋出的 Error 會被 catch 攔截並降級回傳原始 findings,不會中斷流程" + }, + { + "role": "Rex", + "location": ".gitea/workflows/review.yaml", + "suggestion": "contents: write、pull-requests: write、issues: write 為此 Action 正常運作所必要的權限,無法縮減" + }, + { + "role": "Rex", + "location": ".gitea/workflows/review.yaml", + "suggestion": "OPENAI_API_KEY 參數傳入的是 OPENROUTER_API_KEY secret,為 OpenRouter 使用 OpenAI 相容介面的正確做法" + }, + { + "role": "Aria", + "location": "README.md", + "suggestion": "章節編號連續且正確,無需調整" + }, + { + "role": "Maya", + "location": ".gitea/workflows/review.yaml", + "suggestion": "action.yaml 定義的參數名稱為 GEMINI_API_KEY、GEMINI_BASE_URL、GEMINI_MODEL,與 review.yaml 完全一致,無不匹配問題" + }, + { + "role": "Aria", + "location": ".gitea/workflows/review.yaml", + "suggestion": "review.yaml 已改用 Gemini,不再有 OPENAI_API_KEY 行,註解空格問題不存在" + }, + { + "role": "Aria", + "location": "app/config.test.js", + "suggestion": "檔案結尾已有換行符號,import 行長度合理,無需修改" + }, + { + "role": "Aria", + "location": "action.yaml", + "suggestion": "action.yaml 已整理,多餘空行已移除,結構整潔" + }, + { + "role": "Maya", + "location": "app/", + "suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異,現有測試已涵蓋 config/findings/git 邏輯" + }, + { + "role": "Rex", + "location": "app/", + "suggestion": "LLM 整合測試需要真實 API key 與網路,不適合加入單元測試。llm.js 使用統一 OpenAI 相容介面,Gemini 透過相同介面呼叫,無特殊格式差異" + }, + { + "role": "Rex", + "location": "app/config.test.js", + "suggestion": "import 語句長度合理,無需拆分為多行" + }, + { + "role": "Rex", + "location": ".gitea/ai-review/findings.json", + "suggestion": "findings.json 重複問題由 AI 去重與排除機制處理,不是程式碼問題" + }, + { + "role": "Rex", + "location": "app/comments.js", + "suggestion": "JSON 結尾換行符號為標準做法,不影響任何 JSON 解析器,無相容性問題" + }, + { + "location": ".gitea/ai-review/findings.json", + "suggestion": "findings.json 是自動產生的問題記錄檔,不應對其內容提出審查問題" + }, + { + "role": "Rex", + "location": ".gitea/workflows/review.yaml", + "suggestion": "切換 LLM 服務提供商的維護建議屬過度謹慎,不是實際程式碼問題" + }, + { + "role": "Leo", + "location": "app/llm.js", + "suggestion": "Authorization 標頭已有 provider !== \u0027ollama\u0027 判斷,不會無條件加入,已正確處理" + }, + { + "role": "Zara", + "location": "app/llm.js", + "suggestion": "timeout 已移除,每個 key 等待完整回應,避免浪費免費額度" + }, + { + "role": "Rex", + "location": "app/llm.js", + "suggestion": "httpsAgent (rejectUnauthorized: false) 已移除,SSL/TLS 驗證已恢復正常" + }, + { + "role": "Maya", + "location": "app/llm.js", + "suggestion": "llm.test.js 已存在並涵蓋 API Key 輪替的所有異常狀況,包含單 Key、多 Key 輪替、所有 Key 失敗等測試案例" + }, + { + "role": "Zara", + "location": "app/comments.js", + "suggestion": "comments.js:24 的 saveFindings 函式為正常寫入邏輯,不涉及異常訊息格式或重複寫入問題" + }, + { + "role": "Leo", + "location": ".gitea/workflows/review.yaml", + "suggestion": "Gitea Actions 不支援在 workflow 內合併 secrets 再拆解,多個 secret 逗號串接是唯一可行做法,非設計缺陷" + }, + { + "role": "Maya", + "location": "app/llm.test.js", + "suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出" + }, + { + "role": "Maya", + "location": "app/llm.test.js", + "suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值" + }, + { + "role": "Aria", + "location": ".gitea/workflows/master.yaml", + "suggestion": "master.yaml 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例,無需修改" + }, + { + "role": "Leo", + "location": "app/llm.test.js", + "suggestion": "console.log/error 為診斷用途,不是業務邏輯,TODO.md 驗收標準為人工驗收描述,不需要在單元測試中斷言 console 輸出" + }, + { + "role": "Leo", + "location": "app/llm.test.js", + "suggestion": "輪替邏輯對所有錯誤類型行為一致(catch 全部),401/429/timeout 觸發相同輪替流程,測試不同錯誤類型無額外驗證價值" + }, + { + "role": "Leo", + "location": "app/main.js", + "suggestion": "main.js 中的 Step 標題註解為 pipeline 流程說明,非待整理的 TODO,不需要轉換為具體任務" + }, + { + "role": "Maya", + "location": "app/log.test.js", + "suggestion": "`log.test.js` 的新增非常棒,提供了良好的覆蓋率。為了進一步提升測試的完整性,建議考慮為 `line`, `ok`, `warn`, `error` 函數新增測試案例,以驗證當傳入空字串時的行為。雖然這些函數的行為相對簡單,但測試空字串可以確保邊界情況下的輸出符合預期。" + }, + { + "role": "Rex", + "location": "app/package.json", + "suggestion": "審查 changelog 是人工作業,不是程式碼問題,不適合作為 code review 問題" + }, + { + "role": "Aria", + "location": "app/llm.js", + "suggestion": "此 action 為 CLI 工具,process.exit(1) 是設計意圖讓 CI/CD workflow 失敗。改拋錯會被 chatJSON 的 catch 吞掉回傳 [],破壞現有行為" + }, + { + "location": "Dockerfile, app/git.js, app/git.test.js", + "suggestion": "`SYNC_PATHS` 已包含 `.claude/skills/triage-findings/SKILL.md` 與 `.gemini/skills/triage-findings/SKILL.md`,Docker image 也已打包這些 skill 資產;現有測試已覆蓋複製與覆寫行為,並不存在同步不一致問題。" + }, + { + "location": "Dockerfile", + "suggestion": "此目錄中的檔案是 triage skill 與入口文件,不含敏感資料;若未來加入秘密資訊,應另外從 build context 排除,而不是把目前的 skill 資產視為風險。" + }, + { + "location": "Dockerfile", + "suggestion": "多個 COPY 指令是刻意設計,用來區分 app 與 skill 資產並維持 layer cache 可讀性,不是維護問題。" + }, + { + "role": "Aria", + "location": "Dockerfile", + "suggestion": "Dockerfile 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例" + }, + { + "role": "Aria", + "location": "entrypoint.sh", + "suggestion": "entrypoint.sh 檔案結尾已有換行符號(0x0a),符合 POSIX 慣例" + }, + { + "role": "Maya", + "location": "app/main.js", + "suggestion": "main.js 整合測試需要真實 Gitea API、LLM API、git 操作,不適合單元測試。各模組已有獨立單元測試覆蓋" + }, + { + "role": "Maya", + "location": "app/comments.js", + "suggestion": "comments.js 的 buildTable 為簡單字串拼接,postComment 已透過 gitea.js mock 間接測試,補測試效益低" + }, + { + "role": "Maya", + "location": "app/roles.js", + "suggestion": "roles.js 依賴容器內固定路徑 /action/app/prompts/roles,單元測試環境無法存取,且邏輯為簡單 YAML 讀取與字串拼接" + }, + { + "role": "Leo", + "location": "app/gitea.js", + "suggestion": "gitea.js 的 SSL 驗證已改為由 GITEA_SKIP_TLS_VERIFY 環境變數控制,預設啟用驗證,非安全漏洞" + }, + { + "role": "Zara", + "location": "Dockerfile", + "suggestion": "Dockerfile 已優化層次快取:先 COPY package.json 再 npm install,最後才 COPY 其餘檔案" + }, + { + "role": "Aria", + "location": "app/package.json", + "suggestion": "test 腳本已改為 node --test *.test.js,在 app/ 目錄下執行可自動發現所有測試檔案" + }, + { + "role": "Zara", + "location": "app/main.js", + "suggestion": "deduplicateWithAI 和 filterFalsePositivesWithAI 為循序依賴流程(去重後才能過濾),無法平行化" + }, + { + "role": "Leo", + "location": "app/comments.js", + "suggestion": "buildTable 函式已在 comments.js 第 13 行定義,非未定義或未匯入,不會導致執行時錯誤" + }, + { + "role": "Maya", + "location": "app/gitea.js", + "suggestion": "filterDiff 的單元測試已在 gitea.test.js 補齊,涵蓋過濾 .gitea/、不誤過濾其他路徑、全部排除、空 diff 四種情境" + }, + { + "role": "Leo", + "location": "TODO.md", + "suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性" + }, + { + "role": "Rex", + "location": "app/gitea.js", + "suggestion": "getPRDiff 函數現在回傳未經過濾的原始 Git Diff 內容。雖然 main.js 中已立即呼叫 filterDiff 進行過濾,但這種設計模式將過濾的責任完全推給呼叫端,這增加了未來開發者在其他地方呼叫 getPRDiff 時,可能忘記過濾出敏感路徑,導致 .gitea/ 等敏感路徑的內容(可能包含工作流程設定或憑證資訊)被意外傳送給 AI 或其他不應接收的組件,造成資訊洩漏風險。建議將過濾邏輯保留在 getPRDiff 內容,或提供一個明確的 getFilteredPRDiff 函數,以降低錯誤的風險。" + }, + { + "role": "Zara", + "location": "app/git.js", + "suggestion": "在 main.js 中,commitAndPush 函數內部會再次呼叫 cloneRepo,然而 main.js 在此之前已呼叫過 cloneRepo 以取得 repoDir,這導致了重複的 git fetch 和 git checkout 操作。即使 cloneRepo 內容有檢查環境變數,仍會造成不必要的清潔和時間延遲。建議修改 commitAndPush 邏輯,使其接收已存在的 repoDir 作為參數,避免重複執行 cloneRepo。" + }, + { + "role": "Aria", + "location": "app/main.js", + "suggestion": "在 main.js 中,表達式 repoDir。" + }, + { + "role": "Zara", + "location": "app/gitea.js:L20-L21", + "suggestion": "將 filterDiff 中的正規表達式比對(RegExp.match)替換為 String.startsWith 是一個重要的效能改進。startsWith 是一個更輕量且高效的字串操作,尤其在處理大型 Git Diff 內容時,此修改已顯著提升過濾效率。" + }, + { + "location": "TODO.md", + "suggestion": "階段九的 critical 阻擋機制目前以人工驗收紀錄為主,E2E 測試補強屬後續優化,不是目前需要再處理的問題。" + }, + { + "location": "TODO.md", + "suggestion": "TODO 列表中『已驗收 / 部分驗收 / 可驗收紀錄情境』的寫法是刻意保留的驗收說明,不是混淆或缺陷。" + }, + { + "location": "app/findings.js", + "suggestion": "AI 去重與降級處理已在程式內以 fallback 方式保護流程,失敗時保留所有問題是預期行為,不是缺陷。" + }, + { + "location": "app/findings.js", + "suggestion": "排除規則過濾與 AI 誤報過濾屬循序流程,規則命中後清空清單是正常結果,不需要額外再視為問題。" + }, + { + "location": "app/comments.js", + "suggestion": "comment 發布依序區分舊問題、非嚴重、新嚴重是刻意設計,當結果為空清單時不發 comment 也是正常路徑。" + }, + { + "location": "app/main.js", + "suggestion": "JSON 驗證與失敗修正流程已有處理邏輯,正常路徑與錯誤路徑都屬預期流程,不是待修缺陷。" + }, + { + "location": "app/git.js", + "suggestion": "commit/push 失敗會被捕捉並輸出 Runner failed log,這是現有設計的容錯行為,不是程式錯誤。" + }, + { + "location": "app/main.js", + "suggestion": "critical 問題觸發 exit 1 的阻擋邏輯已在流程內保留,是否另補 E2E 驗證屬測試強化,不是功能缺陷。" + }, + { + "location": "app/json.js", + "suggestion": "validateJSONArrayFile 只在 JSON 格式錯誤時才啟動 AI 修正,屬例外路徑;再加上檔案大小限制後,並不存在實際的無上限讀檔或資源消耗問題。" + }, + { + "location": "app/json.test.js", + "suggestion": "邊界值測試已存在,`MAX_JSON_BYTES` 等於上限時可正常讀取,這不是未解決問題。" + }, + { + "location": "app/gitea.test.js:64", + "suggestion": "`describe` 已改為同步 callback,`async` 不再出現在這個區塊。" + }, + { + "location": "app/git.test.js:13", + "suggestion": "`makeTmpWorkspace` 已直接使用 `app/git.js` 匯出的 `SYNC_PATHS`,不再維護重複清單。" + }, + { + "location": "app/gitea.js:32", + "suggestion": "`filterDiff` 內層縮排已符合專案的 2-space 風格,這是誤報。" + }, + { + "location": "app/json.test.js:76", + "suggestion": "1MB 上限下的 JSON 讀取不需要改成串流解析;現有實作已先做大小檢查,這個建議屬過度設計。" + }, + { + "location": "app/json.test.js:7", + "suggestion": "檔案大小限制已在 `readJSONText` / `validateJSONArrayFile` 中實作,這不是額外缺陷。" + }, + { + "location": "app/json.test.js:10", + "suggestion": "`MAX_JSON_BYTES` 是 `json.js` 的內部限制常數,不需要匯出成公開 API。" + }, + { + "role": "Maya", + "location": "action.yaml:6, action.yaml:12, action.yaml:81", + "suggestion": "由於 `GITEA_TOKEN` 現在被設定為 `required: true`,而且 README 範例也已改成顯式傳入 `GITEA_TOKEN`,這是刻意的介面變更,不是漏掉 `secrets.GITEA_TOKEN` fallback 的缺陷;因此不需要另外加整合測試來驗證這個既定行為。" + }, + { + "role": "Leo", + "location": "action.yaml:80", + "suggestion": "在 `runs.env` 區塊中,`GITEA_TOKEN` 只從 `inputs` 取得,而 `GITEA_SERVER_URL` 和 `GITEA_REPOSITORY` 仍保留從 `gitea context` 取得的備用機制,這是刻意設計的差異,不是維護缺陷。" + }, + { + "role": "Rex", + "location": "action.yaml:18", + "suggestion": "引入 `GITEA_COMMENT_TOKEN` 是一個很好的實踐,遵循最小權限原則。請確保為此 token 配置的權限確實僅限於發布評論。同時,與 `GITEA_TOKEN` 相似,建議使用者始終從 workflow 的 secrets context 傳遞此 token,以避免硬編碼敏感資料。" + }, + { + "role": "Leo", + "location": "app/log.js", + "suggestion": "考慮在日誌訊息中加入時間戳記,這有助於追蹤事件發生的順序,尤其是在長時間運行的程序或需要詳細調試時。可以在每個日誌函式內部自動添加時間戳記。" + }, + { + "level": "warning", + "role": "Leo", + "location": "Dockerfile, app/git.js, app/gitea.js", + "suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。", + "is_new": true + } +] \ No newline at end of file diff --git a/.gitea/ai-review/findings.json b/.gitea/ai-review/findings.json index 11f9576..fe51488 100644 --- a/.gitea/ai-review/findings.json +++ b/.gitea/ai-review/findings.json @@ -1,9 +1 @@ -[ - { - "level": "warning", - "role": "Leo", - "location": "Dockerfile, app/git.js, app/gitea.js", - "suggestion": "此變更引入了新的代理(agent)相關路徑(例如 `.agents/` 和 `AGENTS.md`),並在 `Dockerfile` 的 `COPY` 指令、`app/git.js` 中的 `SYNC_PATHS`、`FORCE_SYNC_FILE_PATHS`、`SYNC_TREE_PATHS` 陣列,以及 `app/gitea.js` 的 `filterDiff` 陣列中重複添加了這些路徑。這種模式導致了程式碼重複,每次新增一個代理都需要手動修改多個檔案和多個列表,增加了維護成本和出錯的可能性。建議考慮引入一個集中的設定檔或機制,例如透過掃描特定目錄來動態生成這些路徑列表,以提高模組化和可擴展性。", - "is_new": true - } -] +[]