Skip to content

Commit 2ea76c6

Browse files
author
Gavin Williams
committed
fix(web): rename PublicAgentConfigRepo fields to camelCase and harden agent config error handling
- Rename `external_id` → `externalId` and `external_codeHostType` → `externalCodeHostType` in the PublicAgentConfigRepo OpenAPI schema and Zod schema; serialize Prisma snake_case fields to camelCase in all GET/POST/PATCH agent API route responses - Add missing `contextFiles` property to PublicAgentConfigSettings in the OpenAPI JSON spec (was already present in the Zod schema) - Wrap `fetch` calls in `handleDelete` and `handleSubmit` in `agentConfigForm.tsx` with try/catch/finally so network errors surface a destructive toast and loading state is always cleared - Add tests covering the repo field camelCase serialization for all four agent API endpoints
1 parent d3991b8 commit 2ea76c6

9 files changed

Lines changed: 191 additions & 41 deletions

File tree

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,18 +1005,18 @@
10051005
"type": "string",
10061006
"nullable": true
10071007
},
1008-
"external_id": {
1008+
"externalId": {
10091009
"type": "string"
10101010
},
1011-
"external_codeHostType": {
1011+
"externalCodeHostType": {
10121012
"type": "string"
10131013
}
10141014
},
10151015
"required": [
10161016
"id",
10171017
"displayName",
1018-
"external_id",
1019-
"external_codeHostType"
1018+
"externalId",
1019+
"externalCodeHostType"
10201020
]
10211021
},
10221022
"PublicAgentConfigConnection": {
@@ -1052,6 +1052,10 @@
10521052
"model": {
10531053
"type": "string",
10541054
"description": "Display name of the language model to use for this config. Overrides the REVIEW_AGENT_MODEL env var."
1055+
},
1056+
"contextFiles": {
1057+
"type": "string",
1058+
"description": "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."
10551059
}
10561060
}
10571061
},

packages/web/src/app/(app)/agents/configs/[agentId]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type Props = {
1111
export default authenticatedPage(async ({ prisma, org }, { params }: Props) => {
1212
const { agentId } = await params;
1313

14-
const [config, connections, repos] = await Promise.all([
14+
const [config, connections, reposRaw] = await Promise.all([
1515
prisma.agentConfig.findFirst({
1616
where: { id: agentId, orgId: org.id },
1717
include: {
@@ -35,6 +35,8 @@ export default authenticatedPage(async ({ prisma, org }, { params }: Props) => {
3535
notFound();
3636
}
3737

38+
const repos = reposRaw.map(r => ({ id: r.id, displayName: r.displayName, externalId: r.external_id, externalCodeHostType: r.external_codeHostType }));
39+
3840
return (
3941
<div className="flex flex-col items-center overflow-hidden min-h-screen">
4042
<NavigationMenu />

packages/web/src/app/(app)/agents/configs/agentConfigForm.tsx

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Trash2 } from "lucide-react";
1313
import { AgentScope, AgentType, PromptMode } from "@sourcebot/db";
1414

1515
type Connection = { id: number; name: string; connectionType: string };
16-
type Repo = { id: number; displayName: string | null; external_id: string; external_codeHostType: string };
16+
type Repo = { id: number; displayName: string | null; externalId: string; externalCodeHostType: string };
1717
type ModelInfo = { provider: string; model: string; displayName: string };
1818

1919
type InitialValues = {
@@ -96,31 +96,39 @@ export function AgentConfigForm({ initialValues, connections, repos }: Props) {
9696

9797
const handleSubmit = () => {
9898
startTransition(async () => {
99-
const payload = buildPayload();
100-
const url = isEditing ? `/api/agents/${initialValues!.id}` : `/api/agents`;
101-
const method = isEditing ? "PATCH" : "POST";
99+
try {
100+
const payload = buildPayload();
101+
const url = isEditing ? `/api/agents/${initialValues!.id}` : `/api/agents`;
102+
const method = isEditing ? "PATCH" : "POST";
103+
104+
const res = await fetch(url, {
105+
method,
106+
headers: { "Content-Type": "application/json" },
107+
body: JSON.stringify(payload),
108+
});
102109

103-
const res = await fetch(url, {
104-
method,
105-
headers: { "Content-Type": "application/json" },
106-
body: JSON.stringify(payload),
107-
});
110+
if (!res.ok) {
111+
const err = await res.json().catch(() => ({}));
112+
toast({
113+
title: "Error",
114+
description: err.message ?? "Failed to save agent config",
115+
variant: "destructive",
116+
});
117+
return;
118+
}
108119

109-
if (!res.ok) {
110-
const err = await res.json().catch(() => ({}));
120+
toast({
121+
title: isEditing ? "Config updated" : "Config created",
122+
description: `Agent config '${name}' was ${isEditing ? "updated" : "created"} successfully.`,
123+
});
124+
router.push("/agents");
125+
} catch {
111126
toast({
112127
title: "Error",
113-
description: err.message ?? "Failed to save agent config",
128+
description: "Failed to save agent config",
114129
variant: "destructive",
115130
});
116-
return;
117131
}
118-
119-
toast({
120-
title: isEditing ? "Config updated" : "Config created",
121-
description: `Agent config '${name}' was ${isEditing ? "updated" : "created"} successfully.`,
122-
});
123-
router.push("/agents");
124132
});
125133
};
126134

@@ -130,16 +138,21 @@ export function AgentConfigForm({ initialValues, connections, repos }: Props) {
130138
}
131139

132140
setIsDeleting(true);
133-
const res = await fetch(`/api/agents/${initialValues.id}`, { method: "DELETE" });
134-
setIsDeleting(false);
141+
try {
142+
const res = await fetch(`/api/agents/${initialValues.id}`, { method: "DELETE" });
135143

136-
if (!res.ok) {
144+
if (!res.ok) {
145+
toast({ title: "Error", description: "Failed to delete agent config", variant: "destructive" });
146+
return;
147+
}
148+
149+
toast({ title: "Config deleted", description: `Agent config '${name}' was deleted.` });
150+
router.push("/agents");
151+
} catch {
137152
toast({ title: "Error", description: "Failed to delete agent config", variant: "destructive" });
138-
return;
153+
} finally {
154+
setIsDeleting(false);
139155
}
140-
141-
toast({ title: "Config deleted", description: `Agent config '${name}' was deleted.` });
142-
router.push("/agents");
143156
};
144157

145158
const toggleRepoId = (id: number) => {
@@ -215,7 +228,7 @@ export function AgentConfigForm({ initialValues, connections, repos }: Props) {
215228
{scope === AgentScope.REPO && (() => {
216229
const lc = repoFilter.toLowerCase();
217230
const visible = repos.filter((r) =>
218-
(r.displayName ?? r.external_id).toLowerCase().includes(lc)
231+
(r.displayName ?? r.externalId).toLowerCase().includes(lc)
219232
);
220233
const hiddenSelected = selectedRepoIds.filter(
221234
(id) => !visible.some((r) => r.id === id)
@@ -251,8 +264,8 @@ export function AgentConfigForm({ initialValues, connections, repos }: Props) {
251264
checked={selectedRepoIds.includes(repo.id)}
252265
onChange={() => toggleRepoId(repo.id)}
253266
/>
254-
<span className="text-sm">{repo.displayName ?? repo.external_id}</span>
255-
<span className="text-xs text-muted-foreground ml-auto">{repo.external_codeHostType}</span>
267+
<span className="text-sm">{repo.displayName ?? repo.externalId}</span>
268+
<span className="text-xs text-muted-foreground ml-auto">{repo.externalCodeHostType}</span>
256269
</label>
257270
))
258271
)}

packages/web/src/app/(app)/agents/configs/new/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ export default authenticatedPage(async ({ prisma, org }) => {
1010
orderBy: { name: "asc" },
1111
});
1212

13-
const repos = await prisma.repo.findMany({
13+
const reposRaw = await prisma.repo.findMany({
1414
where: { orgId: org.id },
1515
select: { id: true, displayName: true, external_id: true, external_codeHostType: true },
1616
orderBy: { displayName: "asc" },
1717
});
18+
const repos = reposRaw.map(r => ({ id: r.id, displayName: r.displayName, externalId: r.external_id, externalCodeHostType: r.external_codeHostType }));
1819

1920
return (
2021
<div className="flex flex-col items-center overflow-hidden min-h-screen">

packages/web/src/app/api/(server)/agents/[agentId]/route.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ describe('GET /api/agents/[agentId]', () => {
111111
const body = await res.json();
112112
expect(body.id).toBe('cfg-abc');
113113
});
114+
115+
test('serializes nested repo fields to camelCase', async () => {
116+
const config = {
117+
...makeDbConfig(),
118+
repos: [{
119+
repoId: 1,
120+
agentConfigId: 'cfg-abc',
121+
repo: { id: 1, displayName: 'my-repo', external_id: 'repo-123', external_codeHostType: 'github' },
122+
}],
123+
};
124+
prisma.agentConfig.findFirst.mockResolvedValue(config as any);
125+
126+
const res = await GET(makeGetRequest('cfg-abc'), { params: Promise.resolve({ agentId: 'cfg-abc' }) });
127+
const body = await res.json();
128+
129+
const repo = body.repos[0].repo;
130+
expect(repo.externalId).toBe('repo-123');
131+
expect(repo.externalCodeHostType).toBe('github');
132+
expect(repo.external_id).toBeUndefined();
133+
expect(repo.external_codeHostType).toBeUndefined();
134+
});
114135
});
115136

116137
// ── PATCH /api/agents/[agentId] ───────────────────────────────────────────────
@@ -214,6 +235,27 @@ describe('PATCH /api/agents/[agentId]', () => {
214235
}),
215236
);
216237
});
238+
239+
test('serializes nested repo fields to camelCase', async () => {
240+
const updated = {
241+
...makeDbConfig({ scope: AgentScope.REPO }),
242+
repos: [{
243+
repoId: 1,
244+
agentConfigId: 'cfg-abc',
245+
repo: { id: 1, displayName: 'my-repo', external_id: 'repo-123', external_codeHostType: 'github' },
246+
}],
247+
};
248+
prisma.agentConfig.update.mockResolvedValue(updated as any);
249+
250+
const res = await PATCH(makePatchRequest(AGENT_ID, { enabled: false }), params);
251+
const body = await res.json();
252+
253+
const repo = body.repos[0].repo;
254+
expect(repo.externalId).toBe('repo-123');
255+
expect(repo.externalCodeHostType).toBe('github');
256+
expect(repo.external_id).toBeUndefined();
257+
expect(repo.external_codeHostType).toBeUndefined();
258+
});
217259
});
218260

219261
describe('scope conflict', () => {

packages/web/src/app/api/(server)/agents/[agentId]/route.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,18 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
6161
return notFound(`Agent config '${agentId}' not found`);
6262
}
6363

64-
return config;
64+
return {
65+
...config,
66+
repos: config.repos.map(r => ({
67+
...r,
68+
repo: {
69+
id: r.repo.id,
70+
displayName: r.repo.displayName,
71+
externalId: r.repo.external_id,
72+
externalCodeHostType: r.repo.external_codeHostType,
73+
},
74+
})),
75+
};
6576
});
6677

6778
if (isServiceError(result)) {
@@ -245,7 +256,18 @@ export const PATCH = apiHandler(async (request: NextRequest, { params }: RoutePa
245256
include: includeRelations,
246257
});
247258

248-
return updated;
259+
return {
260+
...updated,
261+
repos: updated.repos.map(r => ({
262+
...r,
263+
repo: {
264+
id: r.repo.id,
265+
displayName: r.repo.displayName,
266+
externalId: r.repo.external_id,
267+
externalCodeHostType: r.repo.external_codeHostType,
268+
},
269+
})),
270+
};
249271
} catch (error) {
250272
logger.error('Error updating agent config', { error, agentId, orgId: org.id });
251273
throw error;

packages/web/src/app/api/(server)/agents/route.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,27 @@ describe('GET /api/agents', () => {
8484
const body = await res.json();
8585
expect(body).toHaveLength(2);
8686
});
87+
88+
test('serializes nested repo fields to camelCase', async () => {
89+
const config = {
90+
...makeDbConfig(),
91+
repos: [{
92+
repoId: 1,
93+
agentConfigId: 'cfg-abc',
94+
repo: { id: 1, displayName: 'my-repo', external_id: 'repo-123', external_codeHostType: 'github' },
95+
}],
96+
};
97+
prisma.agentConfig.findMany.mockResolvedValue([config] as any);
98+
99+
const res = await GET(new NextRequest('http://localhost/api/agents'));
100+
const body = await res.json();
101+
102+
const repo = body[0].repos[0].repo;
103+
expect(repo.externalId).toBe('repo-123');
104+
expect(repo.externalCodeHostType).toBe('github');
105+
expect(repo.external_id).toBeUndefined();
106+
expect(repo.external_codeHostType).toBeUndefined();
107+
});
87108
});
88109

89110
// ── POST /api/agents ──────────────────────────────────────────────────────────
@@ -319,5 +340,28 @@ describe('POST /api/agents', () => {
319340
expect(body.id).toBe(created.id);
320341
expect(body.name).toBe('new-config');
321342
});
343+
344+
test('serializes nested repo fields to camelCase', async () => {
345+
const created = {
346+
...makeDbConfig({ scope: AgentScope.REPO }),
347+
repos: [{
348+
repoId: 1,
349+
agentConfigId: 'cfg-abc',
350+
repo: { id: 1, displayName: 'my-repo', external_id: 'repo-123', external_codeHostType: 'github' },
351+
}],
352+
};
353+
prisma.repo.count.mockResolvedValue(1);
354+
prisma.agentConfig.findFirst.mockResolvedValue(null);
355+
prisma.agentConfig.create.mockResolvedValue(created as any);
356+
357+
const res = await POST(makePostRequest({ name: 'repo-config', type: 'CODE_REVIEW', scope: 'REPO', repoIds: [1] }));
358+
const body = await res.json();
359+
360+
const repo = body.repos[0].repo;
361+
expect(repo.externalId).toBe('repo-123');
362+
expect(repo.externalCodeHostType).toBe('github');
363+
expect(repo.external_id).toBeUndefined();
364+
expect(repo.external_codeHostType).toBeUndefined();
365+
});
322366
});
323367
});

packages/web/src/app/api/(server)/agents/route.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,18 @@ export const GET = apiHandler(async (_request: NextRequest) => {
5252
orderBy: { createdAt: 'desc' },
5353
});
5454

55-
return configs;
55+
return configs.map(config => ({
56+
...config,
57+
repos: config.repos.map(r => ({
58+
...r,
59+
repo: {
60+
id: r.repo.id,
61+
displayName: r.repo.displayName,
62+
externalId: r.repo.external_id,
63+
externalCodeHostType: r.repo.external_codeHostType,
64+
},
65+
})),
66+
}));
5667
});
5768

5869
if (isServiceError(result)) {
@@ -216,7 +227,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
216227
},
217228
});
218229

219-
return config;
230+
return {
231+
...config,
232+
repos: config.repos.map(r => ({
233+
...r,
234+
repo: {
235+
id: r.repo.id,
236+
displayName: r.repo.displayName,
237+
externalId: r.repo.external_id,
238+
externalCodeHostType: r.repo.external_codeHostType,
239+
},
240+
})),
241+
};
220242
} catch (error) {
221243
logger.error('Error creating agent config', { error, name, orgId: org.id });
222244
throw error;

packages/web/src/openapi/publicApiSchemas.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ const publicAgentConfigSettingsSchema = z.object({
7171
const publicAgentConfigRepoSchema = z.object({
7272
id: z.number().int(),
7373
displayName: z.string().nullable(),
74-
external_id: z.string(),
75-
external_codeHostType: z.string(),
74+
externalId: z.string(),
75+
externalCodeHostType: z.string(),
7676
}).openapi('PublicAgentConfigRepo');
7777

7878
const publicAgentConfigConnectionSchema = z.object({

0 commit comments

Comments
 (0)