Skip to content

Commit

Permalink
feat(github): Add webhook handler for GitHub issue comments
Browse files Browse the repository at this point in the history
Implement GitHub integration webhook endpoint to process issue commands:
- Add command parser to handle /giselle commands in issues
- Create GitHub API client with app authentication
- Set up webhook verification and payload processing
- Add tests for command parsing with different formats

The webhook enables automated responses to GitHub issue comments, laying
groundwork for GitHub-based agent interactions.

Requires env vars:
- GITHUB_APP_ID
- GITHUB_APP_PRIVATE_KEY
- GITHUB_APP_CLIENT_ID
- GITHUB_APP_CLIENT_SECRET
- GITHUB_APP_WEBHOOK_SECRET
  • Loading branch information
toyamarinyon committed Dec 24, 2024
1 parent 7ef4367 commit 242cca8
Show file tree
Hide file tree
Showing 4 changed files with 180 additions and 0 deletions.
35 changes: 35 additions & 0 deletions app/webhooks/github/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { parseCommand } from "./command";

test("parseCommand", () => {
expect(
parseCommand(`
/giselle hello\r\n\r\nPlease write a blog.\r\nTheme is free.
`),
).toStrictEqual({
callSign: "hello",
content: "Please write a blog.\nTheme is free.",
});
});

test("parseCommand2", () => {
expect(
parseCommand(`
/giselle hello\r\nPlease write a blog.\r\nTheme is free.
`),
).toStrictEqual({
callSign: "hello",
content: "Please write a blog.\nTheme is free.",
});
});

test("parseCommand3", () => {
expect(
parseCommand(`
/giselle hello\r\nPlease write a blog.\r\nTheme is free.\r\n\r\nText mood is ....
`),
).toStrictEqual({
callSign: "hello",
content: "Please write a blog.\nTheme is free.\n\nText mood is ....",
});
});
19 changes: 19 additions & 0 deletions app/webhooks/github/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface Command {
callSign: string;
instruction: string;
}

export function parseCommand(text: string) {
const lines = text.trim().split("\r\n");

const commandLine = lines[0];
const commandMatch = commandLine.match(/^\/giselle\s+([^\]]+)/);
if (!commandMatch) {
return null;
}

return {
callSign: commandMatch[1],
content: lines.slice(1).join("\n").trim(),
};
}
76 changes: 76 additions & 0 deletions app/webhooks/github/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { db } from "@/drizzle";
import { Webhooks } from "@octokit/webhooks";
import type { WebhookEventName } from "@octokit/webhooks-types";
import { waitUntil } from "@vercel/functions";
import type { NextRequest } from "next/server";
import { parseCommand } from "./command";
import { assertIssueCommentEvent, createOctokit } from "./utils";

export async function POST(request: NextRequest) {
if (process.env.GITHUB_APP_WEBHOOK_SECRET === undefined) {
throw new Error("GITHUB_APP_WEBHOOK_SECRET is not set");
}
const webhooks = new Webhooks({
secret: process.env.GITHUB_APP_WEBHOOK_SECRET,
});

const signature = request.headers.get("X-Hub-Signature-256") ?? "";
const body = await request.text();
const verifyOK = await webhooks.verify(body, signature);
// if (!verifyOK) {
// return new Response("Failed to verify webhook", { status: 400 });
// }

const id = request.headers.get("X-GitHub-Delivery") ?? "";
const name = request.headers.get("X-GitHub-Event") as WebhookEventName;
const payload = JSON.parse(body);
const event = { id, name, payload };
assertIssueCommentEvent(event);

const command = parseCommand(payload.comment.body);
if (command === null) {
return;
}
if (payload.installation === undefined) {
return;
}
const octokit = await createOctokit(payload.installation.id);

const integrationSettings = await db.query.githubIntegrationSettings.findMany(
{
where: (gitHubIntegrationSettings, { eq, and }) =>
and(
eq(
gitHubIntegrationSettings.repositoryFullName,
payload.repository.full_name,
),
eq(gitHubIntegrationSettings.callSign, command.callSign),
),
},
);

waitUntil(
Promise.all(
integrationSettings.map(async (integrationSetting) => {
const agent = await db.query.agents.findFirst({
where: (agents, { eq }) =>
eq(agents.dbId, integrationSetting.agentDbId),
});
if (agent === undefined) {
return;
}
await octokit.request(
"POST /repos/{owner}/{repo}/issues/{issue_number}/comments",
{
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.issue.number,
body: `hello! this is a test from ${agent.name}`,
},
);
}),
),
);

return new Response("Accepted", { status: 202 });
}
50 changes: 50 additions & 0 deletions app/webhooks/github/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/core";
import type { EmitterWebhookEvent } from "@octokit/webhooks";

export function assertIssueCommentEvent(
payload: unknown,
): asserts payload is EmitterWebhookEvent<"issue_comment"> {
if (payload === null || typeof payload !== "object") {
throw new Error("Payload is not an object");
}
if (!("id" in payload)) {
throw new Error("Payload is missing id field");
}
if (!("name" in payload)) {
throw new Error("Payload is missing name field");
}
if (payload.name !== "issue_comment") {
throw new Error("Payload name is not issue_comment");
}
}

export async function createOctokit(installationId: number | string) {
const appId = process.env.GITHUB_APP_ID;
if (!appId) {
throw new Error("GITHUB_APP_ID is empty");
}
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
if (!privateKey) {
throw new Error("GITHUB_APP_PRIVATE_KEY is empty");
}
const clientId = process.env.GITHUB_APP_CLIENT_ID;
if (!clientId) {
throw new Error("GITHUB_APP_CLIENT_ID is empty");
}
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
if (!clientSecret) {
throw new Error("GITHUB_APP_CLIENT_SECRET is empty");
}

const auth = await createAppAuth({
appId,
privateKey,
clientId,
clientSecret,
})({ type: "installation", installationId });

return new Octokit({
auth: auth.token,
});
}

0 comments on commit 242cca8

Please sign in to comment.