Skip to content

Commit 2be3928

Browse files
Add GitHub webhook endpoint for release events (#1805)
- Add @octokit/webhooks-types package - Create github.ts with webhook verification and release handler - Add GITHUB_WEBHOOK_SECRET and DEVIN_API_KEY to env config - Implement /webhook/github endpoint that listens for release created events - Call Devin API with idempotent=true and release info in prompt - Update .env.sample with new environment variables Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yujonglee <[email protected]>
1 parent eb686f8 commit 2be3928

File tree

6 files changed

+129
-0
lines changed

6 files changed

+129
-0
lines changed

apps/api/.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
STRIPE_API_KEY=""
22
STRIPE_WEBHOOK_SECRET=""
33
OPENROUTER_API_KEY=""
4+
GITHUB_WEBHOOK_SECRET=""
5+
DEVIN_API_KEY=""
46

57
SUPABASE_ANON_KEY=""
68
S3_SECRET_KEY=""

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"typecheck": "tsc --noEmit"
88
},
99
"dependencies": {
10+
"@octokit/webhooks-types": "^7.6.1",
1011
"@sentry/bun": "^10.25.0",
1112
"@supabase/supabase-js": "^2.81.1",
1213
"@t3-oss/env-core": "^0.13.8",

apps/api/src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const env = createEnv({
1414
STRIPE_WEBHOOK_SECRET: z.string().min(1),
1515
OPENROUTER_API_KEY: z.string().min(1),
1616
DEEPGRAM_API_KEY: z.string().min(1),
17+
GITHUB_WEBHOOK_SECRET: z.string().min(1),
18+
DEVIN_API_KEY: z.string().min(1),
1719
},
1820
runtimeEnv: Bun.env,
1921
emptyStringAsUndefined: true,

apps/api/src/github.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { ReleaseEvent } from "@octokit/webhooks-types";
2+
import { createMiddleware } from "hono/factory";
3+
4+
import { env } from "./env";
5+
6+
type GitHubWebhookEvent = ReleaseEvent;
7+
8+
async function verifyGitHubSignature(
9+
payload: string,
10+
signature: string,
11+
secret: string,
12+
): Promise<boolean> {
13+
const encoder = new TextEncoder();
14+
const key = await crypto.subtle.importKey(
15+
"raw",
16+
encoder.encode(secret),
17+
{ name: "HMAC", hash: "SHA-256" },
18+
false,
19+
["sign"],
20+
);
21+
22+
const signatureBuffer = await crypto.subtle.sign(
23+
"HMAC",
24+
key,
25+
encoder.encode(payload),
26+
);
27+
28+
const expectedSignature = `sha256=${Array.from(
29+
new Uint8Array(signatureBuffer),
30+
)
31+
.map((b) => b.toString(16).padStart(2, "0"))
32+
.join("")}`;
33+
34+
return signature === expectedSignature;
35+
}
36+
37+
export const verifyGitHubWebhook = createMiddleware<{
38+
Variables: { githubEvent: GitHubWebhookEvent };
39+
}>(async (c, next) => {
40+
const signature = c.req.header("X-Hub-Signature-256");
41+
42+
if (!signature) {
43+
return c.text("missing_github_signature", 400);
44+
}
45+
46+
const body = await c.req.text();
47+
try {
48+
const isValid = await verifyGitHubSignature(
49+
body,
50+
signature,
51+
env.GITHUB_WEBHOOK_SECRET,
52+
);
53+
54+
if (!isValid) {
55+
return c.text("invalid_signature", 401);
56+
}
57+
58+
const event = JSON.parse(body) as GitHubWebhookEvent;
59+
c.set("githubEvent", event);
60+
await next();
61+
} catch (err) {
62+
console.error(err);
63+
const message = err instanceof Error ? err.message : "unknown_error";
64+
return c.text(message, 400);
65+
}
66+
});
67+
68+
export async function handleReleaseEvent(event: ReleaseEvent): Promise<void> {
69+
if (event.action !== "created") {
70+
return;
71+
}
72+
73+
const { release, repository } = event;
74+
const prompt = `New release created for ${repository.full_name}:
75+
- Tag: ${release.tag_name}
76+
- Name: ${release.name || release.tag_name}
77+
- Created at: ${release.created_at}
78+
- Author: ${release.author?.login || "unknown"}
79+
- URL: ${release.html_url}
80+
81+
${release.body ? `Release notes:\n${release.body}` : "No release notes provided."}`;
82+
83+
const response = await fetch("https://api.devin.ai/v1/sessions", {
84+
method: "POST",
85+
headers: {
86+
"Content-Type": "application/json",
87+
Authorization: `Bearer ${env.DEVIN_API_KEY}`,
88+
},
89+
body: JSON.stringify({
90+
idempotent: true,
91+
prompt,
92+
}),
93+
});
94+
95+
if (!response.ok) {
96+
const errorText = await response.text();
97+
throw new Error(
98+
`Failed to create Devin session: ${response.status} ${errorText}`,
99+
);
100+
}
101+
102+
const result = await response.json();
103+
console.log("Devin session created:", result);
104+
}

apps/api/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { logger } from "hono/logger";
88

99
import { syncBillingForStripeEvent } from "./billing";
1010
import { env } from "./env";
11+
import { handleReleaseEvent, verifyGitHubWebhook } from "./github";
1112
import { listenSocketHandler } from "./listen";
1213
import { verifyStripeWebhook } from "./stripe";
1314
import { requireSupabaseAuth } from "./supabase";
@@ -101,6 +102,17 @@ app.post("/webhook/stripe", verifyStripeWebhook, async (c) => {
101102
return c.json({ ok: true });
102103
});
103104

105+
app.post("/webhook/github", verifyGitHubWebhook, async (c) => {
106+
try {
107+
await handleReleaseEvent(c.var.githubEvent);
108+
} catch (error) {
109+
console.error(error);
110+
return c.json({ error: "github_webhook_failed" }, 500);
111+
}
112+
113+
return c.json({ ok: true });
114+
});
115+
104116
if (env.NODE_ENV === "development") {
105117
app.get("/listen", listenSocketHandler);
106118
} else {

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)