feat(ai-review 統計): 將統計改為新舊問題分列 #41

Merged
jiantw83 merged 4 commits from develop into master 2026-06-23 02:31:19 +00:00
3 changed files with 86 additions and 15 deletions
Showing only changes of commit 12c54212f0 - Show all commits
+22 -6
View File
@@ -60,16 +60,28 @@ function countBy(findings, predicate) {
return findings.filter(predicate).length;
}
function newFindingsOnly(findings) {
return findings.filter(f => f.is_new !== false);
}
export function formatFindingsStats(findings) {
const oldFindings = findings.filter(f => f.is_new === false);
const newFindings = newFindingsOnly(findings);
const row = (label, items) => `| ${label} | ${countBy(items, f => f.level === 'critical')} 筆 | ${countBy(items, f => f.level === 'warning')} 筆 | ${countBy(items, f => f.level === 'info')} 筆 |`;
return [
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 |',
'| --- | --- | --- | --- |',
row('舊問題', oldFindings),
row('新問題', newFindings),
].join('\n');
}
function buildReviewSummary(findings) {
const criticalCount = countBy(findings, f => f.level === 'critical');
const warningCount = countBy(findings, f => f.level === 'warning');
const infoCount = countBy(findings, f => f.level === 'info');
return [
'## AI Code Review 統計',
'',
'| 🔴 嚴重 | 🟡 警告 | 🔵 建議 |',
'| --- | --- | --- |',
`| ${criticalCount} 筆 | ${warningCount} 筆 | ${infoCount} 筆 |`,
formatFindingsStats(findings),
].join('\n');
}
@@ -99,6 +111,10 @@ export async function postFindingsReview(findings, deps = {}) {
const body = buildReviewSummary(summaryFindings);
await postReview({ body, comments });
ok(`review 發布: summary=${summaryFindings.length} total=${sortedComments.length} commentable=${comments.length}`);
line('review summary 統計:');
for (const row of formatFindingsStats(summaryFindings).split('\n')) line(row);
line('review comments 統計:');
for (const row of formatFindingsStats(sortedComments).split('\n')) line(row);
}
/**
+45 -5
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { saveFindings, parseLocation, postNewCriticalComments, postFindingsReview } from './comments.js';
import { saveFindings, parseLocation, postNewCriticalComments, postFindingsReview, formatFindingsStats } from './comments.js';
import { FINDINGS_PATH } from './config.js';
describe('saveFindings', () => {
@@ -96,6 +96,24 @@ describe('parseLocation', () => {
});
});
describe('formatFindingsStats', () => {
it('formats old and new findings by severity', () => {
const stats = formatFindingsStats([
{ level: 'critical', is_new: false },
{ level: 'warning', is_new: true },
{ level: 'info' },
{ level: 'custom', is_new: true },
]);
assert.equal(stats, [
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 |',
'| --- | --- | --- | --- |',
'| 舊問題 | 1 筆 | 0 筆 | 0 筆 |',
'| 新問題 | 0 筆 | 1 筆 | 1 筆 |',
].join('\n'));
});
});
describe('postNewCriticalComments', () => {
const critical = { level: 'critical', role: 'Rex', location: 'app/preflight.js:19', suggestion: '修這個', is_new: true };
@@ -190,6 +208,11 @@ describe('postFindingsReview', () => {
const REVIEW_SEVERITY_LABELS = ['🔴 嚴重', '🟡 警告', '🔵 建議'];
const REVIEW_SEVERITY_PATTERN = new RegExp(`\\*\\*嚴重等級\\*\\*(${REVIEW_SEVERITY_LABELS.join('|')})(?:\\n|$)`);
/**
* 從 review comment body 擷取嚴重等級標籤。
* @param {object | null | undefined} comment - 預期包含 body 欄位的 review comment。
* @returns {string | undefined} 嚴重等級標籤;格式不符時回傳 undefined。
*/
function reviewSeverityLabel(comment) {
return comment?.body?.match(REVIEW_SEVERITY_PATTERN)?.[1];
}
@@ -221,14 +244,15 @@ describe('postFindingsReview', () => {
];
await postFindingsReview(findings, {
summaryFindings: findings.filter(f => f.is_new !== false),
summaryFindings: findings,
commentFindings: findings,
postReview: async (args) => { reviewCalls.push(args); },
});
assert.equal(reviewCalls.length, 1);
assert.match(reviewCalls[0].body, /\| 🔴 嚴重 \| 🟡 警告 \| 🔵 建議 \|/);
assert.match(reviewCalls[0].body, /\| 0 筆 \| 1 筆 \| 1 筆 \|/);
assert.match(reviewCalls[0].body, /\| 類型 \| 🔴 嚴重 \| 🟡 警告 \| 🔵 建議 \|/);
assert.match(reviewCalls[0].body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \|/);
assert.match(reviewCalls[0].body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \|/);
assert.deepEqual(
reviewCalls[0].comments.map(c => c.path),
['app/a.js', 'app/b.js', 'app/c.js'],
@@ -248,6 +272,22 @@ describe('postFindingsReview', () => {
assert.match(reviewCalls[0].comments[0].body, /.*C/s);
});
it('separates old and new findings in default review statistics', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'old critical', is_new: false },
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'new warning', is_new: true },
{ level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'new info' },
], {
postReview: async (args) => { reviewCalls.push(args); },
});
assert.equal(reviewCalls.length, 1);
assert.match(reviewCalls[0].body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \|/);
assert.match(reviewCalls[0].body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \|/);
assert.equal(reviewCalls[0].comments.length, 3);
});
it('only adds comments for findings with parseable file and line', async () => {
const reviewCalls = [];
await postFindingsReview([
@@ -258,7 +298,7 @@ describe('postFindingsReview', () => {
});
assert.equal(reviewCalls.length, 1);
assert.match(reviewCalls[0].body, /\| 1 筆 \| 1 筆 \| 0 筆 \|/);
assert.match(reviewCalls[0].body, /\| 新問題 \| 1 筆 \| 1 筆 \| 0 筆 \|/);
assert.equal(reviewCalls[0].comments.length, 1);
assert.equal(reviewCalls[0].comments[0].path, 'app/b.js');
});
+19 -4
View File
@@ -3,7 +3,7 @@ import { GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_BRANCH, PR_BASE_BRANCH, getLLMConf
import { loadRoles, getRoleIntro } from './roles.js';
import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, shouldSkipBotCommit } from './gitea.js';
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
import { saveFindings, postFindingsReview } from './comments.js';
import { saveFindings, postFindingsReview, formatFindingsStats } from './comments.js';
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
import { runPreflight } from './preflight.js';
@@ -11,6 +11,11 @@ import { section, step, line, ok, warn, error } from './log.js';
const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace';
function logFindingsStats(label, findings) {
line(`${label}:`);
for (const row of formatFindingsStats(findings).split('\n')) line(row);
}
async function main() {
section('AI Code Review Pipeline');
step('Step1', 'Pipeline 啟動');
@@ -82,6 +87,7 @@ async function main() {
}
}
ok(`Step3 完成: 新 findings 總計 ${newFindings.length}`);
logFindingsStats('Step3 統計', newFindings);
step('Step4', 'Findings 合併與語意去重');
let repoDir;
@@ -95,25 +101,34 @@ async function main() {
line(`repo 狀態: branch=${repoState.branch || 'detached'} commit=${repoState.shortSha || 'unknown'} commit_time=${repoState.commitTime || 'unknown'} path=${repoState.repoDir}`);
}
const oldFindings = loadOldFindings(repoDir || WORKSPACE);
logFindingsStats('Step4 舊 findings 統計', oldFindings);
logFindingsStats('Step4 新 findings 統計', newFindings);
const mergedFindings = mergeFindings(oldFindings, newFindings);
ok(`Step4 merged findings total=${mergedFindings.length}`);
logFindingsStats('Step4 合併後統計', mergedFindings);
const deduped = await deduplicateWithAI(mergedFindings);
logFindingsStats('Step4 AI 去重後統計', deduped);
const sorted = sortByLevel(deduped);
ok(`Step4 去重完成: ${mergedFindings.length} -> ${sorted.length} (critical=${sorted.filter(f=>f.level==='critical').length} warning=${sorted.filter(f=>f.level==='warning').length} info=${sorted.filter(f=>f.level==='info').length})`);
ok(`Step4 去重完成: ${mergedFindings.length} -> ${sorted.length}`);
logFindingsStats('Step4 排序後統計', sorted);
step('Step5', 'AI 排除問題過濾');
const exclusions = loadExclusions(repoDir || WORKSPACE, repoState, WORKSPACE);
const ruleFiltered = applyExclusions(sorted, exclusions);
logFindingsStats('Step5 規則排除後統計', ruleFiltered);
const filtered = await filterFalsePositivesWithAI(ruleFiltered, exclusions);
logFindingsStats('Step5 AI 誤報過濾後統計', filtered);
ok(`Step5 完成: findings total=${filtered.length}`);
step('Step6', 'Findings 寫入與 Review 發布');
const reviewDir = repoDir || WORKSPACE;
saveFindings(WORKSPACE, filtered, reviewDir);
try {
const newFindings = filtered.filter(f => f.is_new !== false);
logFindingsStats('Step6 儲存 findings 統計', filtered);
logFindingsStats('Step6 review summary 統計', filtered);
logFindingsStats('Step6 review comments 統計', filtered);
await postFindingsReview(filtered, {
summaryFindings: newFindings,
summaryFindings: filtered,
commentFindings: filtered,
});
ok('Step6 完成');