forked from giselles-ai/giselle
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
7ef4367
commit 242cca8
Showing
4 changed files
with
180 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ....", | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
} |