Revert "test: cover review edge cases and repair paths"

This reverts commit 61942eeebbba95c81431896c7fd8f43ff0e7c0d5.
This commit is contained in:
2026-05-13 06:25:28 +00:00
parent 3f3ead0f08
commit 0c9748049c
8 changed files with 41 additions and 243 deletions
-10
View File
@@ -224,16 +224,6 @@
"location": "TODO.md",
"suggestion": "TODO.md 的階段編號僅供內部開發追蹤,無外部文件引用,階段編號調整不影響任何外部一致性"
},
{
"role": "Leo",
"location": "TODO.md",
"suggestion": "TODO.md 已清楚區分已驗收、部分驗收與可驗收紀錄情境,文件結構本身不是問題"
},
{
"role": "Leo",
"location": "TODO.md",
"suggestion": "TODO.md 中針對 critical 阻擋與 JSON 驗證的驗收說明屬文件補充,不是程式碼缺陷"
},
{
"role": "Rex",
"location": "app/gitea.js",
+6 -6
View File
@@ -28,35 +28,35 @@ export function saveFindings(workspace, findings) {
/**
* 發布所有舊問題 comment(一次發布,依等級排序)
*/
export async function postOldFindingsComment(findings, postFn = postComment) {
export async function postOldFindingsComment(findings) {
const old = findings.filter(f => !f.is_new);
if (old.length === 0) {
console.log(' 無舊問題,跳過');
return;
}
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
await postFn(body);
await postComment(body);
console.log(` ✅ 舊問題 comment 發布 (${old.length} 筆)`);
}
/**
* 發布新問題中非 critical 的 comment(一次發布)
*/
export async function postNewNonCriticalComment(findings, postFn = postComment) {
export async function postNewNonCriticalComment(findings) {
const items = findings.filter(f => f.is_new && f.level !== 'critical');
if (items.length === 0) {
console.log(' 無新的非嚴重問題,跳過');
return;
}
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
await postFn(body);
await postComment(body);
console.log(` ✅ 新問題(非嚴重)comment 發布 (${items.length} 筆)`);
}
/**
* 每個新 critical 問題各發一個 comment
*/
export async function postNewCriticalComments(findings, postFn = postComment) {
export async function postNewCriticalComments(findings) {
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
if (criticals.length === 0) {
console.log(' 無新的嚴重問題,跳過');
@@ -64,7 +64,7 @@ export async function postNewCriticalComments(findings, postFn = postComment) {
}
for (const f of criticals) {
const body = `## 🚨 嚴重問題\n\n${buildTable([f])}`;
await postFn(body);
await postComment(body);
console.log(` ✅ 嚴重問題 comment 發布: [${f.role}] ${f.location}`);
}
}
-31
View File
@@ -1,31 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { postOldFindingsComment, postNewNonCriticalComment, postNewCriticalComments } from './comments.js';
describe('comment publishers', () => {
it('skips publishing when there are no matching findings', async () => {
let called = 0;
await postOldFindingsComment([], async () => { called += 1; });
await postNewNonCriticalComment([], async () => { called += 1; });
await postNewCriticalComments([], async () => { called += 1; });
assert.equal(called, 0);
});
it('publishes old, non-critical, and critical comments in separate calls', async () => {
const bodies = [];
const findings = [
{ level: 'warning', role: 'Rex', location: 'a.js:1', suggestion: 'fix', is_new: false },
{ level: 'info', role: 'Maya', location: 'b.js:2', suggestion: 'note', is_new: true },
{ level: 'critical', role: 'Aria', location: 'c.js:3', suggestion: 'stop', is_new: true },
];
await postOldFindingsComment(findings, async body => bodies.push(body));
await postNewNonCriticalComment(findings, async body => bodies.push(body));
await postNewCriticalComments(findings, async body => bodies.push(body));
assert.equal(bodies.length, 3);
assert.match(bodies[0], /舊有未解決問題/);
assert.match(bodies[1], /新發現問題/);
assert.match(bodies[2], /嚴重問題/);
});
});
+5 -5
View File
@@ -77,20 +77,20 @@ function fallback(label, findings, e) {
}
/** 只保留 AI 需要的欄位,減少 token 用量 */
export function toAIPayload(findings) {
function toAIPayload(findings) {
return findings.map(({ level, role, location, suggestion }) => ({ level, role, location, suggestion }));
}
/**
* 呼叫 LLM 進行語意去重,失敗時降級回傳原始 findings
*/
export async function deduplicateWithAI(findings, chatFn = chatJSON) {
export async function deduplicateWithAI(findings) {
if (findings.length === 0) return findings;
const systemPrompt = `移除語意重複的程式碼審查問題(JSON 陣列)。保留等級較高者(critical > warning > info)。只回傳去重後的 JSON 陣列。`;
try {
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings)));
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) {
console.log(` AI 去重: ${findings.length} -> ${result.length}`);
// 以 location+suggestion 為 key,將原始 findings 的完整欄位(含 is_new)補回
@@ -131,7 +131,7 @@ export function applyExclusions(findings, exclusions) {
/**
* 呼叫 AI 判斷哪些問題是誤報或不需處理,失敗時降級回傳原始 findings
*/
export async function filterFalsePositivesWithAI(findings, exclusions = [], chatFn = chatJSON) {
export async function filterFalsePositivesWithAI(findings, exclusions = []) {
if (findings.length === 0) return findings;
const exclusionHint = exclusions.length > 0
@@ -141,7 +141,7 @@ export async function filterFalsePositivesWithAI(findings, exclusions = [], chat
const systemPrompt = `判斷以下程式碼審查問題是否為誤報或不適用(如已正確使用 secrets、CI/CD 必要權限等),移除後只回傳需保留的 JSON 陣列。${exclusionHint}`;
try {
const result = await chatFn(systemPrompt, JSON.stringify(toAIPayload(findings)));
const result = await chatJSON(systemPrompt, JSON.stringify(toAIPayload(findings)));
if (Array.isArray(result) && result.length > 0) {
console.log(` AI 誤報過濾: ${findings.length} -> ${result.length}`);
const origMap = new Map(findings.map(f => [`${f.location}|${String(f.suggestion).slice(0, 50)}`, f]));
-83
View File
@@ -1,83 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyExclusions, deduplicateWithAI, filterFalsePositivesWithAI, toAIPayload } from './findings.js';
describe('findings helpers', () => {
it('toAIPayload strips internal fields', () => {
const payload = toAIPayload([
{ level: 'critical', role: 'Rex', location: 'a.js:1', suggestion: 'fix', is_new: true, extra: 'x' },
]);
assert.deepEqual(payload, [
{ level: 'critical', role: 'Rex', location: 'a.js:1', suggestion: 'fix' },
]);
});
it('deduplicateWithAI preserves original findings on quota failure', async () => {
const findings = [
{ level: 'warning', role: 'Rex', location: 'a.js:1', suggestion: 'fix', is_new: true },
];
const quotaError = new Error('quota');
quotaError.response = { status: 402 };
const result = await deduplicateWithAI(findings, async () => {
throw quotaError;
});
assert.deepEqual(result, findings);
});
it('deduplicateWithAI remaps AI output back to original findings', async () => {
const findings = [
{ level: 'warning', role: 'Rex', location: 'a.js:1', suggestion: 'fix' , is_new: true },
{ level: 'info', role: 'Maya', location: 'b.js:2', suggestion: 'note', is_new: false },
];
const result = await deduplicateWithAI(findings, async () => ([
{ level: 'warning', role: 'Rex', location: 'a.js:1', suggestion: 'fix' },
]));
assert.equal(result[0].is_new, true);
assert.equal(result[0].location, 'a.js:1');
});
it('applyExclusions filters by path and role', () => {
const findings = [
{ level: 'warning', role: 'Rex', location: 'src/a.js:1', suggestion: 'fix' },
{ level: 'info', role: 'Maya', location: 'src/b.js:2', suggestion: 'note' },
];
const exclusions = [
{ location: 'src/a.js', role: 'Rex' },
];
assert.deepEqual(applyExclusions(findings, exclusions), [
{ level: 'info', role: 'Maya', location: 'src/b.js:2', suggestion: 'note' },
]);
});
it('filterFalsePositivesWithAI preserves findings on error', async () => {
const findings = [
{ level: 'critical', role: 'Aria', location: 'main.js:1', suggestion: 'fix', is_new: true },
];
const result = await filterFalsePositivesWithAI(findings, [], async () => {
throw new Error('api down');
});
assert.deepEqual(result, findings);
});
it('filterFalsePositivesWithAI returns AI filtered findings', async () => {
const findings = [
{ level: 'critical', role: 'Aria', location: 'main.js:1', suggestion: 'fix', is_new: true },
{ level: 'warning', role: 'Rex', location: 'git.js:2', suggestion: 'note', is_new: false },
];
const result = await filterFalsePositivesWithAI(findings, [{ location: 'git.js', suggestion: 'note' }], async () => ([
{ level: 'critical', role: 'Aria', location: 'main.js:1', suggestion: 'fix' },
]));
assert.equal(result.length, 1);
assert.equal(result[0].location, 'main.js:1');
assert.equal(result[0].is_new, true);
});
});
+1 -13
View File
@@ -1,4 +1,4 @@
import { describe, it, before, after, beforeEach, mock } from 'node:test';
import { describe, it, before, after, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
@@ -89,18 +89,6 @@ describe('commitAndPush', () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn));
});
it('logs failure when git command fails', async () => {
const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null });
const logs = [];
mock.method(console, 'log', (...args) => { logs.push(args.join(' ')); });
try {
await commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn);
assert.ok(logs.some(line => line.includes('Runner failed: commit/push 失敗')), 'expected failure log');
} finally {
mock.restoreAll();
}
});
});
describe('cloneRepo', () => {
+24 -41
View File
@@ -1,6 +1,5 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConfig, FINDINGS_PATH, EXCLUSIONS_PATH } from './config.js';
import { loadRoles, getRoleIntro } from './roles.js';
import { getPRDiff, postComment } from './gitea.js';
@@ -10,42 +9,6 @@ import { cloneRepo, commitAndPush } from './git.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
export function validateAndRepairJsonFile(fullPath, relPath = fullPath) {
if (!fs.existsSync(fullPath)) {
console.log(` ⚠️ ${relPath} 不存在,跳過驗證`);
return true;
}
try {
JSON.parse(fs.readFileSync(fullPath, 'utf8'));
console.log(`${relPath} JSON 格式正確`);
return true;
} catch (e) {
console.error(`${relPath} JSON 格式錯誤: ${e.message},嘗試修正...`);
try {
const backupPath = fullPath + '.bak';
fs.copyFileSync(fullPath, backupPath);
fs.writeFileSync(fullPath, '[]\n', 'utf8');
console.log(`${relPath} 已重置為空陣列(原檔備份至 ${relPath}.bak`);
return true;
} catch (repairErr) {
console.error(`${relPath} 修正失敗: ${repairErr.message}`);
return false;
}
}
}
export function handleCriticalFindings(findings, exitFn = process.exit) {
const criticalCount = findings.filter(f => f.level === 'critical').length;
if (criticalCount > 0) {
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
console.log('='.repeat(60));
exitFn(1);
return true;
}
console.log(' ✅ 無嚴重問題');
return false;
}
async function main() {
console.log('='.repeat(60));
console.log('🚀 Step1: Pipeline 啟動');
@@ -139,10 +102,26 @@ async function main() {
console.log('\n🔎 Step6: JSON 格式驗證');
for (const relPath of [FINDINGS_PATH, EXCLUSIONS_PATH]) {
const fullPath = path.join(repoDir || WORKSPACE, relPath);
if (!validateAndRepairJsonFile(fullPath, relPath)) {
if (!fs.existsSync(fullPath)) {
console.log(` ⚠️ ${relPath} 不存在,跳過驗證`);
continue;
}
try {
JSON.parse(fs.readFileSync(fullPath, 'utf8'));
console.log(`${relPath} JSON 格式正確`);
} catch (e) {
console.error(`${relPath} JSON 格式錯誤: ${e.message},嘗試修正...`);
try {
const backupPath = fullPath + '.bak';
fs.copyFileSync(fullPath, backupPath);
fs.writeFileSync(fullPath, '[]\n', 'utf8');
console.log(`${relPath} 已重置為空陣列(原檔備份至 ${relPath}.bak`);
} catch (repairErr) {
console.error(`${relPath} 修正失敗: ${repairErr.message}`);
process.exit(1);
}
}
}
// Step7: commit/push findings.json 到來源分支
console.log('\n💾 Step7: 記憶區 Commit/Push');
@@ -150,14 +129,18 @@ async function main() {
// Step9: 有 critical 問題則 exit 1
console.log('\n🚦 Step8: 嚴重問題檢查');
handleCriticalFindings(filtered);
const criticalCount = filtered.filter(f => f.level === 'critical').length;
if (criticalCount > 0) {
console.log(` ❌ 發現 ${criticalCount} 個嚴重問題,workflow 結束(exit 1`);
console.log('='.repeat(60));
process.exit(1);
}
console.log(' ✅ 無嚴重問題');
console.log('\n✅ Pipeline 完成');
console.log('='.repeat(60));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(e => {
console.error('❌ Runner failed:', e.message);
process.exit(1);
});
}
-49
View File
@@ -1,49 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { handleCriticalFindings, validateAndRepairJsonFile } from './main.js';
describe('main helpers', () => {
it('validateAndRepairJsonFile accepts valid JSON', () => {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'main-valid-'));
const file = path.join(ws, 'data.json');
fs.writeFileSync(file, '[{}]\n');
assert.equal(validateAndRepairJsonFile(file, 'data.json'), true);
assert.equal(fs.readFileSync(file, 'utf8'), '[{}]\n');
fs.rmSync(ws, { recursive: true, force: true });
});
it('validateAndRepairJsonFile repairs invalid JSON to empty array', () => {
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'main-repair-'));
const file = path.join(ws, 'data.json');
fs.writeFileSync(file, '{invalid json');
assert.equal(validateAndRepairJsonFile(file, 'data.json'), true);
assert.equal(fs.readFileSync(file, 'utf8'), '[]\n');
assert.equal(fs.existsSync(`${file}.bak`), true);
fs.rmSync(ws, { recursive: true, force: true });
});
it('handleCriticalFindings exits when critical findings exist', () => {
let exitCode = null;
const findings = [
{ level: 'critical' },
{ level: 'warning' },
];
const result = handleCriticalFindings(findings, code => { exitCode = code; });
assert.equal(result, true);
assert.equal(exitCode, 1);
});
it('handleCriticalFindings passes when no critical findings exist', () => {
let exitCode = null;
const result = handleCriticalFindings([{ level: 'info' }], code => { exitCode = code; });
assert.equal(result, false);
assert.equal(exitCode, null);
});
});