Compare commits

..

13 Commits

17 changed files with 320 additions and 58 deletions
+14
View File
@@ -0,0 +1,14 @@
# 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.
+29
View File
@@ -0,0 +1,29 @@
---
name: triage-findings
description: Triage findings, fix real issues, and exclude false positives.
---
# Triage Findings
## Use
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
## Workflow
1. Merge all findings.
2. Sort by severity:
- critical
- warning
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
6. Add tests when behavior changes.
## Output Rules
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+2 -1
View File
@@ -21,7 +21,7 @@ It is also used when some findings are false positives and should be moved into
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 using the existing schema in the repo or task context.
7. Add false positives to the exclusions list using the existing schema in the repo or task context, and preserve the original finding wording as much as possible, including language and semantics.
## Resolution Flow
@@ -41,4 +41,5 @@ After the list is merged and ordered, resolve the remaining findings one by one.
- 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, 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.
+29
View File
@@ -0,0 +1,29 @@
---
name: triage-findings
description: Triage findings, fix real issues, and exclude false positives.
---
# Triage Findings
## Use
直接輸入:`triage-findings 問題原始檔(文字或截圖)`
## Workflow
1. Merge all findings.
2. Sort by severity:
- critical
- warning
- info
3. Renumber from 1.
4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`, preserving the original wording, language, and semantics as much as possible.
6. Add tests when behavior changes.
## Output Rules
- Keep the final list short.
- Keep numbering contiguous.
- Preserve file path, location, and fix.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+12
View File
@@ -164,6 +164,18 @@
"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",
+14
View File
@@ -0,0 +1,14 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.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 after each fix.
The full reusable skill lives in `.claude/skills/triage-findings/SKILL.md`.
+14
View File
@@ -0,0 +1,14 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.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 after each fix.
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
+16
View File
@@ -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`.
+8
View File
@@ -10,6 +10,14 @@ WORKDIR /action
COPY app/package.json /action/app/
RUN cd /action/app && npm install
COPY .amazonq/ /action/.amazonq/
COPY .codex/ /action/.codex/
COPY .claude/ /action/.claude/
COPY .gemini/ /action/.gemini/
COPY .github/ /action/.github/
COPY CLAUDE.md /action/
COPY GEMINI.md /action/
COPY app/ /action/app/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
+14
View File
@@ -0,0 +1,14 @@
# Triage Findings
Use the triage-finding workflow for review issue lists:
1. Merge findings into one list.
2. Remove duplicates.
3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1.
5. Fix real issues with the smallest safe change.
6. Put false positives into `.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 after each fix.
The reusable skill lives in `.gemini/skills/triage-findings/SKILL.md`.
+2 -2
View File
@@ -11,7 +11,7 @@
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 不存在但記憶區已存在的同步檔則移除後再同步,避免 `git add` 因缺檔 pathspec 失敗
8. Commit 問題檔案,將 workspace 中實際存在的同步檔覆蓋到記憶區;workspace 沒有的同步檔就略過,不會刪除記憶區既有內容
9. 如果PR問題表格中有嚴重問題,則不要讓 workflow 執行成功(exit 1)
# 設計
@@ -227,4 +227,4 @@ Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
### 版本包含
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 已移除某個同步檔,記憶區中的對應檔案也會一併刪除,讓 commit 結果與 workspace 保持一致
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節
+5 -5
View File
@@ -38,15 +38,15 @@
- 已驗收:log 已明確顯示 `.gitea/ai-review/findings.json``.gitea/ai-review/exclusions.json` 都是 `JSON 格式正確`
## 階段八:記憶區 commit/push 與錯誤處理
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,缺少的同步檔則記憶區刪除,達到鏡像同步效果;錯誤時有明確 log,流程結束有總結訊息。
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被鏡像同步;當 workspace 不再包含某個同步檔時,記憶區中的對應檔案會被刪除且不再出現在 commit;錯誤時有「Runner failed: ...」等明確錯誤說明。
- 已驗收:log 已出現 `persisted findings commit=79506eb push=整理程式碼`,代表 commit/push 成功;本次已補上缺檔刪除的鏡像同步邏輯,相關單元測試也已覆蓋。
- 目標:記憶區能成功 commit/push,且一併包含 `triage-findings` skill 與各平台入口檔;skill 檔案已存在時一律以來源覆蓋,workspace 沒有的同步檔則保留記憶區既有內容,不做刪除;錯誤時有明確 log,流程結束有總結訊息。
- 驗收:log 有「persisted findings」、「commit=...」、「push=...」等訊息,且能看出 skill 相關檔案已一併提交並被來源覆蓋;當 workspace 缺少某個同步檔時,記憶區中的對應檔案會被刪除;錯誤時有「Runner failed: ...」等明確錯誤說明。
- 已驗收:log 已出現 `persisted findings commit=b867eaa push=feat/解決問題`,代表 commit/push 成功;本次已補上「來源覆蓋、缺檔刪除」的同步規則,相關單元測試也已覆蓋。
## 階段九:阻擋嚴重問題 PR(第 8 點)
- 目標:如果 PR 問題表格中有嚴重(critical)問題,workflow 需直接 exit 1,不讓流程成功。
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
- 部分驗收:這次 log 顯示 `✅ 無嚴重問題`,因此只驗到正常放行路徑;`exit 1` 的阻擋分支仍需另一次含 critical 的 PR log 驗證
- 可驗收紀錄情境:只要 `Step8` 出現 `發現 X 個嚴重問題,workflow 結束(exit 1`,且 job 以失敗結束,就能驗收這一項;如果該次 PR 的 `filtered` 清單含 `critical`,就應該會看到這段 log
- 驗收:這次 log 已明確出現 `❌ 發現 2 個嚴重問題,workflow 結束(exit 1`,且 job 以失敗結束,證明阻擋分支確實生效
- 補充紀錄:`Step8` 的退出訊息屬於預期行為,不代表 Step7 commit/push 失敗
## 階段十:API Key 輪替
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
+12 -6
View File
@@ -16,13 +16,19 @@ function buildTable(findings) {
}
/**
* 寫入 findings.json 到 workspace
* 寫入 findings.json
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
*/
export function saveFindings(workspace, findings) {
const fullPath = path.join(workspace, FINDINGS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`);
export function saveFindings(workspace, findings, mirrorDir = null) {
const targets = [workspace];
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
for (const targetDir of targets) {
const fullPath = path.join(targetDir, FINDINGS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`);
}
}
/**
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { saveFindings } from './comments.js';
import { FINDINGS_PATH } from './config.js';
describe('saveFindings', () => {
const tempDirs = [];
const makeTempDir = prefix => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
};
it('writes findings to workspace and mirror dirs when provided', () => {
const workspace = makeTempDir('findings-ws-');
const mirrorDir = makeTempDir('findings-mirror-');
const findings = [{ level: 'warning', role: 'Leo', location: 'file.js:1', suggestion: 'test' }];
saveFindings(workspace, findings, mirrorDir);
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8');
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n');
});
it('writes only to workspace when mirrorDir is omitted', () => {
const workspace = makeTempDir('findings-ws-');
const findings = [{ level: 'info', role: 'Maya', location: 'file.js:2', suggestion: 'note' }];
saveFindings(workspace, findings);
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
});
it('does not duplicate writes when mirrorDir matches workspace', () => {
const workspace = makeTempDir('findings-same-');
const findings = [];
const writeCalls = [];
const originalWriteFileSync = fs.writeFileSync;
fs.writeFileSync = (...args) => {
writeCalls.push(args[0]);
return originalWriteFileSync(...args);
};
try {
saveFindings(workspace, findings, workspace);
} finally {
fs.writeFileSync = originalWriteFileSync;
}
assert.equal(writeCalls.length, 1);
assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH));
});
it('writes an empty JSON array when findings is empty', () => {
const workspace = makeTempDir('findings-empty-');
saveFindings(workspace, []);
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
assert.equal(workspaceText, '[]\n');
});
afterEach(() => {
while (tempDirs.length > 0) {
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
});
+22 -13
View File
@@ -1,12 +1,16 @@
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';
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
export const SYNC_PATHS = [
FINDINGS_PATH,
'.amazonq/rules/triage-findings.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',
@@ -57,39 +61,44 @@ export function cloneRepo(workspace, _spawnSync = spawnSync) {
});
}
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync) {
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, sourceRoot = ACTION_ROOT) {
const run = makeRunner(_spawnSync);
try {
await withAskpass(workspace, async credEnv => {
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
run(['config', 'user.name', 'AI Review Bot'], repoDir);
if (PR_HEAD_BRANCH) {
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
}
const existingSyncPaths = [];
const missingSyncPaths = [];
// Mirror sync files from workspace into the repo copy.
// Copy action skill files into the target repo. Existing files are overwritten;
// missing source files are ignored so we do not delete target repo content.
for (const relPath of SYNC_PATHS) {
const src = path.join(workspace, relPath);
const src = path.join(sourceRoot, relPath);
const dest = path.join(repoDir, relPath);
if (fs.existsSync(src)) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
existingSyncPaths.push(relPath);
continue;
}
if (fs.existsSync(dest)) {
fs.rmSync(dest, { force: true });
}
missingSyncPaths.push(relPath);
}
if (existingSyncPaths.length > 0) {
run(['add', ...existingSyncPaths], repoDir);
}
if (missingSyncPaths.length > 0) {
run(['rm', '--cached', '--ignore-unmatch', '--', ...missingSyncPaths], repoDir);
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
if (generatedSyncPaths.length > 0) {
for (const relPath of generatedSyncPaths) {
const src = path.join(workspace, relPath);
const dest = path.join(repoDir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
run(['add', ...generatedSyncPaths], repoDir);
}
const status = run(['status', '--porcelain'], repoDir);
+48 -28
View File
@@ -8,14 +8,18 @@ import { commitAndPush, cloneRepo, SYNC_PATHS } from './git.js';
// --- helpers ---
function makeTmpWorkspace() {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'git-test-'));
// Pre-create repo dir so clone branch is skipped
fs.mkdirSync(path.join(ws, 'repo'), { recursive: true });
return ws;
}
function makeActionSource() {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'git-source-'));
for (const relPath of SYNC_PATHS) {
const fullPath = path.join(ws, relPath);
const fullPath = path.join(sourceRoot, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, relPath);
}
return ws;
return sourceRoot;
}
// Default stub: all commands succeed, status returns changes
@@ -35,9 +39,12 @@ function makeSpawn(overrides = {}) {
describe('commitAndPush', () => {
let workspace;
let sourceRoot;
before(() => { workspace = makeTmpWorkspace(); });
after(() => { fs.rmSync(workspace, { recursive: true, force: true }); });
before(() => { sourceRoot = makeActionSource(); });
after(() => { fs.rmSync(sourceRoot, { recursive: true, force: true }); });
beforeEach(() => {
for (const f of fs.readdirSync(workspace)) {
if (f.endsWith('.git-askpass.sh')) fs.unlinkSync(path.join(workspace, f));
@@ -46,7 +53,7 @@ describe('commitAndPush', () => {
it('does not embed token in any git command argument', async () => {
const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
for (const { args } of spawn.calls) {
assert.ok(!args.join(' ').includes('test-token'), `Token leaked in git args: ${args.join(' ')}`);
@@ -55,7 +62,7 @@ describe('commitAndPush', () => {
it('uses GIT_ASKPASS env for network operations (fetch, push, clone)', async () => {
const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
const networkOps = ['fetch', 'push', 'clone'];
const networkCalls = spawn.calls.filter(c => networkOps.includes(c.args[0]));
@@ -67,52 +74,65 @@ describe('commitAndPush', () => {
});
it('cleans up askpass script after successful run', async () => {
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn());
await commitAndPush(workspace, path.join(workspace, 'repo'), makeSpawn(), sourceRoot);
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
assert.equal(leftover.length, 0, 'askpass script was not cleaned up');
});
it('cleans up askpass script even when git fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot);
const leftover = fs.readdirSync(workspace).filter(f => f.endsWith('.git-askpass.sh'));
assert.equal(leftover.length, 0, 'askpass script was not cleaned up after failure');
});
it('skips commit when status shows no changes', async () => {
const spawn = makeSpawn({ status: () => ({ status: 0, stdout: '', stderr: '', error: null }) });
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
const commitCalled = spawn.calls.some(c => c.args[0] === 'commit');
assert.equal(commitCalled, false, 'commit should not run when there are no changes');
});
it('adds skill and entry files together with findings', async () => {
const repoDir = path.join(workspace, 'repo');
fs.mkdirSync(path.join(workspace, '.gitea/ai-review'), { recursive: true });
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/findings.json'), '[]\n');
fs.writeFileSync(path.join(workspace, '.gitea/ai-review/exclusions.json'), '[]\n');
fs.mkdirSync(path.join(repoDir, '.gitea/ai-review'), { recursive: true });
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/findings.json'), '[]\n');
fs.writeFileSync(path.join(repoDir, '.gitea/ai-review/exclusions.json'), '[]\n');
const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
const addCall = spawn.calls.find(c => c.args[0] === 'add');
assert.ok(addCall, 'expected git add to run');
assert.ok(addCall.args.includes('.github/skills/triage-findings/SKILL.md'));
assert.ok(addCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
assert.ok(addCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
assert.ok(addCall.args.includes('.github/copilot-instructions.md'));
assert.ok(addCall.args.includes('.amazonq/rules/triage-findings.md'));
assert.ok(addCall.args.includes('CLAUDE.md'));
assert.ok(addCall.args.includes('GEMINI.md'));
assert.ok(!addCall.args.includes('README.md'));
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
const addCalls = spawn.calls.filter(c => c.args[0] === 'add');
const skillAddCall = addCalls.find(c => c.args.includes('.github/skills/triage-findings/SKILL.md'));
const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json'));
assert.ok(skillAddCall, 'expected git add for synced skill files');
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('.claude/skills/triage-findings/SKILL.md'));
assert.ok(skillAddCall.args.includes('.gemini/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('CLAUDE.md'));
assert.ok(skillAddCall.args.includes('GEMINI.md'));
assert.ok(!skillAddCall.args.includes('README.md'));
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/findings.json'));
assert.ok(generatedAddCall.args.includes('.gitea/ai-review/exclusions.json'));
});
it('removes missing sync paths from the repo copy', async () => {
const missingPath = path.join(workspace, '.amazonq/rules/triage-findings.md');
it('keeps repo copies when the source sync file is missing', async () => {
const missingPath = path.join(sourceRoot, '.amazonq/rules/triage-findings.md');
fs.rmSync(missingPath, { force: true });
fs.rmSync(path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md'), { force: true });
const repoPath = path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md');
fs.writeFileSync(repoPath, 'stale');
const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn);
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot);
const rmCall = spawn.calls.find(c => c.args[0] === 'rm');
assert.ok(rmCall, 'expected git rm to run');
assert.ok(rmCall.args.includes('.amazonq/rules/triage-findings.md'));
assert.equal(fs.existsSync(path.join(workspace, 'repo', '.amazonq/rules/triage-findings.md')), false);
assert.equal(rmCall, undefined, 'git rm should not run for missing source files');
assert.equal(fs.readFileSync(repoPath, 'utf8'), 'stale');
});
it('overwrites existing repo copies with workspace files', async () => {
@@ -120,7 +140,7 @@ describe('commitAndPush', () => {
fs.writeFileSync(path.join(repoDir, '.github/skills/triage-findings/SKILL.md'), 'stale');
fs.writeFileSync(path.join(repoDir, 'CLAUDE.md'), 'stale');
await commitAndPush(workspace, repoDir, makeSpawn());
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, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md');
@@ -128,7 +148,7 @@ describe('commitAndPush', () => {
it('does not throw when git command fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn));
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
});
});
+4 -3
View File
@@ -88,7 +88,8 @@ async function main() {
// Step6: 寫入 findings.json,依序發布 comment
console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
saveFindings(WORKSPACE, filtered);
const reviewDir = repoDir || WORKSPACE;
saveFindings(WORKSPACE, filtered, reviewDir);
try {
await postOldFindingsComment(filtered);
await postNewNonCriticalComment(filtered);
@@ -102,7 +103,7 @@ async function main() {
console.log('\n🔎 Step6: JSON 格式驗證');
const missingPaths = [];
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
const fullPath = path.join(repoDir || WORKSPACE, relPath);
const fullPath = path.join(reviewDir, relPath);
try {
const result = await validateJSONArrayFile(fullPath, relPath);
if (!result.exists) missingPaths.push({ fullPath, relPath });
@@ -117,7 +118,7 @@ async function main() {
// Step7: commit/push findings.json 到來源分支
console.log('\n💾 Step7: 記憶區 Commit/Push');
await commitAndPush(WORKSPACE, repoDir);
await commitAndPush(WORKSPACE, repoDir || WORKSPACE);
// Step9: 有 critical 問題則 exit 1
console.log('\n🚦 Step8: 嚴重問題檢查');