feat(review): remove legacy mode and harden agent/codex pipeline

Drop legacy runtime paths and role assignments across backend/frontend, and add upgrade-safe DB migration for existing installs. This aligns config, docs, tests, and UI to the agent-first architecture with codex as the only alternate engine.
This commit is contained in:
jeffusion
2026-03-19 14:50:21 +08:00
committed by 路遥知码力
parent 5bb1c3a2d1
commit 1c0c9afd17
35 changed files with 1595 additions and 1102 deletions

View File

@@ -10,7 +10,7 @@ AI-powered code review assistant for Gitea. Automatically reviews Pull Requests
- 🤖 **AI Code Review** - Automatic review of PRs and commits using pluggable LLM providers
- 📝 **Line-Level Comments** - Precise feedback on specific code changes
- 🔄 **Dual Review Engines** - Legacy (simple) or Agent-based (multi-agent) review modes
- 🔄 **Task-Based Review Engines** - Agent staged review (skip/light/full) plus optional Codex CLI execution mode
- 🔔 **Feishu Notifications** - Integrated notification system for PR events
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and LLM provider configuration
- 🔐 **Secure Webhooks** - HMAC-SHA256 signature verification
@@ -34,8 +34,8 @@ AI-powered code review assistant for Gitea. Automatically reviews Pull Requests
| Engine | Description | Use Case |
|--------|-------------|----------|
| `legacy` | Single-pass AI review with summary + line comments | Simple, fast reviews |
| `agent` | Multi-agent orchestration with specialists, reflection, and debate | Deep, comprehensive analysis |
| `agent` | Task-based staged review (`skip` / `light` / `full`) with scoped specialist routing and optional reflection/debate escalation | Deep reviews with token-aware execution |
| `codex` | Codex CLI review execution with independent configuration | External Codex-driven review pipeline |
## Quick Start
@@ -136,7 +136,7 @@ LLM providers and models are configured exclusively through the **Admin Dashboar
1. Navigate to **LLM 配置** (LLM Configuration)
2. Add your LLM providers (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini)
3. Assign models to review roles (legacy, planner, specialist, judge, embedding)
3. Assign models to review roles (planner, specialist, judge, embedding)
> API keys are stored encrypted (AES-256-GCM) in the local SQLite database.
@@ -151,13 +151,28 @@ LLM providers and models are configured exclusively through the **Admin Dashboar
| Setting | Description | Default |
|---------|-------------|---------|
| Review Engine | Engine mode (`legacy` or `agent`) | `legacy` |
| Review Engine | Engine mode (`agent` or `codex`) | `agent` |
| Enable Triage | Enable planner triage for task routing | `true` |
| Small Max Files | Upper file-count bound for `small` review size | `3` |
| Small Max Changed Lines | Upper changed-lines bound for `small` review size | `80` |
| Medium Max Files | Upper file-count bound for `medium` review size | `10` |
| Medium Max Changed Lines | Upper changed-lines bound for `medium` review size | `400` |
| Token Budget Small | Token budget cap for `small` staged tasks | `12000` |
| Token Budget Medium | Token budget cap for `medium` staged tasks | `45000` |
| Token Budget Large | Token budget cap for `large` staged tasks | `120000` |
| Review Work Directory | Working directory for repo clones | `/tmp/gitea-assistant` |
| Max Parallel Runs | Max concurrent review tasks | `2` |
| Max Files per Run | Max files analyzed per review | `200` |
| Auto-publish Min Confidence | Min confidence score for auto-publish | `0.8` |
| Enable Human Gate | Require human approval before publishing | `true` |
Agent review execution model (current):
- `skip`: docs/assets/rename-only style changes can bypass specialist review.
- `light`: low-risk code changes run minimal scoped specialist checks.
- `full`: sensitive or larger changes run full specialist tasks, with optional reflection/debate escalation.
- Triage outputs task scopes (`paths`, `riskTags`, `mode`, `tokenBudget`) and orchestrator dispatches specialists by task scope instead of broad fan-out.
#### Memory & Learning (Experimental)
| Setting | Description | Default |

View File

@@ -10,7 +10,7 @@
- 🤖 **AI 代码审查** - 使用可插拔的 LLM 提供商自动审查 PR 和提交
- 📝 **行级评论** - 针对具体代码变更的精确反馈
- 🔄 **双引擎模式** - Legacy简单或 Agent多代理审查模式
- 🔄 **任务化审查引擎** - Agent 分级审查skip/light/full+ 可选 Codex CLI 审查模式
- 🔔 **飞书通知** - PR 事件通知集成
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和 LLM 提供商配置的 Web 界面
- 🔐 **安全验证** - HMAC-SHA256 签名验证
@@ -34,8 +34,8 @@
| 引擎 | 描述 | 适用场景 |
|------|------|----------|
| `legacy` | 单次 AI 审查,生成总结和行级评论 | 简单、快速的审查 |
| `agent` | 多代理编排,支持专家、反思和辩论 | 深度、全面的分析 |
| `agent` | 任务化分级审查(`skip` / `light` / `full`),按路径范围派发 specialist并按需升级到反思/辩论 | 在控制 token 成本的前提下做深度审查 |
| `codex` | 通过 Codex CLI 执行审查,独立配置 | 对接外部 Codex 审查流程 |
## 快速开始
@@ -136,7 +136,7 @@ LLM 提供商和模型通过**管理后台** Web 界面进行配置:
1. 导航到 **LLM 配置** 页面
2. 添加 LLM 提供商OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini
3. 为审查角色分配模型(legacy、planner、specialist、judge、embedding
3. 为审查角色分配模型planner、specialist、judge、embedding
> API 密钥使用 AES-256-GCM 加密存储在本地 SQLite 数据库中。
@@ -151,13 +151,28 @@ LLM 提供商和模型通过**管理后台** Web 界面进行配置:
| 配置项 | 描述 | 默认值 |
|--------|------|--------|
| 审查引擎 | 引擎模式(`legacy``agent` | `legacy` |
| 审查引擎 | 引擎模式(`agent``codex` | `agent` |
| 启用分流Enable Triage | 启用 planner 分流并输出任务化审查计划 | `true` |
| Small 文件上限 | 判定 `small` 规模审查的文件数上限 | `3` |
| Small 变更行上限 | 判定 `small` 规模审查的变更行数上限 | `80` |
| Medium 文件上限 | 判定 `medium` 规模审查的文件数上限 | `10` |
| Medium 变更行上限 | 判定 `medium` 规模审查的变更行数上限 | `400` |
| Small Token 预算 | `small` 任务的 token 预算上限 | `12000` |
| Medium Token 预算 | `medium` 任务的 token 预算上限 | `45000` |
| Large Token 预算 | `large` 任务的 token 预算上限 | `120000` |
| 工作目录 | 仓库克隆工作目录 | `/tmp/gitea-assistant` |
| 最大并发数 | 最大并发审查任务数 | `2` |
| 最大文件数 | 单次审查最大文件数 | `200` |
| 自动发布置信度 | 自动发布最小置信度 | `0.8` |
| 启用人工审批 | 发布前要求人工确认 | `true` |
当前 Agent 审查执行模型:
- `skip`:文档/资源/纯重命名等低风险改动可直接跳过 specialist。
- `light`:低风险代码改动执行最小化、按路径范围限定的 specialist 审查。
- `full`:敏感路径或中大型改动执行完整任务审查,并可按配置升级到 reflection/debate。
- Triage 输出任务(`paths``riskTags``mode``tokenBudget`Orchestrator 按任务范围派发,不再默认全量扇出。
#### 记忆与学习(实验性)
| 配置项 | 描述 | 默认值 |

View File

@@ -132,11 +132,10 @@ CREATE TABLE llm_secrets (
-- ============================================================
-- 表3: model_role_assignments — 场景 → 模型映射
-- ============================================================
-- 每个业务场景(如 planner/specialist/judge/legacy/embedding绑定到
-- 每个业务场景(如 planner/specialist/judge/embedding绑定到
-- 一个 provider + 具体 model支持不同场景用不同 provider。
CREATE TABLE model_role_assignments (
role TEXT PRIMARY KEY CHECK (role IN (
'legacy', -- 旧版单次审查ai-review.ts
'planner', -- Agent 审查 planner
'specialist', -- Agent 审查 specialist
'judge', -- Agent 审查 judge
@@ -184,7 +183,7 @@ CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
// ── src/llm/types.ts ────────────────────────────────────────
/** 模型角色枚举 */
export type ModelRole = 'legacy' | 'planner' | 'specialist' | 'judge' | 'embedding';
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
/** 统一消息格式(内部表达,不暴露 provider 差异) */
export interface LLMMessage {
@@ -346,7 +345,7 @@ export class LLMGateway {
/**
* 按业务角色调用 LLM
* @param role 业务角色(legacy/planner/specialist/judge/embedding
* @param role 业务角色planner/specialist/judge/embedding
* @param request 请求(不含 model由角色映射决定
*/
async chatForRole(
@@ -686,19 +685,19 @@ Settings 页面
│ │ ├── ▸ 高级配置 (collapsible, JSON key-value editor)
│ │ └── [测试连接] [保存] [取消]
│ │
│ └── 🧩 角色分配 区域
│ └── 🧩 角色分配与分级审查映射 区域
│ ┌──────────────────────────────────────────────────────────────┐
│ │ 角色 │ Provider 下拉 │ 模型 ID │
│ │ 角色/阶段 │ Provider 下拉 │ 模型 ID │
│ ├──────────────┼──────────────────────┼──────────────────────┤
│ │ Legacy 审查 │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
│ │ Planner │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
│ │ Specialist │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
│ │ Judge │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
│ │ Embedding │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
│ │ Planner(Triage) │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
│ │ Specialist(任务执行) │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
│ │ Judge(汇总裁决) │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
│ │ Embedding(记忆检索) │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
│ └──────────────────────────────────────────────────────────────┘
│ [保存角色分配]
├── ⚙️ 通用设置(现有 Gitea / 飞书 / App / Review 参数)
│ ├── Agent 分级审查参数small/medium 阈值、token budget、triage 开关
│ └── (复用现有 ConfigManager 组件,数据源统一为 DB)
```
@@ -734,9 +733,9 @@ const MODEL_SUGGESTIONS: Record<string, string[]> = {
| # | 文件 | 当前代码 | 改造为 | 影响范围 |
|---|---|---|---|---|
| 1 | `src/index.ts:69-71` | `const openaiClient = new OpenAI({baseURL, apiKey})` | 删除;初始化 `LLMGateway` 单例并传入业务层 | 入口 |
| 2 | `src/services/ai-review.ts:8-10` | `const openai = new OpenAI({...})` | 删除模块级 client函数接收 `LLMGateway` 参数;`openai.chat.completions.create()` `gateway.chatForRole('legacy', ...)` | Legacy 审查 |
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent | Agent 编排 |
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway``reviewLegacy()``reviewWithReAct()` 中的 `this.openai.chat.completions.create()` 改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
| 2 | `src/controllers/review.ts` | 旧版 webhook 存在回退分支 | 删除回退分支,仅保留 `agent` / `codex` 入队逻辑 | 审查主入口 |
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent任务化分级编排skip/light/full | Agent 编排 |
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway``reviewWithOptions()` 与 ReAct 调用改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
| 5 | `src/review/agents/critic-agent.ts:23` | `private openai: OpenAI` | 同上 | 评审 agent |
| 6 | `src/review/agents/reflexion-agent.ts:24` | `constructor(openai: OpenAI, ...)` | 构造传 gateway | 反思 agent |
| 7 | `src/review/agents/debate-orchestrator.ts:17` | `private openai: OpenAI` | 同上 | 辩论 agent |

View File

@@ -17,15 +17,12 @@ import { toast } from 'sonner';
// Engine-specific field visibility
// ---------------------------------------------------------------------------
type EngineMode = 'legacy' | 'agent' | 'codex';
type EngineMode = 'agent' | 'codex';
/** The engine selector field — always visible at the top. */
const ENGINE_FIELD = 'REVIEW_ENGINE';
/** Fields shared across legacy & agent (but NOT codex). */
const LEGACY_AGENT_FIELDS = new Set([
'CUSTOM_SUMMARY_PROMPT',
'CUSTOM_LINE_COMMENT_PROMPT',
const AGENT_SHARED_FIELDS = new Set([
'GLOBAL_PROMPT',
'REVIEW_WORKDIR',
'REVIEW_MAX_PARALLEL_RUNS',
@@ -65,10 +62,8 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
return fields.filter((f) => {
if (f.envKey === ENGINE_FIELD) return false; // rendered separately
switch (engine) {
case 'legacy':
return LEGACY_AGENT_FIELDS.has(f.envKey);
case 'agent':
return LEGACY_AGENT_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
return AGENT_SHARED_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
case 'codex':
return CODEX_FIELDS.has(f.envKey);
default:
@@ -82,7 +77,6 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
// ---------------------------------------------------------------------------
const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [
{ value: 'legacy', label: 'Legacy', description: '传统单次 LLM 审查' },
{ value: 'agent', label: 'Agent', description: '多代理编排深度审查' },
{ value: 'codex', label: 'Codex', description: 'Codex CLI 审查' },
];
@@ -105,7 +99,7 @@ export function ReviewConfigPage() {
const engine: EngineMode = useMemo(() => {
const val = localConfig[ENGINE_FIELD];
if (val === 'agent' || val === 'codex') return val;
return 'legacy';
return 'agent';
}, [localConfig]);
// Derived: review group and memory group from fetched data
@@ -231,13 +225,11 @@ export function ReviewConfigPage() {
const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup
? {
...reviewGroup,
label: engine === 'codex' ? 'Codex 审查设置' : engine === 'agent' ? 'Agent 审查设置' : 'Legacy 审查设置',
label: engine === 'codex' ? 'Codex 审查设置' : 'Agent 审查设置',
description:
engine === 'codex'
? 'Codex CLI 审查引擎配置'
: engine === 'agent'
? '多代理编排审查引擎配置'
: '传统单次 LLM 审查引擎配置',
: '多代理编排审查引擎配置',
fields: visibleReviewFields,
}
: null;
@@ -327,7 +319,7 @@ export function ReviewConfigPage() {
</div>
</CardHeader>
<CardContent className="p-6 bg-zinc-950/20">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{ENGINE_OPTIONS.map((opt) => (
<button
key={opt.value}
@@ -377,7 +369,6 @@ export function ReviewConfigPage() {
/>
)}
{/* LLM Provider config — legacy & agent only */}
{engine !== 'codex' && (
<>
<ProviderList />

View File

@@ -10,14 +10,13 @@ import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderServi
import { ModelCombobox } from './ModelCombobox';
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
legacy: { label: 'Legacy 审查', desc: '基础的单次代码审查模式,速度快但分析较浅' },
planner: { label: '规划器 Planner', desc: '多阶段审查的第一步,负责分析上下文并分配任务' },
specialist: { label: '专家 Specialist', desc: '执行深度代码审查的主力模型,专注于发现具体问题' },
judge: { label: '评审 Judge', desc: '对专家的建议进行审核、合并和过滤,确保评论质量' },
embedding: { label: '嵌入 Embedding', desc: '用于向量化代码和注释,支持语义搜索 (Qdrant)' },
};
const ROLES = ['legacy', 'planner', 'specialist', 'judge', 'embedding'];
const ROLES = ['planner', 'specialist', 'judge', 'embedding'];
interface RoleState {
providerId: string | null;

View File

@@ -57,7 +57,7 @@ describe('RoleAssignment', () => {
vi.mocked(fetchRoles).mockResolvedValueOnce([
{
role: 'legacy',
role: 'planner',
providerId: 'p1',
providerName: 'OpenAI',
providerType: 'openai_responses',
@@ -77,7 +77,6 @@ describe('RoleAssignment', () => {
renderWithQuery(<RoleAssignment />);
expect(await screen.findByText('角色分配')).toBeInTheDocument();
expect(await screen.findByText('Legacy 审查')).toBeInTheDocument();
expect(await screen.findByText('规划器 Planner')).toBeInTheDocument();
// Radix Select renders placeholder in a span with pointer-events: none.
@@ -89,11 +88,11 @@ describe('RoleAssignment', () => {
const modelInputs = screen.getAllByPlaceholderText('选择或输入模型...') as HTMLInputElement[];
await waitFor(() => {
expect(modelInputs[1].value).toBe('gpt-4o-mini');
expect(modelInputs[0].value).toBe('gpt-4o');
});
await user.clear(modelInputs[1]);
await user.type(modelInputs[1], 'custom-planner-model');
expect(modelInputs[1].value).toBe('custom-planner-model');
await user.clear(modelInputs[0]);
await user.type(modelInputs[0], 'custom-planner-model');
expect(modelInputs[0].value).toBe('custom-planner-model');
});
});

View File

@@ -1,25 +1,12 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const it: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
export const beforeAll: any;
export const afterAll: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { configManager } from '../config-manager';
import { initMasterKey } from '../../crypto/secrets';
import { closeDatabase, initDatabase } from '../../db/database';
import { settingsRepo } from '../../db/repositories/settings-repo';
import { configManager } from '../config-manager';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -39,7 +26,9 @@ describe('ConfigManager (DB backend)', () => {
beforeEach(() => {
dbPath = makeTmpDb();
process.env.DATABASE_PATH = dbPath;
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
'hex'
);
initMasterKey();
initDatabase();
});
@@ -47,30 +36,42 @@ describe('ConfigManager (DB backend)', () => {
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (savedEncryptionKey === undefined) {
delete process.env.ENCRYPTION_KEY;
Reflect.deleteProperty(process.env, 'ENCRYPTION_KEY');
} else {
process.env.ENCRYPTION_KEY = savedEncryptionKey;
}
try { if (existsSync(dbPath)) unlinkSync(dbPath); } catch { /* ok */ }
try { if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); } catch { /* ok */ }
try { if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); } catch { /* ok */ }
try {
if (existsSync(dbPath)) unlinkSync(dbPath);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
} catch {
/* ok */
}
try {
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
} catch {
/* ok */
}
});
// ─── 1. getCurrent() defaults ─────────────────────────────────────────────
describe('getCurrent() defaults', () => {
test('returns default engine when DB is empty', () => {
expect(configManager.getCurrent().review.engine).toBe('legacy');
expect(configManager.getCurrent().review.engine).toBe('agent');
});
test('reads port from process.env.PORT, defaults to 5174', () => {
const orig = process.env.PORT;
delete process.env.PORT;
Reflect.deleteProperty(process.env, 'PORT');
expect(configManager.getCurrent().app.port).toBe(5174);
if (orig !== undefined) process.env.PORT = orig;
});
@@ -85,7 +86,17 @@ describe('ConfigManager (DB backend)', () => {
expect(cfg.feishu.webhookSecret).toBeUndefined();
expect(cfg.admin.giteaAdminToken).toBeUndefined();
expect(cfg.review.qdrantUrl).toBeUndefined();
expect(cfg.review.customSummaryPrompt).toBeUndefined();
});
test('returns review size thresholds and token budget defaults', () => {
const cfg = configManager.getCurrent();
expect(cfg.review.smallMaxFiles).toBe(3);
expect(cfg.review.smallMaxChangedLines).toBe(80);
expect(cfg.review.mediumMaxFiles).toBe(10);
expect(cfg.review.mediumMaxChangedLines).toBe(400);
expect(cfg.review.tokenBudgetSmall).toBe(12000);
expect(cfg.review.tokenBudgetMedium).toBe(45000);
expect(cfg.review.tokenBudgetLarge).toBe(120000);
});
});
@@ -100,7 +111,7 @@ describe('ConfigManager (DB backend)', () => {
test('setOverrides with empty string deletes the key (resets to default)', async () => {
await configManager.setOverrides({ REVIEW_ENGINE: 'agent' });
await configManager.setOverrides({ REVIEW_ENGINE: '' });
expect(configManager.getCurrent().review.engine).toBe('legacy');
expect(configManager.getCurrent().review.engine).toBe('agent');
});
test('getSource returns "db" when value is stored', async () => {
@@ -119,7 +130,7 @@ describe('ConfigManager (DB backend)', () => {
test('unknown keys are silently ignored', async () => {
await configManager.setOverrides({ UNKNOWN_KEY_XYZ: 'value' });
expect(configManager.getCurrent().review.engine).toBe('legacy');
expect(configManager.getCurrent().review.engine).toBe('agent');
});
});
@@ -129,13 +140,13 @@ describe('ConfigManager (DB backend)', () => {
test('resetKeys deletes key from DB, value reverts to default', async () => {
await configManager.setOverrides({ REVIEW_ENGINE: 'agent' });
await configManager.resetKeys(['REVIEW_ENGINE']);
expect(configManager.getCurrent().review.engine).toBe('legacy');
expect(configManager.getCurrent().review.engine).toBe('agent');
expect(configManager.getSource('REVIEW_ENGINE')).toBe('default');
});
test('resetKeys on non-existent key does not throw', async () => {
await configManager.resetKeys(['REVIEW_ENGINE']);
expect(configManager.getCurrent().review.engine).toBe('legacy');
expect(configManager.getCurrent().review.engine).toBe('agent');
});
});
@@ -198,6 +209,16 @@ describe('ConfigManager (DB backend)', () => {
expect(configManager.getCurrent().review.maxParallelRuns).toBe(4);
});
test('review budget fields are parsed correctly', async () => {
await configManager.setOverrides({
REVIEW_SMALL_MAX_FILES: '5',
REVIEW_TOKEN_BUDGET_SMALL: '22222',
});
expect(configManager.getCurrent().review.smallMaxFiles).toBe(5);
expect(configManager.getCurrent().review.tokenBudgetSmall).toBe(22222);
});
test('comma-separated REVIEW_ALLOWED_COMMANDS parsed to array', async () => {
await configManager.setOverrides({ REVIEW_ALLOWED_COMMANDS: 'git, rg, cat' });
expect(configManager.getCurrent().review.allowedCommands).toEqual(['git', 'rg', 'cat']);

View File

@@ -25,10 +25,8 @@ export interface AppConfig {
giteaAdminToken: string | undefined;
};
review: {
engine: string;
engine: 'agent' | 'codex';
workdir: string;
customSummaryPrompt: string | undefined;
customLineCommentPrompt: string | undefined;
globalPrompt: string | undefined;
maxParallelRuns: number;
maxFilesPerRun: number;
@@ -41,6 +39,13 @@ export interface AppConfig {
llmRetryMaxAttempts: number;
llmRetryBaseDelayMs: number;
enableTriage: boolean;
smallMaxFiles: number;
smallMaxChangedLines: number;
mediumMaxFiles: number;
mediumMaxChangedLines: number;
tokenBudgetSmall: number;
tokenBudgetMedium: number;
tokenBudgetLarge: number;
// Codex engine
codexApiUrl: string;
codexApiKey: string | undefined;
@@ -146,10 +151,8 @@ class ConfigManager {
giteaAdminToken: values.GITEA_ADMIN_TOKEN,
},
review: {
engine: values.REVIEW_ENGINE ?? 'legacy',
engine: values.REVIEW_ENGINE === 'codex' ? 'codex' : 'agent',
workdir: values.REVIEW_WORKDIR ?? '/tmp/gitea-assistant',
customSummaryPrompt: values.CUSTOM_SUMMARY_PROMPT,
customLineCommentPrompt: values.CUSTOM_LINE_COMMENT_PROMPT,
globalPrompt: values.GLOBAL_PROMPT,
maxParallelRuns: toNumber('REVIEW_MAX_PARALLEL_RUNS', 2),
maxFilesPerRun: toNumber('REVIEW_MAX_FILES_PER_RUN', 200),
@@ -168,6 +171,13 @@ class ConfigManager {
llmRetryMaxAttempts: toNumber('LLM_RETRY_MAX_ATTEMPTS', 3),
llmRetryBaseDelayMs: toNumber('LLM_RETRY_BASE_DELAY_MS', 1000),
enableTriage: toBoolean('ENABLE_TRIAGE', true),
smallMaxFiles: toNumber('REVIEW_SMALL_MAX_FILES', 3),
smallMaxChangedLines: toNumber('REVIEW_SMALL_MAX_CHANGED_LINES', 80),
mediumMaxFiles: toNumber('REVIEW_MEDIUM_MAX_FILES', 10),
mediumMaxChangedLines: toNumber('REVIEW_MEDIUM_MAX_CHANGED_LINES', 400),
tokenBudgetSmall: toNumber('REVIEW_TOKEN_BUDGET_SMALL', 12000),
tokenBudgetMedium: toNumber('REVIEW_TOKEN_BUDGET_MEDIUM', 45000),
tokenBudgetLarge: toNumber('REVIEW_TOKEN_BUDGET_LARGE', 120000),
// Codex engine
codexApiUrl: values.CODEX_API_URL ?? 'https://api.openai.com/v1',
codexApiKey: values.CODEX_API_KEY,

View File

@@ -101,22 +101,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
sensitive: true,
},
{
envKey: 'CUSTOM_SUMMARY_PROMPT',
group: 'review',
label: '自定义总结提示词',
description: '覆盖默认的代码审查总结提示词(留空使用内置提示词)',
type: 'text',
sensitive: false,
},
{
envKey: 'CUSTOM_LINE_COMMENT_PROMPT',
group: 'review',
label: '自定义行评论提示词',
description: '覆盖默认的行级评论提示词(留空使用内置提示词)',
type: 'text',
sensitive: false,
},
{
envKey: 'GLOBAL_PROMPT',
group: 'review',
@@ -178,11 +162,11 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
envKey: 'REVIEW_ENGINE',
group: 'review',
label: '审查引擎',
description: '代码审查模式:legacy传统agent多代理编排)或 codexCodex CLI',
description: '代码审查模式agent任务化分级编排)或 codexCodex CLI',
type: 'enum',
sensitive: false,
enumValues: ['legacy', 'agent', 'codex'],
defaultValue: 'legacy',
enumValues: ['agent', 'codex'],
defaultValue: 'agent',
},
{
envKey: 'REVIEW_WORKDIR',
@@ -308,6 +292,83 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
sensitive: false,
defaultValue: true,
},
{
envKey: 'REVIEW_SMALL_MAX_FILES',
group: 'review',
label: '小型变更文件上限',
description: '判定为 small 规模审查时的文件数量上限',
type: 'number',
sensitive: false,
min: 1,
max: 50,
defaultValue: 3,
},
{
envKey: 'REVIEW_SMALL_MAX_CHANGED_LINES',
group: 'review',
label: '小型变更行数上限',
description: '判定为 small 规模审查时的变更行数上限additions+deletions',
type: 'number',
sensitive: false,
min: 1,
max: 1000,
defaultValue: 80,
},
{
envKey: 'REVIEW_MEDIUM_MAX_FILES',
group: 'review',
label: '中型变更文件上限',
description: '判定为 medium 规模审查时的文件数量上限',
type: 'number',
sensitive: false,
min: 1,
max: 200,
defaultValue: 10,
},
{
envKey: 'REVIEW_MEDIUM_MAX_CHANGED_LINES',
group: 'review',
label: '中型变更行数上限',
description: '判定为 medium 规模审查时的变更行数上限additions+deletions',
type: 'number',
sensitive: false,
min: 1,
max: 5000,
defaultValue: 400,
},
{
envKey: 'REVIEW_TOKEN_BUDGET_SMALL',
group: 'review',
label: 'Small 令牌预算',
description: 'small 规模审查任务的 token 预算上限',
type: 'number',
sensitive: false,
min: 1000,
max: 100000,
defaultValue: 12000,
},
{
envKey: 'REVIEW_TOKEN_BUDGET_MEDIUM',
group: 'review',
label: 'Medium 令牌预算',
description: 'medium 规模审查任务的 token 预算上限',
type: 'number',
sensitive: false,
min: 1000,
max: 200000,
defaultValue: 45000,
},
{
envKey: 'REVIEW_TOKEN_BUDGET_LARGE',
group: 'review',
label: 'Large 令牌预算',
description: 'large 规模审查任务的 token 预算上限',
type: 'number',
sensitive: false,
min: 1000,
max: 500000,
defaultValue: 120000,
},
// ── Codex 审查引擎 ──────────────────────────────────────────────────────
{

View File

@@ -1,13 +1,3 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
@@ -68,7 +58,9 @@ describe('llm-config controller', () => {
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
'hex'
);
initMasterKey();
initDatabase();
@@ -78,12 +70,12 @@ describe('llm-config controller', () => {
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (savedEncryptionKey === undefined) {
delete process.env.ENCRYPTION_KEY;
Reflect.deleteProperty(process.env, 'ENCRYPTION_KEY');
} else {
process.env.ENCRYPTION_KEY = savedEncryptionKey;
}
@@ -168,7 +160,7 @@ describe('llm-config controller', () => {
const { data: roles } = await jsonRequest(app, 'GET', '/roles');
const assignedRoles = roles.filter((r: any) => r.providerId !== null);
expect(assignedRoles).toHaveLength(5); // All 5 roles bound
expect(assignedRoles).toHaveLength(4);
});
test('rejects missing required fields', async () => {
@@ -334,7 +326,7 @@ describe('llm-config controller', () => {
test('returns all MODEL_ROLES with null assignments when unassigned', async () => {
const { status, data } = await jsonRequest(app, 'GET', '/roles');
expect(status).toBe(200);
expect(data).toHaveLength(5); // 5 roles
expect(data).toHaveLength(4);
expect(data[0]).toHaveProperty('role');
expect(data[0]).toHaveProperty('providerId');
});
@@ -346,13 +338,13 @@ describe('llm-config controller', () => {
baseUrl: 'https://api.example.com/v1',
defaultModel: 'gpt-4o-mini',
});
modelRoleRepo.set('legacy', provider.id, 'gpt-4o');
modelRoleRepo.set('planner', provider.id, 'gpt-4o');
const { data } = await jsonRequest(app, 'GET', '/roles');
const legacy = data.find((r: any) => r.role === 'legacy');
expect(legacy.providerId).toBe(provider.id);
expect(legacy.providerName).toBe('RoleTest');
expect(legacy.model).toBe('gpt-4o');
const planner = data.find((r: any) => r.role === 'planner');
expect(planner.providerId).toBe(provider.id);
expect(planner.providerName).toBe('RoleTest');
expect(planner.model).toBe('gpt-4o');
});
});
@@ -385,7 +377,7 @@ describe('llm-config controller', () => {
});
test('rejects missing providerId or model', async () => {
const { status, data } = await jsonRequest(app, 'PUT', '/roles/legacy', {
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
providerId: 'some-id',
});
expect(status).toBe(400);
@@ -393,7 +385,7 @@ describe('llm-config controller', () => {
});
test('returns 404 for non-existent provider', async () => {
const { status } = await jsonRequest(app, 'PUT', '/roles/legacy', {
const { status } = await jsonRequest(app, 'PUT', '/roles/planner', {
providerId: 'non-existent',
model: 'model',
});

View File

@@ -94,7 +94,7 @@ llmConfigRouter.post('/providers', async (c) => {
const allProviders = providerRepo.list();
if (allProviders.length === 1) {
const modelRolesToBind: ModelRole[] = ['legacy', 'planner', 'specialist', 'judge', 'embedding'];
const modelRolesToBind: ModelRole[] = ['planner', 'specialist', 'judge', 'embedding'];
for (const role of modelRolesToBind) {
modelRoleRepo.set(role, created.id, body.defaultModel);
}

View File

@@ -3,14 +3,12 @@ import { Context } from 'hono';
import { map } from 'lodash-es';
import config from '../config';
import { codexEngine } from '../review/codex/codex-engine';
import { reviewEngine } from '../review/engine';
import { aiReviewService } from '../services/ai-review';
import { feishuService } from '../services/feishu';
import { PullRequestDetails, PullRequestFile, giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
import { LocalRepoManager } from '../review/context/local-repo-manager';
import { SandboxExec } from '../review/context/sandbox-exec';
import { reviewEngine } from '../review/engine';
import { feishuService } from '../services/feishu';
import { PullRequestDetails, giteaService } from '../services/gitea';
import { logger } from '../utils/logger';
// Gitea webhook事件类型
enum GiteaEventType {
@@ -24,7 +22,6 @@ enum GiteaEventType {
* 验证Webhook请求签名
*/
function verifyWebhookSignature(body: string, signature: string): boolean {
if (!config.app.webhookSecret) {
logger.warn('未配置Webhook密钥跳过签名验证');
return false;
@@ -158,54 +155,42 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
}
}
if (config.review.engine === 'agent' || config.review.engine === 'codex') {
// Fork PR策略始终clone base repo保证有baseShaheadCloneUrl作为额外remote保证有headSha
const baseCloneUrl = resolveCloneUrl(repo);
const headSha = pullRequest.head?.sha;
const baseSha = pullRequest.base?.sha;
if (!baseCloneUrl || !headSha || !baseSha) {
return c.json({ error: '缺少审查所需字段(clone_url/base sha/head sha)' }, 400);
}
// 检测fork PRhead.repo存在且与base repo不同
const headCloneUrl = pullRequest.head?.repo
? resolveCloneUrl(pullRequest.head.repo)
: undefined;
const isForkPR = headCloneUrl && headCloneUrl !== baseCloneUrl;
// 包含baseSha以支持retarget场景相同headSha但baseSha变化时需要重新审查
const idempotencyKey = `${owner}/${repoName}#${prNumber}:${baseSha}...${headSha}`;
const engineInstance = config.review.engine === 'codex' ? codexEngine : reviewEngine;
const { run, reused } = await engineInstance.enqueuePullRequest({
eventType: 'pull_request',
idempotencyKey,
owner,
repo: repoName,
cloneUrl: baseCloneUrl,
headCloneUrl: isForkPR ? headCloneUrl : undefined,
prNumber,
baseSha,
headSha,
});
const engineLabel = config.review.engine === 'codex' ? 'Codex' : 'Agent';
return c.json(
{
status: reused ? 'deduplicated' : 'accepted',
message: reused ? '审查任务已存在,已去重' : `${engineLabel}代码审查任务已入队`,
runId: run.id,
},
202
);
// Fork PR策略始终clone base repo保证有baseShaheadCloneUrl作为额外remote保证有headSha
const baseCloneUrl = resolveCloneUrl(repo);
const headSha = pullRequest.head?.sha;
const baseSha = pullRequest.base?.sha;
if (!baseCloneUrl || !headSha || !baseSha) {
return c.json({ error: '缺少审查所需字段(clone_url/base sha/head sha)' }, 400);
}
// Legacy模式开始异步审查流程
reviewPullRequest(owner, repoName, prNumber).catch((error) => {
logger.error(`审查PR ${owner}/${repoName}#${prNumber} 失败:`, error);
// 检测fork PRhead.repo存在且与base repo不同
const headCloneUrl = pullRequest.head?.repo ? resolveCloneUrl(pullRequest.head.repo) : undefined;
const isForkPR = headCloneUrl && headCloneUrl !== baseCloneUrl;
// 包含baseSha以支持retarget场景相同headSha但baseSha变化时需要重新审查
const idempotencyKey = `${owner}/${repoName}#${prNumber}:${baseSha}...${headSha}`;
const engineInstance = config.review.engine === 'codex' ? codexEngine : reviewEngine;
const { run, reused } = await engineInstance.enqueuePullRequest({
eventType: 'pull_request',
idempotencyKey,
owner,
repo: repoName,
cloneUrl: baseCloneUrl,
headCloneUrl: isForkPR ? headCloneUrl : undefined,
prNumber,
baseSha,
headSha,
});
// 立即返回以不阻塞Webhook
return c.json({ status: 'accepted', message: '代码审查请求已接受' }, 202);
const engineLabel = config.review.engine === 'codex' ? 'Codex' : 'Agent';
return c.json(
{
status: reused ? 'deduplicated' : 'accepted',
message: reused ? '审查任务已存在,已去重' : `${engineLabel}代码审查任务已入队`,
runId: run.id,
},
202
);
}
/**
@@ -221,7 +206,12 @@ async function handlePullRequestClosed(c: Context, body: any): Promise<Response>
const owner = repo.owner.login;
const repoName = repo.name;
logger.info('PR 已关闭,开始清理审查快照', { owner, repo: repoName, prNumber, merged: !!pullRequest.merged });
logger.info('PR 已关闭,开始清理审查快照', {
owner,
repo: repoName,
prNumber,
merged: !!pullRequest.merged,
});
// 异步清理,不阻塞 webhook 响应
(async () => {
@@ -320,54 +310,33 @@ async function handleCommitStatusEvent(c: Context, body: any): Promise<Response>
removed: commitInfo.removed.length,
});
// Agent/Codex模式优先处理从本地仓库派生diff不依赖webhook文件列表
if (config.review.engine === 'agent' || config.review.engine === 'codex') {
const cloneUrl = resolveCloneUrl(body.repository);
if (!cloneUrl) {
return c.json({ error: '缺少审查所需字段(clone_url)' }, 400);
}
const idempotencyKey = `${owner}/${repoName}@${commitSha}`;
const engineInstance = config.review.engine === 'codex' ? codexEngine : reviewEngine;
const { run, reused } = await engineInstance.enqueueCommit({
eventType: 'commit_status',
idempotencyKey,
owner,
repo: repoName,
cloneUrl,
commitSha,
commitMessage: commitInfo.message,
relatedPrNumber: relatedPR?.number,
});
const engineLabel = config.review.engine === 'codex' ? 'Codex' : 'Agent';
return c.json(
{
status: reused ? 'deduplicated' : 'accepted',
message: reused ? '审查任务已存在,已去重' : `${engineLabel}提交审查任务已入队`,
runId: run.id,
},
202
);
const cloneUrl = resolveCloneUrl(body.repository);
if (!cloneUrl) {
return c.json({ error: '缺少审查所需字段(clone_url)' }, 400);
}
// Legacy模式需要webhook文件列表
if (
commitInfo.added.length === 0 &&
commitInfo.modified.length === 0 &&
commitInfo.removed.length === 0
) {
logger.warn('提交没有文件变更信息,忽略审查', { commitSha });
return c.json({ status: 'ignored', message: '提交没有文件变更信息' }, 200);
}
// 开始异步审查流程传入关联的PR信息
reviewCommit(owner, repoName, commitSha, commitInfo, relatedPR).catch((error) => {
logger.error(`审查提交 ${owner}/${repoName}@${commitSha} 失败:`, error);
const idempotencyKey = `${owner}/${repoName}@${commitSha}`;
const engineInstance = config.review.engine === 'codex' ? codexEngine : reviewEngine;
const { run, reused } = await engineInstance.enqueueCommit({
eventType: 'commit_status',
idempotencyKey,
owner,
repo: repoName,
cloneUrl,
commitSha,
commitMessage: commitInfo.message,
relatedPrNumber: relatedPR?.number,
});
// 立即返回以不阻塞Webhook
return c.json({ status: 'accepted', message: '提交代码审查请求已接受' }, 202);
const engineLabel = config.review.engine === 'codex' ? 'Codex' : 'Agent';
return c.json(
{
status: reused ? 'deduplicated' : 'accepted',
message: reused ? '审查任务已存在,已去重' : `${engineLabel}提交审查任务已入队`,
runId: run.id,
},
202
);
}
/**
@@ -421,183 +390,6 @@ async function handleIssueEvent(c: Context, body: any): Promise<Response> {
return c.json({ status: 'success', message: '工单事件处理完成' }, 200);
}
/**
* 审查Pull Request的代码
*/
async function reviewPullRequest(owner: string, repo: string, prNumber: number): Promise<void> {
try {
logger.info(`开始审查PR ${owner}/${repo}#${prNumber}`);
// 从Gitea获取PR详情和差异
const [prDetails, diffContent] = await Promise.all([
giteaService.getPullRequestDetails(owner, repo, prNumber),
giteaService.getPullRequestDiff(owner, repo, prNumber),
]);
// 提取commit SHA
const commitId = prDetails.head.sha;
// 使用增强的AI代码审查服务
const reviewResult = await aiReviewService.reviewCode(
owner,
repo,
prNumber,
diffContent,
commitId
);
logger.info('代码审查结果', {
summary: `${reviewResult.summary.substring(0, 100)}...`,
commentCount: reviewResult.lineComments.length,
});
// 添加总结评论
await giteaService.addPullRequestComment(
owner,
repo,
prNumber,
`## AI代码审查结果\n\n${reviewResult.summary}`
);
// 添加行级评论
if (reviewResult.lineComments.length > 0) {
await giteaService.addLineComments(
owner,
repo,
prNumber,
commitId,
reviewResult.lineComments
);
}
logger.info(`完成PR ${owner}/${repo}#${prNumber} 的代码审查`);
} catch (error) {
logger.error('审查PR失败:', error);
throw error;
}
}
/**
* 审查提交的代码变更
*/
async function reviewCommit(
owner: string,
repo: string,
commitSha: string,
commitInfo: {
sha: string;
message: string;
added: string[];
modified: string[];
removed: string[];
},
relatedPR?: PullRequestDetails | null
): Promise<void> {
try {
logger.info(`开始审查提交 ${owner}/${repo}@${commitSha}`);
logger.info('提交信息', {
message:
commitInfo.message.substring(0, 100) + (commitInfo.message.length > 100 ? '...' : ''),
added: commitInfo.added.length,
modified: commitInfo.modified.length,
removed: commitInfo.removed.length,
});
// 创建自定义文件列表因为Gitea API不直接提供
const webhookFiles: PullRequestFile[] = [
...commitInfo.added.map((filename) => ({
filename,
status: 'added',
additions: 0, // 不知道具体行数
deletions: 0,
changes: 0,
})),
...commitInfo.modified.map((filename) => ({
filename,
status: 'modified',
additions: 0,
deletions: 0,
changes: 0,
})),
...commitInfo.removed.map((filename) => ({
filename,
status: 'removed',
additions: 0,
deletions: 0,
changes: 0,
})),
];
// 使用AI审查服务分析提交并传入webhook提供的文件列表
const reviewResult = await aiReviewService.reviewCommit(owner, repo, commitSha, webhookFiles);
logger.info('提交代码审查结果', {
summary: `${reviewResult.summary.substring(0, 100)}...`,
commentCount: reviewResult.lineComments.length,
});
// 添加总结评论到提交
try {
await giteaService.addCommitComment(
owner,
repo,
commitSha,
`## AI代码审查结果\n\n${reviewResult.summary}`
);
} catch (error) {
logger.error('添加提交评论失败:', error);
// 继续处理尝试添加到PR
}
// 尝试使用传入的PR信息或者查找相关的PR
try {
// 如果已经有关联PR直接使用
if (relatedPR?.number) {
logger.info(`使用已知关联的PR #${relatedPR.number}`);
// 添加行级评论
if (reviewResult.lineComments.length > 0) {
await giteaService.addLineComments(
owner,
repo,
relatedPR.number,
commitSha,
reviewResult.lineComments
);
}
} else {
// 否则尝试查找
logger.info('尝试查找与提交关联的PR');
const response = await giteaService.getRelatedPullRequest(owner, repo, commitSha);
if (response?.number) {
logger.info(`找到与提交关联的PR #${response.number}`);
// 添加行级评论
if (reviewResult.lineComments.length > 0) {
await giteaService.addLineComments(
owner,
repo,
response.number,
commitSha,
reviewResult.lineComments
);
}
} else {
logger.info('未找到与提交关联的PR无法添加行级评论');
}
}
} catch (error) {
logger.warn('处理PR关联失败将跳过行级评论', error);
}
logger.info(`完成提交 ${owner}/${repo}@${commitSha} 的代码审查`);
} catch (error) {
logger.error('审查提交失败:', error);
throw error;
}
}
/**
* 统一处理Gitea Webhook事件
*/

View File

@@ -0,0 +1,132 @@
import { Database } from 'bun:sqlite';
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { closeDatabase, getDatabase, initDatabase } from '../database';
function createLegacySchema(dbPath: string): void {
const db = new Database(dbPath);
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
CREATE TABLE llm_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
base_url TEXT,
default_model TEXT NOT NULL,
is_enabled INTEGER NOT NULL DEFAULT 1,
extra_config TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
CREATE TABLE model_role_assignments (
role TEXT PRIMARY KEY CHECK (role IN ('legacy','planner','specialist','judge','embedding')),
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
model TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
is_sensitive INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.query('INSERT INTO _migrations (version, name) VALUES (?, ?)').run(
1,
'init_llm_provider_schema'
);
db.query(
'INSERT INTO llm_providers (id, name, type, base_url, default_model, is_enabled, extra_config) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(
'provider-1',
'LegacyProvider',
'openai_compatible',
'https://api.example.com/v1',
'gpt-4o',
1,
'{}'
);
db.query('INSERT INTO model_role_assignments (role, provider_id, model) VALUES (?, ?, ?)').run(
'legacy',
'provider-1',
'gpt-4o'
);
db.query('INSERT INTO model_role_assignments (role, provider_id, model) VALUES (?, ?, ?)').run(
'planner',
'provider-1',
'gpt-4o-mini'
);
db.query('INSERT INTO system_settings (key, value, is_sensitive) VALUES (?, ?, ?)').run(
'REVIEW_ENGINE',
'legacy',
0
);
db.close();
}
describe('migration 002 remove legacy review mode', () => {
let dbPath: string;
const savedDbPath = process.env.DATABASE_PATH;
beforeEach(() => {
const tmpDir = join(tmpdir(), `db-migration-test-${randomUUID()}`);
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
createLegacySchema(dbPath);
});
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
try {
if (existsSync(dbPath)) unlinkSync(dbPath);
} catch {}
try {
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
} catch {}
try {
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
} catch {}
});
test('normalizes REVIEW_ENGINE and drops legacy model-role rows', () => {
initDatabase();
const db = getDatabase();
const engineRow = db
.query('SELECT value FROM system_settings WHERE key = ?')
.get('REVIEW_ENGINE') as { value: string } | null;
expect(engineRow?.value).toBe('agent');
const roles = db
.query('SELECT role FROM model_role_assignments ORDER BY role ASC')
.all() as Array<{ role: string }>;
expect(roles.map((row) => row.role)).toEqual(['planner']);
expect(() => {
db.query(
'INSERT INTO model_role_assignments (role, provider_id, model) VALUES (?, ?, ?)'
).run('legacy', 'provider-1', 'gpt-4o');
}).toThrow();
});
});

View File

@@ -1,13 +1,3 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
@@ -45,7 +35,7 @@ describe('model-role-repo', () => {
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
@@ -70,25 +60,25 @@ describe('model-role-repo', () => {
describe('set()', () => {
test('creates a new role assignment', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
const assignment = modelRoleRepo.getByRole('legacy');
const assignment = modelRoleRepo.getByRole('planner');
expect(assignment).not.toBeNull();
expect(assignment!.role).toBe('legacy');
expect(assignment!.role).toBe('planner');
expect(assignment!.provider_id).toBe(providerId);
expect(assignment!.model).toBe('gpt-4o-mini');
});
test('upserts: updates existing role assignment', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('legacy', providerId, 'gpt-4o');
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o');
const assignment = modelRoleRepo.getByRole('legacy');
const assignment = modelRoleRepo.getByRole('planner');
expect(assignment!.model).toBe('gpt-4o');
});
test('can assign different roles', () => {
const roles: ModelRole[] = ['legacy', 'planner', 'specialist', 'judge', 'embedding'];
const roles: ModelRole[] = ['planner', 'specialist', 'judge', 'embedding'];
for (const role of roles) {
modelRoleRepo.set(role, providerId, `model-for-${role}`);
}
@@ -104,7 +94,7 @@ describe('model-role-repo', () => {
describe('getByRole()', () => {
test('returns null when no assignment exists', () => {
expect(modelRoleRepo.getByRole('legacy')).toBeNull();
expect(modelRoleRepo.getByRole('planner')).toBeNull();
});
test('returns the correct assignment', () => {
@@ -123,7 +113,7 @@ describe('model-role-repo', () => {
});
test('returns all assignments with provider info (JOIN)', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o');
const all = modelRoleRepo.list();
@@ -136,7 +126,7 @@ describe('model-role-repo', () => {
test('results are ordered by role', () => {
modelRoleRepo.set('specialist', providerId, 'model-a');
modelRoleRepo.set('embedding', providerId, 'model-b');
modelRoleRepo.set('legacy', providerId, 'model-c');
modelRoleRepo.set('planner', providerId, 'model-c');
const all = modelRoleRepo.list();
const roles = all.map((a) => a.role);
@@ -148,13 +138,13 @@ describe('model-role-repo', () => {
describe('delete()', () => {
test('deletes existing assignment, returns true', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
expect(modelRoleRepo.delete('legacy')).toBe(true);
expect(modelRoleRepo.getByRole('legacy')).toBeNull();
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
expect(modelRoleRepo.delete('planner')).toBe(true);
expect(modelRoleRepo.getByRole('planner')).toBeNull();
});
test('returns false for non-existent role', () => {
expect(modelRoleRepo.delete('legacy')).toBe(false);
expect(modelRoleRepo.delete('planner')).toBe(false);
});
});
@@ -166,13 +156,13 @@ describe('model-role-repo', () => {
});
test('returns all roles assigned to a provider', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o');
modelRoleRepo.set('judge', providerId, 'gpt-4o');
const roles = modelRoleRepo.getRolesByProvider(providerId);
expect(roles).toHaveLength(3);
expect(roles).toContain('legacy');
expect(roles).toContain('specialist');
expect(roles).toContain('planner');
expect(roles).toContain('judge');
});
@@ -183,11 +173,11 @@ describe('model-role-repo', () => {
name: 'Other Provider',
type: 'anthropic',
});
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', p2.id, 'claude-3-5-sonnet');
const roles1 = modelRoleRepo.getRolesByProvider(providerId);
expect(roles1).toEqual(['legacy']);
expect(roles1).toEqual(['specialist']);
const roles2 = modelRoleRepo.getRolesByProvider(p2.id);
expect(roles2).toEqual(['planner']);
@@ -198,14 +188,14 @@ describe('model-role-repo', () => {
describe('foreign key constraint', () => {
test('cannot delete provider while role assignments exist (no CASCADE)', () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o');
// FK constraint prevents delete — must remove assignments first
expect(() => providerRepo.delete(providerId)).toThrow();
// Clean up assignments first, then delete succeeds
modelRoleRepo.delete('legacy');
modelRoleRepo.delete('specialist');
modelRoleRepo.delete('planner');
expect(providerRepo.delete(providerId)).toBe(true);
});

View File

@@ -10,6 +10,7 @@ import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { migration001Init } from './migrations/001_init';
import { migration002RemoveLegacyReviewMode } from './migrations/002_remove_legacy_review_mode';
// ---------------------------------------------------------------------------
// Types
@@ -25,7 +26,7 @@ export interface Migration {
// Migration registry (ordered by version)
// ---------------------------------------------------------------------------
const MIGRATIONS: Migration[] = [migration001Init];
const MIGRATIONS: Migration[] = [migration001Init, migration002RemoveLegacyReviewMode];
// ---------------------------------------------------------------------------
// Database singleton

View File

@@ -52,7 +52,6 @@ export const migration001Init: Migration = {
db.exec(`
CREATE TABLE model_role_assignments (
role TEXT PRIMARY KEY CHECK (role IN (
'legacy',
'planner',
'specialist',
'judge',

View File

@@ -0,0 +1,34 @@
import type { Database } from 'bun:sqlite';
import type { Migration } from '../database';
const ALLOWED_ROLES = "'planner','specialist','judge','embedding'";
export const migration002RemoveLegacyReviewMode: Migration = {
version: 2,
name: 'remove_legacy_review_mode',
up(db: Database): void {
db.exec(
"UPDATE system_settings SET value = 'agent' WHERE key = 'REVIEW_ENGINE' AND value NOT IN ('agent','codex')"
);
db.exec(`
CREATE TABLE model_role_assignments_new (
role TEXT PRIMARY KEY CHECK (role IN (${ALLOWED_ROLES})),
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
model TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
INSERT INTO model_role_assignments_new (role, provider_id, model, updated_at)
SELECT role, provider_id, model, updated_at
FROM model_role_assignments
WHERE role IN (${ALLOWED_ROLES})
`);
db.exec('DROP TABLE model_role_assignments');
db.exec('ALTER TABLE model_role_assignments_new RENAME TO model_role_assignments');
},
};

View File

@@ -1,6 +1,6 @@
/**
* Repository for model_role_assignments table.
* Maps business roles (legacy, planner, specialist, judge, embedding)
* Maps business roles (planner, specialist, judge, embedding)
* to specific provider + model combinations.
*/
@@ -10,7 +10,7 @@ import { getDatabase } from '../database';
// Types
// ---------------------------------------------------------------------------
export type ModelRole = 'legacy' | 'planner' | 'specialist' | 'judge' | 'embedding';
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
export interface RoleAssignmentRow {
role: ModelRole;

View File

@@ -1,13 +1,3 @@
// @ts-expect-error bun:test is provided by Bun at runtime
declare module 'bun:test' {
export const describe: any;
export const test: any;
export const expect: any;
export const beforeEach: any;
export const afterEach: any;
}
// @ts-expect-error bun:test is provided by Bun at runtime
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
@@ -40,7 +30,9 @@ describe('LLMGateway', () => {
mkdirSync(tmpDir, { recursive: true });
dbPath = join(tmpDir, 'test.db');
process.env.DATABASE_PATH = dbPath;
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
'hex'
);
initMasterKey();
initDatabase();
@@ -56,12 +48,12 @@ describe('LLMGateway', () => {
afterEach(() => {
closeDatabase();
if (savedDbPath === undefined) {
delete process.env.DATABASE_PATH;
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
if (savedEncryptionKey === undefined) {
delete process.env.ENCRYPTION_KEY;
Reflect.deleteProperty(process.env, 'ENCRYPTION_KEY');
} else {
process.env.ENCRYPTION_KEY = savedEncryptionKey;
}
@@ -87,22 +79,22 @@ describe('LLMGateway', () => {
describe('chatForRole() — error handling', () => {
test('throws LLMNoProviderError when role is not assigned', async () => {
try {
await gateway.chatForRole('legacy', {
await gateway.chatForRole('planner', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false); // Should not reach
} catch (e: any) {
expect(e.name).toBe('LLMNoProviderError');
expect(e.role).toBe('legacy');
expect(e.role).toBe('planner');
}
});
test('throws LLMError when provider is disabled', async () => {
providerRepo.update(providerId, { isEnabled: false });
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
try {
await gateway.chatForRole('legacy', {
await gateway.chatForRole('planner', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false);
@@ -114,10 +106,10 @@ describe('LLMGateway', () => {
test('throws LLMAuthError when no API key configured', async () => {
secretRepo.delete(providerId);
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
try {
await gateway.chatForRole('legacy', {
await gateway.chatForRole('planner', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false);
@@ -128,9 +120,9 @@ describe('LLMGateway', () => {
});
test('throws LLMError when provider not found after role assignment manually deleted', async () => {
modelRoleRepo.set('legacy', providerId, 'gpt-4o-mini');
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
// Must remove assignments before deleting provider (no CASCADE on model_role_assignments)
modelRoleRepo.delete('legacy');
modelRoleRepo.delete('planner');
secretRepo.delete(providerId);
providerRepo.delete(providerId);
@@ -138,7 +130,7 @@ describe('LLMGateway', () => {
// (simulating stale data)
try {
// No assignment exists now, so this throws LLMNoProviderError
await gateway.chatForRole('legacy', {
await gateway.chatForRole('planner', {
messages: [{ role: 'user', content: 'hello' }],
});
expect(true).toBe(false);

View File

@@ -10,11 +10,10 @@
// ---------------------------------------------------------------------------
/** Business role that maps to a specific provider + model via DB config. */
export type ModelRole = 'legacy' | 'planner' | 'specialist' | 'judge' | 'embedding';
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
/** All valid model roles. */
export const MODEL_ROLES: readonly ModelRole[] = [
'legacy',
'planner',
'specialist',
'judge',

View File

@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test';
import { DiffExtractor } from '../context/diff-extractor';
function createExtractor(): DiffExtractor {
return new DiffExtractor({} as any, {} as any, 1000, 200, 10000);
}
describe('DiffExtractor.parseDiff', () => {
test('captures added, context, and deleted lines', () => {
const extractor = createExtractor();
const diff = `diff --git a/src/a.ts b/src/a.ts
index 111..222 100644
--- a/src/a.ts
+++ b/src/a.ts
@@ -1,3 +1,4 @@
const a = 1;
-const b = 2;
+const b = 3;
const c = 4;`;
const parsed = extractor.parseDiff(diff);
expect(parsed).toHaveLength(1);
expect(parsed[0].path).toBe('src/a.ts');
const addLine = parsed[0].changes.find((change) => change.type === 'add');
const deleteLine = parsed[0].changes.find((change) => change.type === 'delete');
expect(addLine).toBeDefined();
expect(deleteLine).toBeDefined();
expect(deleteLine?.oldLineNumber).toBe(2);
});
test('respects allowedPaths filter', () => {
const extractor = createExtractor();
const diff = `diff --git a/src/a.ts b/src/a.ts
--- a/src/a.ts
+++ b/src/a.ts
@@ -1 +1 @@
-const a = 1;
+const a = 2;
diff --git a/src/b.ts b/src/b.ts
--- a/src/b.ts
+++ b/src/b.ts
@@ -1 +1 @@
-const b = 1;
+const b = 2;`;
const parsed = extractor.parseDiff(diff, new Set(['src/b.ts']));
expect(parsed).toHaveLength(1);
expect(parsed[0].path).toBe('src/b.ts');
});
});

View File

@@ -1,11 +1,16 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mock } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { JudgeAgent } from '../agents/judge-agent';
import type { TriageResult } from '../agents/triage-agent';
import type { DiffExtractor } from '../context/diff-extractor';
import type { LocalRepoManager } from '../context/local-repo-manager';
import { ReviewOrchestrator } from '../orchestrator';
import { applyPublishPolicy } from '../policy/publish-policy';
import { FileReviewStore } from '../store/file-review-store';
import type { Finding, PullRequestReviewPayload } from '../types';
import type { Finding, PullRequestReviewPayload, ReviewContext, ReviewRun } from '../types';
type PartialFinding = Omit<Finding, 'id' | 'runId' | 'published'>;
@@ -43,6 +48,47 @@ function makeAgentFindings(
}));
}
function makeReviewContext(overrides: Partial<ReviewContext> = {}): ReviewContext {
return {
workspacePath: '/tmp/workspace',
mirrorPath: '/tmp/mirror',
diff: 'diff --git a/src/core.ts b/src/core.ts\n+export const a = 1;',
changedFiles: [{ path: 'src/core.ts', status: 'M', additions: 1, deletions: 0 }],
parsedDiff: [
{
path: 'src/core.ts',
changes: [{ lineNumber: 1, oldLineNumber: 1, content: 'export const a = 1;', type: 'add' }],
},
],
fileContents: { 'src/core.ts': 'export const a = 1;' },
...overrides,
};
}
function createOrchestratorDeps(context: ReviewContext) {
const localRepoManager = {
prepareWorkspace: mock(async () => ({
mirrorPath: '/tmp/mirror',
workspacePath: '/tmp/workspace',
})),
resolveReviewedRef: mock(async () => null),
saveReviewedRef: mock(async () => undefined),
cleanupWorkspace: mock(async () => undefined),
};
const diffExtractor = {
getSandbox: mock(() => ({
execute: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
})),
buildContext: mock(async () => context),
};
return {
localRepoManager,
diffExtractor,
};
}
/**
* Integration tests: Store → JudgeAgent → PublishPolicy → Store pipeline
*
@@ -370,3 +416,203 @@ describe('Integration: Store → Judge → Policy pipeline', () => {
expect(details!.findings[0].fingerprint).toBe('persist-fp-1');
});
});
describe('Integration: orchestrator staged routing pipeline', () => {
let tempDir: string;
let store: FileReviewStore;
beforeEach(async () => {
mock.restore();
tempDir = await mkdtemp(path.join(tmpdir(), 'orchestrator-integration-'));
store = new FileReviewStore(tempDir);
await store.init();
});
afterEach(async () => {
mock.restore();
await rm(tempDir, { recursive: true, force: true });
});
test('skip mode bypasses specialists end-to-end', async () => {
const payload = makePRPayload({ idempotencyKey: 'stage-skip' });
const { run } = await store.createOrReuseRun(payload);
const acquired = await store.acquireNextQueuedRun();
expect(acquired).not.toBeNull();
const context = makeReviewContext({
changedFiles: [{ path: 'README.md', status: 'M', additions: 2, deletions: 0 }],
parsedDiff: [
{
path: 'README.md',
changes: [{ lineNumber: 10, oldLineNumber: 10, content: 'new docs', type: 'add' }],
},
],
fileContents: { 'README.md': 'new docs' },
diff: 'diff --git a/README.md b/README.md\n+new docs',
});
const { localRepoManager, diffExtractor } = createOrchestratorDeps(context);
const orchestrator = new ReviewOrchestrator(
store,
localRepoManager as unknown as LocalRepoManager,
diffExtractor as unknown as DiffExtractor
);
const internal = orchestrator as unknown as {
triageAgent: { analyze: (ctx: ReviewContext) => Promise<TriageResult> };
correctnessAgent: {
reviewWithOptions: (
runArg: ReviewRun,
ctx: ReviewContext,
options?: unknown
) => Promise<unknown>;
};
publishSummary: (runArg: ReviewRun, summary: string, gatedCount: number) => Promise<void>;
publishLineComments: (
runArg: ReviewRun,
comments: Array<{ path: string; line: number; comment: string }>
) => Promise<boolean>;
};
internal.triageAgent = {
analyze: mock(
async (): Promise<TriageResult> => ({
complexity: 'trivial',
reviewSize: 'small',
mode: 'skip',
tasks: [],
riskTags: [],
rationale: 'docs-only',
})
),
};
const correctnessSpy = mock(async () => ({ agentName: 'Correctness Agent', findings: [] }));
internal.correctnessAgent.reviewWithOptions = correctnessSpy;
internal.publishSummary = mock(async () => undefined);
internal.publishLineComments = mock(async () => false);
await orchestrator.execute(acquired!);
expect(correctnessSpy).not.toHaveBeenCalled();
const details = await store.getRunDetails(run.id);
expect(details).not.toBeNull();
expect(details!.findings).toHaveLength(0);
});
test('full task mode passes scoped options and publishes finding', async () => {
const payload = makePRPayload({ idempotencyKey: 'stage-full' });
const { run } = await store.createOrReuseRun(payload);
const acquired = await store.acquireNextQueuedRun();
expect(acquired).not.toBeNull();
const context = makeReviewContext();
const { localRepoManager, diffExtractor } = createOrchestratorDeps(context);
const orchestrator = new ReviewOrchestrator(
store,
localRepoManager as unknown as LocalRepoManager,
diffExtractor as unknown as DiffExtractor
);
const internal = orchestrator as unknown as {
triageAgent: { analyze: (ctx: ReviewContext) => Promise<TriageResult> };
correctnessAgent: {
reviewWithOptions: (
runArg: ReviewRun,
ctx: ReviewContext,
options?: {
scopePaths?: string[];
allowTools?: boolean;
maxIterations?: number;
mode?: 'skip' | 'light' | 'full';
maxContextTokens?: number;
}
) => Promise<{
agentName: string;
findings: Array<Omit<Finding, 'id' | 'runId' | 'published'>>;
}>;
};
publishSummary: (runArg: ReviewRun, summary: string, gatedCount: number) => Promise<void>;
publishLineComments: (
runArg: ReviewRun,
comments: Array<{ path: string; line: number; comment: string }>
) => Promise<boolean>;
};
internal.triageAgent = {
analyze: mock(
async (): Promise<TriageResult> => ({
complexity: 'standard',
reviewSize: 'small',
mode: 'full',
riskTags: ['security-sensitive'],
rationale: 'auth file changed',
tasks: [
{
domain: 'correctness',
paths: ['src/core.ts'],
riskTags: ['security-sensitive'],
mode: 'full',
tokenBudget: 12000,
maxIterations: 2,
allowTools: false,
allowReflection: false,
allowDebate: false,
},
],
})
),
};
const correctnessSpy = mock(
async (
_runArg: ReviewRun,
_ctx: ReviewContext,
_options?: {
scopePaths?: string[];
allowTools?: boolean;
maxIterations?: number;
mode?: 'skip' | 'light' | 'full';
maxContextTokens?: number;
}
) => ({
agentName: 'Correctness Agent',
findings: [
{
fingerprint: 'stage-full-fp-1',
category: 'correctness' as const,
severity: 'high' as const,
confidence: 0.95,
path: 'src/core.ts',
line: 1,
title: 'critical issue',
detail: 'detail',
evidence: 'evidence',
suggestion: 'fix',
},
],
})
);
internal.correctnessAgent.reviewWithOptions = correctnessSpy;
internal.publishSummary = mock(async () => undefined);
internal.publishLineComments = mock(async () => true);
await orchestrator.execute(acquired!);
expect(correctnessSpy).toHaveBeenCalledTimes(1);
const callArgs = correctnessSpy.mock.calls[0];
const options = callArgs?.[2];
expect(options?.scopePaths).toEqual(['src/core.ts']);
expect(options?.allowTools).toBe(false);
expect(options?.maxIterations).toBe(2);
expect(options?.mode).toBe('full');
const details = await store.getRunDetails(run.id);
expect(details).not.toBeNull();
expect(details!.findings).toHaveLength(1);
expect(details!.findings[0].published).toBe(true);
expect(details!.findings[0].path).toBe('src/core.ts');
});
});

View File

@@ -4,7 +4,7 @@ import type { DiffExtractor } from '../context/diff-extractor';
import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
import { ReviewOrchestrator } from '../orchestrator';
import type { FileReviewStore } from '../store/file-review-store';
import type { Finding, ReviewContext, ReviewRun } from '../types';
import type { Finding, ReviewContext, ReviewRun, ReviewTask } from '../types';
type Snapshot = { baseSha: string; headSha: string } | null;
@@ -81,7 +81,11 @@ function wireOrchestratorFastPath(orchestrator: ReviewOrchestrator) {
triageAgent: {
analyze: (context: ReviewContext) => Promise<{
complexity: 'trivial' | 'standard' | 'complex';
relevantDomains: Array<'correctness' | 'security' | 'reliability' | 'maintainability'>;
reviewSize: 'small' | 'medium' | 'large';
mode: 'skip' | 'light' | 'full';
tasks: ReviewTask[];
riskTags: string[];
rationale: string;
}>;
};
judgeAgent: {
@@ -98,7 +102,14 @@ function wireOrchestratorFastPath(orchestrator: ReviewOrchestrator) {
};
internal.triageAgent = {
analyze: mock(async () => ({ complexity: 'trivial' as const, relevantDomains: [] })),
analyze: mock(async () => ({
complexity: 'trivial' as const,
reviewSize: 'small' as const,
mode: 'skip' as const,
tasks: [],
riskTags: [],
rationale: 'test fast-path',
})),
};
internal.judgeAgent = {

View File

@@ -129,7 +129,7 @@ describe('SpecialistAgent ReAct loop', () => {
expect(result.agentName).toBe('TestAgent');
});
test('no toolRegistry → uses legacy single-call mode', async () => {
test('no toolRegistry → uses single-call json mode', async () => {
const finding = {
severity: 'high',
confidence: 0.9,
@@ -188,15 +188,12 @@ describe('SpecialistAgent ReAct loop', () => {
expect(calls).toHaveLength(2);
});
test('ReAct: last iteration forces json_object + tool_choice=none', async () => {
test('ReAct: default staged mode uses 2 iterations and forces final json', async () => {
const registry = new ToolRegistry();
registry.register(makeDummyTool());
const { gateway, getCalls } = createMockGateway([
() => toolCallResponse([{ id: 'call_1', name: 'search_code', args: { query: 'x' } }]),
() => toolCallResponse([{ id: 'call_2', name: 'search_code', args: { query: 'y' } }]),
() => toolCallResponse([{ id: 'call_3', name: 'search_code', args: { query: 'z' } }]),
() => toolCallResponse([{ id: 'call_4', name: 'search_code', args: { query: 'w' } }]),
() => jsonResponse({ findings: [], need_more_investigation: false }),
]);
@@ -204,14 +201,11 @@ describe('SpecialistAgent ReAct loop', () => {
await agent.review(makeRun(), makeContext());
const calls = getCalls();
expect(calls).toHaveLength(5);
for (let i = 0; i < 4; i++) {
expect(calls[i].providerOptions).toEqual({ tool_choice: 'auto' });
expect(calls[i].responseFormat).toBeUndefined();
}
expect(calls[4].providerOptions).toEqual({ tool_choice: 'none' });
expect(calls[4].responseFormat).toBe('json');
expect(calls).toHaveLength(2);
expect(calls[0].providerOptions).toEqual({ tool_choice: 'auto' });
expect(calls[0].responseFormat).toBeUndefined();
expect(calls[1].providerOptions).toEqual({ tool_choice: 'none' });
expect(calls[1].responseFormat).toBe('json');
});
test('ReAct: dead-loop prevention — need_more_investigation=true but no tool call injects user prompt', async () => {
@@ -413,6 +407,46 @@ describe('SpecialistAgent ReAct loop', () => {
expect(result.findings).toHaveLength(0);
});
test('staged context includes deleted lines metadata for review', async () => {
const { gateway, getCalls } = createMockGateway([
() =>
jsonResponse({
findings: [],
need_more_investigation: false,
}),
]);
const context = makeContext({
parsedDiff: [
{
path: 'src/foo.ts',
changes: [
{ lineNumber: 12, oldLineNumber: 11, content: 'if (auth) {', type: 'context' },
{ lineNumber: 12, oldLineNumber: 12, content: 'if (isAdmin(user)) {', type: 'delete' },
{ lineNumber: 13, oldLineNumber: 13, content: 'return true;', type: 'add' },
],
},
],
});
const agent = new SpecialistAgent(gateway as any, category, 'TestAgent', 'bugs');
await agent.reviewWithOptions(makeRun(), context, {
mode: 'full',
allowTools: false,
scopePaths: ['src/foo.ts'],
maxContextTokens: 6000,
});
const calls = getCalls();
expect(calls).toHaveLength(1);
const userMessage = calls[0].messages.find((message) => message.role === 'user');
expect(userMessage).toBeDefined();
if (!userMessage) throw new Error('Expected user message in request');
expect(userMessage.content).toContain('"type": "delete"');
expect(userMessage.content).toContain('"oldLineNumber": 12');
});
test('ReAct: auto-generates fingerprint when finding has none', async () => {
const registry = new ToolRegistry();
registry.register(makeDummyTool());

View File

@@ -3,13 +3,6 @@ import type { LLMChatResponse, ModelRole } from '../../llm/types';
import { TriageAgent } from '../agents/triage-agent';
import type { ChangedFile, FindingCategory, ReviewContext } from '../types';
const ALL_DOMAINS: FindingCategory[] = [
'correctness',
'security',
'reliability',
'maintainability',
];
function makeChangedFile(overrides: Partial<ChangedFile> = {}): ChangedFile {
return {
path: 'src/file.ts',
@@ -62,23 +55,37 @@ function createMockGateway(
};
}
describe('TriageAgent', () => {
test('heuristic: empty changedFiles -> trivial + correctness (no LLM call)', async () => {
describe('TriageAgent task-based routing', () => {
test('heuristic: empty changedFiles -> skip mode with no tasks', async () => {
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS }))
makeChatResponse(
JSON.stringify({
complexity: 'complex',
review_size: 'large',
mode: 'full',
relevant_domains: ['correctness', 'security', 'reliability', 'maintainability'],
})
)
);
const agent = new TriageAgent(gateway as any);
const result = await agent.analyze(makeContext({ changedFiles: [] }));
expect(result.complexity).toBe('trivial');
expect(result.relevantDomains).toEqual(['correctness']);
expect(result.mode).toBe('skip');
expect(result.tasks).toHaveLength(0);
expect(getCalls()).toHaveLength(0);
});
test('heuristic: all non-code files -> trivial + correctness (no LLM call)', async () => {
test('heuristic: docs/assets only -> skip mode with no tasks', async () => {
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS }))
makeChatResponse(
JSON.stringify({
complexity: 'complex',
review_size: 'large',
mode: 'full',
relevant_domains: ['correctness', 'security', 'reliability', 'maintainability'],
})
)
);
const agent = new TriageAgent(gateway as any);
@@ -86,23 +93,19 @@ describe('TriageAgent', () => {
makeContext({
changedFiles: [
makeChangedFile({ path: 'README.md' }),
makeChangedFile({ path: 'config/app.json' }),
makeChangedFile({ path: 'styles/base.css' }),
makeChangedFile({ path: 'docs/usage.adoc' }),
makeChangedFile({ path: 'assets/logo.png' }),
makeChangedFile({ path: 'bun.lock', additions: 10, deletions: 10 }),
],
})
);
expect(result.complexity).toBe('trivial');
expect(result.relevantDomains).toEqual(['correctness']);
expect(result.mode).toBe('skip');
expect(result.tasks).toHaveLength(0);
expect(getCalls()).toHaveLength(0);
});
test('heuristic: single file <=3 line changes -> trivial + correctness (no LLM call)', async () => {
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS }))
);
test('heuristic: tiny single-file code change -> light correctness task', async () => {
const { gateway, getCalls } = createMockGateway(async () => makeChatResponse(null));
const agent = new TriageAgent(gateway as any);
const result = await agent.analyze(
@@ -111,37 +114,36 @@ describe('TriageAgent', () => {
})
);
expect(result.complexity).toBe('trivial');
expect(result.relevantDomains).toEqual(['correctness']);
expect(result.mode).toBe('light');
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].domain).toBe('correctness');
expect(result.tasks[0].allowTools).toBe(false);
expect(result.tasks[0].maxIterations).toBe(1);
expect(getCalls()).toHaveLength(0);
});
test('heuristic: security-sensitive small PR -> standard + correctness/security (no LLM call)', async () => {
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS }))
);
test('heuristic: security-sensitive small change -> full correctness+security tasks', async () => {
const { gateway, getCalls } = createMockGateway(async () => makeChatResponse(null));
const agent = new TriageAgent(gateway as any);
const result = await agent.analyze(
makeContext({
changedFiles: [
makeChangedFile({ path: 'src/auth/service.ts', additions: 20, deletions: 10 }),
makeChangedFile({ path: 'src/user/profile.ts', additions: 5, deletions: 5 }),
makeChangedFile({ path: 'src/auth/service.ts', additions: 12, deletions: 6 }),
makeChangedFile({ path: 'src/user/profile.ts', additions: 10, deletions: 4 }),
],
})
);
expect(result.complexity).toBe('standard');
expect(result.relevantDomains).toEqual(['correctness', 'security']);
expect(result.mode).toBe('full');
const domains = result.tasks.map((task) => task.domain);
expect(domains).toContain('correctness');
expect(domains).toContain('security');
expect(getCalls()).toHaveLength(0);
});
test('heuristic: large PR by file count (>20) -> complex + all domains (no LLM call)', async () => {
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(
JSON.stringify({ complexity: 'standard', relevant_domains: ['correctness'] })
)
);
test('heuristic: large PR by file count -> full mode with all domains', async () => {
const { gateway, getCalls } = createMockGateway(async () => makeChatResponse(null));
const agent = new TriageAgent(gateway as any);
const changedFiles = Array.from({ length: 21 }, (_, index) =>
@@ -150,39 +152,28 @@ describe('TriageAgent', () => {
const result = await agent.analyze(makeContext({ changedFiles }));
expect(result.mode).toBe('full');
expect(result.reviewSize).toBe('large');
expect(result.complexity).toBe('complex');
expect(result.relevantDomains).toEqual(ALL_DOMAINS);
const expectedDomains: FindingCategory[] = [
'correctness',
'maintainability',
'reliability',
'security',
];
expect(result.tasks.map((task) => task.domain).sort()).toEqual(expectedDomains.sort());
expect(getCalls()).toHaveLength(0);
});
test('heuristic: large PR by total changes (>500) -> complex + all domains (no LLM call)', async () => {
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(
JSON.stringify({ complexity: 'standard', relevant_domains: ['correctness'] })
)
);
const agent = new TriageAgent(gateway as any);
const result = await agent.analyze(
makeContext({
changedFiles: [
makeChangedFile({ path: 'src/a.ts', additions: 250, deletions: 10 }),
makeChangedFile({ path: 'src/b.ts', additions: 240, deletions: 10 }),
],
})
);
expect(result.complexity).toBe('complex');
expect(result.relevantDomains).toEqual(ALL_DOMAINS);
expect(getCalls()).toHaveLength(0);
});
test('LLM fallback: standard code change calls planner and returns parsed JSON result', async () => {
test('LLM fallback: inconclusive change uses planner and normalizes tasks', async () => {
const { gateway, getCalls } = createMockGateway(async () =>
makeChatResponse(
JSON.stringify({
complexity: 'standard',
review_size: 'medium',
mode: 'light',
relevant_domains: ['security', 'maintainability'],
risk_tags: ['security-sensitive'],
rationale: '跨文件业务逻辑调整',
})
)
@@ -192,11 +183,11 @@ describe('TriageAgent', () => {
const result = await agent.analyze(
makeContext({
changedFiles: [
makeChangedFile({ path: 'src/service/order.ts', additions: 10, deletions: 6 }),
makeChangedFile({ path: 'src/controller/order.ts', additions: 12, deletions: 8 }),
makeChangedFile({ path: 'src/repo/order.ts', additions: 8, deletions: 6 }),
makeChangedFile({ path: 'src/service/order.ts', additions: 20, deletions: 10 }),
makeChangedFile({ path: 'src/controller/order.ts', additions: 18, deletions: 12 }),
makeChangedFile({ path: 'src/repo/order.ts', additions: 15, deletions: 12 }),
makeChangedFile({ path: 'src/model/order.ts', additions: 14, deletions: 13 }),
],
diff: 'diff --git a/src/service/order.ts b/src/service/order.ts\n+export function calc(){}',
})
);
@@ -206,12 +197,14 @@ describe('TriageAgent', () => {
expect(calls[0].request.temperature).toBe(0);
expect(calls[0].request.responseFormat).toBe('json');
expect(result.complexity).toBe('standard');
expect(result.relevantDomains).toEqual(['correctness', 'security', 'maintainability']);
expect(result.reviewSize).toBe('medium');
expect(result.mode).toBe('light');
expect(result.tasks.map((task) => task.domain)).toContain('correctness');
expect(result.tasks.map((task) => task.domain)).toContain('security');
expect(result.rationale).toBe('跨文件业务逻辑调整');
});
test('LLM fallback: planner throws -> fallback standard + all domains', async () => {
test('LLM fallback: planner throws -> default full review with all domains', async () => {
const { gateway, getCalls } = createMockGateway(async () => {
throw new Error('planner unavailable');
});
@@ -220,16 +213,24 @@ describe('TriageAgent', () => {
const result = await agent.analyze(
makeContext({
changedFiles: [
makeChangedFile({ path: 'src/service/foo.ts', additions: 10, deletions: 4 }),
makeChangedFile({ path: 'src/service/bar.ts', additions: 12, deletions: 6 }),
makeChangedFile({ path: 'src/service/baz.ts', additions: 8, deletions: 10 }),
makeChangedFile({ path: 'src/service/foo.ts', additions: 20, deletions: 12 }),
makeChangedFile({ path: 'src/service/bar.ts', additions: 18, deletions: 10 }),
makeChangedFile({ path: 'src/service/baz.ts', additions: 16, deletions: 8 }),
makeChangedFile({ path: 'src/service/qux.ts', additions: 12, deletions: 6 }),
makeChangedFile({ path: 'src/service/quux.ts', additions: 10, deletions: 4 }),
],
})
);
expect(getCalls()).toHaveLength(1);
expect(result.complexity).toBe('standard');
expect(result.relevantDomains).toEqual(ALL_DOMAINS);
expect(result.mode).toBe('full');
const expectedDomains: FindingCategory[] = [
'correctness',
'maintainability',
'reliability',
'security',
];
expect(result.tasks.map((task) => task.domain).sort()).toEqual(expectedDomains.sort());
expect(result.rationale).toContain('LLM');
});
});

View File

@@ -1,7 +1,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt';
import { withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import { Finding, ReviewContext } from '../types';
@@ -73,7 +73,7 @@ ${tokenCounter.clip(context.diff, 1000)}
const messages: LLMMessage[] = [
{
role: 'system',
content: withGlobalPrompt(
content: withCoreGlobalPrompt(
'你是严格的代码审查质量评估专家以高标准评估findings的质量。',
config.review.globalPrompt
),
@@ -166,7 +166,7 @@ ${tokenCounter.clip(context.diff, 700)}
const messages: LLMMessage[] = [
{
role: 'system',
content: withGlobalPrompt('你是代码审查质量评估专家。', config.review.globalPrompt),
content: withCoreGlobalPrompt('你是代码审查质量评估专家。', config.review.globalPrompt),
},
{ role: 'user', content: prompt },
];

View File

@@ -1,7 +1,7 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt';
import { withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { Finding, FindingSeverity } from '../types';
import { SpecialistAgent } from './specialist-agent';
@@ -105,7 +105,7 @@ export class DebateOrchestrator {
const messages: LLMMessage[] = [
{
role: 'system',
content: withGlobalPrompt(
content: withCoreGlobalPrompt(
`你是${agentName},从你的专业角度独立评估代码问题。`,
config.review.globalPrompt
),
@@ -186,7 +186,7 @@ ${otherOpinions
const messages: LLMMessage[] = [
{
role: 'system',
content: withGlobalPrompt(
content: withCoreGlobalPrompt(
`你是${agentName},根据同行意见重新评估,但也要坚持你的专业判断。`,
config.review.globalPrompt
),

View File

@@ -10,7 +10,7 @@ import { findingResponseSchema } from '../schema/finding-schema';
import { ToolRegistry } from '../tools/registry';
import { AgentResult, Finding, FindingCategory, ReviewContext, ReviewRun } from '../types';
import { CriticAgent, CritiqueResult } from './critic-agent';
import { SpecialistAgent } from './specialist-agent';
import { SpecialistAgent, type SpecialistReviewOptions } from './specialist-agent';
function buildFingerprint(category: string, path: string, line: number, title: string): string {
return createHash('sha256')
@@ -37,7 +37,8 @@ export class ReflexionAgent extends SpecialistAgent {
async reviewWithReflection(
run: ReviewRun,
context: ReviewContext,
maxReflectionRounds = 2
maxReflectionRounds = 2,
options?: SpecialistReviewOptions
): Promise<AgentResult> {
let bestFindings: Omit<Finding, 'id' | 'runId' | 'published'>[] = [];
let bestQualityScore = 0;
@@ -49,7 +50,7 @@ export class ReflexionAgent extends SpecialistAgent {
});
// 生成初步findings首轮或基于上一轮refined结果
const draft = await this.generateDraft(run, context, currentFindings, round);
const draft = await this.generateDraft(run, context, currentFindings, round, options);
// 自我批评
const critique = await this.criticAgent.critique(draft, context);
@@ -95,11 +96,12 @@ export class ReflexionAgent extends SpecialistAgent {
run: ReviewRun,
context: ReviewContext,
previousFindings: Omit<Finding, 'id' | 'runId' | 'published'>[],
round: number
round: number,
options?: SpecialistReviewOptions
): Promise<Omit<Finding, 'id' | 'runId' | 'published'>[]> {
// 第一轮使用父类的review方法
if (round === 0) {
const result = await super.review(run, context);
const result = await super.reviewWithOptions(run, context, options);
return result.findings;
}

View File

@@ -4,12 +4,19 @@ import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage, LLMToolCall } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import { tokenCounter } from '../context/token-counter';
import type { LearningSystem } from '../learning/learning-system';
import { findingResponseSchema } from '../schema/finding-schema';
import { ToolRegistry } from '../tools/registry';
import type { ToolExecutionContext, ToolResult } from '../tools/types';
import { AgentResult, Finding, FindingCategory, ReviewContext, ReviewRun } from '../types';
import { tokenCounter } from '../context/token-counter';
import {
AgentResult,
Finding,
FindingCategory,
ReviewContext,
ReviewMode,
ReviewRun,
} from '../types';
function buildFingerprint(category: string, path: string, line: number, title: string): string {
return createHash('sha256')
@@ -18,11 +25,39 @@ function buildFingerprint(category: string, path: string, line: number, title: s
.slice(0, 24);
}
function toCompactContext(context: ReviewContext): string {
// Token-based budget: 25k tokens for context, leaving room for system prompt + few-shot + response
const MAX_CONTEXT_TOKENS = 25_000;
interface CompactContextOptions {
scopePaths?: string[];
maxContextTokens?: number;
}
const files = context.changedFiles.map((file) => ({
export interface SpecialistReviewOptions {
scopePaths?: string[];
allowTools?: boolean;
maxIterations?: number;
mode?: ReviewMode;
maxContextTokens?: number;
}
function toCompactContext(context: ReviewContext, options?: CompactContextOptions): string {
const MAX_CONTEXT_TOKENS = options?.maxContextTokens ?? 25_000;
const scopedPaths = options?.scopePaths ? new Set(options.scopePaths) : null;
const scopedChangedFiles = scopedPaths
? context.changedFiles.filter((file) => scopedPaths.has(file.path))
: context.changedFiles;
const scopedParsedDiff = scopedPaths
? context.parsedDiff.filter((file) => scopedPaths.has(file.path))
: context.parsedDiff;
const scopedFileContents = scopedPaths
? Object.fromEntries(
Object.entries(context.fileContents).filter(([filePath]) => scopedPaths.has(filePath))
)
: context.fileContents;
const files = scopedChangedFiles.map((file) => ({
path: file.path,
status: file.status,
additions: file.additions,
@@ -35,19 +70,19 @@ function toCompactContext(context: ReviewContext): string {
// 3. fileContents最大按需截断或移除部分文件
let maxChangesPerFile = 200;
let maxFileContentsEntries = Object.keys(context.fileContents).length;
let maxFileContentsEntries = Object.keys(scopedFileContents).length;
const tryBuild = (changesLimit: number, contentEntriesLimit: number): string => {
const snippets = context.parsedDiff.map((file) => ({
const snippets = scopedParsedDiff.map((file) => ({
path: file.path,
changes: file.changes.slice(0, changesLimit),
}));
const limitedContents: Record<string, string> = {};
const contentKeys = Object.keys(context.fileContents);
const contentKeys = Object.keys(scopedFileContents);
for (let i = 0; i < Math.min(contentEntriesLimit, contentKeys.length); i++) {
const key = contentKeys[i];
limitedContents[key] = context.fileContents[key];
limitedContents[key] = scopedFileContents[key];
}
return JSON.stringify(
@@ -100,27 +135,49 @@ export class SpecialistAgent {
) {}
async review(run: ReviewRun, context: ReviewContext): Promise<AgentResult> {
return this.reviewWithOptions(run, context);
}
async reviewWithOptions(
run: ReviewRun,
context: ReviewContext,
options?: SpecialistReviewOptions
): Promise<AgentResult> {
if (!context.diff.trim()) {
return { agentName: this.agentName, findings: [] };
}
// 如果没有工具注册表,使用传统单次调用模式
if (!this.toolRegistry || this.toolRegistry.getAll().length === 0) {
return this.reviewLegacy(run, context);
if (options?.mode === 'skip') {
return { agentName: this.agentName, findings: [] };
}
if (
!this.toolRegistry ||
this.toolRegistry.getAll().length === 0 ||
options?.allowTools === false
) {
return this.reviewSinglePass(run, context, options);
}
// ReAct循环模式
return this.reviewWithReAct(run, context);
return this.reviewWithReAct(run, context, options);
}
private async reviewLegacy(run: ReviewRun, context: ReviewContext): Promise<AgentResult> {
private async reviewSinglePass(
run: ReviewRun,
context: ReviewContext,
options?: SpecialistReviewOptions
): Promise<AgentResult> {
const prompt = `你是${this.agentName},只关注${this.focusPrompt}
输出必须是JSON对象格式:
{"findings": [{"severity": "high"|"medium"|"low", "confidence": 0-1, "path": "文件路径", "line": 正整数, "title": "标题", "detail": "详情", "evidence": "证据", "suggestion": "建议"}]}
每个 finding 的所有字段都是必填的。仅报告有明确证据的问题;无问题时返回空数组。
审查上下文如下:
${toCompactContext(context)}`;
${toCompactContext(context, {
scopePaths: options?.scopePaths,
maxContextTokens: options?.maxContextTokens,
})}`;
try {
const messages: LLMMessage[] = [
@@ -166,9 +223,20 @@ ${toCompactContext(context)}`;
}
}
private async reviewWithReAct(run: ReviewRun, context: ReviewContext): Promise<AgentResult> {
const maxIterations = 5;
private async reviewWithReAct(
run: ReviewRun,
context: ReviewContext,
options?: SpecialistReviewOptions
): Promise<AgentResult> {
const maxIterations = Math.max(
1,
options?.maxIterations ?? (options?.mode === 'light' ? 1 : 2)
);
const findingsMap = new Map<string, Omit<Finding, 'id' | 'runId' | 'published'>>();
const compactContext = toCompactContext(context, {
scopePaths: options?.scopePaths,
maxContextTokens: options?.maxContextTokens,
});
const messages: LLMMessage[] = [
{
role: 'system',
@@ -248,7 +316,7 @@ ${this.toolRegistry!.getAll()
// 添加当前审查任务
messages.push({
role: 'user',
content: `审查以下代码变更:\n${toCompactContext(context)}`,
content: `审查以下代码变更:\n${compactContext}`,
});
try {

View File

@@ -12,9 +12,17 @@
import config from '../../config';
import type { LLMGateway } from '../../llm/gateway';
import type { LLMMessage } from '../../llm/types';
import { withGlobalPrompt } from '../../utils/global-prompt';
import { withCoreGlobalPrompt } from '../../utils/global-prompt';
import { logger } from '../../utils/logger';
import type { ChangedFile, FindingCategory, ReviewContext } from '../types';
import type {
ChangedFile,
FindingCategory,
ReviewBudgetPolicy,
ReviewContext,
ReviewMode,
ReviewSize,
ReviewTask,
} from '../types';
// ---------------------------------------------------------------------------
// Types
@@ -25,8 +33,10 @@ export type TriageComplexity = 'trivial' | 'standard' | 'complex';
export interface TriageResult {
/** How complex the change is — drives how many agents to dispatch. */
complexity: TriageComplexity;
/** Which specialist domains are relevant for this change. */
relevantDomains: FindingCategory[];
reviewSize: ReviewSize;
mode: ReviewMode;
tasks: ReviewTask[];
riskTags: string[];
/** Brief rationale from the planner model. */
rationale: string;
}
@@ -39,6 +49,277 @@ const ALL_DOMAINS: FindingCategory[] = [
'maintainability',
];
const DOCUMENTATION_EXTENSIONS = new Set([
'.md',
'.txt',
'.rst',
'.adoc',
'.svg',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.ico',
'.webp',
]);
const LOCKFILE_PATTERNS = [
/\.lock$/i,
/^package-lock\.json$/i,
/^pnpm-lock\.yaml$/i,
/^yarn\.lock$/i,
];
const SECURITY_SENSITIVE_PATTERNS = [
/auth/i,
/login/i,
/password/i,
/secret/i,
/token/i,
/crypt/i,
/permission/i,
/role/i,
/acl/i,
/cors/i,
/csrf/i,
/xss/i,
/credential/i,
/oauth/i,
/jwt/i,
/session/i,
/\.env/i,
/dockerfile/i,
/k8s\//i,
/helm\//i,
/terraform\//i,
/\.github\/workflows\//i,
/deploy/i,
/migration/i,
];
const RELIABILITY_PATTERNS = [
/retry/i,
/timeout/i,
/circuit/i,
/queue/i,
/worker/i,
/concurr/i,
/transaction/i,
/lock/i,
/cache/i,
/db/i,
/repository/i,
];
const MAINTAINABILITY_PATTERNS = [
/interface/i,
/service/i,
/controller/i,
/api/i,
/schema/i,
/dto/i,
];
function getReviewBudgetPolicy(): ReviewBudgetPolicy {
return {
smallMaxFiles: config.review.smallMaxFiles,
smallMaxChangedLines: config.review.smallMaxChangedLines,
mediumMaxFiles: config.review.mediumMaxFiles,
mediumMaxChangedLines: config.review.mediumMaxChangedLines,
tokenBudgetSmall: config.review.tokenBudgetSmall,
tokenBudgetMedium: config.review.tokenBudgetMedium,
tokenBudgetLarge: config.review.tokenBudgetLarge,
};
}
function hasPattern(path: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(path));
}
function getFileExtension(path: string): string {
const idx = path.lastIndexOf('.');
return idx >= 0 ? path.slice(idx).toLowerCase() : '';
}
function classifyReviewSize(changedFiles: ChangedFile[], policy: ReviewBudgetPolicy): ReviewSize {
const fileCount = changedFiles.length;
const changedLines = changedFiles.reduce((sum, file) => sum + file.additions + file.deletions, 0);
if (fileCount <= policy.smallMaxFiles && changedLines <= policy.smallMaxChangedLines) {
return 'small';
}
if (fileCount <= policy.mediumMaxFiles && changedLines <= policy.mediumMaxChangedLines) {
return 'medium';
}
return 'large';
}
function getTokenBudget(reviewSize: ReviewSize, policy: ReviewBudgetPolicy): number {
if (reviewSize === 'small') {
return policy.tokenBudgetSmall;
}
if (reviewSize === 'medium') {
return policy.tokenBudgetMedium;
}
return policy.tokenBudgetLarge;
}
function toComplexity(mode: ReviewMode, reviewSize: ReviewSize): TriageComplexity {
if (mode === 'skip') {
return 'trivial';
}
if (reviewSize === 'small') {
return mode === 'full' ? 'standard' : 'trivial';
}
if (reviewSize === 'large') {
return 'complex';
}
return 'standard';
}
function collectRiskTags(changedFiles: ChangedFile[]): string[] {
const tags = new Set<string>();
for (const file of changedFiles) {
const filePath = file.path;
if (hasPattern(filePath, SECURITY_SENSITIVE_PATTERNS)) {
tags.add('security-sensitive');
}
if (hasPattern(filePath, RELIABILITY_PATTERNS)) {
tags.add('reliability-sensitive');
}
if (hasPattern(filePath, MAINTAINABILITY_PATTERNS)) {
tags.add('maintainability-hotspot');
}
if (/test|spec|__tests__/i.test(filePath)) {
tags.add('test-change');
}
if (
getFileExtension(filePath) === '.json' ||
getFileExtension(filePath) === '.yaml' ||
getFileExtension(filePath) === '.yml'
) {
tags.add('runtime-config-change');
}
}
return [...tags];
}
function isSkipFriendlyChange(changedFiles: ChangedFile[], riskTags: string[]): boolean {
if (changedFiles.length === 0) {
return true;
}
if (riskTags.includes('security-sensitive') || riskTags.includes('runtime-config-change')) {
return false;
}
const allRenameOnly = changedFiles.every(
(file) => file.status === 'R' && file.additions + file.deletions === 0
);
if (allRenameOnly) {
return true;
}
return changedFiles.every((file) => {
const ext = getFileExtension(file.path);
if (LOCKFILE_PATTERNS.some((pattern) => pattern.test(file.path))) {
return true;
}
return DOCUMENTATION_EXTENSIONS.has(ext);
});
}
function buildDomainPaths(changedFiles: ChangedFile[], domain: FindingCategory): string[] {
const candidatePaths = changedFiles
.filter((file) => file.status !== 'D')
.map((file) => file.path);
if (candidatePaths.length === 0) {
return [];
}
if (domain === 'security') {
const scoped = candidatePaths.filter((filePath) =>
hasPattern(filePath, SECURITY_SENSITIVE_PATTERNS)
);
return scoped.length > 0 ? scoped : candidatePaths;
}
if (domain === 'reliability') {
const scoped = candidatePaths.filter((filePath) => hasPattern(filePath, RELIABILITY_PATTERNS));
return scoped.length > 0 ? scoped : candidatePaths;
}
if (domain === 'maintainability') {
const scoped = candidatePaths.filter((filePath) =>
hasPattern(filePath, MAINTAINABILITY_PATTERNS)
);
return scoped.length > 0 ? scoped : candidatePaths;
}
return candidatePaths;
}
function buildTasks(
domains: FindingCategory[],
changedFiles: ChangedFile[],
reviewSize: ReviewSize,
mode: ReviewMode,
riskTags: string[],
policy: ReviewBudgetPolicy
): ReviewTask[] {
if (mode === 'skip') {
return [];
}
const tokenBudget = getTokenBudget(reviewSize, policy);
const maxIterations = mode === 'light' ? 1 : 2;
return domains.map((domain) => {
const scopedPaths = buildDomainPaths(changedFiles, domain);
return {
domain,
paths: scopedPaths,
riskTags,
mode,
tokenBudget,
maxIterations,
allowTools: mode === 'full',
allowReflection: mode === 'full' && (domain === 'correctness' || domain === 'security'),
allowDebate: mode === 'full' && domain !== 'maintainability',
};
});
}
function decideDomains(
changedFiles: ChangedFile[],
riskTags: string[],
reviewSize: ReviewSize
): FindingCategory[] {
const domains: FindingCategory[] = ['correctness'];
const hasSecurityFiles = riskTags.includes('security-sensitive');
const hasReliabilityFiles = riskTags.includes('reliability-sensitive');
const hasMaintainabilityHotspot = riskTags.includes('maintainability-hotspot');
if (hasSecurityFiles) {
domains.push('security');
}
if (hasReliabilityFiles || reviewSize === 'large') {
domains.push('reliability');
}
if (hasMaintainabilityHotspot || changedFiles.length >= 4 || reviewSize === 'large') {
domains.push('maintainability');
}
if (reviewSize === 'large') {
return [...ALL_DOMAINS];
}
return [...new Set(domains)];
}
// ---------------------------------------------------------------------------
// Triage Agent
// ---------------------------------------------------------------------------
@@ -59,7 +340,8 @@ export class TriageAgent {
if (heuristicResult) {
logger.info('Triage: 使用启发式规则快速分流', {
complexity: heuristicResult.complexity,
domains: heuristicResult.relevantDomains.join(','),
tasks: heuristicResult.tasks.length,
mode: heuristicResult.mode,
rationale: heuristicResult.rationale,
});
return heuristicResult;
@@ -73,8 +355,18 @@ export class TriageAgent {
error: error instanceof Error ? error.message : String(error),
});
return {
complexity: 'standard',
relevantDomains: [...ALL_DOMAINS],
complexity: 'complex',
reviewSize: 'large',
mode: 'full',
tasks: buildTasks(
[...ALL_DOMAINS],
context.changedFiles,
'large',
'full',
collectRiskTags(context.changedFiles),
getReviewBudgetPolicy()
),
riskTags: collectRiskTags(context.changedFiles),
rationale: 'Triage LLM 调用失败,使用默认全量审查',
};
}
@@ -85,100 +377,73 @@ export class TriageAgent {
* Returns null if heuristic is inconclusive (should use LLM).
*/
private heuristicTriage(changedFiles: ChangedFile[]): TriageResult | null {
const policy = getReviewBudgetPolicy();
if (changedFiles.length === 0) {
return {
complexity: 'trivial',
relevantDomains: ['correctness'],
reviewSize: 'small',
mode: 'skip',
tasks: [],
riskTags: [],
rationale: '无变更文件',
};
}
const NON_CODE_EXTENSIONS = new Set([
'.md',
'.txt',
'.rst',
'.adoc', // docs
'.json',
'.yaml',
'.yml',
'.toml',
'.ini', // config (non-security)
'.css',
'.scss',
'.less',
'.svg', // styles/assets
'.png',
'.jpg',
'.jpeg',
'.gif',
'.ico',
'.webp', // images
'.lock', // lockfiles
]);
const riskTags = collectRiskTags(changedFiles);
const reviewSize = classifyReviewSize(changedFiles, policy);
const totalChanges = changedFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0);
const SECURITY_SENSITIVE_PATTERNS = [
/auth/i,
/login/i,
/password/i,
/secret/i,
/token/i,
/crypt/i,
/permission/i,
/role/i,
/acl/i,
/cors/i,
/csrf/i,
/xss/i,
/\.env/,
/credential/i,
/oauth/i,
/jwt/i,
/session/i,
];
const allNonCode = changedFiles.every((f) => {
const ext = f.path.substring(f.path.lastIndexOf('.')).toLowerCase();
return NON_CODE_EXTENSIONS.has(ext);
});
if (allNonCode) {
if (isSkipFriendlyChange(changedFiles, riskTags)) {
return {
complexity: 'trivial',
relevantDomains: ['correctness'],
rationale: '所有变更文件均为非代码文件(文档/配置/资源)',
reviewSize,
mode: 'skip',
tasks: [],
riskTags,
rationale: '文档/资源/锁文件或纯重命名变更,启用 skip 模式',
};
}
// Very small change (≤3 lines total) in a single file → trivial
const totalChanges = changedFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0);
if (changedFiles.length === 1 && totalChanges <= 3) {
const domains = ['correctness'] as FindingCategory[];
return {
complexity: 'trivial',
relevantDomains: ['correctness'],
reviewSize: 'small',
mode: 'light',
tasks: buildTasks(domains, changedFiles, 'small', 'light', riskTags, policy),
riskTags,
rationale: `单文件微量变更(${totalChanges} 行)`,
};
}
// Check for security-sensitive files
const hasSecurityFiles = changedFiles.some((f) =>
SECURITY_SENSITIVE_PATTERNS.some((p) => p.test(f.path))
);
// Large PR (many files or large changes) → complex
if (changedFiles.length > 20 || totalChanges > 500) {
if (reviewSize === 'large') {
const domains = [...ALL_DOMAINS];
return {
complexity: 'complex',
relevantDomains: [...ALL_DOMAINS],
rationale: `大规模变更(${changedFiles.length} 文件, ${totalChanges} 行)`,
reviewSize,
mode: 'full',
tasks: buildTasks(domains, changedFiles, reviewSize, 'full', riskTags, policy),
riskTags,
rationale: `大规模变更(${changedFiles.length} 文件, ${totalChanges} 行),全量任务审查`,
};
}
// Security-sensitive file detected → ensure security agent is included
if (hasSecurityFiles && changedFiles.length <= 5 && totalChanges <= 100) {
const domains = decideDomains(changedFiles, riskTags, reviewSize);
const hasSensitiveRisk =
riskTags.includes('security-sensitive') || riskTags.includes('runtime-config-change');
const mode: ReviewMode = hasSensitiveRisk || reviewSize === 'medium' ? 'full' : 'light';
if (hasSensitiveRisk || reviewSize === 'small') {
return {
complexity: 'standard',
relevantDomains: ['correctness', 'security'],
rationale: '涉及安全相关文件,仅派发 correctness + security',
complexity: toComplexity(mode, reviewSize),
reviewSize,
mode,
tasks: buildTasks(domains, changedFiles, reviewSize, mode, riskTags, policy),
riskTags,
rationale: hasSensitiveRisk
? '命中安全/运行时敏感风险,提升到 full 模式并限制到相关路径'
: '小型代码变更,使用 light 模式快速审查',
};
}
@@ -190,6 +455,8 @@ export class TriageAgent {
* LLM-based triage using the 'planner' role.
*/
private async llmTriage(context: ReviewContext): Promise<TriageResult> {
const policy = getReviewBudgetPolicy();
const riskTags = collectRiskTags(context.changedFiles);
const fileSummary = context.changedFiles
.map((f) => `${f.status} ${f.path} (+${f.additions} -${f.deletions})`)
.join('\n');
@@ -197,7 +464,7 @@ export class TriageAgent {
// Use a small slice of diff for context (just the first 2000 chars for speed)
const diffPreview = context.diff.slice(0, 2000);
const prompt = `你是代码审查分流专家。分析以下变更并判断其复杂度和需要哪些审查领域。
const prompt = `你是代码审查分流专家。分析以下变更并判断其复杂度、审查模式和审查领域。
变更文件列表:
${fileSummary}
@@ -206,23 +473,26 @@ Diff 预览前2000字符
${diffPreview}
判断标准:
- **trivial**: 纯文档、注释、字符串修改、格式化、重命名等无逻辑变更 → 只需 correctness
- **standard**: 单模块逻辑修改、普通功能开发 → 按实际涉及领域选择
- **complex**: 多模块/跨层修改、架构变更、并发/安全关键路径 → 全部领域
- **mode=skip**: 纯文档/资源/锁文件/无逻辑改动
- **mode=light**: 小范围可执行代码改动,低风险,最小深度审查
- **mode=full**: 安全/配置/跨模块/中大型改动,需要完整审查
可选领域correctness逻辑正确性, security安全, reliability可靠性, maintainability可维护性
返回 JSON
{
"complexity": "trivial" | "standard" | "complex",
"review_size": "small" | "medium" | "large",
"mode": "skip" | "light" | "full",
"relevant_domains": ["correctness", ...],
"risk_tags": ["security-sensitive", ...],
"rationale": "简要理由"
}`;
const messages: LLMMessage[] = [
{
role: 'system',
content: withGlobalPrompt(
content: withCoreGlobalPrompt(
'你是代码变更分流专家,快速判断变更复杂度。返回结构化 JSON不输出额外文字。',
config.review.globalPrompt
),
@@ -248,26 +518,55 @@ ${diffPreview}
? (parsed.complexity as TriageComplexity)
: 'standard';
const relevantDomains: FindingCategory[] = Array.isArray(parsed.relevant_domains)
? (parsed.relevant_domains.filter((d: string) =>
ALL_DOMAINS.includes(d as FindingCategory)
) as FindingCategory[])
: [...ALL_DOMAINS];
const reviewSize = (['small', 'medium', 'large'] as const).includes(parsed.review_size)
? (parsed.review_size as ReviewSize)
: classifyReviewSize(context.changedFiles, policy);
const mode = (['skip', 'light', 'full'] as const).includes(parsed.mode)
? (parsed.mode as ReviewMode)
: reviewSize === 'small'
? 'light'
: 'full';
const relevantDomains: FindingCategory[] =
mode === 'skip'
? []
: Array.isArray(parsed.relevant_domains)
? (parsed.relevant_domains.filter((d: string) =>
ALL_DOMAINS.includes(d as FindingCategory)
) as FindingCategory[])
: [...ALL_DOMAINS];
// Ensure at least correctness is always included
if (!relevantDomains.includes('correctness')) {
if (mode !== 'skip' && !relevantDomains.includes('correctness')) {
relevantDomains.unshift('correctness');
}
const normalizedRiskTags = Array.isArray(parsed.risk_tags)
? parsed.risk_tags.filter((tag: unknown) => typeof tag === 'string')
: riskTags;
const result: TriageResult = {
complexity,
relevantDomains,
reviewSize,
mode,
tasks: buildTasks(
relevantDomains,
context.changedFiles,
reviewSize,
mode,
normalizedRiskTags,
policy
),
riskTags: normalizedRiskTags,
rationale: parsed.rationale || '',
};
logger.info('Triage: LLM 分流完成', {
complexity: result.complexity,
domains: result.relevantDomains.join(','),
reviewSize: result.reviewSize,
mode: result.mode,
tasks: result.tasks.length,
rationale: result.rationale,
});

View File

@@ -288,7 +288,12 @@ export class DiffExtractor {
const content = await readFile(filePath, 'utf-8');
result[file.path] = content.slice(0, this.maxFileContentChars);
} catch {}
} catch (error) {
logger.debug('读取变更文件失败,已跳过', {
path: file.path,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
@@ -299,7 +304,8 @@ export class DiffExtractor {
const lines = diffContent.split('\n');
let currentFile: DiffFile | null = null;
let lineNumber = 0;
let oldLineNumber = 0;
let newLineNumber = 0;
let inHunk = false;
let skipCurrentFile = false;
@@ -333,9 +339,10 @@ export class DiffExtractor {
}
if (line.startsWith('@@')) {
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match?.[1]) {
lineNumber = Number.parseInt(match[1], 10) - 1;
const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match?.[1] && match[2]) {
oldLineNumber = Number.parseInt(match[1], 10) - 1;
newLineNumber = Number.parseInt(match[2], 10) - 1;
inHunk = true;
}
continue;
@@ -346,11 +353,30 @@ export class DiffExtractor {
}
if (line.startsWith('+')) {
lineNumber += 1;
currentFile.changes.push({ lineNumber, content: line.slice(1), type: 'add' });
newLineNumber += 1;
currentFile.changes.push({
lineNumber: newLineNumber,
oldLineNumber,
content: line.slice(1),
type: 'add',
});
} else if (line.startsWith(' ')) {
lineNumber += 1;
currentFile.changes.push({ lineNumber, content: line.slice(1), type: 'context' });
oldLineNumber += 1;
newLineNumber += 1;
currentFile.changes.push({
lineNumber: newLineNumber,
oldLineNumber,
content: line.slice(1),
type: 'context',
});
} else if (line.startsWith('-')) {
oldLineNumber += 1;
currentFile.changes.push({
lineNumber: Math.max(1, newLineNumber + 1),
oldLineNumber,
content: line.slice(1),
type: 'delete',
});
}
}

View File

@@ -17,7 +17,7 @@ import { createCodeSearchTool } from './tools/code-search-tool';
import { createFileReadTool } from './tools/file-read-tool';
import { createFunctionReferenceSearchTool } from './tools/function-reference-search-tool';
import { ToolRegistry } from './tools/registry';
import { Finding, ReviewRun } from './types';
import { Finding, FindingCategory, ReviewRun, ReviewTask } from './types';
interface LineCommentInput {
path: string;
@@ -266,28 +266,84 @@ export class ReviewOrchestrator {
const enableReflection = config.review.enableReflection ?? false;
const maxReflectionRounds = config.review.maxReflectionRounds ?? 2;
// Select agents based on triage result
const agentsToRun = triage
? triage.relevantDomains.map((domain) => this.agentMap[domain]).filter(Boolean)
: [this.correctnessAgent, this.securityAgent, this.reliabilityAgent, this.maintainabilityAgent];
const defaultDomains: FindingCategory[] = [
'correctness',
'security',
'reliability',
'maintainability',
];
// For trivial changes, skip reflection even if globally enabled
const useReflection = triage?.complexity === 'trivial' ? false : enableReflection;
const defaultTasks: ReviewTask[] = defaultDomains.map((domain) => ({
domain,
paths: context.changedFiles.map((f) => f.path),
riskTags: [],
mode: 'full',
tokenBudget: config.review.tokenBudgetLarge,
maxIterations: 2,
allowTools: true,
allowReflection: true,
allowDebate: true,
}));
const triageTasks = triage?.tasks ?? defaultTasks;
const tasksByDomain = new Map<ReviewTask['domain'], ReviewTask>();
for (const task of triageTasks) {
const existing = tasksByDomain.get(task.domain);
if (!existing) {
tasksByDomain.set(task.domain, {
...task,
paths: [...new Set(task.paths)],
});
continue;
}
tasksByDomain.set(task.domain, {
...existing,
paths: [...new Set([...existing.paths, ...task.paths])],
riskTags: [...new Set([...existing.riskTags, ...task.riskTags])],
maxIterations: Math.max(existing.maxIterations, task.maxIterations),
tokenBudget: Math.max(existing.tokenBudget, task.tokenBudget),
allowTools: existing.allowTools || task.allowTools,
allowReflection: existing.allowReflection || task.allowReflection,
allowDebate: existing.allowDebate || task.allowDebate,
mode: existing.mode === 'full' || task.mode === 'full' ? 'full' : 'light',
});
}
const domainTasks = [...tasksByDomain.values()];
logger.info('Specialist 派发决策', {
runId: run.id,
triageComplexity: triage?.complexity ?? 'disabled',
agentCount: agentsToRun.length,
domains: triage?.relevantDomains ?? ['correctness', 'security', 'reliability', 'maintainability'],
reflection: useReflection,
reviewMode: triage?.mode ?? 'full',
taskCount: triageTasks.length,
domainCount: domainTasks.length,
domains: domainTasks.map((task) => task.domain),
});
const agentResults = await Promise.all(
agentsToRun.map((agent) =>
useReflection
? agent.reviewWithReflection(run, context, maxReflectionRounds)
: agent.review(run, context)
)
domainTasks.map(async (task) => {
const agent = this.agentMap[task.domain];
const reviewOptions = {
scopePaths: task.paths,
allowTools: task.allowTools,
maxIterations: task.maxIterations,
mode: task.mode,
maxContextTokens: Math.max(1500, Math.floor(task.tokenBudget * 0.7)),
} as const;
const useReflection =
enableReflection &&
task.allowReflection &&
task.mode !== 'light' &&
triage?.complexity !== 'trivial';
if (useReflection) {
return agent.reviewWithReflection(run, context, maxReflectionRounds, reviewOptions);
}
return agent.reviewWithOptions(run, context, reviewOptions);
})
);
await this.store.addStep({
@@ -305,7 +361,12 @@ export class ReviewOrchestrator {
const enableDebate = config.review.enableDebate ?? false;
const debateThreshold = config.review.debateThreshold ?? 'high';
if (enableDebate && allFindings.length > 0 && triage?.complexity !== 'trivial') {
if (
enableDebate &&
allFindings.length > 0 &&
triage?.mode === 'full' &&
triage?.complexity !== 'trivial'
) {
const debateStart = Date.now();
await this.store.addStep({
runId: run.id,
@@ -327,14 +388,31 @@ export class ReviewOrchestrator {
threshold: debateThreshold,
});
const allowDebateDomains = new Set(
domainTasks.filter((task) => task.allowDebate).map((task) => task.domain)
);
const debatedFindings: typeof allFindings = [];
for (const finding of debatableFindings) {
const debatedFinding = await this.debateOrchestrator.conductDebate(finding, [
this.correctnessAgent,
this.securityAgent,
this.reliabilityAgent,
this.maintainabilityAgent,
]);
if (!allowDebateDomains.has(finding.category)) {
debatedFindings.push(finding);
continue;
}
const debateAgents = [this.agentMap[finding.category]];
if (finding.category !== 'correctness' && allowDebateDomains.has('correctness')) {
debateAgents.push(this.correctnessAgent);
}
if (finding.category !== 'security' && allowDebateDomains.has('security')) {
debateAgents.push(this.securityAgent);
}
const uniqueDebateAgents = [...new Set(debateAgents)];
const debatedFinding = await this.debateOrchestrator.conductDebate(
finding,
uniqueDebateAgents
);
debatedFindings.push(debatedFinding);
}

View File

@@ -1,4 +1,4 @@
export type ReviewEngineMode = 'legacy' | 'agent' | 'codex';
export type ReviewEngineMode = 'agent' | 'codex';
export type ReviewEventType = 'pull_request' | 'commit_status';
@@ -8,6 +8,32 @@ export type FindingSeverity = 'high' | 'medium' | 'low';
export type FindingCategory = 'correctness' | 'security' | 'reliability' | 'maintainability';
export type ReviewMode = 'skip' | 'light' | 'full';
export type ReviewSize = 'small' | 'medium' | 'large';
export interface ReviewTask {
domain: FindingCategory;
paths: string[];
riskTags: string[];
mode: ReviewMode;
tokenBudget: number;
maxIterations: number;
allowTools: boolean;
allowReflection: boolean;
allowDebate: boolean;
}
export interface ReviewBudgetPolicy {
smallMaxFiles: number;
smallMaxChangedLines: number;
mediumMaxFiles: number;
mediumMaxChangedLines: number;
tokenBudgetSmall: number;
tokenBudgetMedium: number;
tokenBudgetLarge: number;
}
export interface ReviewRun {
id: string;
idempotencyKey: string;
@@ -108,8 +134,9 @@ export interface ChangedFile {
export interface DiffLine {
lineNumber: number;
oldLineNumber?: number;
content: string;
type: 'add' | 'context';
type: 'add' | 'context' | 'delete';
}
export interface DiffFile {

View File

@@ -1,404 +0,0 @@
import config from '../config';
import { llmGateway } from '../llm/gateway';
import { LLMMessage } from '../llm/types';
import { withGlobalPrompt } from '../utils/global-prompt';
import { logger } from '../utils/logger';
import { PullRequestFile, giteaService } from './gitea';
// 代码审查结果接口
export interface CodeReviewResult {
summary: string;
lineComments: LineComment[];
}
// 行评论接口
export interface LineComment {
path: string;
line: number;
comment: string;
}
// 审查上下文
interface ReviewContext {
changedFiles: PullRequestFile[];
fileContents: Record<string, string>;
diffContent: string;
}
// AI代码审查服务
export const aiReviewService = {
/**
* 对代码差异进行审查
* @param owner 仓库所有者
* @param repo 仓库名称
* @param prNumber PR编号
* @param diffContent 代码差异内容
* @param commitSha 提交SHA
* @returns 代码审查结果
*/
async reviewCode(
owner: string,
repo: string,
prNumber: number,
diffContent: string,
commitSha: string
): Promise<CodeReviewResult> {
try {
logger.info('开始PR代码审查', { owner, repo, prNumber });
// 获取完整的审查上下文
const context = await this.getReviewContext(owner, repo, prNumber, diffContent, commitSha);
// 使用上下文进行总体评价
const summary = await this.generateSummary(context);
// 使用上下文生成行级评论
const lineComments = await this.generateLineComments(context);
return {
summary,
lineComments,
};
} catch (error: any) {
logger.error('AI代码审查失败:', error);
throw new Error(`AI代码审查失败: ${error.message}`);
}
},
/**
* 对单个提交进行代码审查
* @param owner 仓库所有者
* @param repo 仓库名称
* @param commitSha 提交SHA
* @param customFiles 可选的自定义文件列表
* @returns 代码审查结果
*/
async reviewCommit(
owner: string,
repo: string,
commitSha: string,
customFiles?: PullRequestFile[]
): Promise<CodeReviewResult> {
try {
logger.info('开始提交代码审查', { owner, repo, commitSha });
// 获取提交差异
const diffContent = await giteaService.getCommitDiff(owner, repo, commitSha);
if (!diffContent) {
logger.warn('提交差异为空,无法进行代码审查');
return {
summary: '提交差异为空,无法进行代码审查',
lineComments: [],
};
}
// 获取或使用提供的文件列表
let files: PullRequestFile[] = [];
if (customFiles && customFiles.length > 0) {
files = customFiles;
logger.info(`使用自定义文件列表,包含 ${files.length} 个文件`);
} else {
files = await giteaService.getCommitFiles(owner, repo, commitSha);
logger.info(`从API获取到 ${files.length} 个变更文件`);
}
// 获取文件内容
const fileContents = await giteaService.getRelatedFiles(owner, repo, files, commitSha);
const context: ReviewContext = {
changedFiles: files,
fileContents,
diffContent,
};
// 使用上下文进行总体评价
const summary = await this.generateSummary(context);
// 使用上下文生成行级评论
const lineComments = await this.generateLineComments(context);
return {
summary,
lineComments,
};
} catch (error: any) {
logger.error('AI提交代码审查失败:', error);
throw new Error(`AI提交代码审查失败: ${error.message}`);
}
},
/**
* 获取完整的审查上下文
*/
async getReviewContext(
owner: string,
repo: string,
prNumber: number,
diffContent: string,
commitSha: string
): Promise<ReviewContext> {
try {
// 获取PR变更的文件列表
const changedFiles = await giteaService.getPullRequestFiles(owner, repo, prNumber);
logger.info(`获取到 ${changedFiles.length} 个变更文件`);
// 获取所有变更文件的完整内容
const fileContents = await giteaService.getRelatedFiles(owner, repo, changedFiles, commitSha);
logger.info(`获取到 ${Object.keys(fileContents).length} 个文件的完整内容`);
return {
changedFiles,
fileContents,
diffContent,
};
} catch (error: any) {
logger.error('获取审查上下文失败:', error);
// 如果获取上下文失败至少返回diff内容
return {
changedFiles: [],
fileContents: {},
diffContent,
};
}
},
/**
* 生成总体评价
* @param context 审查上下文
* @returns 总体评价
*/
async generateSummary(context: ReviewContext): Promise<string> {
try {
// 准备上下文信息
const fileInfo = context.changedFiles.map((file) => {
return {
path: file.filename,
status: file.status,
additions: file.additions,
deletions: file.deletions,
content: context.fileContents[file.filename] || '无法获取文件内容',
};
});
// 使用自定义prompt或默认prompt
const defaultSummaryPrompt = `作为经验丰富的代码审查专家,请对以下代码变更进行深入审查,提供一个全面详细的评价和建议。
关注点包括但不限于代码质量、潜在bug、性能问题、安全问题、最佳实践等。
请用中文回复,保持专业简洁。
==== diff变更内容 ====
${context.diffContent}
==== 变更文件的完整信息 ====
${JSON.stringify(fileInfo, null, 2)}
请根据以上信息,特别是考虑每个文件的完整内容和上下文,提供代码审查评价。如果没有发现明显问题,请简短说明代码质量良好即可。`;
const summaryPrompt = config.review.customSummaryPrompt || defaultSummaryPrompt;
// 获取总体评价
const messages: LLMMessage[] = [
{
role: 'system',
content: withGlobalPrompt(
'你是一个专业的代码审查助手擅长识别代码中的严重问题和bug。你会查看代码的完整上下文而不是为了评论而评论。如无明显问题应给予简短肯定。',
config.review.globalPrompt
),
},
{ role: 'user', content: summaryPrompt },
];
const summaryResponse = await llmGateway.chatForRole('legacy', {
messages,
temperature: 0.1,
});
const summary = summaryResponse.content || '无法生成代码审查摘要';
return summary;
} catch (error: any) {
logger.error('生成总体评价失败:', error);
return '由于技术原因,无法生成详细的代码审查评价。';
}
},
/**
* 生成行级评论
* @param context 审查上下文
* @returns 行级评论数组
*/
async generateLineComments(context: ReviewContext): Promise<LineComment[]> {
try {
// 解析差异内容,提取文件和变更行
const diffFiles = this.parseDiff(context.diffContent);
const lineComments: LineComment[] = [];
// 对每个文件的变更行进行审查
for (const file of diffFiles) {
// 只对添加的行进行评论
const addedLines = file.changes.filter((change) => change.type === 'add');
if (addedLines.length === 0) continue;
// 获取文件的完整内容作为上下文
const fileContent = context.fileContents[file.path] || '';
// 使用自定义prompt或默认prompt
const defaultFilePrompt = `分析以下代码文件的新增代码行只针对存在明显bug或严重问题的代码行提供具体评论。
大多数代码行不需要评论,除非它们包含以下问题:
- 明显的bug或逻辑错误
- 严重的安全漏洞
- 可能导致崩溃的代码
- 明显的性能瓶颈
- 数据一致性问题
如果没有发现严重问题,请返回空数组。不要为了提供评论而勉强寻找问题。
文件路径: ${file.path}
完整文件内容:
${fileContent}
变更部分上下文:
${file.changes.map((c) => `${c.lineNumber}: ${c.content} (${c.type === 'add' ? '新增' : '上下文'})`).join('\n')}
请以JSON格式返回评论格式如下:
[
{
"line": 行号,
"comment": "评论内容"
}
]
只返回JSON数组不要有其他文本。`;
const filePrompt = config.review.customLineCommentPrompt || defaultFilePrompt;
// 获取行级评论
const messages: LLMMessage[] = [
{
role: 'system',
content: withGlobalPrompt(
'你是一个谨慎的代码审查助手只对有明显bug或严重问题的代码行提供评论。大多数情况下如果代码没有严重问题你应该返回空数组。请以JSON格式返回结果。',
config.review.globalPrompt
),
},
{ role: 'user', content: filePrompt },
];
const lineResponse = await llmGateway.chatForRole('legacy', {
messages,
temperature: 0.1,
responseFormat: 'json',
});
const content = lineResponse.content;
if (!content) continue;
try {
// 解析JSON响应
const responseObject = JSON.parse(content);
const comments = Array.isArray(responseObject)
? responseObject
: responseObject.comments || [];
// 添加到结果中
for (const comment of comments) {
if (comment.line && comment.comment) {
lineComments.push({
path: file.path,
line: comment.line,
comment: comment.comment,
});
}
}
} catch (parseError: any) {
logger.error(`解析行评论JSON失败: ${parseError.message}`, content);
}
}
return lineComments;
} catch (error: any) {
logger.error('生成行级评论失败:', error);
return [];
}
},
/**
* 解析git diff内容
* @param diffContent diff内容
* @returns 解析后的文件变更信息
*/
parseDiff(diffContent: string): Array<{
path: string;
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
}> {
const files: Array<{
path: string;
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
}> = [];
const diffLines = diffContent.split('\n');
let currentFile: {
path: string;
changes: Array<{ lineNumber: number; content: string; type: 'add' | 'context' }>;
} | null = null;
let lineNumber = 0;
let inHunk = false;
for (const line of diffLines) {
// 新文件开始
if (line.startsWith('diff --git')) {
if (currentFile) {
files.push(currentFile);
}
currentFile = { path: '', changes: [] };
inHunk = false;
}
// 获取文件路径
else if (line.startsWith('+++ b/')) {
if (currentFile) {
currentFile.path = line.substring(6);
}
}
// Hunk头记录起始行号
else if (line.startsWith('@@')) {
const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (match?.[1]) {
lineNumber = Number.parseInt(match[1], 10) - 1; // 因为下面会+1
inHunk = true;
}
}
// 解析变更行
else if (inHunk && currentFile) {
if (line.startsWith('+')) {
// 添加的行
lineNumber++;
currentFile.changes.push({
lineNumber,
content: line.substring(1),
type: 'add',
});
} else if (line.startsWith(' ')) {
// 上下文行
lineNumber++;
currentFile.changes.push({
lineNumber,
content: line.substring(1),
type: 'context',
});
} else if (line.startsWith('-')) {
// 删除的行,不增加行号
// 我们不对删除的行做评论,所以这里不处理
} else {
// 其他行,比如"No newline at end of file"
// 不增加行号,不做特殊处理
}
}
}
// 添加最后一个文件
if (currentFile) {
files.push(currentFile);
}
return files;
},
};

View File

@@ -10,3 +10,15 @@ export function withGlobalPrompt(systemContent: string, globalPrompt: string | u
}
return `${systemContent}\n\n${globalPrompt}`;
}
export function withCoreGlobalPrompt(
systemContent: string,
globalPrompt: string | undefined,
maxChars = 240
): string {
if (!globalPrompt || globalPrompt.trim() === '') {
return systemContent;
}
const compact = globalPrompt.trim().slice(0, maxChars);
return `${systemContent}\n\n${compact}`;
}