From 4559776ba7d67f312a8351e0b935f477df078023 Mon Sep 17 00:00:00 2001 From: Ebenezer Arthur Date: Mon, 9 Jun 2025 16:51:40 +0000 Subject: [PATCH 1/3] github integration --- README.md | 22 ++++ app/components/status.tsx | 1 + app/lib/github.ts | 102 ++++++++++++++++++ app/lib/send-discord.ts | 76 +++++++++++++ app/lib/statuses.tsx | 5 + app/lib/webhook-types.ts | 18 ++++ app/routes/webhook.github.tsx | 43 ++++++++ .../migration.sql | 6 ++ prisma/schema.prisma | 29 ++--- 9 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 app/lib/github.ts create mode 100644 app/routes/webhook.github.tsx create mode 100644 prisma/migrations/20250609070203_github_integration/migration.sql diff --git a/README.md b/README.md index 997e180..e859f37 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ BASE_URL= # falls back to VERCEL_PROJECT_PRODUCTION_URL which is set on Vercel e WEBHOOK_URL= # optional DISCORD_WEBHOOK_URL= # optional, see Webhook section DISCORD_BOT_NAME= # optional, defaults to "kovacs" +GITHUB_WEBHOOK_SECRET= # required for GitHub integration, see GitHub Integration section ``` ## Webhook Integration @@ -38,6 +39,8 @@ If a `WEBHOOK_URL` is provided, the endpoint is called with the following events | `task.deleted` | Triggered when a task is deleted | | `task.status_changed` | Triggered when a task's status changes | | `task.assigned` | Triggered when a task is assigned to a user | +| `task.pr_opened` | Triggered when a GitHub PR is opened for a task | +| `task.pr_merged` | Triggered when a GitHub PR is merged for a task | | `comment.created` | Triggered when a comment is added to a task | | `user.joined` | Triggered when a new user joins the system | @@ -52,4 +55,23 @@ To set up Discord notifications: Voila, your discord server will start receiving events. +### GitHub Integration + +Automatically update task statuses based on GitHub pull request events. + +**Setup:** +1. Generate a webhook secret: `openssl rand -hex 32` +2. Add `GITHUB_WEBHOOK_SECRET=your_secret` to environment variables +3. In GitHub repo → Settings → Webhooks → Add webhook: + - Payload URL: `https:///webhook/github` + - Content type: `application/json` + - Secret: your generated secret + - Events: Pull requests + +**Branch naming:** Include task IDs with `#` format (e.g., `art/auth-#123`, `test-#4`) + +**Automatic updates:** +- PR opened → Task status: "inReview" +- PR merged → Task status: "done" + diff --git a/app/components/status.tsx b/app/components/status.tsx index 468af57..12b0f6b 100644 --- a/app/components/status.tsx +++ b/app/components/status.tsx @@ -11,6 +11,7 @@ interface StatusProps { const StatusIcons: Record = { pending: "i-lucide-circle text-secondary", inProgress: "i-lucide-loader-circle text-amber-500", + inReview: "i-solar-document-add-linear text-green-500", done: "i-solar-check-circle-linear text-stone-400 dark:text-neutral-700", }; diff --git a/app/lib/github.ts b/app/lib/github.ts new file mode 100644 index 0000000..04a0f0b --- /dev/null +++ b/app/lib/github.ts @@ -0,0 +1,102 @@ +import type { Task } from "@prisma/client"; +import { TASK_ID_REGEX } from "./constants"; +import { prisma } from "./prisma.server"; +import { sendWebhook } from "./webhook"; + +interface GitHubPullRequestEvent { + action: string; + pull_request: { + head: { + ref: string; + }; + html_url: string; + number: number; + merged: boolean; + }; +} + +async function handlePROpened(event: GitHubPullRequestEvent) { + const branchName = event.pull_request.head.ref; + const taskId = extractTaskId(branchName); + + if (!taskId) return; + + const task = await prisma.task.findUnique({ + where: { id: taskId }, + }); + + if (task) { + await updateTask({ + taskId: task.id, + updates: { + status: "inReview", + githubPrUrl: event.pull_request.html_url, + githubPrNumber: event.pull_request.number, + }, + }); + + sendWebhook("task.pr_opened", { + task, + prUrl: event.pull_request.html_url, + prNumber: event.pull_request.number, + branchName, + projectId: task.projectId, + }); + } +} + +async function handlePRMerged(event: GitHubPullRequestEvent) { + const branchName = event.pull_request.head.ref; + const taskId = extractTaskId(branchName); + + if (!taskId) return; + + const task = await prisma.task.findUnique({ + where: { id: taskId }, + }); + + if (task) { + await updateTask({ + taskId: task.id, + updates: { + status: "done", + }, + }); + + sendWebhook("task.pr_merged", { + task, + prUrl: event.pull_request.html_url, + prNumber: event.pull_request.number, + branchName, + projectId: task.projectId, + }); + } +} + +function extractTaskId(branchName: string): number | null { + const parts = branchName.split(/[\/\-_]/); + + for (const part of parts) { + const match = part.match(TASK_ID_REGEX); + if (match) { + return Number.parseInt(match[1]); + } + } + + return null; +} + +async function updateTask({ + taskId, + updates, +}: { + taskId: number; + updates: Partial; +}): Promise { + return await prisma.task.update({ + where: { id: taskId }, + data: updates, + }); +} + +export { handlePRMerged, handlePROpened, type GitHubPullRequestEvent }; diff --git a/app/lib/send-discord.ts b/app/lib/send-discord.ts index 73a5ea2..079f920 100644 --- a/app/lib/send-discord.ts +++ b/app/lib/send-discord.ts @@ -220,6 +220,78 @@ async function createWebhookPayload( break; } + case "task.pr_opened": { + const { task, prUrl, prNumber, branchName } = + event as WebhookEvent<"task.pr_opened">; + + embed.title = "🔀 Pull Request Opened"; + embed.description = `${task.title} \`#${task.id}\``; + embed.url = prUrl; + + embed.fields = [ + { + name: "Status", + value: "`In Review`", + inline: true, + }, + { + name: "Branch", + value: `\`${branchName}\``, + inline: true, + }, + { + name: "Pull Request", + value: `[PR #${prNumber}](${prUrl})`, + inline: false, + }, + ]; + + if (task.assignee) { + embed.fields.push({ + name: "Assignee", + value: `\`@${task.assignee.username}\``, + inline: true, + }); + } + break; + } + + case "task.pr_merged": { + const { task, prUrl, prNumber, branchName } = + event as WebhookEvent<"task.pr_merged">; + + embed.title = "✅ Pull Request Merged"; + embed.description = `~~${task.title}~~ \`#${task.id}\``; + embed.url = prUrl; + + embed.fields = [ + { + name: "Status", + value: "`Done`", + inline: true, + }, + { + name: "Branch", + value: `\`${branchName}\``, + inline: true, + }, + { + name: "Pull Request", + value: `[PR #${prNumber}](${prUrl})`, + inline: false, + }, + ]; + + if (task.assignee) { + embed.fields.push({ + name: "Assignee", + value: `\`@${task.assignee.username}\``, + inline: true, + }); + } + break; + } + case "task.deleted": { const { task, user } = event as WebhookEvent<"task.deleted">; if (!task) break; @@ -294,6 +366,10 @@ function getColorForEvent(eventType: EventType): number { return 0x818cf8; case "task.assigned": return 0xa78bfa; + case "task.pr_opened": + return 0x6366f1; + case "task.pr_merged": + return 0x10b981; case "task.deleted": return 0xf87171; case "comment.created": diff --git a/app/lib/statuses.tsx b/app/lib/statuses.tsx index 4d857e4..9d58ab9 100644 --- a/app/lib/statuses.tsx +++ b/app/lib/statuses.tsx @@ -17,6 +17,11 @@ const statuses: StatusProps[] = [ label: "In Progress", icon:
, }, + { + id: "inReview", + label: "In Review", + icon:
, + }, { id: "done", label: "Done", diff --git a/app/lib/webhook-types.ts b/app/lib/webhook-types.ts index e38e5a1..6e51afc 100644 --- a/app/lib/webhook-types.ts +++ b/app/lib/webhook-types.ts @@ -8,6 +8,8 @@ export type EventType = | "task.deleted" | "task.status_changed" | "task.assigned" + | "task.pr_opened" + | "task.pr_merged" | "comment.created" | "user.joined"; @@ -55,6 +57,22 @@ export type WebhookPayload = { user: SafeUser; projectId: number; }; + + "task.pr_opened": { + task: Task & { assignee?: SafeUser }; + prUrl: string; + prNumber: number; + branchName: string; + projectId: number; + }; + + "task.pr_merged": { + task: Task & { assignee?: SafeUser }; + prUrl: string; + prNumber: number; + branchName: string; + projectId: number; + }; }; export type WebhookEvent = { diff --git a/app/routes/webhook.github.tsx b/app/routes/webhook.github.tsx new file mode 100644 index 0000000..b210bac --- /dev/null +++ b/app/routes/webhook.github.tsx @@ -0,0 +1,43 @@ +import crypto from "node:crypto"; +import type { ActionFunctionArgs } from "react-router"; +import { + type GitHubPullRequestEvent, + handlePRMerged, + handlePROpened, +} from "~/lib/github"; +import { badRequest } from "~/lib/responses"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const event = await request.json(); + + const signature = request.headers.get("x-hub-signature-256")!; + + verifyRequest(event, signature); + + switch (event.action) { + case "opened": + case "reopened": + await handlePROpened(event); + break; + case "closed": + if (event.pull_request.merged) { + await handlePRMerged(event); + } + break; + } + + return {}; +}; + +function verifyRequest(event: GitHubPullRequestEvent, signature: string) { + const hash = `sha256=${crypto + .createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET!) + .update(JSON.stringify(event)) + .digest("hex")}`; + + if (hash === signature) { + return true; + } + + throw badRequest("Verifying request failed"); +} diff --git a/prisma/migrations/20250609070203_github_integration/migration.sql b/prisma/migrations/20250609070203_github_integration/migration.sql new file mode 100644 index 0000000..3a0fd35 --- /dev/null +++ b/prisma/migrations/20250609070203_github_integration/migration.sql @@ -0,0 +1,6 @@ +-- AlterEnum +ALTER TYPE "Status" ADD VALUE 'inReview'; + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "githubPrNumber" INTEGER, +ADD COLUMN "githubPrUrl" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b7ae4b2..16d27d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,24 +37,27 @@ model ProjectAccess { } model Task { - id Int @id @default(autoincrement()) - title String - status Status @default(pending) - author User @relation("author", fields: [authorId], references: [id]) - assignee User @relation("assignee", fields: [assigneeId], references: [id]) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - projectId Int - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - Comment Comment[] - authorId Int - assigneeId Int + id Int @id @default(autoincrement()) + title String + status Status @default(pending) + author User @relation("author", fields: [authorId], references: [id]) + assignee User @relation("assignee", fields: [assigneeId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + projectId Int + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + Comment Comment[] + authorId Int + assigneeId Int + githubPrUrl String? + githubPrNumber Int? } enum Status { pending inProgress + inReview done } From 8632e7e7d23bcbc719807980b74e729f46161e3a Mon Sep 17 00:00:00 2001 From: Ebenezer Arthur Date: Tue, 10 Jun 2025 22:21:56 +0000 Subject: [PATCH 2/3] github app --- app/components/user-menu.tsx | 135 ++++++++++++++++-- app/lib/github.ts | 54 +++++-- app/routes/$project.tsx | 6 + app/routes/github-callback.tsx | 43 ++++++ app/routes/webhook.github.tsx | 25 ++-- biome.json | 3 + package.json | 1 + .../migration.sql | 6 - .../migration.sql | 26 ++++ prisma/schema.prisma | 16 ++- vite.config.ts | 4 + yarn.lock | 29 +++- 12 files changed, 298 insertions(+), 50 deletions(-) create mode 100644 app/routes/github-callback.tsx delete mode 100644 prisma/migrations/20250609070203_github_integration/migration.sql create mode 100644 prisma/migrations/20250610185754_github_integration/migration.sql diff --git a/app/components/user-menu.tsx b/app/components/user-menu.tsx index acd8eec..4b3cee6 100644 --- a/app/components/user-menu.tsx +++ b/app/components/user-menu.tsx @@ -2,26 +2,100 @@ import clsx from "clsx"; import React from "react"; import { Link, useLoaderData, useNavigate } from "react-router"; import type { loader } from "~/routes/$project"; +import { Button } from "./button"; import { InviteCard } from "./invite-card"; +import { usePopoverContext } from "./popover"; + +type View = "default" | "github"; function UserMenu() { - const { user } = useLoaderData(); + const { user, installation } = useLoaderData(); const navigate = useNavigate(); const [showInvite, setShowInvite] = React.useState(false); + const [view, setView] = React.useState("default"); + + const popover = usePopoverContext(); - const handleLogout = () => { + function handleLogout() { const confirmed = window.confirm("Are you sure you want to logout?"); if (confirmed) { navigate("/logout"); } - }; + } + + function to() { + if (installation) { + return `https://github.com/settings/installations/${installation.githubInstallationId}`; + } + + // []: Replace slug before merge + return "https://github.com/apps/gr-s-todo-list/installations/new"; + } + + if (view === "github") { + return ( + +
+ +
+ +
+
+
+
+ +

Github Integration

+ + {installation ? ( +

+ GitHub integration is{" "} + + {installation.active ? "active" : "paused"} + + . Tasks will {installation.active ? "" : "not "}automatically update + when you open or merge pull requests. +

+ ) : ( +

+ Connect Todo List with your GitHub account to automatically update + the status of tasks. +

+ )} + + + + + + ); + } return ( -
{!showInvite ? ( <> @@ -37,6 +111,7 @@ function UserMenu() {
)} +
    {user.superUser && (
  • @@ -50,6 +125,33 @@ function UserMenu() {
  • )} + + {user.superUser && ( +
  • + +
  • + )} +
  • +
  • +
+ +
+
+
+
+ +

Github Integration

+ + {installation ? ( +

+ GitHub integration is{" "} + + {installation.active ? "active" : "paused"} + + . Tasks will {installation.active ? "" : "not "}automatically update + when you open or merge pull requests. +

+ ) : ( +

+ Connect Todo List with your GitHub account to automatically update the + status of tasks. +

+ )} + + + + + + ); +} + +export { GitHubCard }; diff --git a/app/components/user-menu.tsx b/app/components/user-menu.tsx index 4b3cee6..b4d982c 100644 --- a/app/components/user-menu.tsx +++ b/app/components/user-menu.tsx @@ -2,9 +2,8 @@ import clsx from "clsx"; import React from "react"; import { Link, useLoaderData, useNavigate } from "react-router"; import type { loader } from "~/routes/$project"; -import { Button } from "./button"; +import { GitHubCard } from "./github-card"; import { InviteCard } from "./invite-card"; -import { usePopoverContext } from "./popover"; type View = "default" | "github"; @@ -14,8 +13,6 @@ function UserMenu() { const [showInvite, setShowInvite] = React.useState(false); const [view, setView] = React.useState("default"); - const popover = usePopoverContext(); - function handleLogout() { const confirmed = window.confirm("Are you sure you want to logout?"); if (confirmed) { @@ -23,72 +20,10 @@ function UserMenu() { } } - function to() { - if (installation) { - return `https://github.com/settings/installations/${installation.githubInstallationId}`; - } - - // []: Replace slug before merge - return "https://github.com/apps/gr-s-todo-list/installations/new"; - } - if (view === "github") { return ( -
- -
- -
-
-
-
- -

Github Integration

- - {installation ? ( -

- GitHub integration is{" "} - - {installation.active ? "active" : "paused"} - - . Tasks will {installation.active ? "" : "not "}automatically update - when you open or merge pull requests. -

- ) : ( -

- Connect Todo List with your GitHub account to automatically update - the status of tasks. -

- )} - - - - + setView("default")} /> ); } diff --git a/app/lib/github.ts b/app/lib/github.ts index 833d3c2..21bbfb8 100644 --- a/app/lib/github.ts +++ b/app/lib/github.ts @@ -3,7 +3,32 @@ import { TASK_ID_REGEX } from "./constants"; import { prisma } from "./prisma.server"; import { sendWebhook } from "./webhook"; -async function handlePullRequestEvent(event: any) { +export interface InstallationEvent { + installation: { + id: number; + }; + action: "deleted" | "suspend" | "unsuspend"; +} + +export interface PREvent { + pull_request: { + head: { + ref: string; + }; + html_url: string; + number: number; + merged: boolean; + }; + action: "opened" | "reopened" | "closed"; +} + +export type EventTypeMap = { + installation: InstallationEvent; + pull_request: PREvent; +}; + +export type GitHubEventType = keyof EventTypeMap; +async function handlePullRequestEvent(event: PREvent) { switch (event.action) { case "opened": case "reopened": @@ -17,7 +42,7 @@ async function handlePullRequestEvent(event: any) { } } -async function handleInstallationEvent(event: any) { +async function handleInstallationEvent(event: InstallationEvent) { const installationId = event.installation.id; switch (event.action) { @@ -43,7 +68,7 @@ async function handleInstallationEvent(event: any) { } } -async function handlePROpened(event: any) { +async function handlePROpened(event: PREvent) { const branchName = event.pull_request.head.ref; const taskId = extractTaskId(branchName); @@ -73,7 +98,7 @@ async function handlePROpened(event: any) { } } -async function handlePRMerged(event: any) { +async function handlePRMerged(event: PREvent) { const branchName = event.pull_request.head.ref; const taskId = extractTaskId(branchName); diff --git a/app/routes/webhook.github.tsx b/app/routes/webhook.github.tsx index 2184a37..9163a22 100644 --- a/app/routes/webhook.github.tsx +++ b/app/routes/webhook.github.tsx @@ -1,21 +1,28 @@ import crypto from "node:crypto"; import type { ActionFunctionArgs } from "react-router"; -import { handleInstallationEvent, handlePullRequestEvent } from "~/lib/github"; +import { + type EventTypeMap, + type GitHubEventType, + handleInstallationEvent, + handlePullRequestEvent, + type InstallationEvent, + type PREvent, +} from "~/lib/github"; import { badRequest } from "~/lib/responses"; export const action = async ({ request }: ActionFunctionArgs) => { - const event = await request.json(); - const eventType = request.headers.get("x-github-event"); + const eventType = request.headers.get("x-github-event") as GitHubEventType; + const event = (await request.json()) as EventTypeMap[typeof eventType]; const signature = request.headers.get("x-hub-signature-256")!; - + verifyRequest(event, signature); switch (eventType) { case "installation": - await handleInstallationEvent(event); + await handleInstallationEvent(event as InstallationEvent); break; case "pull_request": - await handlePullRequestEvent(event); + await handlePullRequestEvent(event as PREvent); break; } diff --git a/vite.config.ts b/vite.config.ts index 606e399..19b0b46 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,8 +5,4 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ plugins: [reactRouter(), UnoCSS(), tsconfigPaths()], - - server: { - allowedHosts: ["36fd-154-161-8-33.ngrok-free.app"], - }, });