From 12c54212f081a3809d2d6b5b9b6d09d55857b26a Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 23 Jun 2026 02:20:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(ai-review=20=E7=B5=B1=E8=A8=88):=20?= =?UTF-8?q?=E5=B0=87=E7=B5=B1=E8=A8=88=E6=94=B9=E7=82=BA=E6=96=B0=E8=88=8A?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E5=88=86=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comments.js | 28 +++++++++++++++++++------ app/comments.test.js | 50 +++++++++++++++++++++++++++++++++++++++----- app/main.js | 23 ++++++++++++++++---- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/app/comments.js b/app/comments.js index c597c6d..c81b9ed 100644 --- a/app/comments.js +++ b/app/comments.js @@ -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); } /** diff --git a/app/comments.test.js b/app/comments.test.js index 319c55e..72da459 100644 --- a/app/comments.test.js +++ b/app/comments.test.js @@ -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'); }); diff --git a/app/main.js b/app/main.js index eb87469..8316b54 100644 --- a/app/main.js +++ b/app/main.js @@ -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 完成');