Revert "test: cover review edge cases and repair paths"
This reverts commit 61942eeebbba95c81431896c7fd8f43ff0e7c0d5.
This commit is contained in:
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]));
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
+29
-46
@@ -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,8 +102,24 @@ 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)) {
|
||||
process.exit(1);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
main().catch(e => {
|
||||
console.error('❌ Runner failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user