diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json
index a2a38db77..7d3f681f7 100644
--- a/docs/api-reference/sourcebot-public.openapi.json
+++ b/docs/api-reference/sourcebot-public.openapi.json
@@ -22,6 +22,10 @@
"name": "System",
"description": "System health and version endpoints."
},
+ {
+ "name": "Agents",
+ "description": "Manage customisable AI agent configurations."
+ },
{
"name": "Enterprise (EE)",
"description": "Enterprise endpoints for user management and audit logging."
@@ -965,6 +969,346 @@
"parents"
]
},
+ "PublicAgentConfigRepo": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "displayName": {
+ "type": "string",
+ "nullable": true
+ },
+ "external_id": {
+ "type": "string"
+ },
+ "external_codeHostType": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "displayName",
+ "external_id",
+ "external_codeHostType"
+ ]
+ },
+ "PublicAgentConfigConnection": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "connectionType": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "connectionType"
+ ]
+ },
+ "PublicAgentConfigSettings": {
+ "type": "object",
+ "properties": {
+ "autoReviewEnabled": {
+ "type": "boolean",
+ "description": "Whether the agent automatically reviews new PRs/MRs. Overrides the REVIEW_AGENT_AUTO_REVIEW_ENABLED env var."
+ },
+ "reviewCommand": {
+ "type": "string",
+ "description": "Comment command that triggers a manual review (without the leading /). Overrides the REVIEW_AGENT_REVIEW_COMMAND env var."
+ },
+ "model": {
+ "type": "string",
+ "description": "Display name of the language model to use for this config. Overrides the REVIEW_AGENT_MODEL env var."
+ }
+ }
+ },
+ "PublicAgentConfig": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "orgId": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "CODE_REVIEW"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "prompt": {
+ "type": "string",
+ "nullable": true,
+ "description": "Custom prompt instructions. Null uses the built-in rules only."
+ },
+ "promptMode": {
+ "type": "string",
+ "enum": [
+ "REPLACE",
+ "APPEND"
+ ],
+ "description": "Whether the custom prompt replaces or appends to the built-in rules."
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "ORG",
+ "CONNECTION",
+ "REPO"
+ ],
+ "description": "What this config is scoped to."
+ },
+ "repos": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "agentConfigId": {
+ "type": "string"
+ },
+ "repoId": {
+ "type": "integer"
+ },
+ "repo": {
+ "$ref": "#/components/schemas/PublicAgentConfigRepo"
+ }
+ },
+ "required": [
+ "agentConfigId",
+ "repoId",
+ "repo"
+ ]
+ }
+ },
+ "connections": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "agentConfigId": {
+ "type": "string"
+ },
+ "connectionId": {
+ "type": "integer"
+ },
+ "connection": {
+ "$ref": "#/components/schemas/PublicAgentConfigConnection"
+ }
+ },
+ "required": [
+ "agentConfigId",
+ "connectionId",
+ "connection"
+ ]
+ }
+ },
+ "settings": {
+ "$ref": "#/components/schemas/PublicAgentConfigSettings"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ "required": [
+ "id",
+ "orgId",
+ "name",
+ "description",
+ "type",
+ "enabled",
+ "prompt",
+ "promptMode",
+ "scope",
+ "repos",
+ "connections",
+ "settings",
+ "createdAt",
+ "updatedAt"
+ ]
+ },
+ "PublicAgentConfigList": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PublicAgentConfig"
+ }
+ },
+ "PublicCreateAgentConfigBody": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255,
+ "description": "Unique name for this agent config within the org."
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional description."
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "CODE_REVIEW"
+ ],
+ "description": "The type of agent."
+ },
+ "enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Whether this config is active."
+ },
+ "prompt": {
+ "type": "string",
+ "description": "Custom prompt instructions."
+ },
+ "promptMode": {
+ "type": "string",
+ "enum": [
+ "REPLACE",
+ "APPEND"
+ ],
+ "default": "APPEND",
+ "description": "How the custom prompt interacts with the built-in rules."
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "ORG",
+ "CONNECTION",
+ "REPO"
+ ],
+ "description": "What this config is scoped to."
+ },
+ "repoIds": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "exclusiveMinimum": true
+ },
+ "description": "Required when scope is REPO."
+ },
+ "connectionIds": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "exclusiveMinimum": true
+ },
+ "description": "Required when scope is CONNECTION."
+ },
+ "settings": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/PublicAgentConfigSettings"
+ },
+ {
+ "description": "Per-config overrides for model, auto-review, and review command."
+ }
+ ]
+ }
+ },
+ "required": [
+ "name",
+ "type",
+ "scope"
+ ]
+ },
+ "PublicUpdateAgentConfigBody": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 255
+ },
+ "description": {
+ "type": "string",
+ "nullable": true
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "CODE_REVIEW"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "prompt": {
+ "type": "string",
+ "nullable": true
+ },
+ "promptMode": {
+ "type": "string",
+ "enum": [
+ "REPLACE",
+ "APPEND"
+ ]
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "ORG",
+ "CONNECTION",
+ "REPO"
+ ]
+ },
+ "repoIds": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "exclusiveMinimum": true
+ }
+ },
+ "connectionIds": {
+ "type": "array",
+ "items": {
+ "type": "integer",
+ "minimum": 0,
+ "exclusiveMinimum": true
+ }
+ },
+ "settings": {
+ "$ref": "#/components/schemas/PublicAgentConfigSettings"
+ }
+ }
+ },
+ "PublicDeleteAgentConfigResponse": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success"
+ ]
+ },
"PublicEeUser": {
"type": "object",
"properties": {
@@ -1944,6 +2288,318 @@
}
}
},
+ "/api/agents": {
+ "get": {
+ "operationId": "listAgentConfigs",
+ "tags": [
+ "Agents"
+ ],
+ "summary": "List agent configs",
+ "description": "Returns all agent configurations for the organization.",
+ "responses": {
+ "200": {
+ "description": "List of agent configs.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicAgentConfigList"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Unexpected failure.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "operationId": "createAgentConfig",
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Create an agent config",
+ "description": "Creates a new agent configuration scoped to the organization, a set of connections, or specific repositories.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicCreateAgentConfigBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Agent config created.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicAgentConfig"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request body.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "An agent config with that name already exists.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Unexpected failure.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/agents/{agentId}": {
+ "get": {
+ "operationId": "getAgentConfig",
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Get an agent config",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string"
+ },
+ "required": true,
+ "name": "agentId",
+ "in": "path"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Agent config.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicAgentConfig"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Agent config not found.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Unexpected failure.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "operationId": "updateAgentConfig",
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Update an agent config",
+ "description": "Partially updates an agent configuration. Only provided fields are changed.",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string"
+ },
+ "required": true,
+ "name": "agentId",
+ "in": "path"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicUpdateAgentConfigBody"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated agent config.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicAgentConfig"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request body.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Agent config not found.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Unexpected failure.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "operationId": "deleteAgentConfig",
+ "tags": [
+ "Agents"
+ ],
+ "summary": "Delete an agent config",
+ "parameters": [
+ {
+ "schema": {
+ "type": "string"
+ },
+ "required": true,
+ "name": "agentId",
+ "in": "path"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Agent config deleted.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicDeleteAgentConfigResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Agent config not found.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Unexpected failure.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PublicApiServiceError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/ee/user": {
"get": {
"operationId": "getUser",
diff --git a/docs/docs/features/agents/review-agent.mdx b/docs/docs/features/agents/review-agent.mdx
index b4dc9c2f6..bb35f8076 100644
--- a/docs/docs/features/agents/review-agent.mdx
+++ b/docs/docs/features/agents/review-agent.mdx
@@ -137,11 +137,36 @@ By default, the agent does not review PRs and MRs automatically. To enable autom
You can also trigger a review manually by commenting `/review` on any PR or MR. To use a different command, set `REVIEW_AGENT_REVIEW_COMMAND` to your preferred value (without the leading slash).
+# Agent configs
+
+Agent configs let you customise how the review agent behaves per repository, connection, or your whole org. You can override the model, review command, auto-review behaviour, custom prompt, and context files — all without changing environment variables.
+
+Configs are managed on the **Agents** page in the Sourcebot UI. Each config has a **scope**:
+
+- **Repo** — applies to specific repositories (highest priority)
+- **Connection** — applies to all repos in a specific connection
+- **Org** — applies to all repositories in your org (lowest priority, catch-all)
+
+When a PR or MR arrives, Sourcebot selects the most specific matching config. If no config exists, the agent falls back to global environment variable defaults.
+
+## Custom prompt
+
+Each config can include a custom prompt. Two modes are available:
+
+- **Append** (default) — your instructions are added after the built-in review rules.
+- **Replace** — your instructions entirely replace the built-in rules. Use this when you want full control over what the agent looks for.
+
+## Context files
+
+You can configure one or more repository files to be fetched at review time and injected as additional context for the model. This is useful for encoding project-specific conventions that the model should be aware of when reviewing diffs — for example, preferred error handling patterns, style rules, or areas of the codebase that need extra scrutiny.
+
+Set **Context files** in the agent config form to a comma or space separated list of paths relative to the repository root (e.g. `AGENTS.md .sourcebot/review.md`). Files that do not exist in the repository are silently ignored. The files are fetched once per PR from the head commit and included in the context for every diff hunk.
+
# Environment variable reference
| Variable | Default | Description |
|---|---|---|
-| `REVIEW_AGENT_AUTO_REVIEW_ENABLED` | `false` | Automatically review new and updated PRs/MRs |
-| `REVIEW_AGENT_REVIEW_COMMAND` | `review` | Comment command that triggers a manual review (without the `/`) |
-| `REVIEW_AGENT_MODEL` | first configured model | `displayName` of the language model to use for reviews |
-| `REVIEW_AGENT_LOGGING_ENABLED` | unset | Write prompt and response logs to disk for debugging |
+| `REVIEW_AGENT_AUTO_REVIEW_ENABLED` | `false` | Automatically review new and updated PRs/MRs. Can be overridden per agent config. |
+| `REVIEW_AGENT_REVIEW_COMMAND` | `review` | Comment command that triggers a manual review (without the `/`). Can be overridden per agent config. |
+| `REVIEW_AGENT_MODEL` | first configured model | `displayName` of the language model to use for reviews. Can be overridden per agent config. |
+| `REVIEW_AGENT_LOGGING_ENABLED` | unset | Write prompt and response logs to disk for debugging. |
diff --git a/packages/db/prisma/migrations/20260421203635_add_agent_config/migration.sql b/packages/db/prisma/migrations/20260421203635_add_agent_config/migration.sql
new file mode 100644
index 000000000..03906f144
--- /dev/null
+++ b/packages/db/prisma/migrations/20260421203635_add_agent_config/migration.sql
@@ -0,0 +1,63 @@
+-- CreateEnum
+CREATE TYPE "AgentType" AS ENUM ('CODE_REVIEW');
+
+-- CreateEnum
+CREATE TYPE "AgentScope" AS ENUM ('ORG', 'CONNECTION', 'REPO');
+
+-- CreateEnum
+CREATE TYPE "PromptMode" AS ENUM ('REPLACE', 'APPEND');
+
+-- CreateTable
+CREATE TABLE "AgentConfig" (
+ "id" TEXT NOT NULL,
+ "orgId" INTEGER NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "type" "AgentType" NOT NULL,
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
+ "prompt" TEXT,
+ "promptMode" "PromptMode" NOT NULL DEFAULT 'APPEND',
+ "scope" "AgentScope" NOT NULL,
+ "settings" JSONB NOT NULL DEFAULT '{}',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "AgentConfig_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AgentConfigToRepo" (
+ "agentConfigId" TEXT NOT NULL,
+ "repoId" INTEGER NOT NULL,
+
+ CONSTRAINT "AgentConfigToRepo_pkey" PRIMARY KEY ("agentConfigId","repoId")
+);
+
+-- CreateTable
+CREATE TABLE "AgentConfigToConnection" (
+ "agentConfigId" TEXT NOT NULL,
+ "connectionId" INTEGER NOT NULL,
+
+ CONSTRAINT "AgentConfigToConnection_pkey" PRIMARY KEY ("agentConfigId","connectionId")
+);
+
+-- CreateIndex
+CREATE INDEX "AgentConfig_orgId_type_enabled_idx" ON "AgentConfig"("orgId", "type", "enabled");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AgentConfig_orgId_name_key" ON "AgentConfig"("orgId", "name");
+
+-- AddForeignKey
+ALTER TABLE "AgentConfig" ADD CONSTRAINT "AgentConfig_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AgentConfigToRepo" ADD CONSTRAINT "AgentConfigToRepo_agentConfigId_fkey" FOREIGN KEY ("agentConfigId") REFERENCES "AgentConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AgentConfigToRepo" ADD CONSTRAINT "AgentConfigToRepo_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AgentConfigToConnection" ADD CONSTRAINT "AgentConfigToConnection_agentConfigId_fkey" FOREIGN KEY ("agentConfigId") REFERENCES "AgentConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AgentConfigToConnection" ADD CONSTRAINT "AgentConfigToConnection_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "Connection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 3a96eea6e..0f27f19ba 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -39,6 +39,28 @@ enum CodeHostType {
azuredevops
}
+enum AgentType {
+ CODE_REVIEW
+}
+
+/// Determines which repositories an AgentConfig applies to.
+enum AgentScope {
+ /// Applies to all repositories in the organization.
+ ORG
+ /// Applies to all repositories under specific connections.
+ CONNECTION
+ /// Applies to specific repositories only.
+ REPO
+}
+
+/// Controls how a custom prompt is combined with the built-in system rules.
+enum PromptMode {
+ /// Custom prompt replaces the built-in rules entirely.
+ REPLACE
+ /// Custom prompt is appended after the built-in rules.
+ APPEND
+}
+
model Repo {
id Int @id @default(autoincrement())
name String /// Full repo name, including the vcs hostname (ex. github.com/sourcebot-dev/sourcebot)
@@ -75,6 +97,8 @@ model Repo {
searchContexts SearchContext[]
+ agentConfigMappings AgentConfigToRepo[]
+
@@unique([external_id, external_codeHostUrl, orgId])
@@index([orgId])
@@index([indexedAt])
@@ -170,6 +194,8 @@ model Connection {
/// When the connection was last synced successfully.
syncedAt DateTime?
+ agentConfigMappings AgentConfigToConnection[]
+
/// Controls whether repository permissions are enforced for this connection.
/// When `PERMISSION_SYNC_ENABLED` is false, this setting has no effect.
/// Defaults to the value of `PERMISSION_SYNC_ENABLED`.
@@ -291,6 +317,8 @@ model Org {
searchContexts SearchContext[]
chats Chat[]
+
+ agentConfigs AgentConfig[]
}
enum OrgRole {
@@ -569,3 +597,64 @@ model OAuthToken {
createdAt DateTime @default(now())
lastUsedAt DateTime?
}
+
+/// Configures a customisable AI agent (e.g. code review) scoped to an org, connection, or specific repos.
+model AgentConfig {
+ id String @id @default(cuid())
+
+ org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
+ orgId Int
+
+ name String
+ description String?
+
+ type AgentType
+ enabled Boolean @default(true)
+
+ /// Custom prompt instructions. Null means the agent uses its built-in rules only.
+ prompt String?
+
+ /// Controls whether the custom prompt replaces or appends to the built-in rules.
+ promptMode PromptMode @default(APPEND)
+
+ /// Determines what this config is scoped to: the whole org, specific connections, or specific repos.
+ scope AgentScope
+
+ /// Repo-level scope mappings (populated when scope = REPO).
+ repos AgentConfigToRepo[]
+
+ /// Connection-level scope mappings (populated when scope = CONNECTION).
+ connections AgentConfigToConnection[]
+
+ /// Extensible per-agent settings stored as JSON.
+ /// Shape: { autoReviewEnabled?: boolean, reviewCommand?: string, model?: string, contextFiles?: string }
+ settings Json @default("{}")
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([orgId, name])
+ @@index([orgId, type, enabled])
+}
+
+/// Maps an AgentConfig to specific repositories (used when scope = REPO).
+model AgentConfigToRepo {
+ agentConfig AgentConfig @relation(fields: [agentConfigId], references: [id], onDelete: Cascade)
+ agentConfigId String
+
+ repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
+ repoId Int
+
+ @@id([agentConfigId, repoId])
+}
+
+/// Maps an AgentConfig to specific connections (used when scope = CONNECTION).
+model AgentConfigToConnection {
+ agentConfig AgentConfig @relation(fields: [agentConfigId], references: [id], onDelete: Cascade)
+ agentConfigId String
+
+ connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
+ connectionId Int
+
+ @@id([agentConfigId, connectionId])
+}
diff --git a/packages/web/src/app/(app)/agents/configs/[agentId]/page.tsx b/packages/web/src/app/(app)/agents/configs/[agentId]/page.tsx
new file mode 100644
index 000000000..723179684
--- /dev/null
+++ b/packages/web/src/app/(app)/agents/configs/[agentId]/page.tsx
@@ -0,0 +1,63 @@
+import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { NavigationMenu } from "@/app/(app)/components/navigationMenu";
+import { AgentConfigForm } from "../agentConfigForm";
+import { notFound } from "next/navigation";
+import { OrgRole } from "@sourcebot/db";
+
+type Props = {
+ params: Promise<{ agentId: string }>;
+};
+
+export default authenticatedPage(async ({ prisma, org }, { params }: Props) => {
+ const { agentId } = await params;
+
+ const [config, connections, repos] = await Promise.all([
+ prisma.agentConfig.findFirst({
+ where: { id: agentId, orgId: org.id },
+ include: {
+ repos: { select: { repoId: true } },
+ connections: { select: { connectionId: true } },
+ },
+ }),
+ prisma.connection.findMany({
+ where: { orgId: org.id },
+ select: { id: true, name: true, connectionType: true },
+ orderBy: { name: "asc" },
+ }),
+ prisma.repo.findMany({
+ where: { orgId: org.id },
+ select: { id: true, displayName: true, external_id: true, external_codeHostType: true },
+ orderBy: { displayName: "asc" },
+ }),
+ ]);
+
+ if (!config) {
+ notFound();
+ }
+
+ return (
+
+
+
+
Edit agent config
+
r.repoId),
+ connectionIds: config.connections.map((c) => c.connectionId),
+ settings: config.settings as Record,
+ }}
+ connections={connections}
+ repos={repos}
+ />
+
+
+ );
+}, { minRole: OrgRole.OWNER, redirectTo: '/agents' });
diff --git a/packages/web/src/app/(app)/agents/configs/agentConfigForm.tsx b/packages/web/src/app/(app)/agents/configs/agentConfigForm.tsx
new file mode 100644
index 000000000..28bdb4008
--- /dev/null
+++ b/packages/web/src/app/(app)/agents/configs/agentConfigForm.tsx
@@ -0,0 +1,483 @@
+'use client';
+
+import { useState, useTransition, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { useToast } from "@/components/hooks/use-toast";
+import { Trash2 } from "lucide-react";
+import { AgentScope, AgentType, PromptMode } from "@sourcebot/db";
+
+type Connection = { id: number; name: string; connectionType: string };
+type Repo = { id: number; displayName: string | null; external_id: string; external_codeHostType: string };
+type ModelInfo = { provider: string; model: string; displayName: string };
+
+type InitialValues = {
+ id: string;
+ name: string;
+ description: string;
+ type: AgentType;
+ enabled: boolean;
+ prompt: string;
+ promptMode: PromptMode;
+ scope: AgentScope;
+ repoIds: number[];
+ connectionIds: number[];
+ settings: Record;
+};
+
+type Props = {
+ initialValues?: InitialValues;
+ connections: Connection[];
+ repos: Repo[];
+};
+
+const DEFAULT_TYPE: AgentType = AgentType.CODE_REVIEW;
+const DEFAULT_SCOPE: AgentScope = AgentScope.ORG;
+const DEFAULT_PROMPT_MODE: PromptMode = PromptMode.APPEND;
+
+export function AgentConfigForm({ initialValues, connections, repos }: Props) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isPending, startTransition] = useTransition();
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ const isEditing = !!initialValues?.id;
+
+ const [name, setName] = useState(initialValues?.name ?? "");
+ const [description, setDescription] = useState(initialValues?.description ?? "");
+ const [type] = useState(initialValues?.type ?? DEFAULT_TYPE);
+ const [enabled, setEnabled] = useState(initialValues?.enabled ?? true);
+ const [prompt, setPrompt] = useState(initialValues?.prompt ?? "");
+ const [promptMode, setPromptMode] = useState(initialValues?.promptMode ?? DEFAULT_PROMPT_MODE);
+ const [scope, setScope] = useState(initialValues?.scope ?? DEFAULT_SCOPE);
+ const [selectedRepoIds, setSelectedRepoIds] = useState(initialValues?.repoIds ?? []);
+ const [selectedConnectionIds, setSelectedConnectionIds] = useState(initialValues?.connectionIds ?? []);
+ const [repoFilter, setRepoFilter] = useState("");
+ const [connectionFilter, setConnectionFilter] = useState("");
+ const [model, setModel] = useState((initialValues?.settings?.model as string) ?? "");
+ const [reviewCommand, setReviewCommand] = useState((initialValues?.settings?.reviewCommand as string) ?? "");
+ const [contextFiles, setContextFiles] = useState((initialValues?.settings?.contextFiles as string) ?? "");
+ const [autoReviewEnabled, setAutoReviewEnabled] = useState(
+ initialValues?.settings?.autoReviewEnabled as boolean | undefined
+ );
+ const [configuredModels, setConfiguredModels] = useState([]);
+ const [isLoadingModels, setIsLoadingModels] = useState(true);
+
+ useEffect(() => {
+ fetch('/api/models')
+ .then((res) => res.ok ? res.json() : [])
+ .then((data: ModelInfo[]) => setConfiguredModels(data))
+ .catch(() => setConfiguredModels([]))
+ .finally(() => setIsLoadingModels(false));
+ }, []);
+
+ const buildPayload = () => ({
+ name,
+ description: description || undefined,
+ type,
+ enabled,
+ prompt: prompt || undefined,
+ promptMode,
+ scope,
+ repoIds: scope === AgentScope.REPO ? selectedRepoIds : undefined,
+ connectionIds: scope === AgentScope.CONNECTION ? selectedConnectionIds : undefined,
+ settings: {
+ ...(autoReviewEnabled !== undefined && { autoReviewEnabled }),
+ ...(reviewCommand && { reviewCommand }),
+ ...(model && { model }),
+ ...(contextFiles && { contextFiles }),
+ },
+ });
+
+ const handleSubmit = () => {
+ startTransition(async () => {
+ const payload = buildPayload();
+ const url = isEditing ? `/api/agents/${initialValues!.id}` : `/api/agents`;
+ const method = isEditing ? "PATCH" : "POST";
+
+ const res = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ toast({
+ title: "Error",
+ description: err.message ?? "Failed to save agent config",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ toast({
+ title: isEditing ? "Config updated" : "Config created",
+ description: `Agent config '${name}' was ${isEditing ? "updated" : "created"} successfully.`,
+ });
+ router.push("/agents");
+ });
+ };
+
+ const handleDelete = async () => {
+ if (!initialValues?.id) {
+ return;
+ }
+
+ setIsDeleting(true);
+ const res = await fetch(`/api/agents/${initialValues.id}`, { method: "DELETE" });
+ setIsDeleting(false);
+
+ if (!res.ok) {
+ toast({ title: "Error", description: "Failed to delete agent config", variant: "destructive" });
+ return;
+ }
+
+ toast({ title: "Config deleted", description: `Agent config '${name}' was deleted.` });
+ router.push("/agents");
+ };
+
+ const toggleRepoId = (id: number) => {
+ setSelectedRepoIds((prev) =>
+ prev.includes(id) ? prev.filter((r) => r !== id) : [...prev, id]
+ );
+ };
+
+ const toggleConnectionId = (id: number) => {
+ setSelectedConnectionIds((prev) =>
+ prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
+ );
+ };
+
+ return (
+
+ {/* Basic info */}
+
+
+ {/* Scope */}
+
+ Scope
+
+ Determines which repositories this config applies to. More specific scopes take priority: Repo > Connection > Org.
+
+
+
+ Applies to
+ setScope(v as AgentScope)}>
+
+
+
+
+ All repositories (org-wide)
+ Specific connections
+ Specific repositories
+
+
+
+
+ {scope === AgentScope.REPO && (() => {
+ const lc = repoFilter.toLowerCase();
+ const visible = repos.filter((r) =>
+ (r.displayName ?? r.external_id).toLowerCase().includes(lc)
+ );
+ const hiddenSelected = selectedRepoIds.filter(
+ (id) => !visible.some((r) => r.id === id)
+ ).length;
+ return (
+
+
+ Repositories
+ {selectedRepoIds.length > 0 && (
+
+ {selectedRepoIds.length} selected
+ {hiddenSelected > 0 && ` (${hiddenSelected} not shown)`}
+
+ )}
+
+ {repos.length > 0 && (
+
setRepoFilter(e.target.value)}
+ />
+ )}
+
+ {repos.length === 0 ? (
+
No repositories found.
+ ) : visible.length === 0 ? (
+
No repositories match “{repoFilter}”.
+ ) : (
+ visible.map((repo) => (
+
+ toggleRepoId(repo.id)}
+ />
+ {repo.displayName ?? repo.external_id}
+ {repo.external_codeHostType}
+
+ ))
+ )}
+
+
+ );
+ })()}
+
+ {scope === AgentScope.CONNECTION && (() => {
+ const lc = connectionFilter.toLowerCase();
+ const visible = connections.filter((c) =>
+ c.name.toLowerCase().includes(lc)
+ );
+ const hiddenSelected = selectedConnectionIds.filter(
+ (id) => !visible.some((c) => c.id === id)
+ ).length;
+ return (
+
+
+ Connections
+ {selectedConnectionIds.length > 0 && (
+
+ {selectedConnectionIds.length} selected
+ {hiddenSelected > 0 && ` (${hiddenSelected} not shown)`}
+
+ )}
+
+ {connections.length > 0 && (
+
setConnectionFilter(e.target.value)}
+ />
+ )}
+
+ {connections.length === 0 ? (
+
No connections found.
+ ) : visible.length === 0 ? (
+
No connections match “{connectionFilter}”.
+ ) : (
+ visible.map((conn) => (
+
+ toggleConnectionId(conn.id)}
+ />
+ {conn.name}
+ {conn.connectionType}
+
+ ))
+ )}
+
+
+ );
+ })()}
+
+
+ {/* Prompt */}
+
+ Custom prompt
+
+ Add custom instructions for the agent. Leave blank to use the built-in defaults only.
+
+
+
+
+
+
Prompt mode
+
setPromptMode(v as PromptMode)}>
+
+
+
+
+ Append (recommended)
+ Replace built-in rules
+
+
+
+ Append adds your instructions after the built-in rules.{" "}
+ Replace discards all built-in rules and uses only your instructions.
+
+
+
+
+ {/* Settings overrides */}
+
+ Settings overrides
+
+ Override global environment variable defaults for this config. Leave blank to inherit the global setting.
+
+
+
+
Model
+
setModel(v === "__inherit__" ? "" : v)}
+ disabled={isLoadingModels}
+ >
+
+
+
+
+ Inherit global setting
+ {configuredModels.map((m) => (
+
+ {m.displayName}
+ {m.provider}
+
+ ))}
+ {!isLoadingModels && configuredModels.length === 0 && (
+
+ No models configured
+
+ )}
+
+
+
+ Overrides the REVIEW_AGENT_MODEL env var for this config.
+
+
+
+
+
Review command
+
setReviewCommand(e.target.value)}
+ placeholder="e.g. review (inherits REVIEW_AGENT_REVIEW_COMMAND)"
+ />
+
Comment trigger without the leading /
+
+
+
+
Context files
+
setContextFiles(e.target.value)}
+ placeholder="e.g. AGENTS.md .sourcebot/review.md"
+ />
+
+ Comma- or space-separated paths to files in the repository that provide review guidance (e.g. coding conventions).
+ Fetched once per PR and injected as context. Missing files are silently ignored.
+
+
+
+
+
+
+ {/* Actions */}
+
+
+ {isEditing && (
+
+
+ {isDeleting ? "Deleting…" : "Delete config"}
+
+ )}
+
+
+ router.push("/agents")}
+ disabled={isPending || isDeleting}
+ >
+ Cancel
+
+
+ {isPending ? "Saving…" : isEditing ? "Save changes" : "Create config"}
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/(app)/agents/configs/new/page.tsx b/packages/web/src/app/(app)/agents/configs/new/page.tsx
new file mode 100644
index 000000000..c4278449a
--- /dev/null
+++ b/packages/web/src/app/(app)/agents/configs/new/page.tsx
@@ -0,0 +1,28 @@
+import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { NavigationMenu } from "@/app/(app)/components/navigationMenu";
+import { AgentConfigForm } from "../agentConfigForm";
+import { OrgRole } from "@sourcebot/db";
+
+export default authenticatedPage(async ({ prisma, org }) => {
+ const connections = await prisma.connection.findMany({
+ where: { orgId: org.id },
+ select: { id: true, name: true, connectionType: true },
+ orderBy: { name: "asc" },
+ });
+
+ const repos = await prisma.repo.findMany({
+ where: { orgId: org.id },
+ select: { id: true, displayName: true, external_id: true, external_codeHostType: true },
+ orderBy: { displayName: "asc" },
+ });
+
+ return (
+
+ );
+}, { minRole: OrgRole.OWNER, redirectTo: '/agents' });
diff --git a/packages/web/src/app/(app)/agents/page.tsx b/packages/web/src/app/(app)/agents/page.tsx
index 1c6d4b2e8..5db11c180 100644
--- a/packages/web/src/app/(app)/agents/page.tsx
+++ b/packages/web/src/app/(app)/agents/page.tsx
@@ -2,65 +2,186 @@ import Link from "next/link";
import { NavigationMenu } from "../components/navigationMenu";
import { FaCogs } from "react-icons/fa";
import { env } from "@sourcebot/shared";
+import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Plus, Settings2 } from "lucide-react";
-const agents = [
- {
- id: "github-review-agent",
- name: "GitHub Review Agent",
- description: "An AI code review agent that reviews your GitHub PRs. Uses the code indexed on Sourcebot to provide codebase-wide context.",
- requiredEnvVars: ["GITHUB_REVIEW_AGENT_APP_ID", "GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET", "GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH"],
- configureUrl: "https://docs.sourcebot.dev/docs/features/agents/review-agent"
- },
- {
- id: "gitlab-review-agent",
- name: "GitLab Review Agent",
- description: "An AI code review agent that reviews your GitLab MRs. Uses the code indexed on Sourcebot to provide codebase-wide context.",
- requiredEnvVars: ["GITLAB_REVIEW_AGENT_WEBHOOK_SECRET", "GITLAB_REVIEW_AGENT_TOKEN"],
- configureUrl: "https://docs.sourcebot.dev/docs/features/agents/review-agent"
- },
+const integrations = [
+ {
+ id: "github-review-agent",
+ name: "GitHub Review Agent",
+ description: "An AI code review agent that reviews your GitHub PRs. Uses the code indexed on Sourcebot to provide codebase-wide context.",
+ requiredEnvVars: ["GITHUB_REVIEW_AGENT_APP_ID", "GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET", "GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH"],
+ configureUrl: "https://docs.sourcebot.dev/docs/features/agents/review-agent",
+ },
+ {
+ id: "gitlab-review-agent",
+ name: "GitLab Review Agent",
+ description: "An AI code review agent that reviews your GitLab MRs. Uses the code indexed on Sourcebot to provide codebase-wide context.",
+ requiredEnvVars: ["GITLAB_REVIEW_AGENT_WEBHOOK_SECRET", "GITLAB_REVIEW_AGENT_TOKEN"],
+ configureUrl: "https://docs.sourcebot.dev/docs/features/agents/review-agent",
+ },
];
-export default async function AgentsPage() {
- return (
-
-
-
-
- {agents.map((agent) => (
-
- {/* Name and description */}
-
-
- {agent.name}
-
-
- {agent.description}
-
-
- {/* Actions */}
-
- {agent.requiredEnvVars.every(envVar => envVar in env && env[envVar as keyof typeof env] !== undefined) ? (
-
- Agent is configured and accepting requests on /api/webhook
-
- ) : (
-
-
Configure
-
- )}
-
+const scopeLabels: Record
= {
+ ORG: "Org-wide",
+ CONNECTION: "Connection",
+ REPO: "Repo",
+};
+
+const typeLabels: Record = {
+ CODE_REVIEW: "Code Review",
+};
+
+export default authenticatedPage(async ({ prisma, org }) => {
+ const agentConfigs = await prisma.agentConfig.findMany({
+ where: { orgId: org.id },
+ include: {
+ repos: {
+ include: {
+ repo: { select: { id: true, displayName: true } },
+ },
+ },
+ connections: {
+ include: {
+ connection: { select: { id: true, name: true } },
+ },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ });
+
+ return (
+
+
+
+
+ {/* Integration status cards */}
+
+ Integrations
+
+ {integrations.map((agent) => {
+ const isConfigured = agent.requiredEnvVars.every(
+ (envVar) => envVar in env && env[envVar as keyof typeof env] !== undefined
+ );
+ return (
+
+
+
+ {agent.name}
+
+
+ {agent.description}
+
+
+
+ {isConfigured ? (
+
+ Configured — accepting requests on /api/webhook
+
+ ) : (
+
+
Configure
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {/* Agent configurations */}
+
+
+
+
Agent Configurations
+
+ Customise agent behaviour per repository, connection, or the whole org.
+
+
+
+
+
+ New config
+
+
+
+
+ {agentConfigs.length === 0 ? (
+
+
+
+ No agent configurations yet. Create one to customise the prompt, scope, and model for your agents.
+
+
+
+
+ Create first config
+
+
+
+ ) : (
+
+ {agentConfigs.map((config) => {
+ const scopeDetail =
+ config.scope === "REPO"
+ ? config.repos.map((r) => r.repo.displayName ?? r.repo.id).join(", ")
+ : config.scope === "CONNECTION"
+ ? config.connections.map((c) => c.connection.name).join(", ")
+ : null;
+
+ return (
+
+
+
+
+
+ {config.name}
+
+
+ {config.enabled ? "Enabled" : "Disabled"}
+
+
+ {typeLabels[config.type] ?? config.type}
+
+
+ {scopeLabels[config.scope] ?? config.scope}
+ {scopeDetail ? `: ${scopeDetail}` : ""}
+
+
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
+
+
+
+ Edit
+
+
+
+ );
+ })}
+
+ )}
+
- ))}
-
-
- );
-}
\ No newline at end of file
+ );
+});
diff --git a/packages/web/src/app/api/(server)/agents/[agentId]/route.test.ts b/packages/web/src/app/api/(server)/agents/[agentId]/route.test.ts
new file mode 100644
index 000000000..36e073bc3
--- /dev/null
+++ b/packages/web/src/app/api/(server)/agents/[agentId]/route.test.ts
@@ -0,0 +1,232 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+import { MOCK_ORG, MOCK_USER_WITH_ACCOUNTS, prisma } from '@/__mocks__/prisma';
+import { AgentConfig, AgentScope, AgentType, OrgRole, PromptMode } from '@sourcebot/db';
+import { StatusCodes } from 'http-status-codes';
+
+vi.mock('@/prisma', async () => {
+ const actual = await vi.importActual
('@/__mocks__/prisma');
+ return { ...actual };
+});
+
+vi.mock('server-only', () => ({ default: vi.fn() }));
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+ env: {},
+}));
+
+vi.mock('@/lib/posthog', () => ({ captureEvent: vi.fn() }));
+
+vi.mock('@/middleware/withAuth', () => ({
+ withAuth: vi.fn(async (fn: Function) =>
+ fn({ org: MOCK_ORG, user: MOCK_USER_WITH_ACCOUNTS, role: OrgRole.OWNER, prisma })
+ ),
+}));
+
+// app.ts imports heavy node deps — provide a real Zod schema so updateAgentConfigBodySchema.safeParse works.
+vi.mock('@/features/agents/review-agent/app', async () => {
+ const { z } = await import('zod');
+ return {
+ agentConfigSettingsSchema: z.object({
+ autoReviewEnabled: z.boolean().optional(),
+ reviewCommand: z.string().optional(),
+ model: z.string().optional(),
+ contextFiles: z.string().optional(),
+ }),
+ };
+});
+
+import { GET, PATCH, DELETE } from './route';
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+function makeUrl(agentId: string): string {
+ return `http://localhost/api/agents/${agentId}`;
+}
+
+function makePatchRequest(agentId: string, body: unknown): NextRequest {
+ return new NextRequest(makeUrl(agentId), {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+function makeGetRequest(agentId: string): NextRequest {
+ return new NextRequest(makeUrl(agentId), { method: 'GET' });
+}
+
+function makeDeleteRequest(agentId: string): NextRequest {
+ return new NextRequest(makeUrl(agentId), { method: 'DELETE' });
+}
+
+function makeDbConfig(overrides: Partial = {}): AgentConfig & { repos: []; connections: [] } {
+ return {
+ id: 'cfg-abc',
+ orgId: MOCK_ORG.id,
+ name: 'my-config',
+ description: null,
+ type: AgentType.CODE_REVIEW,
+ enabled: true,
+ prompt: null,
+ promptMode: PromptMode.APPEND,
+ scope: AgentScope.ORG,
+ settings: {},
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ repos: [],
+ connections: [],
+ ...overrides,
+ };
+}
+
+// ── GET /api/agents/[agentId] ─────────────────────────────────────────────────
+
+describe('GET /api/agents/[agentId]', () => {
+ test('returns 404 when config does not exist', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ const res = await GET(makeGetRequest('cfg-missing'), { params: Promise.resolve({ agentId: 'cfg-missing' }) });
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+ });
+
+ test('returns 200 with the config when found', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(makeDbConfig() as any);
+
+ const res = await GET(makeGetRequest('cfg-abc'), { params: Promise.resolve({ agentId: 'cfg-abc' }) });
+
+ expect(res.status).toBe(StatusCodes.OK);
+ const body = await res.json();
+ expect(body.id).toBe('cfg-abc');
+ });
+});
+
+// ── PATCH /api/agents/[agentId] ───────────────────────────────────────────────
+
+describe('PATCH /api/agents/[agentId]', () => {
+ const AGENT_ID = 'cfg-abc';
+ const params = { params: Promise.resolve({ agentId: AGENT_ID }) };
+
+ describe('not found', () => {
+ test('returns 404 when the config does not exist', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ const res = await PATCH(makePatchRequest(AGENT_ID, { name: 'new-name' }), params);
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+ });
+ });
+
+ describe('name collision', () => {
+ beforeEach(() => {
+ prisma.agentConfig.findFirst.mockResolvedValue(makeDbConfig() as any);
+ });
+
+ test('returns 409 when renaming to a name used by another config', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(makeDbConfig({ id: 'cfg-other', name: 'taken-name' }) as any);
+
+ const res = await PATCH(makePatchRequest(AGENT_ID, { name: 'taken-name' }), params);
+
+ expect(res.status).toBe(StatusCodes.CONFLICT);
+ const body = await res.json();
+ expect(body.message).toContain('taken-name');
+ });
+
+ test('does not call update when a name collision is detected', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(makeDbConfig({ id: 'cfg-other' }) as any);
+
+ await PATCH(makePatchRequest(AGENT_ID, { name: 'taken-name' }), params);
+
+ expect(prisma.agentConfig.update).not.toHaveBeenCalled();
+ });
+
+ test('returns 200 when renaming to the same name the config already has', async () => {
+ // No collision — the config has name 'my-config' and we're "renaming" to the same value.
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+ prisma.agentConfig.update.mockResolvedValue(makeDbConfig() as any);
+
+ const res = await PATCH(makePatchRequest(AGENT_ID, { name: 'my-config' }), params);
+
+ expect(res.status).toBe(StatusCodes.OK);
+ });
+
+ test('does not query for collision when name is not in the patch body', async () => {
+ prisma.agentConfig.update.mockResolvedValue(makeDbConfig() as any);
+
+ await PATCH(makePatchRequest(AGENT_ID, { enabled: false }), params);
+
+ expect(prisma.agentConfig.findUnique).not.toHaveBeenCalled();
+ });
+
+ test('returns 200 when renaming to a free name', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+ prisma.agentConfig.update.mockResolvedValue(makeDbConfig({ name: 'free-name' }) as any);
+
+ const res = await PATCH(makePatchRequest(AGENT_ID, { name: 'free-name' }), params);
+
+ expect(res.status).toBe(StatusCodes.OK);
+ });
+ });
+
+ describe('successful update', () => {
+ beforeEach(() => {
+ prisma.agentConfig.findFirst.mockResolvedValue(makeDbConfig() as any);
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+ });
+
+ test('returns 200 on a valid patch', async () => {
+ prisma.agentConfig.update.mockResolvedValue(makeDbConfig({ enabled: false }) as any);
+
+ const res = await PATCH(makePatchRequest(AGENT_ID, { enabled: false }), params);
+
+ expect(res.status).toBe(StatusCodes.OK);
+ });
+
+ test('calls prisma.agentConfig.update with the patched fields', async () => {
+ prisma.agentConfig.update.mockResolvedValue(makeDbConfig() as any);
+
+ await PATCH(makePatchRequest(AGENT_ID, { enabled: false, prompt: 'Be strict.' }), params);
+
+ expect(prisma.agentConfig.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ enabled: false,
+ prompt: 'Be strict.',
+ }),
+ }),
+ );
+ });
+ });
+});
+
+// ── DELETE /api/agents/[agentId] ──────────────────────────────────────────────
+
+describe('DELETE /api/agents/[agentId]', () => {
+ const AGENT_ID = 'cfg-abc';
+ const params = { params: Promise.resolve({ agentId: AGENT_ID }) };
+
+ test('returns 404 when the config does not exist', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ const res = await DELETE(makeDeleteRequest(AGENT_ID), params);
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+ });
+
+ test('returns 200 and calls delete when the config exists', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(makeDbConfig() as any);
+ prisma.agentConfig.delete.mockResolvedValue(makeDbConfig() as any);
+
+ const res = await DELETE(makeDeleteRequest(AGENT_ID), params);
+
+ expect(res.status).toBe(StatusCodes.OK);
+ expect(prisma.agentConfig.delete).toHaveBeenCalledWith({ where: { id: AGENT_ID } });
+ });
+});
diff --git a/packages/web/src/app/api/(server)/agents/[agentId]/route.ts b/packages/web/src/app/api/(server)/agents/[agentId]/route.ts
new file mode 100644
index 000000000..3353109fe
--- /dev/null
+++ b/packages/web/src/app/api/(server)/agents/[agentId]/route.ts
@@ -0,0 +1,229 @@
+'use server';
+
+import { apiHandler } from "@/lib/apiHandler";
+import { requestBodySchemaValidationError, serviceErrorResponse, notFound } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { ErrorCode } from "@/lib/errorCodes";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+import { NextRequest } from "next/server";
+import { z } from "zod";
+import { StatusCodes } from "http-status-codes";
+import { AgentScope, AgentType, PromptMode } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/shared";
+import { agentConfigSettingsSchema } from "@/features/agents/review-agent/app";
+
+const logger = createLogger('agents-api');
+
+const updateAgentConfigBodySchema = z.object({
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().nullable().optional(),
+ type: z.nativeEnum(AgentType).optional(),
+ enabled: z.boolean().optional(),
+ prompt: z.string().nullable().optional(),
+ promptMode: z.nativeEnum(PromptMode).optional(),
+ scope: z.nativeEnum(AgentScope).optional(),
+ repoIds: z.array(z.number().int().positive()).optional(),
+ connectionIds: z.array(z.number().int().positive()).optional(),
+ settings: agentConfigSettingsSchema.optional(),
+});
+
+type RouteParams = { params: Promise<{ agentId: string }> };
+
+const includeRelations = {
+ repos: {
+ include: {
+ repo: {
+ select: { id: true, displayName: true, external_id: true, external_codeHostType: true },
+ },
+ },
+ },
+ connections: {
+ include: {
+ connection: {
+ select: { id: true, name: true, connectionType: true },
+ },
+ },
+ },
+} as const;
+
+export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => {
+ const { agentId } = await params;
+
+ const result = await withAuth(async ({ org, prisma }) => {
+ const config = await prisma.agentConfig.findFirst({
+ where: { id: agentId, orgId: org.id },
+ include: includeRelations,
+ });
+
+ if (!config) {
+ return notFound(`Agent config '${agentId}' not found`);
+ }
+
+ return config;
+ });
+
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+
+ return Response.json(result, { status: StatusCodes.OK });
+});
+
+export const PATCH = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
+ const { agentId } = await params;
+
+ const body = await request.json();
+ const parsed = updateAgentConfigBodySchema.safeParse(body);
+
+ if (!parsed.success) {
+ return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
+ }
+
+ const { name, description, type, enabled, prompt, promptMode, scope, repoIds, connectionIds, settings } = parsed.data;
+
+ const result = await withAuth(async ({ org, role, prisma }) => {
+ return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const existing = await prisma.agentConfig.findFirst({
+ where: { id: agentId, orgId: org.id },
+ });
+
+ if (!existing) {
+ return notFound(`Agent config '${agentId}' not found`);
+ }
+
+ // Check for name collision with a different config in the same org
+ if (name !== undefined && name !== existing.name) {
+ const collision = await prisma.agentConfig.findUnique({
+ where: { orgId_name: { orgId: org.id, name } },
+ });
+
+ if (collision) {
+ return {
+ statusCode: StatusCodes.CONFLICT,
+ errorCode: ErrorCode.AGENT_CONFIG_ALREADY_EXISTS,
+ message: `An agent config named '${name}' already exists`,
+ };
+ }
+ }
+
+ const effectiveScope = scope ?? existing.scope;
+
+ // When scope changes to REPO/CONNECTION, IDs must be supplied
+ if (effectiveScope === 'REPO' && scope === 'REPO' && (!repoIds || repoIds.length === 0)) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: 'INVALID_REQUEST_BODY',
+ message: "repoIds is required when scope is REPO",
+ };
+ }
+
+ if (effectiveScope === 'CONNECTION' && scope === 'CONNECTION' && (!connectionIds || connectionIds.length === 0)) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: 'INVALID_REQUEST_BODY',
+ message: "connectionIds is required when scope is CONNECTION",
+ };
+ }
+
+ // Verify all provided IDs belong to this org
+ if (repoIds && repoIds.length > 0) {
+ const count = await prisma.repo.count({
+ where: { id: { in: repoIds }, orgId: org.id },
+ });
+ if (count !== repoIds.length) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "One or more repoIds are invalid or do not belong to this org",
+ };
+ }
+ }
+
+ if (connectionIds && connectionIds.length > 0) {
+ const count = await prisma.connection.count({
+ where: { id: { in: connectionIds }, orgId: org.id },
+ });
+ if (count !== connectionIds.length) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "One or more connectionIds are invalid or do not belong to this org",
+ };
+ }
+ }
+
+ try {
+ // Rebuild junction table rows when scope or IDs are updated
+ const updated = await prisma.agentConfig.update({
+ where: { id: agentId },
+ data: {
+ ...(name !== undefined && { name }),
+ ...(description !== undefined && { description }),
+ ...(type !== undefined && { type }),
+ ...(enabled !== undefined && { enabled }),
+ ...(prompt !== undefined && { prompt }),
+ ...(promptMode !== undefined && { promptMode }),
+ ...(scope !== undefined && { scope }),
+ ...(settings !== undefined && { settings }),
+ ...(repoIds !== undefined && {
+ repos: {
+ deleteMany: {},
+ create: repoIds.map((repoId) => ({ repoId })),
+ },
+ }),
+ ...(connectionIds !== undefined && {
+ connections: {
+ deleteMany: {},
+ create: connectionIds.map((connectionId) => ({ connectionId })),
+ },
+ }),
+ },
+ include: includeRelations,
+ });
+
+ return updated;
+ } catch (error) {
+ logger.error('Error updating agent config', { error, agentId, orgId: org.id });
+ throw error;
+ }
+ });
+ });
+
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+
+ return Response.json(result, { status: StatusCodes.OK });
+});
+
+export const DELETE = apiHandler(async (_request: NextRequest, { params }: RouteParams) => {
+ const { agentId } = await params;
+
+ const result = await withAuth(async ({ org, role, prisma }) => {
+ return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const existing = await prisma.agentConfig.findFirst({
+ where: { id: agentId, orgId: org.id },
+ });
+
+ if (!existing) {
+ return notFound(`Agent config '${agentId}' not found`);
+ }
+
+ try {
+ await prisma.agentConfig.delete({ where: { id: agentId } });
+ return { success: true };
+ } catch (error) {
+ logger.error('Error deleting agent config', { error, agentId, orgId: org.id });
+ throw error;
+ }
+ });
+ });
+
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+
+ return Response.json(result, { status: StatusCodes.OK });
+});
diff --git a/packages/web/src/app/api/(server)/agents/route.test.ts b/packages/web/src/app/api/(server)/agents/route.test.ts
new file mode 100644
index 000000000..3e2c0fb3e
--- /dev/null
+++ b/packages/web/src/app/api/(server)/agents/route.test.ts
@@ -0,0 +1,266 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+import { MOCK_ORG, MOCK_USER_WITH_ACCOUNTS, prisma } from '@/__mocks__/prisma';
+import { AgentConfig, AgentScope, AgentType, PromptMode } from '@sourcebot/db';
+import { OrgRole } from '@sourcebot/db';
+import { StatusCodes } from 'http-status-codes';
+
+vi.mock('@/prisma', async () => {
+ const actual = await vi.importActual('@/__mocks__/prisma');
+ return { ...actual };
+});
+
+vi.mock('server-only', () => ({ default: vi.fn() }));
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+ env: {},
+}));
+
+vi.mock('@/lib/posthog', () => ({ captureEvent: vi.fn() }));
+
+vi.mock('@/middleware/withAuth', () => ({
+ withAuth: vi.fn(async (fn: Function) =>
+ fn({ org: MOCK_ORG, user: MOCK_USER_WITH_ACCOUNTS, role: OrgRole.OWNER, prisma })
+ ),
+}));
+
+import { GET, POST } from './route';
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+function makePostRequest(body: unknown): NextRequest {
+ return new NextRequest('http://localhost/api/agents', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+function makeDbConfig(overrides: Partial = {}): AgentConfig & { repos: []; connections: [] } {
+ return {
+ id: 'cfg-abc',
+ orgId: MOCK_ORG.id,
+ name: 'my-config',
+ description: null,
+ type: AgentType.CODE_REVIEW,
+ enabled: true,
+ prompt: null,
+ promptMode: PromptMode.APPEND,
+ scope: AgentScope.ORG,
+ settings: {},
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ repos: [],
+ connections: [],
+ ...overrides,
+ };
+}
+
+// ── GET /api/agents ───────────────────────────────────────────────────────────
+
+describe('GET /api/agents', () => {
+ test('returns 200 and an empty array when no configs exist', async () => {
+ prisma.agentConfig.findMany.mockResolvedValue([]);
+
+ const res = await GET(new NextRequest('http://localhost/api/agents'));
+
+ expect(res.status).toBe(StatusCodes.OK);
+ expect(await res.json()).toEqual([]);
+ });
+
+ test('returns 200 with the list of configs', async () => {
+ const configs = [makeDbConfig(), makeDbConfig({ id: 'cfg-2', name: 'second' })];
+ prisma.agentConfig.findMany.mockResolvedValue(configs as any);
+
+ const res = await GET(new NextRequest('http://localhost/api/agents'));
+
+ expect(res.status).toBe(StatusCodes.OK);
+ const body = await res.json();
+ expect(body).toHaveLength(2);
+ });
+});
+
+// ── POST /api/agents ──────────────────────────────────────────────────────────
+
+describe('POST /api/agents', () => {
+ describe('Zod schema validation', () => {
+ test('returns 400 when name is missing', async () => {
+ const res = await POST(makePostRequest({ type: 'CODE_REVIEW', scope: 'ORG' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ test('returns 400 when name is an empty string', async () => {
+ const res = await POST(makePostRequest({ name: '', type: 'CODE_REVIEW', scope: 'ORG' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ test('returns 400 when type is missing', async () => {
+ const res = await POST(makePostRequest({ name: 'x', scope: 'ORG' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ test('returns 400 when type is an invalid value', async () => {
+ const res = await POST(makePostRequest({ name: 'x', type: 'UNKNOWN_TYPE', scope: 'ORG' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ test('returns 400 when scope is missing', async () => {
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ test('returns 400 when scope is an invalid value', async () => {
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'DEPARTMENT' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+ });
+
+ describe('scope-specific ID validation', () => {
+ test('returns 400 when scope is REPO but repoIds is not provided', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'REPO' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ const body = await res.json();
+ expect(body.message).toContain('repoIds');
+ });
+
+ test('returns 400 when scope is REPO but repoIds is an empty array', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'REPO', repoIds: [] }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ test('returns 400 when scope is CONNECTION but connectionIds is not provided', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'CONNECTION' }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ const body = await res.json();
+ expect(body.message).toContain('connectionIds');
+ });
+
+ test('returns 400 when scope is CONNECTION but connectionIds is an empty array', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'CONNECTION', connectionIds: [] }));
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ test('accepts scope ORG without repoIds or connectionIds', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+ prisma.agentConfig.create.mockResolvedValue(makeDbConfig() as any);
+
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'ORG' }));
+
+ expect(res.status).toBe(StatusCodes.CREATED);
+ });
+
+ test('accepts scope REPO when repoIds is a non-empty array', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+ prisma.agentConfig.create.mockResolvedValue(makeDbConfig({ scope: AgentScope.REPO }) as any);
+
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'REPO', repoIds: [1] }));
+
+ expect(res.status).toBe(StatusCodes.CREATED);
+ });
+
+ test('accepts scope CONNECTION when connectionIds is a non-empty array', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+ prisma.agentConfig.create.mockResolvedValue(makeDbConfig({ scope: AgentScope.CONNECTION }) as any);
+
+ const res = await POST(makePostRequest({ name: 'x', type: 'CODE_REVIEW', scope: 'CONNECTION', connectionIds: [2] }));
+
+ expect(res.status).toBe(StatusCodes.CREATED);
+ });
+ });
+
+ describe('name collision', () => {
+ test('returns 409 when a config with the same name already exists', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(makeDbConfig() as any);
+
+ const res = await POST(makePostRequest({ name: 'my-config', type: 'CODE_REVIEW', scope: 'ORG' }));
+
+ expect(res.status).toBe(StatusCodes.CONFLICT);
+ const body = await res.json();
+ expect(body.message).toContain('my-config');
+ });
+
+ test('does not call create when a name collision is detected', async () => {
+ prisma.agentConfig.findUnique.mockResolvedValue(makeDbConfig() as any);
+
+ await POST(makePostRequest({ name: 'my-config', type: 'CODE_REVIEW', scope: 'ORG' }));
+
+ expect(prisma.agentConfig.create).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('successful creation', () => {
+ beforeEach(() => {
+ prisma.agentConfig.findUnique.mockResolvedValue(null);
+ });
+
+ test('returns 201 on successful creation', async () => {
+ prisma.agentConfig.create.mockResolvedValue(makeDbConfig() as any);
+
+ const res = await POST(makePostRequest({ name: 'new-config', type: 'CODE_REVIEW', scope: 'ORG' }));
+
+ expect(res.status).toBe(StatusCodes.CREATED);
+ });
+
+ test('calls prisma.agentConfig.create with the correct data', async () => {
+ prisma.agentConfig.create.mockResolvedValue(makeDbConfig() as any);
+
+ await POST(makePostRequest({
+ name: 'new-config',
+ type: 'CODE_REVIEW',
+ scope: 'ORG',
+ prompt: 'Be strict.',
+ promptMode: 'REPLACE',
+ enabled: false,
+ }));
+
+ expect(prisma.agentConfig.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ orgId: MOCK_ORG.id,
+ name: 'new-config',
+ type: AgentType.CODE_REVIEW,
+ scope: AgentScope.ORG,
+ prompt: 'Be strict.',
+ promptMode: PromptMode.REPLACE,
+ enabled: false,
+ }),
+ }),
+ );
+ });
+
+ test('response body matches the created config', async () => {
+ const created = makeDbConfig({ name: 'new-config' });
+ prisma.agentConfig.create.mockResolvedValue(created as any);
+
+ const res = await POST(makePostRequest({ name: 'new-config', type: 'CODE_REVIEW', scope: 'ORG' }));
+ const body = await res.json();
+
+ expect(body.id).toBe(created.id);
+ expect(body.name).toBe('new-config');
+ });
+ });
+});
diff --git a/packages/web/src/app/api/(server)/agents/route.ts b/packages/web/src/app/api/(server)/agents/route.ts
new file mode 100644
index 000000000..9bd2d1f70
--- /dev/null
+++ b/packages/web/src/app/api/(server)/agents/route.ts
@@ -0,0 +1,184 @@
+'use server';
+
+import { apiHandler } from "@/lib/apiHandler";
+import { requestBodySchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+import { NextRequest } from "next/server";
+import { z } from "zod";
+import { StatusCodes } from "http-status-codes";
+import { ErrorCode } from "@/lib/errorCodes";
+import { AgentScope, AgentType, PromptMode } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/shared";
+import { agentConfigSettingsSchema } from "@/features/agents/review-agent/app";
+
+const logger = createLogger('agents-api');
+
+const createAgentConfigBodySchema = z.object({
+ name: z.string().min(1).max(255),
+ description: z.string().optional(),
+ type: z.nativeEnum(AgentType),
+ enabled: z.boolean().default(true),
+ prompt: z.string().optional(),
+ promptMode: z.nativeEnum(PromptMode).default(PromptMode.APPEND),
+ scope: z.nativeEnum(AgentScope),
+ repoIds: z.array(z.number().int().positive()).optional(),
+ connectionIds: z.array(z.number().int().positive()).optional(),
+ settings: agentConfigSettingsSchema.optional().default({}),
+});
+
+export const GET = apiHandler(async (_request: NextRequest) => {
+ const result = await withAuth(async ({ org, prisma }) => {
+ const configs = await prisma.agentConfig.findMany({
+ where: { orgId: org.id },
+ include: {
+ repos: {
+ include: {
+ repo: {
+ select: { id: true, displayName: true, external_id: true, external_codeHostType: true },
+ },
+ },
+ },
+ connections: {
+ include: {
+ connection: {
+ select: { id: true, name: true, connectionType: true },
+ },
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return configs;
+ });
+
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+
+ return Response.json(result, { status: StatusCodes.OK });
+});
+
+export const POST = apiHandler(async (request: NextRequest) => {
+ const body = await request.json();
+ const parsed = createAgentConfigBodySchema.safeParse(body);
+
+ if (!parsed.success) {
+ return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
+ }
+
+ const { name, description, type, enabled, prompt, promptMode, scope, repoIds, connectionIds, settings } = parsed.data;
+
+ const result = await withAuth(async ({ org, role, prisma }) => {
+ return withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ // Check for name collision within org
+ const existing = await prisma.agentConfig.findUnique({
+ where: { orgId_name: { orgId: org.id, name } },
+ });
+
+ if (existing) {
+ return {
+ statusCode: StatusCodes.CONFLICT,
+ errorCode: ErrorCode.AGENT_CONFIG_ALREADY_EXISTS,
+ message: `An agent config named '${name}' already exists`,
+ };
+ }
+
+ // Validate scope-specific IDs
+ if (scope === AgentScope.REPO && (!repoIds || repoIds.length === 0)) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "repoIds is required when scope is REPO",
+ };
+ }
+
+ if (scope === AgentScope.CONNECTION && (!connectionIds || connectionIds.length === 0)) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "connectionIds is required when scope is CONNECTION",
+ };
+ }
+
+ // Verify all provided IDs belong to this org
+ if (scope === AgentScope.REPO && repoIds && repoIds.length > 0) {
+ const count = await prisma.repo.count({
+ where: { id: { in: repoIds }, orgId: org.id },
+ });
+ if (count !== repoIds.length) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "One or more repoIds are invalid or do not belong to this org",
+ };
+ }
+ }
+
+ if (scope === AgentScope.CONNECTION && connectionIds && connectionIds.length > 0) {
+ const count = await prisma.connection.count({
+ where: { id: { in: connectionIds }, orgId: org.id },
+ });
+ if (count !== connectionIds.length) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "One or more connectionIds are invalid or do not belong to this org",
+ };
+ }
+ }
+
+ try {
+ const config = await prisma.agentConfig.create({
+ data: {
+ orgId: org.id,
+ name,
+ description,
+ type,
+ enabled,
+ prompt,
+ promptMode,
+ scope,
+ settings,
+ repos: scope === AgentScope.REPO && repoIds
+ ? { create: repoIds.map((repoId) => ({ repoId })) }
+ : undefined,
+ connections: scope === AgentScope.CONNECTION && connectionIds
+ ? { create: connectionIds.map((connectionId) => ({ connectionId })) }
+ : undefined,
+ },
+ include: {
+ repos: {
+ include: {
+ repo: {
+ select: { id: true, displayName: true, external_id: true, external_codeHostType: true },
+ },
+ },
+ },
+ connections: {
+ include: {
+ connection: {
+ select: { id: true, name: true, connectionType: true },
+ },
+ },
+ },
+ },
+ });
+
+ return config;
+ } catch (error) {
+ logger.error('Error creating agent config', { error, name, orgId: org.id });
+ throw error;
+ }
+ });
+ });
+
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+
+ return Response.json(result, { status: StatusCodes.CREATED });
+});
diff --git a/packages/web/src/app/api/(server)/webhook/route.ts b/packages/web/src/app/api/(server)/webhook/route.ts
index e7e380b49..ee8fa4445 100644
--- a/packages/web/src/app/api/(server)/webhook/route.ts
+++ b/packages/web/src/app/api/(server)/webhook/route.ts
@@ -6,10 +6,14 @@ import { WebhookEventDefinition} from "@octokit/webhooks/types";
import { Gitlab } from "@gitbeaker/rest";
import { env } from "@sourcebot/shared";
import { processGitHubPullRequest, processGitLabMergeRequest } from "@/features/agents/review-agent/app";
+import { resolveAgentConfig } from "@/features/agents/review-agent/resolveAgentConfig";
+import { isAutoReviewEnabled, getReviewCommand } from "@/features/agents/review-agent/webhookUtils";
import { throttling, type ThrottlingOptions } from "@octokit/plugin-throttling";
import fs from "fs";
import { GitHubPullRequest, GitLabMergeRequestPayload, gitLabMergeRequestPayloadSchema, gitLabNotePayloadSchema } from "@/features/agents/review-agent/types";
import { createLogger } from "@sourcebot/shared";
+import { __unsafePrisma } from "@/prisma";
+import { AgentConfig } from "@sourcebot/db";
const logger = createLogger('webhook');
@@ -130,6 +134,62 @@ if (env.GITLAB_REVIEW_AGENT_TOKEN) {
}
}
+/**
+ * Resolves the AgentConfig for a GitHub repository.
+ * Looks up the Repo record by GitHub repository ID and the code host URL.
+ */
+async function resolveGitHubAgentConfig(
+ githubRepoId: number,
+ codeHostUrl: string,
+): Promise {
+ try {
+ const repo = await __unsafePrisma.repo.findFirst({
+ where: {
+ external_id: String(githubRepoId),
+ external_codeHostUrl: codeHostUrl,
+ },
+ });
+
+ if (!repo) {
+ logger.debug(`No Repo record found for GitHub repo ${githubRepoId} at ${codeHostUrl}`);
+ return null;
+ }
+
+ return resolveAgentConfig(repo.id, repo.orgId, 'CODE_REVIEW', __unsafePrisma);
+ } catch (error) {
+ logger.error(`Error resolving AgentConfig for GitHub repo ${githubRepoId}:`, error);
+ return null;
+ }
+}
+
+/**
+ * Resolves the AgentConfig for a GitLab project.
+ * Looks up the Repo record by GitLab project ID and the code host URL.
+ */
+async function resolveGitLabAgentConfig(
+ gitlabProjectId: number,
+ codeHostUrl: string,
+): Promise {
+ try {
+ const repo = await __unsafePrisma.repo.findFirst({
+ where: {
+ external_id: String(gitlabProjectId),
+ external_codeHostUrl: codeHostUrl,
+ },
+ });
+
+ if (!repo) {
+ logger.debug(`No Repo record found for GitLab project ${gitlabProjectId} at ${codeHostUrl}`);
+ return null;
+ }
+
+ return resolveAgentConfig(repo.id, repo.orgId, 'CODE_REVIEW', __unsafePrisma);
+ } catch (error) {
+ logger.error(`Error resolving AgentConfig for GitLab project ${gitlabProjectId}:`, error);
+ return null;
+ }
+}
+
export const POST = async (request: NextRequest) => {
const body = await request.json();
const headers = Object.fromEntries(Array.from(request.headers.entries(), ([key, value]) => [key.toLowerCase(), value]));
@@ -148,8 +208,16 @@ export const POST = async (request: NextRequest) => {
}
if (isPullRequestEvent(githubEvent, body)) {
- if (env.REVIEW_AGENT_AUTO_REVIEW_ENABLED === "false") {
- logger.info('Review agent auto review (REVIEW_AGENT_AUTO_REVIEW_ENABLED) is disabled, skipping');
+ const pullRequest = body.pull_request as GitHubPullRequest;
+
+ const codeHostUrl = githubApiBaseUrl === DEFAULT_GITHUB_API_BASE_URL
+ ? 'https://github.com'
+ : githubApiBaseUrl.replace(/\/api\/v3$/, '');
+
+ const config = await resolveGitHubAgentConfig(pullRequest.base.repo.id, codeHostUrl);
+
+ if (!isAutoReviewEnabled(config)) {
+ logger.info('Auto review is disabled for this repo/config, skipping');
return Response.json({ status: 'ok' });
}
@@ -161,8 +229,7 @@ export const POST = async (request: NextRequest) => {
const installationId = body.installation.id;
const octokit = await githubApp.getInstallationOctokit(installationId);
- const pullRequest = body.pull_request as GitHubPullRequest;
- await processGitHubPullRequest(octokit, pullRequest);
+ await processGitHubPullRequest(octokit, pullRequest, config);
}
if (isIssueCommentEvent(githubEvent, body)) {
@@ -172,7 +239,16 @@ export const POST = async (request: NextRequest) => {
return Response.json({ status: 'ok' });
}
- if (comment === `/${env.REVIEW_AGENT_REVIEW_COMMAND}`) {
+ const codeHostUrl = githubApiBaseUrl === DEFAULT_GITHUB_API_BASE_URL
+ ? 'https://github.com'
+ : githubApiBaseUrl.replace(/\/api\/v3$/, '');
+
+ const repoId: number = body.repository?.id;
+ const config = repoId
+ ? await resolveGitHubAgentConfig(repoId, codeHostUrl)
+ : null;
+
+ if (comment === `/${getReviewCommand(config)}`) {
logger.info('Review agent review command received, processing');
if (!body.installation) {
@@ -191,7 +267,7 @@ export const POST = async (request: NextRequest) => {
pull_number: pullRequestNumber,
});
- await processGitHubPullRequest(octokit, pullRequest);
+ await processGitHubPullRequest(octokit, pullRequest, config);
}
}
}
@@ -211,24 +287,29 @@ export const POST = async (request: NextRequest) => {
return Response.json({ status: 'ok' });
}
- if (isGitLabMergeRequestEvent(gitlabEvent, body)) {
- if (env.REVIEW_AGENT_AUTO_REVIEW_ENABLED === "false") {
- logger.info('Review agent auto review (REVIEW_AGENT_AUTO_REVIEW_ENABLED) is disabled, skipping');
- return Response.json({ status: 'ok' });
- }
+ const gitlabCodeHostUrl = `https://${env.GITLAB_REVIEW_AGENT_HOST}`;
+ if (isGitLabMergeRequestEvent(gitlabEvent, body)) {
const parsed = gitLabMergeRequestPayloadSchema.safeParse(body);
if (!parsed.success) {
logger.warn(`GitLab MR webhook payload failed validation: ${parsed.error.message}`);
return Response.json({ status: 'ok' });
}
+ const config = await resolveGitLabAgentConfig(parsed.data.project.id, gitlabCodeHostUrl);
+
+ if (!isAutoReviewEnabled(config)) {
+ logger.info('Auto review is disabled for this project/config, skipping');
+ return Response.json({ status: 'ok' });
+ }
+
try {
await processGitLabMergeRequest(
gitlabClient,
parsed.data.project.id,
parsed.data,
env.GITLAB_REVIEW_AGENT_HOST,
+ config,
);
} catch (error) {
logger.error(`Error in processGitLabMergeRequest for project ${parsed.data.project.id} (${gitlabEvent}):`, error);
@@ -242,8 +323,10 @@ export const POST = async (request: NextRequest) => {
return Response.json({ status: 'ok' });
}
+ const config = await resolveGitLabAgentConfig(parsed.data.project.id, gitlabCodeHostUrl);
+
const noteBody = parsed.data.object_attributes.note;
- if (noteBody === `/${env.REVIEW_AGENT_REVIEW_COMMAND}`) {
+ if (noteBody === `/${getReviewCommand(config)}`) {
logger.info('Review agent review command received on GitLab MR, processing');
const mrPayload: GitLabMergeRequestPayload = {
@@ -265,6 +348,7 @@ export const POST = async (request: NextRequest) => {
parsed.data.project.id,
mrPayload,
env.GITLAB_REVIEW_AGENT_HOST,
+ config,
);
} catch (error) {
logger.error(`Error in processGitLabMergeRequest for project ${parsed.data.project.id} (${gitlabEvent}):`, error);
diff --git a/packages/web/src/features/agents/review-agent/app.test.ts b/packages/web/src/features/agents/review-agent/app.test.ts
new file mode 100644
index 000000000..1308d079d
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/app.test.ts
@@ -0,0 +1,161 @@
+import { expect, test, vi, describe } from 'vitest';
+import { parseAgentConfigSettings, resolveRules } from './app';
+import { AgentConfig, AgentScope, AgentType, PromptMode } from '@sourcebot/db';
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+ env: {
+ REVIEW_AGENT_LOGGING_ENABLED: false,
+ },
+}));
+
+// The nodes are not exercised by these unit tests — mock them to avoid loading
+// their transitive dependencies (LLM SDKs, file-system access, etc.).
+vi.mock('@/features/agents/review-agent/nodes/generatePrReview', () => ({ generatePrReviews: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/githubPushPrReviews', () => ({ githubPushPrReviews: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/githubPrParser', () => ({ githubPrParser: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/invokeDiffReviewLlm', () => ({ getReviewAgentLogDir: vi.fn(() => '/tmp'), invokeDiffReviewLlm: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/gitlabMrParser', () => ({ gitlabMrParser: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/gitlabPushMrReviews', () => ({ gitlabPushMrReviews: vi.fn() }));
+
+function makeConfig(overrides: Partial = {}): AgentConfig {
+ return {
+ id: 'cfg-1',
+ orgId: 1,
+ name: 'test-config',
+ description: null,
+ type: AgentType.CODE_REVIEW,
+ enabled: true,
+ prompt: null,
+ promptMode: PromptMode.APPEND,
+ scope: AgentScope.ORG,
+ settings: {},
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ };
+}
+
+// ─── parseAgentConfigSettings ────────────────────────────────────────────────
+
+describe('parseAgentConfigSettings', () => {
+ test('returns an empty object for empty settings', () => {
+ expect(parseAgentConfigSettings({})).toEqual({});
+ });
+
+ test('parses all recognised fields', () => {
+ const result = parseAgentConfigSettings({
+ autoReviewEnabled: false,
+ reviewCommand: 'check',
+ model: 'claude-sonnet-4-6',
+ });
+
+ expect(result).toEqual({
+ autoReviewEnabled: false,
+ reviewCommand: 'check',
+ model: 'claude-sonnet-4-6',
+ });
+ });
+
+ test('parses a subset of fields', () => {
+ expect(parseAgentConfigSettings({ model: 'gpt-4o' })).toEqual({ model: 'gpt-4o' });
+ });
+
+ test('returns empty object for a non-object value and does not throw', () => {
+ expect(parseAgentConfigSettings("invalid")).toEqual({});
+ expect(parseAgentConfigSettings(42)).toEqual({});
+ expect(parseAgentConfigSettings(null)).toEqual({});
+ expect(parseAgentConfigSettings(undefined)).toEqual({});
+ });
+
+ test('strips unknown fields', () => {
+ const result = parseAgentConfigSettings({ model: 'x', unknownField: true, another: 123 });
+
+ expect(result).not.toHaveProperty('unknownField');
+ expect(result).not.toHaveProperty('another');
+ expect(result).toEqual({ model: 'x' });
+ });
+
+ test('autoReviewEnabled accepts false explicitly', () => {
+ const result = parseAgentConfigSettings({ autoReviewEnabled: false });
+
+ expect(result.autoReviewEnabled).toBe(false);
+ });
+});
+
+// ─── resolveRules ─────────────────────────────────────────────────────────────
+
+describe('resolveRules', () => {
+ test('returns default rules when config is null', () => {
+ const rules = resolveRules(null);
+
+ expect(rules.length).toBeGreaterThan(0);
+ expect(rules.some(r => r.includes('Do NOT provide general feedback'))).toBe(true);
+ });
+
+ test('returns default rules when config has no prompt', () => {
+ const rules = resolveRules(makeConfig({ prompt: null }));
+
+ expect(rules.some(r => r.includes('Do NOT provide general feedback'))).toBe(true);
+ });
+
+ test('returns default rules when config has an empty string prompt', () => {
+ const rules = resolveRules(makeConfig({ prompt: '' }));
+
+ expect(rules.some(r => r.includes('Do NOT provide general feedback'))).toBe(true);
+ });
+
+ test('APPEND mode places the custom prompt after the default rules', () => {
+ const rules = resolveRules(makeConfig({
+ prompt: 'Flag any use of eval().',
+ promptMode: PromptMode.APPEND,
+ }));
+
+ expect(rules[rules.length - 1]).toBe('Flag any use of eval().');
+ expect(rules.some(r => r.includes('Do NOT provide general feedback'))).toBe(true);
+ });
+
+ test('APPEND mode does not remove any default rules', () => {
+ const defaultRules = resolveRules(null);
+ const appendRules = resolveRules(makeConfig({
+ prompt: 'Extra rule.',
+ promptMode: PromptMode.APPEND,
+ }));
+
+ expect(appendRules).toHaveLength(defaultRules.length + 1);
+ defaultRules.forEach(rule => {
+ expect(appendRules).toContain(rule);
+ });
+ });
+
+ test('REPLACE mode returns only the custom prompt as a single rule', () => {
+ const rules = resolveRules(makeConfig({
+ prompt: 'Only this rule.',
+ promptMode: PromptMode.REPLACE,
+ }));
+
+ expect(rules).toEqual(['Only this rule.']);
+ });
+
+ test('REPLACE mode discards all default rules', () => {
+ const rules = resolveRules(makeConfig({
+ prompt: 'Custom only.',
+ promptMode: PromptMode.REPLACE,
+ }));
+
+ expect(rules.some(r => r.includes('Do NOT provide general feedback'))).toBe(false);
+ });
+
+ test('REPLACE mode with a multi-line prompt keeps it as a single entry', () => {
+ const prompt = 'Rule one.\nRule two.\nRule three.';
+ const rules = resolveRules(makeConfig({ prompt, promptMode: PromptMode.REPLACE }));
+
+ expect(rules).toHaveLength(1);
+ expect(rules[0]).toBe(prompt);
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/app.ts b/packages/web/src/features/agents/review-agent/app.ts
index 043a80fad..d3a648874 100644
--- a/packages/web/src/features/agents/review-agent/app.ts
+++ b/packages/web/src/features/agents/review-agent/app.ts
@@ -11,18 +11,51 @@ import { env } from "@sourcebot/shared";
import path from "path";
import fs from "fs";
import { createLogger } from "@sourcebot/shared";
+import { AgentConfig } from "@sourcebot/db";
+import { z } from "zod";
-const rules = [
+const logger = createLogger('review-agent');
+
+const DEFAULT_RULES = [
"Do NOT provide general feedback, summaries, explanations of changes, or praises for making good additions.",
"Do NOT provide any advice that is not actionable or directly related to the changes.",
"Do NOT provide any comments or reviews on code that you believe is good, correct, or a good addition. Your job is only to identify issues and provide feedback on how to fix them.",
"If a review for a chunk contains different reviews at different line ranges, return a separate review object for each line range.",
"Focus solely on offering specific, objective insights based on the given context and refrain from making broad comments about potential impacts on the system or question intentions behind the changes.",
"Keep comments concise and to the point. Every comment must highlight a specific issue and provide a clear and actionable solution to the developer.",
- "If there are no issues found on a line range, do NOT respond with any comments. This includes comments such as \"No issues found\" or \"LGTM\"."
-]
+ "If there are no issues found on a line range, do NOT respond with any comments. This includes comments such as \"No issues found\" or \"LGTM\".",
+];
-const logger = createLogger('review-agent');
+export const agentConfigSettingsSchema = z.object({
+ autoReviewEnabled: z.boolean().optional(),
+ reviewCommand: z.string().optional(),
+ model: z.string().optional(),
+ contextFiles: z.string().optional(),
+});
+
+export type AgentConfigSettings = z.infer;
+
+export function parseAgentConfigSettings(settings: unknown): AgentConfigSettings {
+ const result = agentConfigSettingsSchema.safeParse(settings);
+ if (!result.success) {
+ logger.warn(`Failed to parse AgentConfig settings: ${result.error.message}`);
+ return {};
+ }
+ return result.data;
+}
+
+export function resolveRules(config: AgentConfig | null): string[] {
+ if (!config || !config.prompt) {
+ return DEFAULT_RULES;
+ }
+
+ if (config.promptMode === 'REPLACE') {
+ return [config.prompt];
+ }
+
+ // APPEND: add custom instructions after the built-in rules
+ return [...DEFAULT_RULES, config.prompt];
+}
function getReviewAgentLogPath(identifier: string): string | undefined {
if (!env.REVIEW_AGENT_LOGGING_ENABLED) {
@@ -48,13 +81,23 @@ function getReviewAgentLogPath(identifier: string): string | undefined {
return logPath;
}
-export async function processGitHubPullRequest(octokit: Octokit, pullRequest: GitHubPullRequest) {
+export async function processGitHubPullRequest(
+ octokit: Octokit,
+ pullRequest: GitHubPullRequest,
+ config: AgentConfig | null = null,
+) {
logger.info(`Received a pull request event for #${pullRequest.number}`);
+ if (config) {
+ logger.info(`Applying AgentConfig '${config.name}' (scope: ${config.scope}, promptMode: ${config.promptMode})`);
+ }
+
const reviewAgentLogPath = getReviewAgentLogPath(String(pullRequest.number));
+ const rules = resolveRules(config);
+ const settings = config ? parseAgentConfigSettings(config.settings) : {};
const prPayload = await githubPrParser(octokit, pullRequest);
- const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules);
+ const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules, settings.model, settings.contextFiles);
await githubPushPrReviews(octokit, prPayload, fileDiffReviews);
}
@@ -63,12 +106,19 @@ export async function processGitLabMergeRequest(
projectId: number,
mrPayload: GitLabMergeRequestPayload,
hostDomain: string,
+ config: AgentConfig | null = null,
) {
logger.info(`Received a merge request event for !${mrPayload.object_attributes.iid}`);
+ if (config) {
+ logger.info(`Applying AgentConfig '${config.name}' (scope: ${config.scope}, promptMode: ${config.promptMode})`);
+ }
+
const reviewAgentLogPath = getReviewAgentLogPath(`mr-${mrPayload.object_attributes.iid}`);
+ const rules = resolveRules(config);
+ const settings = config ? parseAgentConfigSettings(config.settings) : {};
const prPayload = await gitlabMrParser(gitlabClient, mrPayload, hostDomain);
- const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules);
+ const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules, settings.model, settings.contextFiles);
await gitlabPushMrReviews(gitlabClient, projectId, prPayload, fileDiffReviews);
}
diff --git a/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.test.ts b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.test.ts
new file mode 100644
index 000000000..64de2cc09
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.test.ts
@@ -0,0 +1,123 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { sourcebot_pr_payload } from '@/features/agents/review-agent/types';
+
+// Mock the org lookup so fetchContextFile doesn't hit the DB.
+vi.mock('@/prisma', async () => {
+ const actual = await vi.importActual('@/__mocks__/prisma');
+ return { ...actual };
+});
+
+const mockGetFileSourceForRepo = vi.fn();
+vi.mock('@/features/git', () => ({
+ getFileSourceForRepo: (...args: unknown[]) => mockGetFileSourceForRepo(...args),
+ fileSourceResponseSchema: {
+ safeParse: (v: unknown) => {
+ if (v && typeof v === 'object' && 'source' in v) {
+ return { success: true, data: v };
+ }
+ return { success: false, error: new Error('parse failure') };
+ },
+ },
+}));
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+vi.mock('@/lib/constants', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, SINGLE_TENANT_ORG_ID: 1 };
+});
+
+// Import after mocks are in place.
+import { fetchContextFile } from './fetchFileContent';
+import { prisma } from '@/__mocks__/prisma';
+
+const MOCK_ORG = { id: 1, name: 'test-org' };
+
+const MOCK_PAYLOAD: sourcebot_pr_payload = {
+ title: 'Test PR',
+ description: '',
+ hostDomain: 'github.com',
+ owner: 'acme',
+ repo: 'api',
+ file_diffs: [],
+ number: 42,
+ head_sha: 'abc123',
+};
+
+beforeEach(() => {
+ mockGetFileSourceForRepo.mockReset();
+ (prisma.org.findUnique as ReturnType).mockResolvedValue(MOCK_ORG);
+});
+
+// ─── fetchContextFile ─────────────────────────────────────────────────────────
+
+describe('fetchContextFile', () => {
+ test('returns null when getFileSourceForRepo returns a service error', async () => {
+ mockGetFileSourceForRepo.mockResolvedValue({
+ statusCode: 404,
+ errorCode: 'NOT_FOUND',
+ message: 'not found',
+ });
+
+ const result = await fetchContextFile(MOCK_PAYLOAD, 'AGENTS.md');
+
+ expect(result).toBeNull();
+ });
+
+ test('returns null when the response fails schema validation', async () => {
+ // Return something that looks like a non-service-error but has no `source` field.
+ mockGetFileSourceForRepo.mockResolvedValue({ unexpected: true });
+
+ const result = await fetchContextFile(MOCK_PAYLOAD, 'AGENTS.md');
+
+ expect(result).toBeNull();
+ });
+
+ test('returns a sourcebot_context with type "repo_instructions" on success', async () => {
+ mockGetFileSourceForRepo.mockResolvedValue({ source: 'Use Result for errors.' });
+
+ const result = await fetchContextFile(MOCK_PAYLOAD, 'AGENTS.md');
+
+ expect(result).not.toBeNull();
+ expect(result!.type).toBe('repo_instructions');
+ });
+
+ test('context field contains the file source content', async () => {
+ const content = 'Avoid inline SQL. Prefer the ORM.';
+ mockGetFileSourceForRepo.mockResolvedValue({ source: content });
+
+ const result = await fetchContextFile(MOCK_PAYLOAD, 'AGENTS.md');
+
+ expect(result!.context).toBe(content);
+ });
+
+ test('description includes the requested file path', async () => {
+ mockGetFileSourceForRepo.mockResolvedValue({ source: 'x' });
+
+ const result = await fetchContextFile(MOCK_PAYLOAD, '.sourcebot/review.md');
+
+ expect(result!.description).toContain('.sourcebot/review.md');
+ });
+
+ test('passes the correct repo path and head SHA to getFileSourceForRepo', async () => {
+ mockGetFileSourceForRepo.mockResolvedValue({ source: '' });
+
+ await fetchContextFile(MOCK_PAYLOAD, 'AGENTS.md');
+
+ expect(mockGetFileSourceForRepo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ path: 'AGENTS.md',
+ repo: 'github.com/acme/api',
+ ref: 'abc123',
+ }),
+ expect.anything(),
+ );
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
index 41e5a6e58..403553f66 100644
--- a/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
+++ b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
@@ -52,3 +52,42 @@ export const fetchFileContent = async (pr_payload: sourcebot_pr_payload, filenam
logger.debug("Completed fetch_file_content");
return fileContentContext;
};
+
+/**
+ * Fetches a repo-level context file (e.g. AGENTS.md) from the repository at the
+ * PR's head commit. Returns null when the file does not exist or cannot be read,
+ * so the caller can silently skip it.
+ */
+export const fetchContextFile = async (
+ pr_payload: sourcebot_pr_payload,
+ filePath: string,
+): Promise => {
+ logger.debug(`Fetching context file: ${filePath}`);
+
+ const org = await getOrg();
+ const repoPath = pr_payload.hostDomain + "/" + pr_payload.owner + "/" + pr_payload.repo;
+
+ const response = await getFileSourceForRepo(
+ { path: filePath, repo: repoPath, ref: pr_payload.head_sha },
+ { org, prisma: __unsafePrisma },
+ );
+
+ if (isServiceError(response)) {
+ logger.debug(`Context file '${filePath}' not found or unreadable — skipping`);
+ return null;
+ }
+
+ const parsed = fileSourceResponseSchema.safeParse(response);
+ if (!parsed.success) {
+ logger.warn(`Failed to parse context file response for '${filePath}'`);
+ return null;
+ }
+
+ const content = parsed.data.source;
+ logger.debug(`Fetched context file '${filePath}' (${Buffer.byteLength(content, 'utf8')} bytes)`);
+ return {
+ type: "repo_instructions",
+ description: `Repository-level review instructions from ${filePath}`,
+ context: content,
+ };
+};
diff --git a/packages/web/src/features/agents/review-agent/nodes/generatePrReview.test.ts b/packages/web/src/features/agents/review-agent/nodes/generatePrReview.test.ts
new file mode 100644
index 000000000..15df648e6
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/generatePrReview.test.ts
@@ -0,0 +1,171 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { sourcebot_pr_payload } from '@/features/agents/review-agent/types';
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+const mockFetchContextFile = vi.fn();
+const mockFetchFileContent = vi.fn();
+const mockGenerateDiffReviewPrompt = vi.fn();
+const mockInvokeDiffReviewLlm = vi.fn();
+
+vi.mock('@/features/agents/review-agent/nodes/fetchFileContent', () => ({
+ fetchContextFile: (...args: unknown[]) => mockFetchContextFile(...args),
+ fetchFileContent: (...args: unknown[]) => mockFetchFileContent(...args),
+}));
+
+vi.mock('@/features/agents/review-agent/nodes/generateDiffReviewPrompt', () => ({
+ generateDiffReviewPrompt: (...args: unknown[]) => mockGenerateDiffReviewPrompt(...args),
+}));
+
+vi.mock('@/features/agents/review-agent/nodes/invokeDiffReviewLlm', () => ({
+ invokeDiffReviewLlm: (...args: unknown[]) => mockInvokeDiffReviewLlm(...args),
+ getReviewAgentLogDir: vi.fn(() => '/tmp'),
+}));
+
+import { generatePrReviews } from './generatePrReview';
+
+// A minimal PR payload with one file and one diff hunk.
+function makePayload(overrides: Partial = {}): sourcebot_pr_payload {
+ return {
+ title: 'Test PR',
+ description: '',
+ hostDomain: 'github.com',
+ owner: 'acme',
+ repo: 'api',
+ number: 1,
+ head_sha: 'abc123',
+ file_diffs: [
+ {
+ from: 'src/foo.ts',
+ to: 'src/foo.ts',
+ diffs: [{ oldSnippet: 'old', newSnippet: 'new' }],
+ },
+ ],
+ ...overrides,
+ };
+}
+
+const FILE_CONTENT_CTX = { type: 'file_content' as const, description: 'file', context: 'content' };
+const REPO_INSTRUCTIONS_CTX = { type: 'repo_instructions' as const, description: 'instructions', context: 'Use Result.' };
+
+beforeEach(() => {
+ mockFetchContextFile.mockReset();
+ mockFetchFileContent.mockReset();
+ mockGenerateDiffReviewPrompt.mockReset();
+ mockInvokeDiffReviewLlm.mockReset();
+
+ mockFetchFileContent.mockResolvedValue(FILE_CONTENT_CTX);
+ mockGenerateDiffReviewPrompt.mockResolvedValue('prompt text');
+ mockInvokeDiffReviewLlm.mockResolvedValue({ reviews: [] });
+});
+
+// ─── contextFiles parameter ───────────────────────────────────────────────────
+
+describe('contextFiles parameter', () => {
+ test('does not call fetchContextFile when contextFiles is undefined', async () => {
+ await generatePrReviews(undefined, makePayload(), [], undefined, undefined);
+
+ expect(mockFetchContextFile).not.toHaveBeenCalled();
+ });
+
+ test('does not call fetchContextFile when contextFiles is an empty string', async () => {
+ await generatePrReviews(undefined, makePayload(), [], undefined, '');
+
+ expect(mockFetchContextFile).not.toHaveBeenCalled();
+ });
+
+ test('calls fetchContextFile once for a single path', async () => {
+ mockFetchContextFile.mockResolvedValue(null);
+
+ await generatePrReviews(undefined, makePayload(), [], undefined, 'AGENTS.md');
+
+ expect(mockFetchContextFile).toHaveBeenCalledTimes(1);
+ expect(mockFetchContextFile).toHaveBeenCalledWith(expect.anything(), 'AGENTS.md');
+ });
+
+ test('calls fetchContextFile once per path in a comma-separated list', async () => {
+ mockFetchContextFile.mockResolvedValue(null);
+
+ await generatePrReviews(undefined, makePayload(), [], undefined, 'AGENTS.md,.sourcebot/review.md');
+
+ expect(mockFetchContextFile).toHaveBeenCalledTimes(2);
+ expect(mockFetchContextFile).toHaveBeenCalledWith(expect.anything(), 'AGENTS.md');
+ expect(mockFetchContextFile).toHaveBeenCalledWith(expect.anything(), '.sourcebot/review.md');
+ });
+
+ test('calls fetchContextFile once per path in a space-separated list', async () => {
+ mockFetchContextFile.mockResolvedValue(null);
+
+ await generatePrReviews(undefined, makePayload(), [], undefined, 'AGENTS.md .sourcebot/review.md');
+
+ expect(mockFetchContextFile).toHaveBeenCalledTimes(2);
+ });
+
+ test('fetchContextFile is called only once regardless of the number of diffs', async () => {
+ mockFetchContextFile.mockResolvedValue(null);
+
+ const payload = makePayload({
+ file_diffs: [
+ { from: 'a.ts', to: 'a.ts', diffs: [{ oldSnippet: 'a', newSnippet: 'b' }, { oldSnippet: 'c', newSnippet: 'd' }] },
+ { from: 'b.ts', to: 'b.ts', diffs: [{ oldSnippet: 'e', newSnippet: 'f' }] },
+ ],
+ });
+
+ await generatePrReviews(undefined, payload, [], undefined, 'AGENTS.md');
+
+ expect(mockFetchContextFile).toHaveBeenCalledTimes(1);
+ });
+
+ test('a null result from fetchContextFile (missing file) is excluded from context', async () => {
+ mockFetchContextFile.mockResolvedValue(null);
+
+ await generatePrReviews(undefined, makePayload(), [], undefined, 'AGENTS.md');
+
+ expect(mockGenerateDiffReviewPrompt).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.not.arrayContaining([expect.objectContaining({ type: 'repo_instructions' })]),
+ expect.anything(),
+ );
+ });
+
+ test('a successful fetchContextFile result is included in the context for every diff', async () => {
+ mockFetchContextFile.mockResolvedValue(REPO_INSTRUCTIONS_CTX);
+
+ const payload = makePayload({
+ file_diffs: [
+ { from: 'a.ts', to: 'a.ts', diffs: [{ oldSnippet: 'a', newSnippet: 'b' }] },
+ { from: 'b.ts', to: 'b.ts', diffs: [{ oldSnippet: 'c', newSnippet: 'd' }] },
+ ],
+ });
+
+ await generatePrReviews(undefined, payload, [], undefined, 'AGENTS.md');
+
+ // generateDiffReviewPrompt is called once per diff (2 files × 1 diff each = 2 calls).
+ expect(mockGenerateDiffReviewPrompt).toHaveBeenCalledTimes(2);
+ for (const call of mockGenerateDiffReviewPrompt.mock.calls) {
+ const context: unknown[] = call[1];
+ expect(context).toContainEqual(expect.objectContaining({ type: 'repo_instructions' }));
+ }
+ });
+
+ test('only non-null context files are included when some paths are missing', async () => {
+ mockFetchContextFile
+ .mockResolvedValueOnce(REPO_INSTRUCTIONS_CTX) // AGENTS.md found
+ .mockResolvedValueOnce(null); // missing.md not found
+
+ await generatePrReviews(undefined, makePayload(), [], undefined, 'AGENTS.md missing.md');
+
+ const context: unknown[] = mockGenerateDiffReviewPrompt.mock.calls[0][1];
+ const repoInstructions = context.filter((c: unknown) =>
+ (c as { type: string }).type === 'repo_instructions'
+ );
+ expect(repoInstructions).toHaveLength(1);
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/nodes/generatePrReview.ts b/packages/web/src/features/agents/review-agent/nodes/generatePrReview.ts
index 4732049a3..4f2f296c2 100644
--- a/packages/web/src/features/agents/review-agent/nodes/generatePrReview.ts
+++ b/packages/web/src/features/agents/review-agent/nodes/generatePrReview.ts
@@ -1,14 +1,28 @@
import { sourcebot_pr_payload, sourcebot_diff_review, sourcebot_file_diff_review, sourcebot_context } from "@/features/agents/review-agent/types";
import { generateDiffReviewPrompt } from "@/features/agents/review-agent/nodes/generateDiffReviewPrompt";
import { invokeDiffReviewLlm } from "@/features/agents/review-agent/nodes/invokeDiffReviewLlm";
-import { fetchFileContent } from "@/features/agents/review-agent/nodes/fetchFileContent";
+import { fetchContextFile, fetchFileContent } from "@/features/agents/review-agent/nodes/fetchFileContent";
import { createLogger } from "@sourcebot/shared";
const logger = createLogger('generate-pr-review');
-export const generatePrReviews = async (reviewAgentLogFileName: string | undefined, pr_payload: sourcebot_pr_payload, rules: string[]): Promise => {
+export const generatePrReviews = async (reviewAgentLogFileName: string | undefined, pr_payload: sourcebot_pr_payload, rules: string[], modelOverride?: string, contextFiles?: string): Promise => {
logger.debug("Executing generate_pr_reviews");
+ // Parse comma- or whitespace-separated list and fetch all files once per PR.
+ const contextFilePaths = contextFiles
+ ? contextFiles.split(/[\s,]+/).map((p) => p.trim()).filter(Boolean)
+ : [];
+ const repoInstructionsContexts = (
+ await Promise.allSettled(contextFilePaths.map((p) => fetchContextFile(pr_payload, p)))
+ ).flatMap((result) => {
+ if (result.status === 'rejected') {
+ logger.warn(`Unexpected error fetching context file: ${result.reason}`);
+ return [];
+ }
+ return result.value !== null ? [result.value] : [];
+ });
+
const file_diff_reviews: sourcebot_file_diff_review[] = [];
for (const file_diff of pr_payload.file_diffs) {
const reviews: sourcebot_diff_review[] = [];
@@ -28,11 +42,12 @@ export const generatePrReviews = async (reviewAgentLogFileName: string | undefin
context: pr_payload.description,
},
fileContentContext,
+ ...repoInstructionsContexts,
];
const prompt = await generateDiffReviewPrompt(diff, context, rules);
-
- const diffReview = await invokeDiffReviewLlm(reviewAgentLogFileName, prompt);
+
+ const diffReview = await invokeDiffReviewLlm(reviewAgentLogFileName, prompt, modelOverride);
reviews.push(...diffReview.reviews);
} catch (error) {
logger.error(`Error generating review for ${file_diff.to}: ${error}`);
diff --git a/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts b/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts
index e43f137fe..8b352462e 100644
--- a/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts
+++ b/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts
@@ -20,7 +20,7 @@ const validateLogPath = (logPath: string): void => {
}
};
-export const invokeDiffReviewLlm = async (reviewAgentLogPath: string | undefined, prompt: string): Promise => {
+export const invokeDiffReviewLlm = async (reviewAgentLogPath: string | undefined, prompt: string, modelOverride?: string): Promise => {
logger.debug("Executing invoke_diff_review_llm");
const models = await getConfiguredLanguageModels();
@@ -29,12 +29,15 @@ export const invokeDiffReviewLlm = async (reviewAgentLogPath: string | undefined
}
let selectedModel = models[0];
- if (env.REVIEW_AGENT_MODEL) {
- const match = models.find((m) => m.displayName === env.REVIEW_AGENT_MODEL);
+
+ // Priority: per-config model override > REVIEW_AGENT_MODEL env var > first configured model
+ const modelName = modelOverride ?? env.REVIEW_AGENT_MODEL;
+ if (modelName) {
+ const match = models.find((m) => m.displayName === modelName);
if (match) {
selectedModel = match;
} else {
- logger.warn(`REVIEW_AGENT_MODEL="${env.REVIEW_AGENT_MODEL}" did not match any configured model displayName. Falling back to the first configured model.`);
+ logger.warn(`Model "${modelName}" did not match any configured model displayName. Falling back to the first configured model.`);
}
}
diff --git a/packages/web/src/features/agents/review-agent/resolveAgentConfig.test.ts b/packages/web/src/features/agents/review-agent/resolveAgentConfig.test.ts
new file mode 100644
index 000000000..dcb6c1830
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/resolveAgentConfig.test.ts
@@ -0,0 +1,195 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { mockDeep, mockReset } from 'vitest-mock-extended';
+import { resolveAgentConfig } from './resolveAgentConfig';
+import { AgentConfig, AgentScope, AgentType, PrismaClient, PromptMode } from '@sourcebot/db';
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+const prisma = mockDeep();
+
+beforeEach(() => {
+ mockReset(prisma);
+});
+
+function makeConfig(scope: AgentScope, overrides: Partial = {}): AgentConfig {
+ return {
+ id: 'cfg-1',
+ orgId: 1,
+ name: 'test-config',
+ description: null,
+ type: AgentType.CODE_REVIEW,
+ enabled: true,
+ prompt: null,
+ promptMode: PromptMode.APPEND,
+ scope,
+ settings: {},
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ };
+}
+
+describe('resolveAgentConfig', () => {
+ describe('priority chain', () => {
+ test('returns REPO-scoped config when one matches', async () => {
+ const cfg = makeConfig(AgentScope.REPO);
+ prisma.agentConfig.findFirst.mockResolvedValueOnce(cfg);
+
+ const result = await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(result).toEqual(cfg);
+ });
+
+ test('falls back to CONNECTION-scoped when there is no REPO match', async () => {
+ const cfg = makeConfig(AgentScope.CONNECTION);
+ prisma.agentConfig.findFirst
+ .mockResolvedValueOnce(null) // REPO miss
+ .mockResolvedValueOnce(cfg); // CONNECTION hit
+
+ const result = await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(result).toEqual(cfg);
+ });
+
+ test('falls back to ORG-scoped when there is no REPO or CONNECTION match', async () => {
+ const cfg = makeConfig(AgentScope.ORG);
+ prisma.agentConfig.findFirst
+ .mockResolvedValueOnce(null) // REPO miss
+ .mockResolvedValueOnce(null) // CONNECTION miss
+ .mockResolvedValueOnce(cfg); // ORG hit
+
+ const result = await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(result).toEqual(cfg);
+ });
+
+ test('returns null when no config matches any scope', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ const result = await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(result).toBeNull();
+ });
+
+ test('stops after the first match and does not query further scopes', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValueOnce(makeConfig(AgentScope.REPO));
+
+ await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(prisma.agentConfig.findFirst).toHaveBeenCalledTimes(1);
+ });
+
+ test('queries CONNECTION scope after a REPO miss, then stops', async () => {
+ prisma.agentConfig.findFirst
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(makeConfig(AgentScope.CONNECTION));
+
+ await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(prisma.agentConfig.findFirst).toHaveBeenCalledTimes(2);
+ });
+
+ test('queries all three scopes when REPO and CONNECTION both miss', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(prisma.agentConfig.findFirst).toHaveBeenCalledTimes(3);
+ });
+ });
+
+ describe('query filters', () => {
+ test('REPO query filters by the given repoId', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ await resolveAgentConfig(42, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(prisma.agentConfig.findFirst).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({
+ where: expect.objectContaining({
+ scope: 'REPO',
+ repos: { some: { repoId: 42 } },
+ }),
+ }),
+ );
+ });
+
+ test('all queries filter by the given orgId', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ await resolveAgentConfig(10, 99, AgentType.CODE_REVIEW, prisma);
+
+ for (const call of prisma.agentConfig.findFirst.mock.calls) {
+ expect(call[0]).toMatchObject({ where: { orgId: 99 } });
+ }
+ });
+
+ test('all queries filter by the given AgentType', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ for (const call of prisma.agentConfig.findFirst.mock.calls) {
+ expect(call[0]).toMatchObject({ where: { type: AgentType.CODE_REVIEW } });
+ }
+ });
+
+ test('all queries only consider enabled configs', async () => {
+ prisma.agentConfig.findFirst.mockResolvedValue(null);
+
+ await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ for (const call of prisma.agentConfig.findFirst.mock.calls) {
+ expect(call[0]).toMatchObject({ where: { enabled: true } });
+ }
+ });
+
+ test('CONNECTION query traverses the repo→connection relationship', async () => {
+ prisma.agentConfig.findFirst
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(null);
+
+ await resolveAgentConfig(7, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(prisma.agentConfig.findFirst).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ where: expect.objectContaining({
+ scope: 'CONNECTION',
+ connections: {
+ some: {
+ connection: {
+ repos: { some: { repoId: 7 } },
+ },
+ },
+ },
+ }),
+ }),
+ );
+ });
+
+ test('ORG query uses ORG scope', async () => {
+ prisma.agentConfig.findFirst
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce(null);
+
+ await resolveAgentConfig(10, 1, AgentType.CODE_REVIEW, prisma);
+
+ expect(prisma.agentConfig.findFirst).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({
+ where: expect.objectContaining({ scope: 'ORG' }),
+ }),
+ );
+ });
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/resolveAgentConfig.ts b/packages/web/src/features/agents/review-agent/resolveAgentConfig.ts
new file mode 100644
index 000000000..523d7f555
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/resolveAgentConfig.ts
@@ -0,0 +1,82 @@
+import { AgentConfig, AgentType, PrismaClient } from "@sourcebot/db";
+import { createLogger } from "@sourcebot/shared";
+
+const logger = createLogger('resolve-agent-config');
+
+/**
+ * Resolves the most specific AgentConfig for a repository, using the following priority:
+ * 1. REPO-scoped config matching the given repo
+ * 2. CONNECTION-scoped config matching any connection the repo belongs to
+ * 3. ORG-scoped config
+ *
+ * Returns null if no enabled config exists.
+ */
+export async function resolveAgentConfig(
+ repoId: number,
+ orgId: number,
+ type: AgentType,
+ prisma: PrismaClient,
+): Promise {
+ // 1. Repo-scoped
+ const repoConfig = await prisma.agentConfig.findFirst({
+ where: {
+ orgId,
+ type,
+ enabled: true,
+ scope: 'REPO',
+ repos: {
+ some: { repoId },
+ },
+ },
+ orderBy: { updatedAt: 'desc' },
+ });
+
+ if (repoConfig) {
+ logger.debug(`Resolved REPO-scoped AgentConfig '${repoConfig.name}' for repo ${repoId}`);
+ return repoConfig;
+ }
+
+ // 2. Connection-scoped (repo must belong to at least one of the config's connections)
+ const connectionConfig = await prisma.agentConfig.findFirst({
+ where: {
+ orgId,
+ type,
+ enabled: true,
+ scope: 'CONNECTION',
+ connections: {
+ some: {
+ connection: {
+ repos: {
+ some: { repoId },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { updatedAt: 'desc' },
+ });
+
+ if (connectionConfig) {
+ logger.debug(`Resolved CONNECTION-scoped AgentConfig '${connectionConfig.name}' for repo ${repoId}`);
+ return connectionConfig;
+ }
+
+ // 3. Org-wide
+ const orgConfig = await prisma.agentConfig.findFirst({
+ where: {
+ orgId,
+ type,
+ enabled: true,
+ scope: 'ORG',
+ },
+ orderBy: { updatedAt: 'desc' },
+ });
+
+ if (orgConfig) {
+ logger.debug(`Resolved ORG-scoped AgentConfig '${orgConfig.name}' for repo ${repoId}`);
+ } else {
+ logger.debug(`No AgentConfig found for repo ${repoId}, using system defaults`);
+ }
+
+ return orgConfig;
+}
diff --git a/packages/web/src/features/agents/review-agent/types.ts b/packages/web/src/features/agents/review-agent/types.ts
index 1a9aba1eb..700095e57 100644
--- a/packages/web/src/features/agents/review-agent/types.ts
+++ b/packages/web/src/features/agents/review-agent/types.ts
@@ -37,7 +37,7 @@ export const sourcebot_pr_payload_schema = z.object({
export type sourcebot_pr_payload = z.infer;
export const sourcebot_context_schema = z.object({
- type: z.enum(["pr_title", "pr_description", "pr_summary", "comment_chains", "file_content"]),
+ type: z.enum(["pr_title", "pr_description", "pr_summary", "comment_chains", "file_content", "repo_instructions"]),
description: z.string().optional(),
context: z.string(),
});
diff --git a/packages/web/src/features/agents/review-agent/webhookUtils.test.ts b/packages/web/src/features/agents/review-agent/webhookUtils.test.ts
new file mode 100644
index 000000000..1018ecb18
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/webhookUtils.test.ts
@@ -0,0 +1,139 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { AgentConfig, AgentScope, AgentType, PromptMode } from '@sourcebot/db';
+
+// Use vi.hoisted so the env object can be mutated per-test before module load.
+const mocks = vi.hoisted(() => ({
+ env: {
+ REVIEW_AGENT_AUTO_REVIEW_ENABLED: undefined as string | undefined,
+ REVIEW_AGENT_REVIEW_COMMAND: 'review',
+ },
+}));
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+ env: mocks.env,
+}));
+
+// app.ts is imported by webhookUtils — keep its heavy node dependencies quiet.
+vi.mock('@/features/agents/review-agent/nodes/generatePrReview', () => ({ generatePrReviews: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/githubPushPrReviews', () => ({ githubPushPrReviews: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/githubPrParser', () => ({ githubPrParser: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/invokeDiffReviewLlm', () => ({ getReviewAgentLogDir: vi.fn(() => '/tmp'), invokeDiffReviewLlm: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/gitlabMrParser', () => ({ gitlabMrParser: vi.fn() }));
+vi.mock('@/features/agents/review-agent/nodes/gitlabPushMrReviews', () => ({ gitlabPushMrReviews: vi.fn() }));
+
+import { isAutoReviewEnabled, getReviewCommand } from './webhookUtils';
+
+function makeConfig(settings: Record = {}): AgentConfig {
+ return {
+ id: 'cfg-1',
+ orgId: 1,
+ name: 'test-config',
+ description: null,
+ type: AgentType.CODE_REVIEW,
+ enabled: true,
+ prompt: null,
+ promptMode: PromptMode.APPEND,
+ scope: AgentScope.ORG,
+ settings: settings as AgentConfig['settings'],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+beforeEach(() => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = undefined;
+ mocks.env.REVIEW_AGENT_REVIEW_COMMAND = 'review';
+});
+
+// ─── isAutoReviewEnabled ─────────────────────────────────────────────────────
+
+describe('isAutoReviewEnabled', () => {
+ test('returns true when no config and env flag is not set', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = undefined;
+
+ expect(isAutoReviewEnabled(null)).toBe(true);
+ });
+
+ test('returns true when no config and env flag is set to "true"', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = 'true';
+
+ expect(isAutoReviewEnabled(null)).toBe(true);
+ });
+
+ test('returns false when no config and env flag is "false"', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = 'false';
+
+ expect(isAutoReviewEnabled(null)).toBe(false);
+ });
+
+ test('per-config true overrides env "false"', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = 'false';
+
+ expect(isAutoReviewEnabled(makeConfig({ autoReviewEnabled: true }))).toBe(true);
+ });
+
+ test('per-config false overrides env "true"', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = 'true';
+
+ expect(isAutoReviewEnabled(makeConfig({ autoReviewEnabled: false }))).toBe(false);
+ });
+
+ test('per-config false overrides unset env (which would default to true)', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = undefined;
+
+ expect(isAutoReviewEnabled(makeConfig({ autoReviewEnabled: false }))).toBe(false);
+ });
+
+ test('falls back to env when config has no autoReviewEnabled setting', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = 'false';
+
+ expect(isAutoReviewEnabled(makeConfig({}))).toBe(false);
+ });
+
+ test('returns true when config is provided but has no settings at all', () => {
+ mocks.env.REVIEW_AGENT_AUTO_REVIEW_ENABLED = undefined;
+
+ expect(isAutoReviewEnabled(makeConfig({}))).toBe(true);
+ });
+});
+
+// ─── getReviewCommand ────────────────────────────────────────────────────────
+
+describe('getReviewCommand', () => {
+ test('returns the env default when config is null', () => {
+ mocks.env.REVIEW_AGENT_REVIEW_COMMAND = 'review';
+
+ expect(getReviewCommand(null)).toBe('review');
+ });
+
+ test('returns the env default when config has no reviewCommand', () => {
+ mocks.env.REVIEW_AGENT_REVIEW_COMMAND = 'review';
+
+ expect(getReviewCommand(makeConfig({}))).toBe('review');
+ });
+
+ test('returns per-config command over the env default', () => {
+ mocks.env.REVIEW_AGENT_REVIEW_COMMAND = 'review';
+
+ expect(getReviewCommand(makeConfig({ reviewCommand: 'check' }))).toBe('check');
+ });
+
+ test('per-config command is returned regardless of the env value', () => {
+ mocks.env.REVIEW_AGENT_REVIEW_COMMAND = 'something-else';
+
+ expect(getReviewCommand(makeConfig({ reviewCommand: 'security-review' }))).toBe('security-review');
+ });
+
+ test('returns the env value when config reviewCommand is an empty string (falsy)', () => {
+ mocks.env.REVIEW_AGENT_REVIEW_COMMAND = 'review';
+
+ // An empty string is falsy — should fall back to env
+ expect(getReviewCommand(makeConfig({ reviewCommand: '' }))).toBe('review');
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/webhookUtils.ts b/packages/web/src/features/agents/review-agent/webhookUtils.ts
new file mode 100644
index 000000000..962b4c366
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/webhookUtils.ts
@@ -0,0 +1,33 @@
+import { AgentConfig } from "@sourcebot/db";
+import { env } from "@sourcebot/shared";
+import { parseAgentConfigSettings } from "./app";
+
+/**
+ * Returns whether auto-review is enabled, checking (in priority order):
+ * 1. Per-config setting (AgentConfig.settings.autoReviewEnabled)
+ * 2. REVIEW_AGENT_AUTO_REVIEW_ENABLED env var (treated as true unless explicitly "false")
+ */
+export function isAutoReviewEnabled(config: AgentConfig | null): boolean {
+ if (config) {
+ const settings = parseAgentConfigSettings(config.settings);
+ if (settings.autoReviewEnabled !== undefined) {
+ return settings.autoReviewEnabled;
+ }
+ }
+ return env.REVIEW_AGENT_AUTO_REVIEW_ENABLED !== "false";
+}
+
+/**
+ * Returns the review command trigger string, checking (in priority order):
+ * 1. Per-config setting (AgentConfig.settings.reviewCommand)
+ * 2. REVIEW_AGENT_REVIEW_COMMAND env var
+ */
+export function getReviewCommand(config: AgentConfig | null): string {
+ if (config) {
+ const settings = parseAgentConfigSettings(config.settings);
+ if (settings.reviewCommand) {
+ return settings.reviewCommand;
+ }
+ }
+ return env.REVIEW_AGENT_REVIEW_COMMAND;
+}
diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts
index 0f589f28b..30bc51022 100644
--- a/packages/web/src/lib/errorCodes.ts
+++ b/packages/web/src/lib/errorCodes.ts
@@ -34,4 +34,6 @@ export enum ErrorCode {
LAST_OWNER_CANNOT_BE_DEMOTED = 'LAST_OWNER_CANNOT_BE_DEMOTED',
LAST_OWNER_CANNOT_BE_REMOVED = 'LAST_OWNER_CANNOT_BE_REMOVED',
API_KEY_USAGE_DISABLED = 'API_KEY_USAGE_DISABLED',
+ AGENT_CONFIG_NOT_FOUND = 'AGENT_CONFIG_NOT_FOUND',
+ AGENT_CONFIG_ALREADY_EXISTS = 'AGENT_CONFIG_ALREADY_EXISTS',
}
diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts
index 147c43c88..fdd87c19d 100644
--- a/packages/web/src/openapi/publicApiDocument.ts
+++ b/packages/web/src/openapi/publicApiDocument.ts
@@ -3,6 +3,10 @@ import type { ComponentsObject, SchemaObject, SecuritySchemeObject } from 'opena
import { type ZodTypeAny } from 'zod';
import z from 'zod';
import {
+ publicAgentConfigListSchema,
+ publicAgentConfigSchema,
+ publicCreateAgentConfigBodySchema,
+ publicDeleteAgentConfigResponseSchema,
publicEeAuditQuerySchema,
publicEeAuditResponseSchema,
publicEeDeleteUserResponseSchema,
@@ -25,6 +29,7 @@ import {
publicSearchRequestSchema,
publicSearchResponseSchema,
publicServiceErrorSchema,
+ publicUpdateAgentConfigBodySchema,
publicVersionResponseSchema,
} from './publicApiSchemas.js';
import dedent from 'dedent';
@@ -34,6 +39,7 @@ const reposTag = { name: 'Repositories', description: 'Repository listing and me
const gitTag = { name: 'Git', description: 'Git history, diff, and file content endpoints.' };
const systemTag = { name: 'System', description: 'System health and version endpoints.' };
const eeTag = { name: 'Enterprise (EE)', description: 'Enterprise endpoints for user management and audit logging.' };
+const agentsTag = { name: 'Agents', description: 'Manage customisable AI agent configurations.' };
const EE_LICENSE_KEY_NOTE = dedent`
@@ -362,6 +368,115 @@ export function createPublicOpenApiDocument(version: string) {
},
});
+ // Agents
+ registry.registerPath({
+ method: 'get',
+ path: '/api/agents',
+ operationId: 'listAgentConfigs',
+ tags: [agentsTag.name],
+ summary: 'List agent configs',
+ description: 'Returns all agent configurations for the organization.',
+ responses: {
+ 200: {
+ description: 'List of agent configs.',
+ content: jsonContent(publicAgentConfigListSchema),
+ },
+ 401: errorJson('Not authenticated.'),
+ 500: errorJson('Unexpected failure.'),
+ },
+ });
+
+ registry.registerPath({
+ method: 'post',
+ path: '/api/agents',
+ operationId: 'createAgentConfig',
+ tags: [agentsTag.name],
+ summary: 'Create an agent config',
+ description: 'Creates a new agent configuration scoped to the organization, a set of connections, or specific repositories.',
+ request: {
+ body: {
+ required: true,
+ content: jsonContent(publicCreateAgentConfigBodySchema),
+ },
+ },
+ responses: {
+ 201: {
+ description: 'Agent config created.',
+ content: jsonContent(publicAgentConfigSchema),
+ },
+ 400: errorJson('Invalid request body.'),
+ 401: errorJson('Not authenticated.'),
+ 409: errorJson('An agent config with that name already exists.'),
+ 500: errorJson('Unexpected failure.'),
+ },
+ });
+
+ registry.registerPath({
+ method: 'get',
+ path: '/api/agents/{agentId}',
+ operationId: 'getAgentConfig',
+ tags: [agentsTag.name],
+ summary: 'Get an agent config',
+ request: {
+ params: z.object({ agentId: z.string() }),
+ },
+ responses: {
+ 200: {
+ description: 'Agent config.',
+ content: jsonContent(publicAgentConfigSchema),
+ },
+ 401: errorJson('Not authenticated.'),
+ 404: errorJson('Agent config not found.'),
+ 500: errorJson('Unexpected failure.'),
+ },
+ });
+
+ registry.registerPath({
+ method: 'patch',
+ path: '/api/agents/{agentId}',
+ operationId: 'updateAgentConfig',
+ tags: [agentsTag.name],
+ summary: 'Update an agent config',
+ description: 'Partially updates an agent configuration. Only provided fields are changed.',
+ request: {
+ params: z.object({ agentId: z.string() }),
+ body: {
+ required: true,
+ content: jsonContent(publicUpdateAgentConfigBodySchema),
+ },
+ },
+ responses: {
+ 200: {
+ description: 'Updated agent config.',
+ content: jsonContent(publicAgentConfigSchema),
+ },
+ 400: errorJson('Invalid request body.'),
+ 401: errorJson('Not authenticated.'),
+ 404: errorJson('Agent config not found.'),
+ 500: errorJson('Unexpected failure.'),
+ },
+ });
+
+ registry.registerPath({
+ method: 'delete',
+ path: '/api/agents/{agentId}',
+ operationId: 'deleteAgentConfig',
+ tags: [agentsTag.name],
+ summary: 'Delete an agent config',
+ request: {
+ params: z.object({ agentId: z.string() }),
+ },
+ responses: {
+ 200: {
+ description: 'Agent config deleted.',
+ content: jsonContent(publicDeleteAgentConfigResponseSchema),
+ },
+ 401: errorJson('Not authenticated.'),
+ 404: errorJson('Agent config not found.'),
+ 500: errorJson('Unexpected failure.'),
+ },
+ });
+
// EE: User Management
registry.registerPath({
method: 'get',
@@ -481,7 +596,7 @@ export function createPublicOpenApiDocument(version: string) {
version,
description: 'OpenAPI description for the public Sourcebot REST endpoints used for search, repository listing, and file browsing. Authentication is instance-dependent: API keys are the standard integration mechanism, OAuth bearer tokens are EE-only, and some instances may allow anonymous access.',
},
- tags: [searchTag, reposTag, gitTag, systemTag, eeTag],
+ tags: [searchTag, reposTag, gitTag, systemTag, agentsTag, eeTag],
security: [
{ [securitySchemeNames.bearerToken]: [] },
{ [securitySchemeNames.apiKeyHeader]: [] },
diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts
index dae94afc1..73ddb6943 100644
--- a/packages/web/src/openapi/publicApiSchemas.ts
+++ b/packages/web/src/openapi/publicApiSchemas.ts
@@ -55,6 +55,84 @@ export const publicHealthResponseSchema = z.object({
status: z.enum(['ok']),
}).openapi('PublicHealthResponse');
+// Agent configs
+const publicAgentConfigSettingsSchema = z.object({
+ autoReviewEnabled: z.boolean().optional().describe('Whether the agent automatically reviews new PRs/MRs. Overrides the REVIEW_AGENT_AUTO_REVIEW_ENABLED env var.'),
+ reviewCommand: z.string().optional().describe('Comment command that triggers a manual review (without the leading /). Overrides the REVIEW_AGENT_REVIEW_COMMAND env var.'),
+ model: z.string().optional().describe('Display name of the language model to use for this config. Overrides the REVIEW_AGENT_MODEL env var.'),
+ contextFiles: z.string().optional().describe('Comma or space separated list of file paths to fetch from the repository and inject as context for each review. Missing files are silently ignored.'),
+}).openapi('PublicAgentConfigSettings');
+
+const publicAgentConfigRepoSchema = z.object({
+ id: z.number().int(),
+ displayName: z.string().nullable(),
+ external_id: z.string(),
+ external_codeHostType: z.string(),
+}).openapi('PublicAgentConfigRepo');
+
+const publicAgentConfigConnectionSchema = z.object({
+ id: z.number().int(),
+ name: z.string(),
+ connectionType: z.string(),
+}).openapi('PublicAgentConfigConnection');
+
+export const publicAgentConfigSchema = z.object({
+ id: z.string(),
+ orgId: z.number().int(),
+ name: z.string(),
+ description: z.string().nullable(),
+ type: z.enum(['CODE_REVIEW']),
+ enabled: z.boolean(),
+ prompt: z.string().nullable().describe('Custom prompt instructions. Null uses the built-in rules only.'),
+ promptMode: z.enum(['REPLACE', 'APPEND']).describe('Whether the custom prompt replaces or appends to the built-in rules.'),
+ scope: z.enum(['ORG', 'CONNECTION', 'REPO']).describe('What this config is scoped to.'),
+ repos: z.array(z.object({
+ agentConfigId: z.string(),
+ repoId: z.number().int(),
+ repo: publicAgentConfigRepoSchema,
+ })),
+ connections: z.array(z.object({
+ agentConfigId: z.string(),
+ connectionId: z.number().int(),
+ connection: publicAgentConfigConnectionSchema,
+ })),
+ settings: publicAgentConfigSettingsSchema,
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+}).openapi('PublicAgentConfig');
+
+export const publicAgentConfigListSchema = z.array(publicAgentConfigSchema).openapi('PublicAgentConfigList');
+
+export const publicCreateAgentConfigBodySchema = z.object({
+ name: z.string().min(1).max(255).describe('Unique name for this agent config within the org.'),
+ description: z.string().optional().describe('Optional description.'),
+ type: z.enum(['CODE_REVIEW']).describe('The type of agent.'),
+ enabled: z.boolean().default(true).describe('Whether this config is active.'),
+ prompt: z.string().optional().describe('Custom prompt instructions.'),
+ promptMode: z.enum(['REPLACE', 'APPEND']).default('APPEND').describe('How the custom prompt interacts with the built-in rules.'),
+ scope: z.enum(['ORG', 'CONNECTION', 'REPO']).describe('What this config is scoped to.'),
+ repoIds: z.array(z.number().int().positive()).optional().describe('Required when scope is REPO.'),
+ connectionIds: z.array(z.number().int().positive()).optional().describe('Required when scope is CONNECTION.'),
+ settings: publicAgentConfigSettingsSchema.optional().describe('Per-config overrides for model, auto-review, and review command.'),
+}).openapi('PublicCreateAgentConfigBody');
+
+export const publicUpdateAgentConfigBodySchema = z.object({
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().nullable().optional(),
+ type: z.enum(['CODE_REVIEW']).optional(),
+ enabled: z.boolean().optional(),
+ prompt: z.string().nullable().optional(),
+ promptMode: z.enum(['REPLACE', 'APPEND']).optional(),
+ scope: z.enum(['ORG', 'CONNECTION', 'REPO']).optional(),
+ repoIds: z.array(z.number().int().positive()).optional(),
+ connectionIds: z.array(z.number().int().positive()).optional(),
+ settings: publicAgentConfigSettingsSchema.optional(),
+}).openapi('PublicUpdateAgentConfigBody');
+
+export const publicDeleteAgentConfigResponseSchema = z.object({
+ success: z.boolean(),
+}).openapi('PublicDeleteAgentConfigResponse');
+
// EE: User Management
export const publicEeUserSchema = z.object({
name: z.string().nullable(),