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
+150
View File
@@ -0,0 +1,150 @@
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_TOKEN, PR_HEAD_BRANCH, FINDINGS_PATH } from './config.js';
import { line, ok, warn } from './log.js';
const REVIEW_FILE_PATHS = [FINDINGS_PATH, '.gitea/ai-review/exclusions.json'];
const remoteUrl = `${GITEA_SERVER_URL.replace(/\/$/, '')}/${GITEA_REPOSITORY}.git`;
export const BOT_COMMIT_MARKER = '[ai-review-bot]';
function makeRunner(spawn) {
return function run(args, cwd, env) {
const opts = { cwd, encoding: 'utf8' };
if (env) opts.env = env;
const result = spawn('git', args, opts);
if (result.error) throw result.error;
if (result.status !== 0) throw new Error((result.stderr || result.stdout || '').trim());
return (result.stdout || '').trim();
};
}
function withAskpass(workspace, fn) {
const askpassScript = path.join(workspace, '.git-askpass.sh');
fs.writeFileSync(askpassScript, '#!/bin/sh\necho "$GIT_TOKEN"\n', { mode: 0o700 });
const credEnv = { ...process.env, GIT_ASKPASS: askpassScript, GIT_USERNAME: 'x-token', GIT_TOKEN: GITEA_TOKEN };
const cleanup = () => { try { fs.unlinkSync(askpassScript); } catch {} };
let result;
try {
result = fn(credEnv);
} catch (e) {
cleanup();
throw e;
}
// Defer cleanup until an async callback settles, otherwise the askpass script
// is deleted at the first `await` and later network ops (e.g. git push) fail
// with "cannot exec .git-askpass.sh". Sync callbacks clean up immediately.
if (result && typeof result.then === 'function') {
return result.finally(cleanup);
}
cleanup();
return result;
}
function readGitOutput(run, args, cwd, env) {
try {
return run(args, cwd, env);
} catch {
return '';
}
}
export function getRepoState(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir);
const shortSha = readGitOutput(run, ['rev-parse', '--short', 'HEAD'], repoDir);
const branch = readGitOutput(run, ['branch', '--show-current'], repoDir);
const commitTime = readGitOutput(run, ['show', '-s', '--format=%cI', 'HEAD'], repoDir);
return { repoDir, branch, headSha, shortSha, commitTime };
}
export function getHeadCommitMessage(repoDir, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
return readGitOutput(run, ['show', '-s', '--format=%B', 'HEAD'], repoDir);
}
export function isBotAutoCommit(repoDir, _spawnSync = spawnSync) {
return getHeadCommitMessage(repoDir, _spawnSync).includes(BOT_COMMIT_MARKER);
}
/**
* 用與 push 相同的 askpass + remote URL 機制跑一次唯讀的 `git ls-remote`
* 驗證 git 對 remote 的認證與連線是否可用(不會寫入任何東西)。
* 這條路徑與 Gitea REST API 不同,API token 有效不代表 git push 認證一定可用,
* 所以放在前置驗證可以提前抓出 askpass 無法執行或 HTTP 認證失敗的問題。
*/
export function verifyRemoteAccess(workspace, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
try {
return withAskpass(workspace, credEnv => {
run(['ls-remote', remoteUrl, PR_HEAD_BRANCH || 'HEAD'], workspace, credEnv);
return { ok: true };
});
} catch (e) {
return { ok: false, error: e.message };
}
}
/**
* Clone PR head branch to workspace/repo (idempotent)
*/
export function cloneRepo(workspace, _spawnSync = spawnSync) {
const run = makeRunner(_spawnSync);
const repoDir = path.join(workspace, 'repo');
return withAskpass(workspace, credEnv => {
if (!fs.existsSync(repoDir)) {
run(['clone', '--depth=1', '--branch', PR_HEAD_BRANCH, remoteUrl, repoDir], workspace, credEnv);
ok(`repo cloned to ${repoDir}`);
} else {
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
run(['checkout', PR_HEAD_BRANCH], repoDir);
ok('repo already exists, fetched latest');
}
return repoDir;
});
}
export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, _sourceRoot = null, reviewOutcome = 'success') {
const run = makeRunner(_spawnSync);
try {
await withAskpass(workspace, async credEnv => {
run(['config', 'user.email', 'ai-review[bot]@gitea'], repoDir);
run(['config', 'user.name', 'AI Review Bot'], repoDir);
if (PR_HEAD_BRANCH) {
run(['fetch', 'origin', PR_HEAD_BRANCH], repoDir, credEnv);
run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir);
}
const reviewFilePaths = REVIEW_FILE_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath)));
if (reviewFilePaths.length > 0) {
for (const relPath of reviewFilePaths) {
const src = path.join(workspace, relPath);
const dest = path.join(repoDir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
run(['add', ...reviewFilePaths], repoDir);
}
const status = run(['status', '--porcelain'], repoDir);
if (!status) {
line('review files 無變更,跳過 commit');
return;
}
const outcomeTag = reviewOutcome === 'failure' ? '[failure]' : '[success]';
const out = run(['commit', '-m', `chore: update ai-review findings ${BOT_COMMIT_MARKER}${outcomeTag}`], repoDir);
const commitHash = out.match(/\[.+ ([a-f0-9]+)\]/)?.[1] || 'unknown';
try {
run(['push', remoteUrl, PR_HEAD_BRANCH], repoDir, credEnv);
ok(`persisted findings commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome}`);
} catch (pushErr) {
warn(`Step8 commit 成功但 push 失敗: commit=${commitHash} push=${PR_HEAD_BRANCH} review_outcome=${reviewOutcome} error=${pushErr.message}`);
}
});
} catch (e) {
warn(`Runner failed: commit/push 失敗: ${e.message}`);
}
}