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 ( +
+

Repository Settings

+ +
+

Integrations

+ +
+
+

Jira Integration

+

+ Connect your Jira account to automatically create todos from Jira + issues. +

+ {session?.user?.jiraToken ? ( +

✓ Connected to Jira

+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/server/auth.ts b/src/server/auth.ts index 79a7950..a12889c 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -25,6 +25,7 @@ declare module "next-auth/adapters" { interface AdapterUser { login?: string; role?: UserRole; + jiraToken?: string; } } @@ -35,6 +36,7 @@ declare module "next-auth" { login: string; role?: UserRole; expires?: string; // ISO DateString + jiraToken?: string; // ...other properties } & DefaultSession["user"]; accessToken: string; @@ -49,6 +51,7 @@ declare module "next-auth" { role: UserRole; login: string; expires?: string; + jiraToken?: string; } } @@ -110,6 +113,7 @@ export const authOptions: NextAuthOptions = { const { session, user } = params; const userId = parseInt(user.id, 10); const account = await db.accounts.findBy({ userId }); + const dbUser = await db.users.find(userId); return { ...session, accessToken: account.access_token, @@ -119,6 +123,7 @@ export const authOptions: NextAuthOptions = { role: user.role, login: user.login, expires: session.expires, + jiraToken: dbUser.jiraToken, }, }; }, diff --git a/src/server/db/migrations/20241101000000_addJiraTokenToUsers.ts b/src/server/db/migrations/20241101000000_addJiraTokenToUsers.ts new file mode 100644 index 0000000..0fdbae0 --- /dev/null +++ b/src/server/db/migrations/20241101000000_addJiraTokenToUsers.ts @@ -0,0 +1,7 @@ +import { change } from "../dbScript"; + +change(async (db) => { + await db.changeTable("users", (t) => ({ + jiraToken: t.text().nullable(), + })); +}); diff --git a/src/server/db/tables/users.table.ts b/src/server/db/tables/users.table.ts index 45db303..e85a5e5 100644 --- a/src/server/db/tables/users.table.ts +++ b/src/server/db/tables/users.table.ts @@ -24,6 +24,7 @@ export class UsersTable extends BaseTable { onboardingStatus: t .enum("onboarding_status", ONBOARDING_STATUS_VALUES) .default(OnboardingStatus.NONE), + jiraToken: t.text().nullable(), ...t.timestamps(), })); }