Compare commits

...

8 Commits

16 changed files with 250 additions and 40 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ When the task is to triage review findings, follow this workflow:
3. Sort by severity: `critical` -> `warning` -> `info`. 3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1 after sorting. 4. Renumber from 1 after sorting.
5. Fix real issues with the smallest safe change. 5. Fix real issues with the smallest safe change.
6. Add false positives to `.gitea/ai-review/exclusions.json`. 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. 7. Add or update tests when behavior changes.
8. Re-check the issue after each fix. 8. Re-check the issue after each fix.
+2 -1
View File
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
- info - info
3. Renumber from 1. 3. Renumber from 1.
4. Fix real issues. 4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`. 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. 6. Add tests when behavior changes.
## Output Rules ## Output Rules
@@ -26,3 +26,4 @@ description: Triage findings, fix real issues, and exclude false positives.
- Keep the final list short. - Keep the final list short.
- Keep numbering contiguous. - Keep numbering contiguous.
- Preserve file path, location, and fix. - 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. 4. Renumber the sorted list from 1 upward.
5. Rewrite each finding concisely so the final list reads cleanly and consistently. 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. 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 ## 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. - Keep numbering contiguous after filtering and merging.
- Preserve useful details like file path, location, and suggested fix. - Preserve useful details like file path, location, and suggested fix.
- Keep exclusions entries minimal and consistent with the project schema. - 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. - If the source already provides a severity or title, keep it unless it conflicts with the final ordering.
+2 -1
View File
@@ -18,7 +18,7 @@ description: Triage findings, fix real issues, and exclude false positives.
- info - info
3. Renumber from 1. 3. Renumber from 1.
4. Fix real issues. 4. Fix real issues.
5. Put false positives into `.gitea/ai-review/exclusions.json`. 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. 6. Add tests when behavior changes.
## Output Rules ## Output Rules
@@ -26,3 +26,4 @@ description: Triage findings, fix real issues, and exclude false positives.
- Keep the final list short. - Keep the final list short.
- Keep numbering contiguous. - Keep numbering contiguous.
- Preserve file path, location, and fix. - Preserve file path, location, and fix.
- When writing exclusions, prefer the original issue text over paraphrased rewrites.
+1 -1
View File
@@ -7,7 +7,7 @@ Use the triage-finding workflow for review issue lists:
3. Sort by severity: `critical` -> `warning` -> `info`. 3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1. 4. Renumber from 1.
5. Fix real issues with the smallest safe change. 5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`. 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. 7. Add or update tests when behavior changes.
8. Re-check after each fix. 8. Re-check after each fix.
+1 -1
View File
@@ -7,7 +7,7 @@ Use the triage-finding workflow for review issue lists:
3. Sort by severity: `critical` -> `warning` -> `info`. 3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1. 4. Renumber from 1.
5. Fix real issues with the smallest safe change. 5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`. 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. 7. Add or update tests when behavior changes.
8. Re-check after each fix. 8. Re-check after each fix.
+1 -1
View File
@@ -7,7 +7,7 @@ When the task is to triage review findings, follow this workflow:
3. Sort by severity: `critical` -> `warning` -> `info`. 3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1 after sorting. 4. Renumber from 1 after sorting.
5. Fix real issues with the smallest safe change. 5. Fix real issues with the smallest safe change.
6. Add false positives to `.gitea/ai-review/exclusions.json`. 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. 7. Add or update tests when behavior changes.
8. Re-check the issue after each fix. 8. Re-check the issue after each fix.
+1 -1
View File
@@ -7,7 +7,7 @@ Use the triage-finding workflow for review issue lists:
3. Sort by severity: `critical` -> `warning` -> `info`. 3. Sort by severity: `critical` -> `warning` -> `info`.
4. Renumber from 1. 4. Renumber from 1.
5. Fix real issues with the smallest safe change. 5. Fix real issues with the smallest safe change.
6. Put false positives into `.gitea/ai-review/exclusions.json`. 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. 7. Add or update tests when behavior changes.
8. Re-check after each fix. 8. Re-check after each fix.
+4 -4
View File
@@ -6,8 +6,8 @@
1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request 1. 服務名稱、模型名稱、角色資訊(個性、符合個性的英文名稱、工作內容),Comment 到 Push Request
2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議) 2. 每個角色個別分析 Git Diff 的內容產生新問題表格(問題等級、角色名稱、問題位置或行數、修改建議)
3. 讀取所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json` 存在於使用此 Action 的專案固定位置)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案 3. 讀取來源分支中的所有未解決舊問題(問題檔案 `.gitea/ai-review/findings.json`)加上新問題後,去除重複產生本次 Push Request 的問題表格(PR問題表格)覆蓋問題檔案
4. 讀取排除問題檔案(`.gitea/ai-review/exclusions.json` 存在於使用此 Action 的專案固定位置),用來過濾PR問題表格中不需要處理的問題 4. 讀取來源分支中的排除問題檔案(`.gitea/ai-review/exclusions.json`),用來過濾PR問題表格中不需要處理的問題
5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request 5. 從PR問題表格中取出所有舊問題,依照等級排序後 Comment 到 Push Request
6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request 6. 從PR問題表格中取出所有新問題,排除嚴重等級的問題後 Comment 到 Push Request
7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request 7. 從PR問題表格中取出所有新問題,將每個嚴重等級的問題 Comment 到 Push Request
@@ -23,7 +23,7 @@
5. 將提示詞放到 ./app/prompts 內供程式讀取 5. 將提示詞放到 ./app/prompts 內供程式讀取
6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1 6. API Key 支援逗號分隔傳入多個,隨機順序各嘗試一次,全部失敗則 exit 1
7. 讀取 Git Diff 時排除 `.gitea/``.amazonq/``.claude/``.codex/``.gemini/``.github/` 資料夾,以及 `CLAUDE.md``GEMINI.md``TODO.md``README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼 7. 讀取 Git Diff 時排除 `.gitea/``.amazonq/``.claude/``.codex/``.gemini/``.github/` 資料夾,以及 `CLAUDE.md``GEMINI.md``TODO.md``README.md`,避免 AI 分析 workflow 設定、skill 入口與文件等非業務程式碼
8. 階段七驗證 `findings.json``exclusions.json` 是否為合法 JSON 格式,格式錯誤時先嘗試透過 AI 修正內容,再重新驗證;修正後仍不合法才 exit 1;之後才檢查檔案是否存在,不存在則建立並寫入 `[]` 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 用量 9. 傳給 AI 的 findings 只保留必要欄位(level、role、location、suggestion),排除 `is_new` 等內部欄位;system prompt 精簡為指令核心;exclusions hint 只傳 location 與 suggestion,減少 token 用量
# 使用說明 # 使用說明
@@ -227,4 +227,4 @@ Amazon Q:直接輸入 `triage-findings 問題原始檔(文字或截圖)`
### 版本包含 ### 版本包含
提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。 提交時一併包含 `triage-findings` skill 與各平台入口檔;已存在檔案一律覆蓋,同步到最新內容;若 workspace 沒有某個同步檔,記憶區會保留原檔,不做刪除。`findings.json``exclusions.json` 都從使用此 action 的存取庫來源分支讀取,而不是從 action 本地 workspace 讀取。寫入 `.gitea/ai-review/exclusions.json` 時,盡量保留原始問題文字的語言與語意,避免過度改寫。未來若新增任何 skill 或新增其他平台的 skill 入口,必須同時把對應檔案複製進 Docker image,並把同步清單更新到會使用此 action 的目標專案,避免 action 與目標專案內容脫節。
+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) { export function saveFindings(workspace, findings, mirrorDir = null) {
const fullPath = path.join(workspace, FINDINGS_PATH); const targets = [workspace];
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
fs.writeFileSync(fullPath, JSON.stringify(findings, null, 2) + '\n', 'utf8');
console.log(` ✅ findings 寫入: ${fullPath} (${findings.length} 筆)`); 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 });
}
});
});
+23 -3
View File
@@ -34,8 +34,14 @@ function readJSONArray(fullPath, label) {
} }
} }
function normalizeExclusions(data) {
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.excluded_findings)) return data.excluded_findings;
return [];
}
/** /**
* 讀取舊 findings(從 workspace 的 FINDINGS_PATH * 讀取舊 findings(從來源分支的 cloned repoDir 中的 FINDINGS_PATH
*/ */
export function loadOldFindings(workspace) { export function loadOldFindings(workspace) {
const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false })); const old = readJSONArray(path.join(workspace, FINDINGS_PATH), '舊 findings ').map(f => ({ ...f, is_new: false }));
@@ -104,10 +110,24 @@ export async function deduplicateWithAI(findings) {
} }
/** /**
* 讀取排除問題檔案(從 workspace 的 EXCLUSIONS_PATH * 讀取排除問題檔案(從來源分支的 cloned repoDir 中的 EXCLUSIONS_PATH
*/ */
export function loadExclusions(workspace) { export function loadExclusions(workspace) {
const exclusions = readJSONArray(path.join(workspace, EXCLUSIONS_PATH), '排除問題'); const fullPath = path.join(workspace, EXCLUSIONS_PATH);
if (!fs.existsSync(fullPath)) {
console.log(' 排除問題檔案不存在,視為空');
console.log(' 讀取排除問題: 0 筆');
return [];
}
let exclusions = [];
try {
const data = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
exclusions = normalizeExclusions(data);
} catch (e) {
console.log(` ⚠️ 讀取排除問題失敗: ${e.message},視為空`);
exclusions = [];
}
console.log(` 讀取排除問題: ${exclusions.length}`); console.log(` 讀取排除問題: ${exclusions.length}`);
return exclusions; return exclusions;
} }
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, beforeEach, 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 { loadExclusions, applyExclusions } from './findings.js';
import { EXCLUSIONS_PATH } from './config.js';
describe('findings exclusions', () => {
let workspace;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'findings-test-'));
});
afterEach(() => {
fs.rmSync(workspace, { recursive: true, force: true });
});
it('loads excluded_findings wrapper format', () => {
const fullPath = path.join(workspace, EXCLUSIONS_PATH);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, JSON.stringify({
excluded_findings: [
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
],
}, null, 2));
const exclusions = loadExclusions(workspace);
assert.equal(exclusions.length, 1);
assert.equal(exclusions[0].location, 'entrypoint.sh:180');
assert.equal(exclusions[0].title, 'fetch_package_versions jq overhead');
});
it('applies exclusions loaded from wrapper format', () => {
const findings = [
{ location: 'entrypoint.sh:180', role: 'Maya', suggestion: 'keep' },
{ location: 'README.md:12', role: 'Maya', suggestion: 'keep' },
];
const exclusions = [
{ location: 'entrypoint.sh:180', title: 'fetch_package_versions jq overhead' },
];
const filtered = applyExclusions(findings, exclusions);
assert.equal(filtered.length, 1);
assert.equal(filtered[0].location, 'README.md:12');
});
});
+21 -3
View File
@@ -5,9 +5,9 @@ import { fileURLToPath } from 'url';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js'; import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ACTION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const GENERATED_SYNC_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`; const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
export const SYNC_PATHS = [ export const SYNC_PATHS = [
FINDINGS_PATH,
'.amazonq/rules/triage-findings.md', '.amazonq/rules/triage-findings.md',
'.codex/skills/triage-findings/SKILL.md', '.codex/skills/triage-findings/SKILL.md',
'.codex/skills/triage-findings/agents/openai.yaml', '.codex/skills/triage-findings/agents/openai.yaml',
@@ -68,6 +68,10 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
await withAskpass(workspace, async credEnv => { await withAskpass(workspace, async credEnv => {
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir); run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
run(['config', 'user.name', 'AI Review Bot'], 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 existingSyncPaths = [];
@@ -86,6 +90,16 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
if (existingSyncPaths.length > 0) { if (existingSyncPaths.length > 0) {
run(['add', ...existingSyncPaths], repoDir); run(['add', ...existingSyncPaths], repoDir);
} }
const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
if (generatedSyncPaths.length > 0) {
for (const relPath of generatedSyncPaths) {
const 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); const status = run(['status', '--porcelain'], repoDir);
if (!status) { if (!status) {
@@ -95,8 +109,12 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync,
const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir); const out = run(['commit', '-m', 'chore: update ai-review findings [skip ci]'], repoDir);
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown'; const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv); try {
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`); run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
console.log(` ✅ persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH}`);
} catch (pushErr) {
console.log(` ⚠️ Step7 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} error=${pushErr.message}`);
}
}); });
} catch (e) { } catch (e) {
console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`); console.log(` ⚠️ Runner failed: commit/push 失敗: ${e.message}`);
+50 -13
View File
@@ -94,20 +94,31 @@ describe('commitAndPush', () => {
}); });
it('adds skill and entry files together with findings', async () => { 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(); const spawn = makeSpawn();
await commitAndPush(workspace, path.join(workspace, 'repo'), spawn, sourceRoot); await commitAndPush(workspace, repoDir, spawn, sourceRoot);
const addCall = spawn.calls.find(c => c.args[0] === 'add'); const addCalls = spawn.calls.filter(c => c.args[0] === 'add');
assert.ok(addCall, 'expected git add to run'); const skillAddCall = addCalls.find(c => c.args.includes('.github/skills/triage-findings/SKILL.md'));
assert.ok(addCall.args.includes('.github/skills/triage-findings/SKILL.md')); const generatedAddCall = addCalls.find(c => c.args.includes('.gitea/ai-review/exclusions.json'));
assert.ok(addCall.args.includes('.codex/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall, 'expected git add for synced skill files');
assert.ok(addCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml')); assert.ok(generatedAddCall, 'expected git add for generated review files');
assert.ok(addCall.args.includes('.claude/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/SKILL.md'));
assert.ok(addCall.args.includes('.gemini/skills/triage-findings/SKILL.md')); assert.ok(skillAddCall.args.includes('.codex/skills/triage-findings/agents/openai.yaml'));
assert.ok(addCall.args.includes('.github/copilot-instructions.md')); assert.ok(skillAddCall.args.includes('.claude/skills/triage-findings/SKILL.md'));
assert.ok(addCall.args.includes('.amazonq/rules/triage-findings.md')); assert.ok(skillAddCall.args.includes('.gemini/skills/triage-findings/SKILL.md'));
assert.ok(addCall.args.includes('CLAUDE.md')); assert.ok(skillAddCall.args.includes('.github/copilot-instructions.md'));
assert.ok(addCall.args.includes('GEMINI.md')); assert.ok(skillAddCall.args.includes('.amazonq/rules/triage-findings.md'));
assert.ok(!addCall.args.includes('README.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('keeps repo copies when the source sync file is missing', async () => { it('keeps repo copies when the source sync file is missing', async () => {
@@ -139,6 +150,32 @@ describe('commitAndPush', () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot)); await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));
}); });
it('logs push failures separately from commit failures', 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({
push: () => ({ status: 1, stdout: '', stderr: 'remote: error: pre-receive hook declined', error: null }),
});
const logs = [];
const originalLog = console.log;
console.log = (...args) => { logs.push(args.join(' ')); };
try {
await commitAndPush(workspace, repoDir, spawn, sourceRoot);
} finally {
console.log = originalLog;
}
assert.ok(logs.some(line => line.includes('Step7 commit 成功但 push 失敗')));
assert.ok(logs.some(line => line.includes('pre-receive hook declined')));
});
}); });
describe('cloneRepo', () => { describe('cloneRepo', () => {
+4 -3
View File
@@ -88,7 +88,8 @@ async function main() {
// Step6: 寫入 findings.json,依序發布 comment // Step6: 寫入 findings.json,依序發布 comment
console.log('\n📝 Step5: Findings 寫入與 Comment 發布'); console.log('\n📝 Step5: Findings 寫入與 Comment 發布');
saveFindings(WORKSPACE, filtered); const reviewDir = repoDir || WORKSPACE;
saveFindings(WORKSPACE, filtered, reviewDir);
try { try {
await postOldFindingsComment(filtered); await postOldFindingsComment(filtered);
await postNewNonCriticalComment(filtered); await postNewNonCriticalComment(filtered);
@@ -102,7 +103,7 @@ async function main() {
console.log('\n🔎 Step6: JSON 格式驗證'); console.log('\n🔎 Step6: JSON 格式驗證');
const missingPaths = []; const missingPaths = [];
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) { for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
const fullPath = path.join(repoDir || WORKSPACE, relPath); const fullPath = path.join(reviewDir, relPath);
try { try {
const result = await validateJSONArrayFile(fullPath, relPath); const result = await validateJSONArrayFile(fullPath, relPath);
if (!result.exists) missingPaths.push({ fullPath, relPath }); if (!result.exists) missingPaths.push({ fullPath, relPath });
@@ -117,7 +118,7 @@ async function main() {
// Step7: commit/push findings.json 到來源分支 // Step7: commit/push findings.json 到來源分支
console.log('\n💾 Step7: 記憶區 Commit/Push'); console.log('\n💾 Step7: 記憶區 Commit/Push');
await commitAndPush(WORKSPACE, repoDir); await commitAndPush(WORKSPACE, repoDir || WORKSPACE);
// Step9: 有 critical 問題則 exit 1 // Step9: 有 critical 問題則 exit 1
console.log('\n🚦 Step8: 嚴重問題檢查'); console.log('\n🚦 Step8: 嚴重問題檢查');