feat: add role management and usage tracking for AI code review

- Implemented role parsing and loading from markdown files, including attributes like name, side, focus, badge, color, and personality.
- Created functions to build prompts for analysis, line location, and verdicts based on roles.
- Added tests for role management functionalities to ensure correct parsing and loading of roles.
- Developed usage tracking for AI assistant interactions, including token usage and rate limits.
- Implemented functions to extract and record usage data from various LLM providers.
- Added tests for usage tracking functionalities to validate correct accumulation and reporting of usage statistics.
This commit is contained in:
2026-06-25 09:34:59 +00:00
parent 120b83c904
commit 525f6f9350
37 changed files with 6405 additions and 23 deletions
+202
View File
@@ -0,0 +1,202 @@
import fs from 'fs';
import path from 'path';
import { postComment, postPullReviewComment, postPullReview } from './gitea.js';
import { FINDINGS_PATH } from './config.js';
import { ok, line, warn } from './log.js';
const LEVEL_EMOJI = { critical: '🔴', warning: '🟡', info: '🔵' };
const LEVEL_LABEL = { critical: '嚴重', warning: '警告', info: '建議' };
const LEVEL_ORDER = ['critical', 'warning', 'info'];
function findingRow(f) {
return `| ${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level} | ${f.role} | ${f.location} | ${f.suggestion} |`;
}
function buildTable(findings) {
const rows = findings.map(findingRow).join('\n');
return `| 等級 | 審查員 | 位置 | 建議 |\n|------|--------|------|------|\n${rows}`;
}
const levelText = f => `${LEVEL_EMOJI[f.level] || ''} ${LEVEL_LABEL[f.level] || f.level}`.trim();
const bySeverity = (a, b) => {
const aLevel = LEVEL_ORDER.includes(a.level) ? LEVEL_ORDER.indexOf(a.level) : LEVEL_ORDER.length;
const bLevel = LEVEL_ORDER.includes(b.level) ? LEVEL_ORDER.indexOf(b.level) : LEVEL_ORDER.length;
if (aLevel !== bLevel) return aLevel - bLevel;
return String(a.location || '').localeCompare(String(b.location || ''));
};
/**
* 解析 finding 的 location 取出檔案與行號,供行內 comment 標註使用。
* 支援 "file:19" 與 "file:70-82"(取起始行);無行號或含多個檔案(逗號)時回傳 null。
*/
export function parseLocation(location) {
if (typeof location !== 'string') return null;
const trimmed = location.trim();
if (trimmed.includes(',')) return null;
const match = trimmed.match(/^(.+?):(\d+)(?:-\d+)?$/);
if (!match) return null;
return { file: match[1], line: Number(match[2]) };
}
/** 行內 comment 內容:等級/審查員/建議 */
function inlineCommentBody(f) {
return `**等級**${levelText(f)}\n**審查員**${f.role}\n**建議**${f.suggestion}`;
}
function problemText(f) {
return f.problem || f.reason || f.description || f.detail || f.title || f.message || '未提供問題原因';
}
function reviewCommentBody(f) {
return [
`**嚴重等級**${levelText(f)}`,
`**審查員**${f.role}`,
`**問題**${problemText(f)}`,
`**建議**${f.suggestion}`,
].join('\n');
}
function countBy(findings, predicate) {
return findings.filter(predicate).length;
}
function newFindingsOnly(findings) {
return findings.filter(f => f.is_new !== false);
}
// 等級無法歸入 critical/warning/info(例如缺漏或無法辨識)時,歸入「無法標示」
const isUnclassified = f => !LEVEL_ORDER.includes(f.level);
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')} 筆 | ${countBy(items, isUnclassified)} 筆 |`;
return [
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
'| --- | --- | --- | --- | --- |',
row('新問題', newFindings),
row('舊問題', oldFindings),
].join('\n');
}
export function formatFindingsStatsLine(findings) {
const oldFindings = findings.filter(f => f.is_new === false);
const newFindings = newFindingsOnly(findings);
const row = items => `嚴重${countBy(items, f => f.level === 'critical')} / 警告${countBy(items, f => f.level === 'warning')} / 建議${countBy(items, f => f.level === 'info')} / 無法標示${countBy(items, isUnclassified)}`;
return `新: ${row(newFindings)};舊: ${row(oldFindings)}`;
}
function buildReviewSummary(findings, usageSection = '') {
const parts = [
'## AI Code Review 統計',
'',
formatFindingsStats(findings),
];
if (usageSection) parts.push('', usageSection);
return parts.join('\n');
}
function toReviewComment(f) {
const loc = parseLocation(f.location);
if (!loc) return null;
return {
path: loc.file,
body: reviewCommentBody(f),
new_position: loc.line,
};
}
/**
* 發布單一 Gitea review
* - summaryFindings 只用來統計本文數字(含新舊問題)
* - commentFindings 用來產生 review comments,並依嚴重等級排序;
* 只為新問題加上行內標註,舊問題(is_new === false)僅計入統計、不再重複標註檔案與行數
*/
export async function postFindingsReview(findings, deps = {}) {
const {
postReview = postPullReview,
summaryFindings = findings,
commentFindings = findings,
usageSection = '',
} = deps;
const sortedComments = [...commentFindings].sort(bySeverity);
const comments = sortedComments.filter(f => f.is_new !== false).map(toReviewComment).filter(Boolean);
const body = buildReviewSummary(summaryFindings, usageSection);
await postReview({ body, comments });
ok(`review 發布: summary=${summaryFindings.length} total=${sortedComments.length} commentable=${comments.length}`);
line(`review summary 統計: ${formatFindingsStatsLine(summaryFindings)}`);
line(`review comments 統計: ${formatFindingsStatsLine(sortedComments)}`);
}
/**
* 寫入 findings.json。
* 預設寫到 workspace;若提供 mirrorDir,則同步寫入另一份供 repo commit 使用。
*/
export function saveFindings(workspace, findings, mirrorDir = null) {
const targets = [workspace];
if (mirrorDir && mirrorDir !== workspace) targets.push(mirrorDir);
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');
ok(`findings 寫入: ${fullPath} (${findings.length} 筆)`);
}
}
/**
* 發布所有舊問題 comment(一次發布,依等級排序)
*/
export async function postOldFindingsComment(findings) {
const old = findings.filter(f => !f.is_new);
if (old.length === 0) {
line('無舊問題,跳過');
return;
}
const body = `## 📋 舊有未解決問題(${old.length} 筆)\n\n${buildTable(old)}`;
await postComment(body);
ok(`舊問題 comment 發布 (${old.length} 筆)`);
}
/**
* 發布新問題中非 critical 的 comment(一次發布)
*/
export async function postNewNonCriticalComment(findings) {
const items = findings.filter(f => f.is_new && f.level !== 'critical');
if (items.length === 0) {
line('無新的非嚴重問題,跳過');
return;
}
const body = `## 🔍 新發現問題(${items.length} 筆)\n\n${buildTable(items)}`;
await postComment(body);
ok(`新問題(非嚴重)comment 發布 (${items.length} 筆)`);
}
/**
* 每個新 critical 問題各發一個 comment。
* 優先用 Gitea 行內 review comment 標註問題檔案與行數(內容為等級/審查員/建議);
* 若 location 無法解析出行號,或行內發布失敗(例如該行不在 diff 範圍),則降級為一般 comment。
*/
export async function postNewCriticalComments(findings, deps = {}) {
const { postInline = postPullReviewComment, postIssue = postComment } = deps;
const criticals = findings.filter(f => f.is_new && f.level === 'critical');
if (criticals.length === 0) {
line('無新的嚴重問題,跳過');
return;
}
for (const f of criticals) {
const loc = parseLocation(f.location);
if (loc) {
try {
await postInline({ path: loc.file, line: loc.line, body: inlineCommentBody(f) });
ok(`嚴重問題 行內 comment 發布: [${f.role}] ${loc.file}:${loc.line}`);
continue;
} catch (e) {
warn(`行內 comment 發布失敗,改用一般 comment: [${f.role}] ${f.location} error=${e.message}`);
}
}
await postIssue(`## 🚨 嚴重問題\n\n${buildTable([f])}`);
ok(`嚴重問題 comment 發布: [${f.role}] ${f.location}`);
}
}