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 */} +
+

General

+ +
+ + setName(e.target.value)} + placeholder="e.g. Security review" + maxLength={255} + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Optional description" + /> +
+ +
+ + +
+
+ + {/* Scope */} +
+

Scope

+

+ Determines which repositories this config applies to. More specific scopes take priority: Repo > Connection > Org. +

+ +
+ + +
+ + {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 ( +
+
+ + {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) => ( + + )) + )} +
+
+ ); + })()} + + {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 ( +
+
+ + {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) => ( + + )) + )} +
+
+ ); + })()} +
+ + {/* Prompt */} +
+

Custom prompt

+

+ Add custom instructions for the agent. Leave blank to use the built-in defaults only. +

+ +
+ +