Add external AI agent callback flow for async PR reviews
swadhinbiswas committed 8 months ago
dd115c4
1 parent eb6c565
commit dd115c4
5 files changed
+487 -3
Showing 5 changed files+487-3
▼.env.example
+30-0
| @@ -38,6 +38,8 @@ UPSTASH_REDIS_REST_TOKEN=your-token | ||
38 | 38 | # Redis example (Self-hosted): |
39 | 39 | # DATABASE_DRIVER=redis |
40 | 40 | # DATABASE_URL=redis://localhost:6379 |
41 | +# Required in production for distributed locking and rate limiting: | |
42 | +REDIS_URL=redis://localhost:6379 | |
41 | 43 | |
42 | 44 | # Neo4j example: |
43 | 45 | # DATABASE_DRIVER=neo4j |
| @@ -60,6 +62,9 @@ CRON_SECRET=change-this-cron-secret-in-production | ||
60 | 62 | # CRITICAL: Encryption key for sensitive config blobs (AI provider keys, etc.) |
61 | 63 | # Generate with: openssl rand -hex 32 |
62 | 64 | AI_CONFIG_ENCRYPTION_KEY=change-this-ai-config-encryption-key-in-production |
65 | +# CRITICAL: Encryption key for workflow/repo secrets at rest. | |
66 | +# Generate with: openssl rand -hex 32 | |
67 | +WORKFLOW_SECRET_ENCRYPTION_KEY=change-this-workflow-secret-encryption-key-in-production | |
63 | 68 | |
64 | 69 | # OAuth Providers (optional) |
65 | 70 | OAUTH_GOOGLE_CLIENT_ID= |
| @@ -111,6 +116,8 @@ RUNNER_TIMEOUT=3600 | ||
111 | 116 | # CRITICAL: Secret shared between server and runners. Must be the same. |
112 | 117 | # Generate with: openssl rand -hex 32 |
113 | 118 | RUNNER_SECRET=change-this-runner-secret-in-production |
119 | +# Registration token validity period (days) before rotation is required. | |
120 | +RUNNER_REGISTRATION_TOKEN_TTL_DAYS=90 | |
114 | 121 | |
115 | 122 | # Email Configuration (optional) |
116 | 123 | SMTP_HOST= |
| @@ -145,3 +152,26 @@ ENABLE_ISSUES=true | ||
145 | 152 | ENABLE_ACTIONS=true |
146 | 153 | ENABLE_PACKAGES=false |
147 | 154 | ENABLE_AI_ASSISTANT=true |
155 | + | |
156 | +# AI Providers (optional) | |
157 | +# Used by /settings/ai and AI review jobs. | |
158 | +OPENAI_API_KEY= | |
159 | +ANTHROPIC_API_KEY= | |
160 | +GROQ_API_KEY= | |
161 | +BYTEZ_API_KEY= | |
162 | +OPENROUTER_API_KEY= | |
163 | +TOGETHER_API_KEY= | |
164 | +GOOGLE_AI_API_KEY= | |
165 | + | |
166 | +# External review agent integration (CodeRabbit-like) | |
167 | +# Outbound webhook used by provider "external_agent" | |
168 | +EXTERNAL_AGENT_WEBHOOK_URL= | |
169 | +# Optional bearer token for outbound webhook auth | |
170 | +EXTERNAL_AGENT_API_KEY= | |
171 | +# Required for authenticated callbacks from external agent. | |
172 | +# Falls back to INTERNAL_HOOK_SECRET when omitted. | |
173 | +EXTERNAL_AGENT_CALLBACK_SECRET= | |
174 | + | |
175 | +# Air-gapped / offline mode | |
176 | +# When true, all outbound third-party integrations are blocked at runtime. | |
177 | +AIR_GAPPED_MODE=false | |
148 | 178 | |
▼src/lib/ai-review.ts
+130-2
| @@ -35,6 +35,14 @@ export interface AIReviewConfig { | ||
35 | 35 | includeStackContext?: boolean; |
36 | 36 | } |
37 | 37 | |
38 | +type ProviderCallResult = { | |
39 | + content: string; | |
40 | + summary: string; | |
41 | + tokensUsed: number; | |
42 | + promptTokens: number; | |
43 | + completionTokens: number; | |
44 | +}; | |
45 | + | |
38 | 46 | export interface ReviewSuggestion { |
39 | 47 | path: string; |
40 | 48 | line?: number; |
| @@ -162,8 +170,41 @@ async function runAIReview( | ||
162 | 170 | // Generate review prompt |
163 | 171 | const prompt = generateReviewPrompt(pr, stackContext, diff); |
164 | 172 | |
165 | - // Call AI provider | |
166 | - const result = await callAIProvider(config, prompt); | |
173 | + let result: ProviderCallResult | null = null; | |
174 | + | |
175 | + // External agents may run asynchronously and callback later. | |
176 | + if (config.provider === "external_agent") { | |
177 | + const external = await dispatchExternalAgentReview({ | |
178 | + config, | |
179 | + prompt, | |
180 | + reviewId, | |
181 | + pullRequestId, | |
182 | + pr, | |
183 | + diff, | |
184 | + stackContext, | |
185 | + }); | |
186 | + | |
187 | + if (external.acceptedAsync) { | |
188 | + await db.update(schema.aiReviews) | |
189 | + .set({ | |
190 | + status: "running", | |
191 | + summary: "External agent accepted review request; awaiting callback", | |
192 | + stackContext, | |
193 | + completedAt: null, | |
194 | + }) | |
195 | + .where(eq(schema.aiReviews.id, reviewId)); | |
196 | + return; | |
197 | + } | |
198 | + | |
199 | + result = external.result; | |
200 | + } else { | |
201 | + // Call built-in AI provider synchronously | |
202 | + result = await callAIProvider(config, prompt); | |
203 | + } | |
204 | + | |
205 | + if (!result) { | |
206 | + throw new Error("AI provider returned no result"); | |
207 | + } | |
167 | 208 | |
168 | 209 | // Parse and save suggestions |
169 | 210 | const suggestions = parseAIResponse(result.content); |
| @@ -221,6 +262,93 @@ async function runAIReview( | ||
221 | 262 | } |
222 | 263 | } |
223 | 264 | |
265 | +async function dispatchExternalAgentReview(options: { | |
266 | + config: AIReviewConfig; | |
267 | + prompt: string; | |
268 | + reviewId: string; | |
269 | + pullRequestId: string; | |
270 | + pr: any; | |
271 | + diff: string; | |
272 | + stackContext: string | null; | |
273 | +}): Promise<{ | |
274 | + acceptedAsync: boolean; | |
275 | + result: ProviderCallResult | null; | |
276 | +}> { | |
277 | + const webhookUrl = options.config.baseUrl || process.env.EXTERNAL_AGENT_WEBHOOK_URL; | |
278 | + if (!webhookUrl) { | |
279 | + throw new Error("External agent webhook URL not configured"); | |
280 | + } | |
281 | + | |
282 | + const callbackSecret = | |
283 | + process.env.EXTERNAL_AGENT_CALLBACK_SECRET || process.env.INTERNAL_HOOK_SECRET; | |
284 | + const siteUrl = process.env.SITE_URL; | |
285 | + const callbackUrl = | |
286 | + callbackSecret && siteUrl | |
287 | + ? `${siteUrl.replace(/\/$/, "")}/api/repos/${encodeURIComponent(options.pr.repository.owner.username)}/${encodeURIComponent(options.pr.repository.name)}/pulls/${options.pr.number}/ai-review/callback` | |
288 | + : undefined; | |
289 | + | |
290 | + const response = await fetch(webhookUrl, { | |
291 | + method: "POST", | |
292 | + headers: { | |
293 | + "Content-Type": "application/json", | |
294 | + ...(options.config.apiKey ? { Authorization: `Bearer ${options.config.apiKey}` } : {}), | |
295 | + }, | |
296 | + body: JSON.stringify({ | |
297 | + source: "opencodehub", | |
298 | + reviewId: options.reviewId, | |
299 | + pullRequestId: options.pullRequestId, | |
300 | + repository: { | |
301 | + owner: options.pr.repository.owner.username, | |
302 | + name: options.pr.repository.name, | |
303 | + id: options.pr.repository.id, | |
304 | + }, | |
305 | + pullRequest: { | |
306 | + number: options.pr.number, | |
307 | + title: options.pr.title, | |
308 | + headBranch: options.pr.headBranch, | |
309 | + baseBranch: options.pr.baseBranch, | |
310 | + }, | |
311 | + prompt: options.prompt, | |
312 | + diff: options.diff, | |
313 | + stackContext: options.stackContext, | |
314 | + callback: callbackUrl | |
315 | + ? { | |
316 | + url: callbackUrl, | |
317 | + token: callbackSecret, | |
318 | + } | |
319 | + : undefined, | |
320 | + }), | |
321 | + }); | |
322 | + | |
323 | + if (response.status === 202) { | |
324 | + return { acceptedAsync: true, result: null }; | |
325 | + } | |
326 | + | |
327 | + if (!response.ok) { | |
328 | + throw new Error(`External agent error: ${response.status} ${await response.text()}`); | |
329 | + } | |
330 | + | |
331 | + const payload = await response.json(); | |
332 | + const content = | |
333 | + typeof payload?.content === "string" | |
334 | + ? payload.content | |
335 | + : JSON.stringify({ | |
336 | + summary: payload?.summary || "External agent review completed", | |
337 | + suggestions: payload?.suggestions || [], | |
338 | + }); | |
339 | + | |
340 | + return { | |
341 | + acceptedAsync: false, | |
342 | + result: { | |
343 | + content, | |
344 | + summary: payload?.summary || "External agent review completed", | |
345 | + tokensUsed: Number(payload?.usage?.totalTokens || payload?.tokensUsed || 0), | |
346 | + promptTokens: Number(payload?.usage?.inputTokens || 0), | |
347 | + completionTokens: Number(payload?.usage?.outputTokens || 0), | |
348 | + }, | |
349 | + }; | |
350 | +} | |
351 | + | |
224 | 352 | /** |
225 | 353 | * Generate the review prompt |
226 | 354 | */ |
227 | 355 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/ai-review.ts
+4-1
| @@ -149,7 +149,10 @@ export const POST: APIRoute = withErrorHandler(async ({ request, params }) => { | ||
149 | 149 | aiConfig = { |
150 | 150 | provider, |
151 | 151 | model: userConfig.model || aiConfig.model, |
152 | - apiKey: keyProvider ? userConfig.apiKeys?.[keyProvider] : undefined, | |
152 | + apiKey: keyProvider | |
153 | + ? userConfig.apiKeys?.[keyProvider] | |
154 | + || (provider === "external_agent" ? process.env.EXTERNAL_AGENT_API_KEY : undefined) | |
155 | + : undefined, | |
153 | 156 | baseUrl: |
154 | 157 | provider === "external_agent" |
155 | 158 | ? (userConfig.externalAgentWebhookUrl || aiConfig.baseUrl) |
156 | 159 | |
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/ai-review/callback.ts
+161-0
| @@ -0,0 +1,161 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import { and, eq } from "drizzle-orm"; | |
3 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { withErrorHandler } from "@/lib/errors"; | |
7 | +import { badRequest, notFound, success, unauthorized } from "@/lib/api"; | |
8 | +import { generateId } from "@/lib/utils"; | |
9 | + | |
10 | +const callbackSchema = z.object({ | |
11 | + reviewId: z.string().min(1), | |
12 | + status: z.enum(["completed", "failed"]).default("completed"), | |
13 | + summary: z.string().optional(), | |
14 | + overallSeverity: z.enum(["info", "warning", "error", "critical"]).optional(), | |
15 | + suggestions: z | |
16 | + .array( | |
17 | + z.object({ | |
18 | + path: z.string().min(1), | |
19 | + line: z.number().int().positive().optional(), | |
20 | + endLine: z.number().int().positive().optional(), | |
21 | + severity: z.enum(["info", "warning", "error", "critical"]).default("warning"), | |
22 | + type: z | |
23 | + .enum(["bug", "security", "performance", "style", "documentation", "suggestion"]) | |
24 | + .default("suggestion"), | |
25 | + title: z.string().min(1), | |
26 | + message: z.string().min(1), | |
27 | + suggestedFix: z.string().optional(), | |
28 | + explanation: z.string().optional(), | |
29 | + }) | |
30 | + ) | |
31 | + .optional(), | |
32 | + usage: z | |
33 | + .object({ | |
34 | + inputTokens: z.number().int().nonnegative().optional(), | |
35 | + outputTokens: z.number().int().nonnegative().optional(), | |
36 | + totalTokens: z.number().int().nonnegative().optional(), | |
37 | + }) | |
38 | + .optional(), | |
39 | + tokensUsed: z.number().int().nonnegative().optional(), | |
40 | + promptTokens: z.number().int().nonnegative().optional(), | |
41 | + completionTokens: z.number().int().nonnegative().optional(), | |
42 | + errorMessage: z.string().optional(), | |
43 | + rawResponse: z.unknown().optional(), | |
44 | +}); | |
45 | + | |
46 | +function isAuthorized(request: Request): boolean { | |
47 | + const expected = | |
48 | + process.env.EXTERNAL_AGENT_CALLBACK_SECRET || process.env.INTERNAL_HOOK_SECRET; | |
49 | + if (!expected) return false; | |
50 | + return request.headers.get("Authorization") === `Bearer ${expected}`; | |
51 | +} | |
52 | + | |
53 | +export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | |
54 | + if (!isAuthorized(request)) { | |
55 | + return unauthorized(); | |
56 | + } | |
57 | + | |
58 | + const { owner, repo: repoName, number } = params; | |
59 | + const prNumber = Number.parseInt(number || "", 10); | |
60 | + if (!owner || !repoName || Number.isNaN(prNumber)) { | |
61 | + return badRequest("Invalid repository or pull request"); | |
62 | + } | |
63 | + | |
64 | + const parsed = callbackSchema.safeParse(await request.json()); | |
65 | + if (!parsed.success) { | |
66 | + return badRequest("Invalid callback payload"); | |
67 | + } | |
68 | + | |
69 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
70 | + | |
71 | + const ownerUser = await db.query.users.findFirst({ | |
72 | + where: eq(schema.users.username, owner), | |
73 | + columns: { id: true }, | |
74 | + }); | |
75 | + if (!ownerUser) return notFound("Repository owner not found"); | |
76 | + | |
77 | + const repository = await db.query.repositories.findFirst({ | |
78 | + where: and(eq(schema.repositories.ownerId, ownerUser.id), eq(schema.repositories.name, repoName)), | |
79 | + columns: { id: true }, | |
80 | + }); | |
81 | + if (!repository) return notFound("Repository not found"); | |
82 | + | |
83 | + const pr = await db.query.pullRequests.findFirst({ | |
84 | + where: and(eq(schema.pullRequests.repositoryId, repository.id), eq(schema.pullRequests.number, prNumber)), | |
85 | + columns: { id: true }, | |
86 | + }); | |
87 | + if (!pr) return notFound("Pull request not found"); | |
88 | + | |
89 | + const review = await db.query.aiReviews.findFirst({ | |
90 | + where: and(eq(schema.aiReviews.id, parsed.data.reviewId), eq(schema.aiReviews.pullRequestId, pr.id)), | |
91 | + }); | |
92 | + if (!review) return notFound("AI review run not found"); | |
93 | + | |
94 | + if (parsed.data.status === "failed") { | |
95 | + await db | |
96 | + .update(schema.aiReviews) | |
97 | + .set({ | |
98 | + status: "failed", | |
99 | + errorMessage: parsed.data.errorMessage || "External agent reported failure", | |
100 | + completedAt: new Date(), | |
101 | + }) | |
102 | + .where(eq(schema.aiReviews.id, review.id)); | |
103 | + return success({ reviewId: review.id, status: "failed" }); | |
104 | + } | |
105 | + | |
106 | + // Replace findings with latest callback result. | |
107 | + await db | |
108 | + .delete(schema.aiReviewSuggestions) | |
109 | + .where(eq(schema.aiReviewSuggestions.aiReviewId, review.id)); | |
110 | + | |
111 | + const suggestions = parsed.data.suggestions || []; | |
112 | + for (const suggestion of suggestions) { | |
113 | + await db.insert(schema.aiReviewSuggestions).values({ | |
114 | + id: generateId(), | |
115 | + aiReviewId: review.id, | |
116 | + path: suggestion.path, | |
117 | + line: suggestion.line, | |
118 | + endLine: suggestion.endLine, | |
119 | + severity: suggestion.severity, | |
120 | + type: suggestion.type, | |
121 | + title: suggestion.title, | |
122 | + message: suggestion.message, | |
123 | + suggestedFix: suggestion.suggestedFix, | |
124 | + explanation: suggestion.explanation, | |
125 | + createdAt: new Date(), | |
126 | + }); | |
127 | + } | |
128 | + | |
129 | + const usage = parsed.data.usage; | |
130 | + const inputTokens = usage?.inputTokens ?? parsed.data.promptTokens; | |
131 | + const outputTokens = usage?.outputTokens ?? parsed.data.completionTokens; | |
132 | + const totalTokens = | |
133 | + usage?.totalTokens ?? | |
134 | + parsed.data.tokensUsed ?? | |
135 | + ((inputTokens || 0) + (outputTokens || 0)); | |
136 | + | |
137 | + await db | |
138 | + .update(schema.aiReviews) | |
139 | + .set({ | |
140 | + status: "completed", | |
141 | + summary: parsed.data.summary || review.summary || "External agent review completed", | |
142 | + overallSeverity: parsed.data.overallSeverity || "info", | |
143 | + suggestionsCount: suggestions.length, | |
144 | + tokensUsed: totalTokens || 0, | |
145 | + promptTokens: inputTokens || 0, | |
146 | + completionTokens: outputTokens || 0, | |
147 | + rawResponse: | |
148 | + parsed.data.rawResponse !== undefined | |
149 | + ? JSON.stringify(parsed.data.rawResponse) | |
150 | + : review.rawResponse, | |
151 | + completedAt: new Date(), | |
152 | + errorMessage: null, | |
153 | + }) | |
154 | + .where(eq(schema.aiReviews.id, review.id)); | |
155 | + | |
156 | + return success({ | |
157 | + reviewId: review.id, | |
158 | + status: "completed", | |
159 | + suggestionsCount: suggestions.length, | |
160 | + }); | |
161 | +}); | |
0 | 162 | |
▼tests/integration/ai-review-callback-route.test.ts
+162-0
| @@ -0,0 +1,162 @@ | ||
1 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { fakeSchema } = vi.hoisted(() => ({ | |
4 | + fakeSchema: { | |
5 | + users: { username: {}, id: {} }, | |
6 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
7 | + pullRequests: { repositoryId: {}, number: {}, id: {} }, | |
8 | + aiReviews: { id: {}, pullRequestId: {} }, | |
9 | + aiReviewSuggestions: { aiReviewId: {} }, | |
10 | + } as any, | |
11 | +})); | |
12 | + | |
13 | +let mockDb: any; | |
14 | + | |
15 | +vi.mock("@/db", () => ({ | |
16 | + getDatabase: () => mockDb, | |
17 | + schema: fakeSchema, | |
18 | +})); | |
19 | + | |
20 | +import { POST as callbackPost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/ai-review/callback"; | |
21 | + | |
22 | +function makeDb() { | |
23 | + const owner = { id: "owner-1" }; | |
24 | + const repository = { id: "repo-1" }; | |
25 | + const pr = { id: "pr-1" }; | |
26 | + const review = { id: "review-1", summary: "old", rawResponse: null }; | |
27 | + | |
28 | + const insertCalls: unknown[] = []; | |
29 | + const updateCalls: unknown[] = []; | |
30 | + const deleteCalls: unknown[] = []; | |
31 | + | |
32 | + return { | |
33 | + query: { | |
34 | + users: { | |
35 | + findFirst: vi.fn(async () => owner), | |
36 | + }, | |
37 | + repositories: { | |
38 | + findFirst: vi.fn(async () => repository), | |
39 | + }, | |
40 | + pullRequests: { | |
41 | + findFirst: vi.fn(async () => pr), | |
42 | + }, | |
43 | + aiReviews: { | |
44 | + findFirst: vi.fn(async () => review), | |
45 | + }, | |
46 | + }, | |
47 | + insert: vi.fn(() => ({ | |
48 | + values: vi.fn(async (value: unknown) => { | |
49 | + insertCalls.push(value); | |
50 | + }), | |
51 | + })), | |
52 | + update: vi.fn(() => ({ | |
53 | + set: vi.fn((value: unknown) => ({ | |
54 | + where: vi.fn(async () => { | |
55 | + updateCalls.push(value); | |
56 | + }), | |
57 | + })), | |
58 | + })), | |
59 | + delete: vi.fn(() => ({ | |
60 | + where: vi.fn(async (value: unknown) => { | |
61 | + deleteCalls.push(value); | |
62 | + }), | |
63 | + })), | |
64 | + __state: { | |
65 | + insertCalls, | |
66 | + updateCalls, | |
67 | + deleteCalls, | |
68 | + }, | |
69 | + }; | |
70 | +} | |
71 | + | |
72 | +async function readJson(response: Response): Promise<any> { | |
73 | + return response.json(); | |
74 | +} | |
75 | + | |
76 | +describe("AI review callback route", () => { | |
77 | + beforeEach(() => { | |
78 | + mockDb = makeDb(); | |
79 | + vi.stubEnv("EXTERNAL_AGENT_CALLBACK_SECRET", "callback-secret"); | |
80 | + }); | |
81 | + | |
82 | + afterEach(() => { | |
83 | + vi.unstubAllEnvs(); | |
84 | + }); | |
85 | + | |
86 | + it("rejects unauthorized callback requests", async () => { | |
87 | + const response = await callbackPost({ | |
88 | + params: { owner: "acme", repo: "demo", number: "42" }, | |
89 | + request: new Request("http://localhost/api/repos/acme/demo/pulls/42/ai-review/callback", { | |
90 | + method: "POST", | |
91 | + headers: { "content-type": "application/json" }, | |
92 | + body: JSON.stringify({ reviewId: "review-1", status: "completed", suggestions: [] }), | |
93 | + }), | |
94 | + } as any); | |
95 | + | |
96 | + const body = await readJson(response); | |
97 | + expect(response.status).toBe(401); | |
98 | + expect(body?.error?.code).toBe("UNAUTHORIZED"); | |
99 | + }); | |
100 | + | |
101 | + it("applies completed callback payload and stores suggestions", async () => { | |
102 | + const response = await callbackPost({ | |
103 | + params: { owner: "acme", repo: "demo", number: "42" }, | |
104 | + request: new Request("http://localhost/api/repos/acme/demo/pulls/42/ai-review/callback", { | |
105 | + method: "POST", | |
106 | + headers: { | |
107 | + "content-type": "application/json", | |
108 | + Authorization: "Bearer callback-secret", | |
109 | + }, | |
110 | + body: JSON.stringify({ | |
111 | + reviewId: "review-1", | |
112 | + status: "completed", | |
113 | + summary: "Looks good overall", | |
114 | + overallSeverity: "warning", | |
115 | + usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 }, | |
116 | + suggestions: [ | |
117 | + { | |
118 | + path: "src/index.ts", | |
119 | + line: 10, | |
120 | + severity: "warning", | |
121 | + type: "bug", | |
122 | + title: "Null check missing", | |
123 | + message: "Add guard before dereference", | |
124 | + }, | |
125 | + ], | |
126 | + }), | |
127 | + }), | |
128 | + } as any); | |
129 | + | |
130 | + const body = await readJson(response); | |
131 | + expect(response.status).toBe(200); | |
132 | + expect(body?.success).toBe(true); | |
133 | + expect(body?.data?.status).toBe("completed"); | |
134 | + expect(mockDb.__state.deleteCalls.length).toBe(1); | |
135 | + expect(mockDb.__state.insertCalls.length).toBe(1); | |
136 | + expect(mockDb.__state.updateCalls.length).toBe(1); | |
137 | + }); | |
138 | + | |
139 | + it("marks review as failed when callback reports failure", async () => { | |
140 | + const response = await callbackPost({ | |
141 | + params: { owner: "acme", repo: "demo", number: "42" }, | |
142 | + request: new Request("http://localhost/api/repos/acme/demo/pulls/42/ai-review/callback", { | |
143 | + method: "POST", | |
144 | + headers: { | |
145 | + "content-type": "application/json", | |
146 | + Authorization: "Bearer callback-secret", | |
147 | + }, | |
148 | + body: JSON.stringify({ | |
149 | + reviewId: "review-1", | |
150 | + status: "failed", | |
151 | + errorMessage: "Agent timeout", | |
152 | + }), | |
153 | + }), | |
154 | + } as any); | |
155 | + | |
156 | + const body = await readJson(response); | |
157 | + expect(response.status).toBe(200); | |
158 | + expect(body?.data?.status).toBe("failed"); | |
159 | + expect(mockDb.__state.insertCalls.length).toBe(0); | |
160 | + expect(mockDb.__state.updateCalls.length).toBe(1); | |
161 | + }); | |
162 | +}); | |
0 | 163 | |