From 242cca8947c7f7a654ebf10d6b95e21d50e78c85 Mon Sep 17 00:00:00 2001 From: satoshi toyama Date: Tue, 24 Dec 2024 10:13:38 +0900 Subject: [PATCH] feat(github): Add webhook handler for GitHub issue comments 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 --- app/webhooks/github/command.test.ts | 35 +++++++++++++ app/webhooks/github/command.ts | 19 ++++++++ app/webhooks/github/route.ts | 76 +++++++++++++++++++++++++++++ app/webhooks/github/utils.ts | 50 +++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 app/webhooks/github/command.test.ts create mode 100644 app/webhooks/github/command.ts create mode 100644 app/webhooks/github/route.ts create mode 100644 app/webhooks/github/utils.ts diff --git a/app/webhooks/github/command.test.ts b/app/webhooks/github/command.test.ts new file mode 100644 index 000000000..d188231d3 --- /dev/null +++ b/app/webhooks/github/command.test.ts @@ -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 ....", + }); +}); diff --git a/app/webhooks/github/command.ts b/app/webhooks/github/command.ts new file mode 100644 index 000000000..cbc422c7a --- /dev/null +++ b/app/webhooks/github/command.ts @@ -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(), + }; +} diff --git a/app/webhooks/github/route.ts b/app/webhooks/github/route.ts new file mode 100644 index 000000000..55eba771a --- /dev/null +++ b/app/webhooks/github/route.ts @@ -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 }); +} diff --git a/app/webhooks/github/utils.ts b/app/webhooks/github/utils.ts new file mode 100644 index 000000000..41be9dcf5 --- /dev/null +++ b/app/webhooks/github/utils.ts @@ -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, + }); +}