mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
25
README.md
25
README.md
@@ -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 |
|
||||
|
||||
@@ -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 按任务范围派发,不再默认全量扇出。
|
||||
|
||||
#### 记忆与学习(实验性)
|
||||
|
||||
| 配置项 | 描述 | 默认值 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(多代理编排)或 codex(Codex CLI)',
|
||||
description: '代码审查模式:agent(任务化分级编排)或 codex(Codex 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 审查引擎 ──────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(保证有baseSha),headCloneUrl作为额外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 PR:head.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(保证有baseSha),headCloneUrl作为额外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 PR:head.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事件
|
||||
*/
|
||||
|
||||
132
src/db/__tests__/migration-legacy-cleanup.test.ts
Normal file
132
src/db/__tests__/migration-legacy-cleanup.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
34
src/db/migrations/002_remove_legacy_review_mode.ts
Normal file
34
src/db/migrations/002_remove_legacy_review_mode.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
52
src/review/__tests__/diff-extractor.test.ts
Normal file
52
src/review/__tests__/diff-extractor.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user