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
+396
View File
@@ -0,0 +1,396 @@
import { describe, it, afterEach } from 'node:test';
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, formatFindingsStats, formatFindingsStatsLine } from './comments.js';
import { FINDINGS_PATH } from './config.js';
describe('saveFindings', () => {
const tempDirs = [];
const makeTempDir = prefix => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
};
it('writes findings to workspace and mirror dirs when provided', () => {
const workspace = makeTempDir('findings-ws-');
const mirrorDir = makeTempDir('findings-mirror-');
const findings = [{ level: 'warning', role: 'Leo', location: 'file.js:1', suggestion: 'test' }];
saveFindings(workspace, findings, mirrorDir);
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
const mirrorText = fs.readFileSync(path.join(mirrorDir, FINDINGS_PATH), 'utf8');
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
assert.equal(mirrorText, JSON.stringify(findings, null, 2) + '\n');
});
it('writes only to workspace when mirrorDir is omitted', () => {
const workspace = makeTempDir('findings-ws-');
const findings = [{ level: 'info', role: 'Maya', location: 'file.js:2', suggestion: 'note' }];
saveFindings(workspace, findings);
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
assert.equal(workspaceText, JSON.stringify(findings, null, 2) + '\n');
});
it('does not duplicate writes when mirrorDir matches workspace', () => {
const workspace = makeTempDir('findings-same-');
const findings = [];
const writeCalls = [];
const originalWriteFileSync = fs.writeFileSync;
fs.writeFileSync = (...args) => {
writeCalls.push(args[0]);
return originalWriteFileSync(...args);
};
try {
saveFindings(workspace, findings, workspace);
} finally {
fs.writeFileSync = originalWriteFileSync;
}
assert.equal(writeCalls.length, 1);
assert.equal(writeCalls[0], path.join(workspace, FINDINGS_PATH));
});
it('writes an empty JSON array when findings is empty', () => {
const workspace = makeTempDir('findings-empty-');
saveFindings(workspace, []);
const workspaceText = fs.readFileSync(path.join(workspace, FINDINGS_PATH), 'utf8');
assert.equal(workspaceText, '[]\n');
});
afterEach(() => {
while (tempDirs.length > 0) {
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
});
describe('parseLocation', () => {
it('parses file and single line', () => {
assert.deepEqual(parseLocation('app/preflight.js:19'), { file: 'app/preflight.js', line: 19 });
});
it('uses the start line for a line range', () => {
assert.deepEqual(parseLocation('app/preflight.js:70-82'), { file: 'app/preflight.js', line: 70 });
});
it('returns null when there is no line number', () => {
assert.equal(parseLocation('app/preflight.test.js'), null);
});
it('returns null when multiple files are listed', () => {
assert.equal(parseLocation('Dockerfile, app/git.js, app/gitea.js'), null);
});
it('returns null for non-string input', () => {
assert.equal(parseLocation(undefined), null);
});
});
describe('formatFindingsStats', () => {
const statsFindings = [
{ level: 'critical', is_new: false },
{ level: 'warning', is_new: true },
{ level: 'info' },
{ level: 'custom', is_new: true },
];
it('formats old and new findings by severity with an unclassified column', () => {
const stats = formatFindingsStats(statsFindings);
assert.equal(stats, [
'| 類型 | 🔴 嚴重 | 🟡 警告 | 🔵 建議 | ⚪ 無法標示 |',
'| --- | --- | --- | --- | --- |',
'| 新問題 | 0 筆 | 1 筆 | 1 筆 | 1 筆 |',
'| 舊問題 | 1 筆 | 0 筆 | 0 筆 | 0 筆 |',
].join('\n'));
});
it('formats compact one-line stats for action logs', () => {
assert.equal(
formatFindingsStatsLine(statsFindings),
'新: 嚴重0 / 警告1 / 建議1 / 無法標示1;舊: 嚴重1 / 警告0 / 建議0 / 無法標示0',
);
});
});
describe('postNewCriticalComments', () => {
const critical = { level: 'critical', role: 'Rex', location: 'app/preflight.js:19', suggestion: '修這個', is_new: true };
it('posts an inline review comment annotating file/line with level/role/suggestion', async () => {
const inlineCalls = [];
const issueCalls = [];
await postNewCriticalComments([critical], {
postInline: async (args) => { inlineCalls.push(args); },
postIssue: async (body) => { issueCalls.push(body); },
});
assert.equal(inlineCalls.length, 1);
assert.equal(issueCalls.length, 0);
assert.equal(inlineCalls[0].path, 'app/preflight.js');
assert.equal(inlineCalls[0].line, 19);
assert.match(inlineCalls[0].body, /等級/);
assert.match(inlineCalls[0].body, /.*Rex/s);
assert.match(inlineCalls[0].body, /.*/s);
});
it('falls back to a normal comment when the location has no line number', async () => {
const inlineCalls = [];
const issueCalls = [];
await postNewCriticalComments([{ ...critical, location: 'app/preflight.js' }], {
postInline: async (args) => { inlineCalls.push(args); },
postIssue: async (body) => { issueCalls.push(body); },
});
assert.equal(inlineCalls.length, 0);
assert.equal(issueCalls.length, 1);
assert.match(issueCalls[0], /嚴重問題/);
});
it('falls back to a normal comment when the inline post fails', async () => {
const issueCalls = [];
await postNewCriticalComments([critical], {
postInline: async () => { throw new Error('line not in diff'); },
postIssue: async (body) => { issueCalls.push(body); },
});
assert.equal(issueCalls.length, 1);
assert.match(issueCalls[0], /嚴重問題/);
});
it('only posts for new critical findings', async () => {
const inlineCalls = [];
const issueCalls = [];
await postNewCriticalComments([
{ ...critical, is_new: false },
{ level: 'warning', role: 'Leo', location: 'a.js:1', suggestion: 'x', is_new: true },
], {
postInline: async (args) => { inlineCalls.push(args); },
postIssue: async (body) => { issueCalls.push(body); },
});
assert.equal(inlineCalls.length, 0);
assert.equal(issueCalls.length, 0);
});
it('posts nothing when given an empty findings array', async () => {
const inlineCalls = [];
const issueCalls = [];
await postNewCriticalComments([], {
postInline: async (args) => { inlineCalls.push(args); },
postIssue: async (body) => { issueCalls.push(body); },
});
assert.equal(inlineCalls.length, 0);
assert.equal(issueCalls.length, 0);
});
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
const criticalCommentPattern = /嚴重問題/;
const inlineCalls = [];
const issueCalls = [];
const findings = [
{ ...critical, location: 'app/a.js:10', suggestion: 'A' }, // 有行號、inline 成功
{ ...critical, location: 'app/b.js', suggestion: 'B' }, // 無行號 → 降級為一般 comment
{ ...critical, location: 'app/c.js:20', suggestion: 'C' }, // inline 拋錯 → 降級為一般 comment
];
await postNewCriticalComments(findings, {
postInline: async (args) => {
if (args.path === 'app/c.js') throw new Error('line not in diff');
inlineCalls.push(args);
},
postIssue: async (body) => { issueCalls.push(body); },
});
assert.equal(inlineCalls.length, 1);
assert.equal(inlineCalls[0].path, 'app/a.js');
assert.equal(inlineCalls[0].line, 10);
assert.equal(issueCalls.length, 2);
assert.ok(issueCalls.every(b => criticalCommentPattern.test(b)));
});
});
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];
}
it('handles missing review severity bodies gracefully', () => {
assert.equal(reviewSeverityLabel(null), undefined);
assert.equal(reviewSeverityLabel(undefined), undefined);
assert.equal(reviewSeverityLabel({}), undefined);
assert.equal(reviewSeverityLabel({ body: null }), undefined);
assert.equal(reviewSeverityLabel({ body: undefined }), undefined);
});
it('extracts review severity labels only when the format is valid', () => {
assert.equal(
reviewSeverityLabel({ body: '**嚴重等級**:🔴 嚴重\n**審查員**Rex' }),
'🔴 嚴重',
);
assert.equal(reviewSeverityLabel({ body: '**審查員**Rex' }), undefined);
assert.equal(reviewSeverityLabel({ body: '**嚴重等級**' }), undefined);
assert.equal(reviewSeverityLabel({ body: '**嚴重等級**:高風險' }), undefined);
});
it('posts inline comments only for new findings, not old ones', async () => {
const reviewCalls = [];
const findings = [
{ level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'I', is_new: true },
{ level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'C', is_new: false },
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
];
await postFindingsReview(findings, {
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, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \|/);
assert.match(reviewCalls[0].body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \|/);
// 舊問題 app/a.jsis_new:false)不應被行內標註,僅新問題依嚴重等級排序後標註
assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
assert.deepEqual(
reviewCalls[0].comments.map(c => c.path),
['app/b.js', 'app/c.js'],
);
assert.deepEqual(
reviewCalls[0].comments.map(reviewSeverityLabel),
['🟡 警告', '🔵 建議'],
);
assert.deepEqual(
reviewCalls[0].comments.map(c => c.new_position),
[20, 30],
);
assert.match(reviewCalls[0].comments[0].body, /嚴重等級/);
assert.match(reviewCalls[0].comments[0].body, /.*Leo/s);
assert.match(reviewCalls[0].comments[0].body, /.*W/s);
});
it('appends the usage section to the review body when provided', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
], {
postReview: async (args) => { reviewCalls.push(args); },
usageSection: '## 🤖 AI 助理使用量\n\n本次:120 token',
});
assert.match(reviewCalls[0].body, /## AI Code Review 統計/);
assert.match(reviewCalls[0].body, /## 🤖 AI 助理使用量\n\n本次:120 token$/);
});
it('omits the usage section when not provided', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
], {
postReview: async (args) => { reviewCalls.push(args); },
});
assert.doesNotMatch(reviewCalls[0].body, /AI 助理使用量/);
// usageSection 省略時,body 不應殘留多餘的尾端空白/換行
assert.equal(reviewCalls[0].body, reviewCalls[0].body.trimEnd());
});
it('appends usageSection verbatim after the stats block without altering structure', async () => {
const reviewCalls = [];
const usageSection = '## 🤖 AI 助理使用量\n\n| x | y |\n| - | - |\n| 1 | 2 |';
await postFindingsReview([
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'W', is_new: true },
], { postReview: async (args) => { reviewCalls.push(args); }, usageSection });
const body = reviewCalls[0].body;
// 統計區塊在前、usageSection 原樣接在後(中間一個空行);不交錯、不被竄改
assert.ok(body.startsWith('## AI Code Review 統計'));
assert.ok(body.endsWith(usageSection));
assert.match(body, /## AI Code Review 統計[\s\S]*\n\n## 🤖 AI 助理使用量/);
});
it('counts both new and old findings in the summary but only inline-comments new ones', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'critical', role: 'Rex', location: 'app/a.js:10', suggestion: 'old crit', is_new: false },
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'new warn', is_new: true },
{ level: 'info', role: 'Maya', location: 'app/c.js:30', suggestion: 'new info', is_new: true },
], { postReview: async (a) => { reviewCalls.push(a); } });
const body = reviewCalls[0].body;
assert.match(body, /\| 新問題 \| 0 筆 \| 1 筆 \| 1 筆 \| 0 筆 \|/);
assert.match(body, /\| 舊問題 \| 1 筆 \| 0 筆 \| 0 筆 \| 0 筆 \|/);
// 舊問題 app/a.js 不產生行內 comment;只有新問題被標註
assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
assert.deepEqual(reviewCalls[0].comments.map(c => c.path), ['app/b.js', 'app/c.js']);
});
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 筆 \|/);
// 統計含新舊(舊問題仍計入本文),但行內 comment 只給新問題(舊 critical 不標註)
assert.equal(reviewCalls[0].comments.length, 2);
assert.ok(!reviewCalls[0].comments.some(c => c.path === 'app/a.js'));
});
it('only adds comments for findings with parseable file and line', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'critical', role: 'Rex', location: 'app/a.js', suggestion: 'missing line', is_new: true },
{ level: 'warning', role: 'Leo', location: 'app/b.js:20', suggestion: 'line', is_new: true },
], {
postReview: async (args) => { reviewCalls.push(args); },
});
assert.equal(reviewCalls.length, 1);
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');
});
it('uses an explicit problem field when present', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'warning', role: 'Leo', location: 'app/a.js:5', problem: '命名不清楚', suggestion: '改成具體名稱' },
], {
postReview: async (args) => { reviewCalls.push(args); },
});
assert.match(reviewCalls[0].comments[0].body, /.*/s);
assert.match(reviewCalls[0].comments[0].body, /.*/s);
});
it('uses reviewer reason fields as the problem text instead of the location', async () => {
const reviewCalls = [];
await postFindingsReview([
{ level: 'warning', role: 'Leo', location: 'app/a.js:5', description: '這裡缺少空值檢查', suggestion: '先判斷 null 再使用' },
], {
postReview: async (args) => { reviewCalls.push(args); },
});
assert.match(reviewCalls[0].comments[0].body, /.*/s);
assert.doesNotMatch(reviewCalls[0].comments[0].body, /.*app\/a\.js:5/s);
});
});