indusagi-coding-agent 0.2.12 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.env.review.example +25 -0
  2. package/README.md +11 -0
  3. package/REVIEW_AGENT.md +137 -0
  4. package/dist/entry.js +101 -23
  5. package/dist/guardrails.js +67 -1
  6. package/dist/index.js +1032 -64
  7. package/dist/review-agent.js +4177 -0
  8. package/dist/review-server.js +4349 -0
  9. package/package.json +14 -6
  10. package/src/boot/contract.ts +1 -1
  11. package/src/boot/runners/session.ts +8 -4
  12. package/src/boot/stages.ts +3 -6
  13. package/src/conductor/catalog/catalog.ts +3 -1
  14. package/src/conductor/conductor.ts +10 -5
  15. package/src/conductor/contract.ts +5 -4
  16. package/src/conductor/index.ts +1 -1
  17. package/src/console/slash/commands/workbench.ts +2 -2
  18. package/src/index.ts +1 -0
  19. package/src/launch/catalog.ts +4 -2
  20. package/src/launch/contract.ts +1 -0
  21. package/src/launch/invocation/flags.ts +1 -1
  22. package/src/launch/invocation/read.ts +4 -3
  23. package/src/models.ts +90 -0
  24. package/src/review-agent/config.ts +75 -0
  25. package/src/review-agent/contract.ts +59 -0
  26. package/src/review-agent/github.ts +242 -0
  27. package/src/review-agent/index.ts +8 -0
  28. package/src/review-agent/prompt.ts +132 -0
  29. package/src/review-agent/reviewer.ts +118 -0
  30. package/src/review-agent/server.ts +221 -0
  31. package/src/review-agent/service.ts +165 -0
  32. package/src/review-agent/supabase.ts +209 -0
  33. package/src/review-agent/webhook.ts +76 -0
  34. package/supabase/migrations/20260827031803_indusagi_review_agent.sql +109 -0
  35. package/supabase/migrations/20260827035747_atomic_webhook_enqueue.sql +75 -0
  36. package/supabase/migrations/20260827040104_check_run_reconciliation.sql +8 -0
  37. package/supabase/migrations/20260827041049_owned_job_leases.sql +80 -0
  38. package/supabase/migrations/20260827042500_static_token_webhooks.sql +75 -0
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
4
+ import { realpathSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadReviewAgentConfig, type ReviewAgentConfig } from "./config.js";
7
+ import { GitHubAppTokenProvider } from "./github.js";
8
+ import { IndusagiReviewer } from "./reviewer.js";
9
+ import { ReviewService } from "./service.js";
10
+ import { SupabaseReviewStore } from "./supabase.js";
11
+ import type { ReviewRequest } from "./contract.js";
12
+ import { parsePullRequestWebhook, verifyGithubSignature } from "./webhook.js";
13
+
14
+ const MAX_WEBHOOK_BYTES = 2 * 1024 * 1024;
15
+
16
+ function json(res: ServerResponse, status: number, body: unknown): void {
17
+ const data = JSON.stringify(body);
18
+ res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) });
19
+ res.end(data);
20
+ }
21
+
22
+ async function readBody(req: IncomingMessage): Promise<string> {
23
+ const declared = Number(req.headers["content-length"] ?? 0);
24
+ if (declared > MAX_WEBHOOK_BYTES) throw new Error("payload_too_large");
25
+ const chunks: Buffer[] = [];
26
+ let size = 0;
27
+ for await (const chunk of req) {
28
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ size += buffer.length;
30
+ if (size > MAX_WEBHOOK_BYTES) throw new Error("payload_too_large");
31
+ chunks.push(buffer);
32
+ }
33
+ return Buffer.concat(chunks).toString("utf8");
34
+ }
35
+
36
+ class StaticTokenProvider {
37
+ constructor(private readonly token: string) {}
38
+ async installationToken(): Promise<string> { return this.token; }
39
+ }
40
+
41
+ export function createReviewAgentRuntime(config: ReviewAgentConfig) {
42
+ const store = new SupabaseReviewStore(config.supabaseUrl, config.supabaseServiceKey);
43
+ const tokenProvider = config.githubApp
44
+ ? new GitHubAppTokenProvider(config.githubApp.appId, config.githubApp.privateKey)
45
+ : new StaticTokenProvider(config.githubToken!);
46
+ const reviewer = new IndusagiReviewer({
47
+ modelId: config.modelId,
48
+ thinking: "high",
49
+ getApiKey: (provider) => config.providerKeys[provider],
50
+ });
51
+ const service = new ReviewService({
52
+ store,
53
+ tokenProvider,
54
+ reviewer,
55
+ workerId: `${process.env.HOSTNAME ?? "local"}-${process.pid}-${randomUUID().slice(0, 8)}`,
56
+ });
57
+ return { store, service };
58
+ }
59
+
60
+ interface WebhookRuntime {
61
+ store: {
62
+ recordDelivery(deliveryId: string, eventName: string): Promise<boolean>;
63
+ enqueueWebhook(request: ReviewRequest, eventName: string): Promise<{ jobId?: string; duplicate: boolean }>;
64
+ };
65
+ poll: () => Promise<unknown>;
66
+ }
67
+
68
+ export function createWebhookHandler(
69
+ config: Pick<ReviewAgentConfig, "githubWebhookSecret" | "githubApp">,
70
+ runtime: WebhookRuntime,
71
+ ) {
72
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
73
+ try {
74
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
75
+ if (req.method === "GET" && url.pathname === "/healthz") {
76
+ json(res, 200, { ok: true, service: "indusagi-review-agent" });
77
+ return;
78
+ }
79
+ if (req.method !== "POST" || url.pathname !== "/webhooks/github") {
80
+ json(res, 404, { error: "not_found" });
81
+ return;
82
+ }
83
+
84
+ const payload = await readBody(req);
85
+ const signature = typeof req.headers["x-hub-signature-256"] === "string"
86
+ ? req.headers["x-hub-signature-256"]
87
+ : null;
88
+ if (!verifyGithubSignature(payload, signature, config.githubWebhookSecret)) {
89
+ json(res, 401, { error: "invalid_signature" });
90
+ return;
91
+ }
92
+
93
+ const eventName = typeof req.headers["x-github-event"] === "string" ? req.headers["x-github-event"] : "";
94
+ const deliveryId = typeof req.headers["x-github-delivery"] === "string" ? req.headers["x-github-delivery"] : "";
95
+ if (!deliveryId) {
96
+ json(res, 400, { error: "missing_delivery_id" });
97
+ return;
98
+ }
99
+ if (eventName === "ping") {
100
+ if (!await runtime.store.recordDelivery(deliveryId, eventName)) {
101
+ json(res, 202, { accepted: true, duplicate: true });
102
+ return;
103
+ }
104
+ json(res, 200, { ok: true, message: "pong" });
105
+ return;
106
+ }
107
+ if (eventName !== "pull_request") {
108
+ if (!await runtime.store.recordDelivery(deliveryId, eventName || "unknown")) {
109
+ json(res, 202, { accepted: true, duplicate: true });
110
+ return;
111
+ }
112
+ json(res, 202, { accepted: true, ignored: true });
113
+ return;
114
+ }
115
+
116
+ const request = parsePullRequestWebhook(payload, deliveryId, {
117
+ allowMissingInstallation: !config.githubApp,
118
+ });
119
+ if (!request) {
120
+ if (!await runtime.store.recordDelivery(deliveryId, eventName)) {
121
+ json(res, 202, { accepted: true, duplicate: true });
122
+ return;
123
+ }
124
+ json(res, 202, { accepted: true, ignored: true });
125
+ return;
126
+ }
127
+ const result = await runtime.store.enqueueWebhook(request, eventName);
128
+ if (result.duplicate) {
129
+ json(res, 202, { accepted: true, duplicate: true });
130
+ return;
131
+ }
132
+ json(res, 202, { accepted: true, jobId: result.jobId });
133
+ void runtime.poll();
134
+ } catch (error) {
135
+ const message = error instanceof Error ? error.message : String(error);
136
+ if (message === "payload_too_large") {
137
+ json(res, 413, { error: message });
138
+ } else {
139
+ console.error(`[review-agent] request failed: ${message}`);
140
+ json(res, 500, { error: "internal_error" });
141
+ }
142
+ }
143
+ };
144
+ }
145
+
146
+ interface WorkerRuntime {
147
+ store: Pick<SupabaseReviewStore, "recoverStaleJobs">;
148
+ service: Pick<ReviewService, "runOnce" | "reconcileCheckRunOnce">;
149
+ }
150
+
151
+ export function createWorkerPoll(
152
+ config: Pick<ReviewAgentConfig, "maxJobsPerTick">,
153
+ runtime: WorkerRuntime,
154
+ options: {
155
+ recoveryIntervalMs?: number;
156
+ now?: () => number;
157
+ logger?: Pick<Console, "error">;
158
+ } = {},
159
+ ) {
160
+ const recoveryIntervalMs = options.recoveryIntervalMs ?? 60_000;
161
+ const now = options.now ?? Date.now;
162
+ const logger = options.logger ?? console;
163
+ let workerBusy = false;
164
+ let lastRecoveryAt = Number.NEGATIVE_INFINITY;
165
+
166
+ return async (): Promise<void> => {
167
+ if (workerBusy) return;
168
+ workerBusy = true;
169
+ try {
170
+ const currentTime = now();
171
+ if (currentTime - lastRecoveryAt >= recoveryIntervalMs) {
172
+ await runtime.store.recoverStaleJobs();
173
+ lastRecoveryAt = currentTime;
174
+ }
175
+ for (let index = 0; index < config.maxJobsPerTick; index++) {
176
+ if (!await runtime.service.runOnce()) break;
177
+ }
178
+ await runtime.service.reconcileCheckRunOnce();
179
+ } catch (error) {
180
+ logger.error(`[review-agent] worker poll failed: ${error instanceof Error ? error.message : String(error)}`);
181
+ } finally {
182
+ workerBusy = false;
183
+ }
184
+ };
185
+ }
186
+
187
+ export async function startReviewAgentServer(config = loadReviewAgentConfig()) {
188
+ const runtime = createReviewAgentRuntime(config);
189
+ const poll = createWorkerPoll(config, runtime);
190
+ const timer = setInterval(() => void poll(), config.pollIntervalMs);
191
+ timer.unref();
192
+ void poll();
193
+
194
+ const server = createServer(createWebhookHandler(config, { store: runtime.store, poll }));
195
+
196
+ await new Promise<void>((resolve) => server.listen(config.port, config.host, resolve));
197
+ console.log(`[review-agent] listening on http://${config.host}:${config.port}`);
198
+
199
+ const close = async () => {
200
+ clearInterval(timer);
201
+ await new Promise<void>((resolve) => server.close(() => resolve()));
202
+ };
203
+ return { server, close, config };
204
+ }
205
+
206
+ function isProgramEntry(): boolean {
207
+ const entry = process.argv[1];
208
+ if (!entry) return false;
209
+ try { return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
210
+ }
211
+
212
+ if (isProgramEntry()) {
213
+ startReviewAgentServer().then(({ close }) => {
214
+ const shutdown = () => void close().finally(() => process.exit(0));
215
+ process.once("SIGINT", shutdown);
216
+ process.once("SIGTERM", shutdown);
217
+ }).catch((error) => {
218
+ process.stderr.write(`indusagi-review-agent: ${error instanceof Error ? error.message : String(error)}\n`);
219
+ process.exitCode = 1;
220
+ });
221
+ }
@@ -0,0 +1,165 @@
1
+ import type { ReviewOutput } from "./contract.js";
2
+ import { GitHubClient } from "./github.js";
3
+ import type { IndusagiReviewer } from "./reviewer.js";
4
+ import type { CheckRunReconciliation, ReviewJob, SupabaseReviewStore } from "./supabase.js";
5
+
6
+ interface TokenProvider {
7
+ installationToken(installationId: number): Promise<string>;
8
+ }
9
+
10
+ interface ReviewStorePort {
11
+ claim(workerId: string): Promise<ReviewJob | null>;
12
+ setCheckRun(job: ReviewJob, checkRunId: number): Promise<void>;
13
+ complete(job: ReviewJob, review: ReviewOutput, githubReviewId?: number, commentId?: number): Promise<void>;
14
+ markCheckRunComplete(jobId: string): Promise<void>;
15
+ nextCheckRunReconciliation(): Promise<CheckRunReconciliation | null>;
16
+ fail(job: ReviewJob, message: string, retryable?: boolean): Promise<void>;
17
+ renewLease(job: ReviewJob, leaseSeconds?: number): Promise<boolean>;
18
+ }
19
+
20
+ class StaleReviewJobError extends Error {}
21
+ class LostReviewLeaseError extends Error {}
22
+
23
+ function isLeaseOwnershipError(error: unknown): boolean {
24
+ return error instanceof LostReviewLeaseError ||
25
+ (error instanceof Error && /lease ownership|lease lost/i.test(error.message));
26
+ }
27
+
28
+ interface ReviewerPort {
29
+ review(context: Awaited<ReturnType<GitHubClient["getPullRequestContext"]>>, github: GitHubClient): Promise<ReviewOutput>;
30
+ }
31
+
32
+ export interface ReviewServiceOptions {
33
+ store: ReviewStorePort | SupabaseReviewStore;
34
+ tokenProvider: TokenProvider;
35
+ githubFactory?: (token: string) => GitHubClient;
36
+ reviewer: ReviewerPort | IndusagiReviewer;
37
+ workerId: string;
38
+ leaseHeartbeatMs?: number;
39
+ leaseSeconds?: number;
40
+ logger?: Pick<Console, "info" | "warn" | "error">;
41
+ }
42
+
43
+ export class ReviewService {
44
+ private readonly githubFactory: (token: string) => GitHubClient;
45
+ private readonly logger: Pick<Console, "info" | "warn" | "error">;
46
+ private readonly leaseHeartbeatMs: number;
47
+ private readonly leaseSeconds: number;
48
+
49
+ constructor(private readonly options: ReviewServiceOptions) {
50
+ this.githubFactory = options.githubFactory ?? ((token) => new GitHubClient(token));
51
+ this.logger = options.logger ?? console;
52
+ this.leaseHeartbeatMs = options.leaseHeartbeatMs ?? 60_000;
53
+ this.leaseSeconds = options.leaseSeconds ?? 300;
54
+ }
55
+
56
+ async runOnce(): Promise<boolean> {
57
+ const job = await this.options.store.claim(this.options.workerId);
58
+ if (!job) return false;
59
+ let leaseLost = false;
60
+ let renewing = false;
61
+ const heartbeat = setInterval(() => {
62
+ if (renewing || leaseLost) return;
63
+ renewing = true;
64
+ void this.options.store.renewLease(job, this.leaseSeconds).then((owned) => {
65
+ if (!owned) leaseLost = true;
66
+ }).catch((error) => {
67
+ this.logger.warn(`[review-agent] lease heartbeat failed for ${job.id}: ${error instanceof Error ? error.message : String(error)}`);
68
+ }).finally(() => { renewing = false; });
69
+ }, this.leaseHeartbeatMs);
70
+ heartbeat.unref?.();
71
+ const assertLease = () => {
72
+ if (leaseLost) throw new LostReviewLeaseError(`review job lease lost: ${job.id}`);
73
+ };
74
+ try {
75
+ await this.process(job, assertLease);
76
+ } catch (error) {
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ this.logger.error(`[review-agent] ${job.repository}#${job.pullNumber} failed: ${message}`);
79
+ if (!isLeaseOwnershipError(error)) {
80
+ await this.options.store.fail(job, message, !(error instanceof StaleReviewJobError));
81
+ }
82
+ } finally {
83
+ clearInterval(heartbeat);
84
+ }
85
+ return true;
86
+ }
87
+
88
+ async reconcileCheckRunOnce(): Promise<boolean> {
89
+ const pending = await this.options.store.nextCheckRunReconciliation();
90
+ if (!pending) return false;
91
+ const token = await this.options.tokenProvider.installationToken(pending.installationId);
92
+ const github = this.githubFactory(token);
93
+ await github.completeCheckRun(pending.owner, pending.repo, pending.checkRunId, pending.review, "success");
94
+ await this.options.store.markCheckRunComplete(pending.id);
95
+ return true;
96
+ }
97
+
98
+ private async process(job: ReviewJob, assertLease: () => void): Promise<void> {
99
+ const token = await this.options.tokenProvider.installationToken(job.installationId);
100
+ const github = this.githubFactory(token);
101
+ let checkRunId = job.checkRunId;
102
+ if (checkRunId === undefined) {
103
+ try {
104
+ assertLease();
105
+ checkRunId = await github.createCheckRun(job.owner, job.repo, job.headSha, `Indusagi Code Review`, job.id);
106
+ await this.options.store.setCheckRun(job, checkRunId);
107
+ } catch (error) {
108
+ if (isLeaseOwnershipError(error)) {
109
+ throw new LostReviewLeaseError(error instanceof Error ? error.message : "review job lease lost");
110
+ }
111
+ this.logger.warn(`[review-agent] could not create check run for ${job.repository}#${job.pullNumber}: ${error instanceof Error ? error.message : String(error)}`);
112
+ }
113
+ }
114
+
115
+ try {
116
+ const context = await github.getPullRequestContext(job.owner, job.repo, job.pullNumber);
117
+ assertLease();
118
+ if (context.headSha !== job.headSha) {
119
+ throw new StaleReviewJobError(`stale review job: expected head ${job.headSha}, current head is ${context.headSha}`);
120
+ }
121
+
122
+ const review = await this.options.reviewer.review(context, github);
123
+ assertLease();
124
+ let githubReviewId: number | undefined;
125
+ let commentId: number | undefined;
126
+ try {
127
+ githubReviewId = await github.postReview(job.owner, job.repo, job.pullNumber, job.headSha, review);
128
+ } catch (error) {
129
+ this.logger.warn(`[review-agent] formal review rejected; using sticky comment: ${error instanceof Error ? error.message : String(error)}`);
130
+ commentId = await github.postStickyComment(job.owner, job.repo, job.pullNumber, review);
131
+ }
132
+
133
+ assertLease();
134
+ await this.options.store.complete(job, review, githubReviewId, commentId);
135
+ if (checkRunId !== undefined) {
136
+ try {
137
+ await github.completeCheckRun(job.owner, job.repo, checkRunId, review, "success");
138
+ await this.options.store.markCheckRunComplete(job.id);
139
+ } catch (error) {
140
+ this.logger.warn(`[review-agent] could not complete check run ${checkRunId}: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+ }
143
+ this.logger.info(`[review-agent] completed ${job.repository}#${job.pullNumber} at ${job.headSha}`);
144
+ } catch (error) {
145
+ if (isLeaseOwnershipError(error)) {
146
+ throw new LostReviewLeaseError(error instanceof Error ? error.message : "review job lease lost");
147
+ }
148
+ if (checkRunId !== undefined) {
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ const failureReview: ReviewOutput = {
151
+ summary: message.slice(0, 1_000),
152
+ risk: "high",
153
+ findings: [],
154
+ testPlan: [],
155
+ };
156
+ try {
157
+ await github.completeCheckRun(job.owner, job.repo, checkRunId, failureReview, "failure");
158
+ } catch (checkError) {
159
+ this.logger.warn(`[review-agent] could not fail check run ${checkRunId}: ${checkError instanceof Error ? checkError.message : String(checkError)}`);
160
+ }
161
+ }
162
+ throw error;
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,209 @@
1
+ import type { ReviewOutput, ReviewRequest } from "./contract.js";
2
+
3
+ export interface ReviewJob {
4
+ id: string;
5
+ installationId: number;
6
+ repository: string;
7
+ owner: string;
8
+ repo: string;
9
+ pullNumber: number;
10
+ headSha: string;
11
+ title: string;
12
+ attempts: number;
13
+ maxAttempts: number;
14
+ workerId: string;
15
+ leaseToken: string;
16
+ checkRunId?: number;
17
+ }
18
+
19
+ export interface CheckRunReconciliation {
20
+ id: string;
21
+ installationId: number;
22
+ owner: string;
23
+ repo: string;
24
+ checkRunId: number;
25
+ review: ReviewOutput;
26
+ }
27
+
28
+ function toJob(row: Record<string, any>): ReviewJob {
29
+ return {
30
+ id: String(row.id),
31
+ installationId: Number(row.installation_id),
32
+ repository: String(row.repository),
33
+ owner: String(row.owner),
34
+ repo: String(row.repo),
35
+ pullNumber: Number(row.pr_number),
36
+ headSha: String(row.head_sha),
37
+ title: String(row.title),
38
+ attempts: Number(row.attempts ?? 0),
39
+ maxAttempts: Number(row.max_attempts ?? 3),
40
+ workerId: String(row.worker_id ?? ""),
41
+ leaseToken: String(row.lease_token ?? ""),
42
+ ...(row.check_run_id != null ? { checkRunId: Number(row.check_run_id) } : {}),
43
+ };
44
+ }
45
+
46
+ export class SupabaseReviewStore {
47
+ private readonly baseUrl: string;
48
+
49
+ constructor(
50
+ url: string,
51
+ private readonly serviceKey: string,
52
+ private readonly fetcher: typeof fetch = fetch,
53
+ private readonly requestTimeoutMs = 30_000,
54
+ ) {
55
+ this.baseUrl = url.replace(/\/$/, "");
56
+ }
57
+
58
+ private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
59
+ const response = await this.fetcher(`${this.baseUrl}${path}`, {
60
+ ...init,
61
+ signal: init.signal ?? AbortSignal.timeout(this.requestTimeoutMs),
62
+ headers: {
63
+ apikey: this.serviceKey,
64
+ Authorization: `Bearer ${this.serviceKey}`,
65
+ "Content-Type": "application/json",
66
+ ...(init.headers as Record<string, string> | undefined),
67
+ },
68
+ });
69
+ if (!response.ok) {
70
+ throw new Error(`Supabase ${init.method ?? "GET"} ${path} failed (${response.status}): ${(await response.text()).slice(0, 500)}`);
71
+ }
72
+ if (response.status === 204) return undefined as T;
73
+ return await response.json() as T;
74
+ }
75
+
76
+ async recordDelivery(deliveryId: string, eventName: string): Promise<boolean> {
77
+ const rows = await this.request<Array<Record<string, unknown>>>(
78
+ "/rest/v1/indusagi_review_deliveries?on_conflict=delivery_id",
79
+ {
80
+ method: "POST",
81
+ headers: { Prefer: "resolution=ignore-duplicates,return=representation" },
82
+ body: JSON.stringify({ delivery_id: deliveryId, event_name: eventName }),
83
+ },
84
+ );
85
+ return rows.length > 0;
86
+ }
87
+
88
+ async enqueueWebhook(request: ReviewRequest, eventName: string): Promise<{ jobId?: string; duplicate: boolean }> {
89
+ const rows = await this.request<Array<{ job_id?: unknown; duplicate?: unknown }>>(
90
+ "/rest/v1/rpc/enqueue_indusagi_review_job",
91
+ {
92
+ method: "POST",
93
+ body: JSON.stringify({
94
+ p_delivery_id: request.deliveryId,
95
+ p_event_name: eventName,
96
+ p_installation_id: request.installationId,
97
+ p_repository: request.repository,
98
+ p_owner: request.owner,
99
+ p_repo: request.repo,
100
+ p_pr_number: request.pullNumber,
101
+ p_head_sha: request.headSha,
102
+ p_title: request.title,
103
+ }),
104
+ },
105
+ );
106
+ const row = rows[0];
107
+ if (!row || typeof row.duplicate !== "boolean") throw new Error("Supabase returned no webhook enqueue result");
108
+ if (!row.duplicate && (typeof row.job_id !== "string" || !row.job_id)) {
109
+ throw new Error("Supabase returned no queued review job id");
110
+ }
111
+ return {
112
+ ...(typeof row.job_id === "string" && row.job_id ? { jobId: row.job_id } : {}),
113
+ duplicate: row.duplicate,
114
+ };
115
+ }
116
+
117
+ async claim(workerId: string): Promise<ReviewJob | null> {
118
+ const rows = await this.request<Array<Record<string, any>>>("/rest/v1/rpc/claim_indusagi_review_job", {
119
+ method: "POST",
120
+ body: JSON.stringify({ p_worker_id: workerId }),
121
+ });
122
+ return rows[0] ? toJob(rows[0]) : null;
123
+ }
124
+
125
+ async setCheckRun(job: ReviewJob, checkRunId: number): Promise<void> {
126
+ await this.ownedPatch(job, { check_run_id: checkRunId });
127
+ }
128
+
129
+ async complete(job: ReviewJob, review: ReviewOutput, githubReviewId?: number, commentId?: number): Promise<void> {
130
+ await this.ownedPatch(job, {
131
+ status: "completed",
132
+ review,
133
+ github_review_id: githubReviewId ?? null,
134
+ github_comment_id: commentId ?? null,
135
+ completed_at: new Date().toISOString(),
136
+ worker_id: null,
137
+ claimed_at: null,
138
+ lease_expires_at: null,
139
+ error: null,
140
+ });
141
+ }
142
+
143
+ async markCheckRunComplete(jobId: string): Promise<void> {
144
+ await this.patch(jobId, { check_completed_at: new Date().toISOString() });
145
+ }
146
+
147
+ async nextCheckRunReconciliation(): Promise<CheckRunReconciliation | null> {
148
+ const path = "/rest/v1/indusagi_review_jobs?status=eq.completed&check_run_id=not.is.null&check_completed_at=is.null&review=not.is.null&select=id,installation_id,owner,repo,check_run_id,review&order=completed_at.asc&limit=1";
149
+ const rows = await this.request<Array<Record<string, any>>>(path);
150
+ const row = rows[0];
151
+ if (!row) return null;
152
+ return {
153
+ id: String(row.id),
154
+ installationId: Number(row.installation_id),
155
+ owner: String(row.owner),
156
+ repo: String(row.repo),
157
+ checkRunId: Number(row.check_run_id),
158
+ review: row.review as ReviewOutput,
159
+ };
160
+ }
161
+
162
+ async fail(job: ReviewJob, message: string, retryable = true): Promise<void> {
163
+ const retry = retryable && job.attempts < job.maxAttempts;
164
+ const delaySeconds = Math.min(300, Math.max(5, 5 * 2 ** Math.max(0, job.attempts - 1)));
165
+ await this.ownedPatch(job, {
166
+ status: retry ? "pending" : "failed",
167
+ error: message.slice(0, 4_000),
168
+ worker_id: null,
169
+ claimed_at: null,
170
+ lease_expires_at: null,
171
+ ...(retry ? { available_at: new Date(Date.now() + delaySeconds * 1_000).toISOString() } : { completed_at: new Date().toISOString() }),
172
+ });
173
+ }
174
+
175
+ async recoverStaleJobs(staleMinutes = 15): Promise<void> {
176
+ await this.request("/rest/v1/rpc/recover_stale_indusagi_review_jobs", {
177
+ method: "POST",
178
+ body: JSON.stringify({ p_stale_minutes: staleMinutes }),
179
+ });
180
+ }
181
+
182
+ async renewLease(job: ReviewJob, leaseSeconds = 300): Promise<boolean> {
183
+ const rows = await this.ownedPatch(job, {
184
+ lease_expires_at: new Date(Date.now() + leaseSeconds * 1_000).toISOString(),
185
+ }, false);
186
+ return rows.length > 0;
187
+ }
188
+
189
+ private async ownedPatch(job: ReviewJob, values: Record<string, unknown>, required = true): Promise<Array<Record<string, unknown>>> {
190
+ if (!job.workerId || !job.leaseToken) throw new Error("review job has no lease ownership");
191
+ const leaseFloor = encodeURIComponent(new Date().toISOString());
192
+ const filters = `id=eq.${encodeURIComponent(job.id)}&status=eq.running&worker_id=eq.${encodeURIComponent(job.workerId)}&lease_token=eq.${encodeURIComponent(job.leaseToken)}&lease_expires_at=gt.${leaseFloor}`;
193
+ const rows = await this.request<Array<Record<string, unknown>>>(`/rest/v1/indusagi_review_jobs?${filters}`, {
194
+ method: "PATCH",
195
+ headers: { Prefer: "return=representation" },
196
+ body: JSON.stringify({ ...values, updated_at: new Date().toISOString() }),
197
+ });
198
+ if (required && rows.length === 0) throw new Error("review job lease ownership was lost");
199
+ return rows;
200
+ }
201
+
202
+ private async patch(jobId: string, values: Record<string, unknown>): Promise<void> {
203
+ await this.request(`/rest/v1/indusagi_review_jobs?id=eq.${encodeURIComponent(jobId)}`, {
204
+ method: "PATCH",
205
+ headers: { Prefer: "return=minimal" },
206
+ body: JSON.stringify({ ...values, updated_at: new Date().toISOString() }),
207
+ });
208
+ }
209
+ }
@@ -0,0 +1,76 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import type { ReviewRequest } from "./contract.js";
3
+
4
+ const REVIEW_ACTIONS = new Set(["opened", "reopened", "synchronize", "ready_for_review"]);
5
+
6
+ export function verifyGithubSignature(
7
+ payload: string,
8
+ signature: string | null | undefined,
9
+ secret: string,
10
+ ): boolean {
11
+ if (!secret || !signature?.startsWith("sha256=")) return false;
12
+ const expected = `sha256=${createHmac("sha256", secret).update(payload).digest("hex")}`;
13
+ const actualBuffer = Buffer.from(signature);
14
+ const expectedBuffer = Buffer.from(expected);
15
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
16
+ }
17
+
18
+ function object(value: unknown): Record<string, unknown> | undefined {
19
+ return value && typeof value === "object" && !Array.isArray(value)
20
+ ? value as Record<string, unknown>
21
+ : undefined;
22
+ }
23
+
24
+ export function parsePullRequestWebhook(
25
+ payload: string,
26
+ deliveryId: string,
27
+ options: { allowMissingInstallation?: boolean } = {},
28
+ ): ReviewRequest | null {
29
+ let decoded: unknown;
30
+ try {
31
+ decoded = JSON.parse(payload);
32
+ } catch {
33
+ throw new Error("GitHub webhook body is not valid JSON");
34
+ }
35
+ const body = object(decoded);
36
+ const action = typeof body?.action === "string" ? body.action : "";
37
+ if (!REVIEW_ACTIONS.has(action)) return null;
38
+
39
+ const installation = object(body?.installation);
40
+ const repository = object(body?.repository);
41
+ const pull = object(body?.pull_request);
42
+ const head = object(pull?.head);
43
+ const fullName = typeof repository?.full_name === "string" ? repository.full_name : "";
44
+ const separator = fullName.indexOf("/");
45
+ const installationId = installation?.id == null && options.allowMissingInstallation
46
+ ? 0
47
+ : Number(installation?.id);
48
+ const pullNumber = Number(body?.number ?? pull?.number);
49
+ const headSha = typeof head?.sha === "string" ? head.sha : "";
50
+ const title = typeof pull?.title === "string" ? pull.title : "";
51
+ const draft = pull?.draft === true;
52
+
53
+ if (draft && action !== "ready_for_review") return null;
54
+ if (
55
+ !deliveryId || separator <= 0 || separator === fullName.length - 1 ||
56
+ !Number.isInteger(installationId) || installationId < 0 ||
57
+ (!options.allowMissingInstallation && installationId === 0) ||
58
+ !Number.isInteger(pullNumber) || pullNumber <= 0 ||
59
+ !headSha || !title
60
+ ) {
61
+ throw new Error("GitHub pull_request webhook is missing required fields");
62
+ }
63
+
64
+ return {
65
+ deliveryId,
66
+ action,
67
+ installationId,
68
+ repository: fullName,
69
+ owner: fullName.slice(0, separator),
70
+ repo: fullName.slice(separator + 1),
71
+ pullNumber,
72
+ headSha,
73
+ title,
74
+ draft,
75
+ };
76
+ }