From f878a49a7f6004ceca1fbc32b63bfd4bbcc4d960 Mon Sep 17 00:00:00 2001 From: "jacob-local-kevin[bot]" <164088400+jacob-local-kevin[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:00:46 -0800 Subject: [PATCH] JACoB PR for Issue Add Jira Webhook integration --- src/app/api/auth/jira/authorize/route.ts | 49 +++++++++++++ src/app/api/auth/jira/callback/route.ts | 69 +++++++++++++++++++ src/app/api/webhooks/jira/route.ts | 55 +++++++++++++++ .../dashboard/[org]/[repo]/settings/page.tsx | 38 ++++++++++ src/server/auth.ts | 5 ++ .../20241101000000_addJiraTokenToUsers.ts | 7 ++ src/server/db/tables/users.table.ts | 1 + 7 files changed, 224 insertions(+) create mode 100644 src/app/api/auth/jira/authorize/route.ts create mode 100644 src/app/api/auth/jira/callback/route.ts create mode 100644 src/app/api/webhooks/jira/route.ts create mode 100644 src/app/dashboard/[org]/[repo]/settings/page.tsx create mode 100644 src/server/db/migrations/20241101000000_addJiraTokenToUsers.ts diff --git a/src/app/api/auth/jira/authorize/route.ts b/src/app/api/auth/jira/authorize/route.ts new file mode 100644 index 0000000..9b839a5 --- /dev/null +++ b/src/app/api/auth/jira/authorize/route.ts @@ -0,0 +1,49 @@ +import { type NextRequest } from "next/server"; +import { getServerAuthSession } from "~/server/auth"; +import { env } from "~/env"; +import { randomBytes } from "crypto"; + +export async function GET(req: NextRequest) { + const session = await getServerAuthSession(); + + if (!session?.user) { + return new Response("Unauthorized", { status: 401 }); + } + + const state = randomBytes(32).toString("hex"); + const codeVerifier = randomBytes(32).toString("hex"); + + // Store state and code verifier in session/cookie + const cookieStore = req.cookies; + cookieStore.set("jira_oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 10, // 10 minutes + }); + cookieStore.set("jira_code_verifier", codeVerifier, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 10, // 10 minutes + }); + + const authUrl = new URL("https://auth.atlassian.com/authorize"); + authUrl.searchParams.set("audience", "api.atlassian.com"); + authUrl.searchParams.set("client_id", env.JIRA_CLIENT_ID); + authUrl.searchParams.set( + "scope", + "read:jira-user read:jira-work write:jira-work", + ); + authUrl.searchParams.set( + "redirect_uri", + `${env.NEXTAUTH_URL}/api/auth/jira/callback`, + ); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("prompt", "consent"); + + return Response.redirect(authUrl.toString()); +} diff --git a/src/app/api/auth/jira/callback/route.ts b/src/app/api/auth/jira/callback/route.ts new file mode 100644 index 0000000..f11d026 --- /dev/null +++ b/src/app/api/auth/jira/callback/route.ts @@ -0,0 +1,69 @@ +import { type NextRequest } from "next/server"; +import { getServerAuthSession } from "~/server/auth"; +import { env } from "~/env"; +import { db } from "~/server/db/db"; + +export async function GET(req: NextRequest) { + const session = await getServerAuthSession(); + + if (!session?.user) { + return new Response("Unauthorized", { status: 401 }); + } + + const searchParams = req.nextUrl.searchParams; + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + if (error) { + return new Response(`Authentication error: ${error}`, { status: 400 }); + } + + const cookieStore = req.cookies; + const storedState = cookieStore.get("jira_oauth_state")?.value; + const codeVerifier = cookieStore.get("jira_code_verifier")?.value; + + if (!storedState || !codeVerifier || state !== storedState) { + return new Response("Invalid state", { status: 400 }); + } + + try { + const tokenResponse = await fetch( + "https://auth.atlassian.com/oauth/token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: env.JIRA_CLIENT_ID, + client_secret: env.JIRA_CLIENT_SECRET, + code, + redirect_uri: `${env.NEXTAUTH_URL}/api/auth/jira/callback`, + code_verifier: codeVerifier, + }), + }, + ); + + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${await tokenResponse.text()}`); + } + + const { access_token } = await tokenResponse.json(); + + // Store the token in the database + await db.users.find(parseInt(session.user.id)).update({ + jiraToken: access_token, + }); + + // Clear OAuth cookies + cookieStore.delete("jira_oauth_state"); + cookieStore.delete("jira_code_verifier"); + + return Response.redirect(`${env.NEXTAUTH_URL}/dashboard`); + } catch (error) { + console.error("Error exchanging code for token:", error); + return new Response("Failed to exchange code for token", { status: 500 }); + } +} diff --git a/src/app/api/webhooks/jira/route.ts b/src/app/api/webhooks/jira/route.ts new file mode 100644 index 0000000..94a898c --- /dev/null +++ b/src/app/api/webhooks/jira/route.ts @@ -0,0 +1,55 @@ +import { type NextRequest } from "next/server"; +import { env } from "~/env"; +import { db } from "~/server/db/db"; +import { TodoStatus } from "~/server/db/enums"; + +export async function POST(req: NextRequest) { + // Verify webhook secret + const webhookSecret = req.headers.get("x-atlassian-webhook-token"); + if (webhookSecret !== env.JIRA_WEBHOOK_SECRET) { + return new Response("Invalid webhook secret", { status: 401 }); + } + + try { + const payload = await req.json(); + + // Only process issue creation events + if (payload.webhookEvent !== "jira:issue_created") { + return new Response("Event type not supported", { status: 200 }); + } + + const issue = payload.issue; + const project = await db.projects.findBy({ + repoFullName: issue.fields.project.key, + }); + + if (!project) { + return new Response("Project not found", { status: 404 }); + } + + // Check if todo already exists for this issue + const existingTodo = await db.todos.findByOptional({ + projectId: project.id, + issueId: issue.id, + }); + + if (existingTodo) { + return new Response("Todo already exists", { status: 200 }); + } + + // Create new todo + await db.todos.create({ + projectId: project.id, + description: `${issue.fields.summary}\n\n${issue.fields.description || ""}`, + name: issue.fields.summary, + status: TodoStatus.TODO, + issueId: issue.id, + position: issue.id, + }); + + return new Response("Todo created successfully", { status: 200 }); + } catch (error) { + console.error("Error processing Jira webhook:", error); + return new Response("Internal server error", { status: 500 }); + } +} diff --git a/src/app/dashboard/[org]/[repo]/settings/page.tsx b/src/app/dashboard/[org]/[repo]/settings/page.tsx new file mode 100644 index 0000000..f3dc381 --- /dev/null +++ b/src/app/dashboard/[org]/[repo]/settings/page.tsx @@ -0,0 +1,38 @@ +import { getServerAuthSession } from "~/server/auth"; +import { Button } from "~/components/ui/button"; + +export default async function SettingsPage() { + const session = await getServerAuthSession(); + + return ( +
+ Connect your Jira account to automatically create todos from Jira + issues. +
+ {session?.user?.jiraToken ? ( +✓ Connected to Jira
+ ) : ( + + )} +