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
▼.env.example
+30-0
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
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
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
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
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
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
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
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
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
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