From 9b728e1a686da48843d9254ec836969d6b2188fb Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 17:17:05 -0800 Subject: [PATCH 01/14] Add github integration --- apps/web/package.json | 5 + .../ConnectionControls/ConnectionControls.tsx | 84 + .../components/ConnectionControls/index.ts | 1 + .../components/ErrorHandler/ErrorHandler.tsx | 47 + .../github/components/ErrorHandler/index.ts | 1 + .../RepositoryList/RepositoryList.tsx | 66 + .../github/components/RepositoryList/index.ts | 1 + .../(dashboard)/integrations/github/page.tsx | 112 + .../src/app/(dashboard)/integrations/page.tsx | 1 - .../api/integrations/github/callback/route.ts | 140 ++ .../api/integrations/github/install/route.ts | 56 + .../github/jobs/initial-sync/route.ts | 231 ++ .../app/api/integrations/github/octokit.ts | 11 + .../api/integrations/github/webhook/route.ts | 61 + .../integrations/github/webhook/webhooks.ts | 377 +++ apps/web/src/env.ts | 8 + bun.lock | 67 +- package.json | 2 +- .../0011_add_github_integration_tables.sql | 70 + packages/db/drizzle/meta/0011_snapshot.json | 2183 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/enums.ts | 2 +- packages/db/src/schema/github.ts | 177 ++ packages/db/src/schema/index.ts | 2 + packages/db/src/schema/relations.ts | 44 + packages/trpc/src/env.ts | 7 + .../src/router/integration/github/github.ts | 198 ++ .../src/router/integration/github/index.ts | 1 + .../src/router/integration/github/utils.ts | 31 + .../src/router/integration/integration.ts | 2 + 30 files changed, 3991 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx create mode 100644 apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/index.ts create mode 100644 apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/ErrorHandler.tsx create mode 100644 apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/index.ts create mode 100644 apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx create mode 100644 apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/index.ts create mode 100644 apps/web/src/app/(dashboard)/integrations/github/page.tsx create mode 100644 apps/web/src/app/api/integrations/github/callback/route.ts create mode 100644 apps/web/src/app/api/integrations/github/install/route.ts create mode 100644 apps/web/src/app/api/integrations/github/jobs/initial-sync/route.ts create mode 100644 apps/web/src/app/api/integrations/github/octokit.ts create mode 100644 apps/web/src/app/api/integrations/github/webhook/route.ts create mode 100644 apps/web/src/app/api/integrations/github/webhook/webhooks.ts create mode 100644 packages/db/drizzle/0011_add_github_integration_tables.sql create mode 100644 packages/db/drizzle/meta/0011_snapshot.json create mode 100644 packages/db/src/schema/github.ts create mode 100644 packages/trpc/src/router/integration/github/github.ts create mode 100644 packages/trpc/src/router/integration/github/index.ts create mode 100644 packages/trpc/src/router/integration/github/utils.ts diff --git a/apps/web/package.json b/apps/web/package.json index 2a169439c00..0244953d528 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@octokit/app": "^16.1.2", + "@octokit/rest": "^22.0.1", + "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.32.1", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", @@ -24,7 +27,9 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "@uiw/react-md-editor": "^4.0.11", + "@upstash/qstash": "^2.8.4", "better-auth": "^1.4.9", + "drizzle-orm": "0.45.1", "framer-motion": "^12.23.26", "geist": "^1.5.1", "import-in-the-middle": "2.0.1", diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx new file mode 100644 index 00000000000..9de01e94f4b --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Unplug } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { env } from "@/env"; +import { useTRPC } from "@/trpc/react"; + +interface ConnectionControlsProps { + organizationId: string; + isConnected: boolean; +} + +export function ConnectionControls({ + organizationId, + isConnected, +}: ConnectionControlsProps) { + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const disconnectMutation = useMutation( + trpc.integration.github.disconnect.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.integration.github.getInstallation.queryKey({ + organizationId, + }), + }); + router.refresh(); + }, + }), + ); + + const handleConnect = () => { + window.location.href = `${env.NEXT_PUBLIC_WEB_URL}/api/integrations/github/install?organizationId=${organizationId}`; + }; + + const handleDisconnect = () => { + disconnectMutation.mutate({ organizationId }); + }; + + if (isConnected) { + return ( + + + + + + + Disconnect GitHub? + + This will remove the GitHub App installation for your + organization. You will need to reinstall the app to reconnect. + + + + Cancel + + Disconnect + + + + + ); + } + + return ; +} diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/index.ts b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/index.ts new file mode 100644 index 00000000000..ca060e28397 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/index.ts @@ -0,0 +1 @@ +export { ConnectionControls } from "./ConnectionControls"; diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/ErrorHandler.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/ErrorHandler.tsx new file mode 100644 index 00000000000..a6a236f6c06 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/ErrorHandler.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { toast } from "@superset/ui/sonner"; +import { useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +const ERROR_MESSAGES: Record = { + installation_cancelled: "GitHub App installation was cancelled.", + missing_params: "Invalid installation response. Please try again.", + invalid_state: "Invalid state parameter. Please try again.", + installation_fetch_failed: + "Failed to fetch installation details. Please try again.", + save_failed: "Failed to save installation. Please try again.", + unexpected: "Something went wrong. Please try again.", +}; + +const WARNING_MESSAGES: Record = { + sync_queue_failed: + "GitHub connected, but initial sync failed to start. Please try reconnecting.", +}; + +const SUCCESS_MESSAGES: Record = { + github_installed: "GitHub App installed successfully!", +}; + +export function ErrorHandler() { + const searchParams = useSearchParams(); + + useEffect(() => { + const error = searchParams.get("error"); + const warning = searchParams.get("warning"); + const success = searchParams.get("success"); + + if (error) { + toast.error(ERROR_MESSAGES[error] ?? "Something went wrong."); + window.history.replaceState({}, "", "/integrations/github"); + } else if (warning) { + toast.warning(WARNING_MESSAGES[warning] ?? "Warning occurred."); + window.history.replaceState({}, "", "/integrations/github"); + } else if (success) { + toast.success(SUCCESS_MESSAGES[success] ?? "Success!"); + window.history.replaceState({}, "", "/integrations/github"); + } + }, [searchParams]); + + return null; +} diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/index.ts b/apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/index.ts new file mode 100644 index 00000000000..c5e593d602a --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ErrorHandler/index.ts @@ -0,0 +1 @@ +export { ErrorHandler } from "./ErrorHandler"; diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx new file mode 100644 index 00000000000..1bbd0f385f0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Badge } from "@superset/ui/badge"; +import { useQuery } from "@tanstack/react-query"; +import { GitBranch, Lock, Unlock } from "lucide-react"; +import { useTRPC } from "@/trpc/react"; + +interface RepositoryListProps { + organizationId: string; +} + +export function RepositoryList({ organizationId }: RepositoryListProps) { + const trpc = useTRPC(); + + const { data: repositories, isLoading } = useQuery( + trpc.integration.github.listRepositories.queryOptions({ + organizationId, + }), + ); + + if (isLoading) { + return ( +
+ Loading repositories... +
+ ); + } + + if (!repositories || repositories.length === 0) { + return ( +
+ No repositories found. Make sure your GitHub App has access to + repositories. +
+ ); + } + + return ( +
+ {repositories.map((repo) => ( +
+
+ {repo.isPrivate ? ( + + ) : ( + + )} +
+

{repo.fullName}

+
+ + {repo.defaultBranch} +
+
+
+ + {repo.isPrivate ? "Private" : "Public"} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/index.ts b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/index.ts new file mode 100644 index 00000000000..e3c5eb195cc --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/index.ts @@ -0,0 +1 @@ +export { RepositoryList } from "./RepositoryList"; diff --git a/apps/web/src/app/(dashboard)/integrations/github/page.tsx b/apps/web/src/app/(dashboard)/integrations/github/page.tsx new file mode 100644 index 00000000000..d6fab3f850c --- /dev/null +++ b/apps/web/src/app/(dashboard)/integrations/github/page.tsx @@ -0,0 +1,112 @@ +import { Badge } from "@superset/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import { ArrowLeft, CheckCircle2 } from "lucide-react"; +import Link from "next/link"; +import { FaGithub } from "react-icons/fa"; +import { api } from "@/trpc/server"; +import { ConnectionControls } from "./components/ConnectionControls"; +import { ErrorHandler } from "./components/ErrorHandler"; +import { RepositoryList } from "./components/RepositoryList"; + +export default async function GitHubIntegrationPage() { + const trpc = await api(); + const organization = await trpc.user.myOrganization.query(); + + if (!organization) { + return ( +
+

+ You need to be part of an organization to use integrations. +

+
+ ); + } + + const installation = await trpc.integration.github.getInstallation.query({ + organizationId: organization.id, + }); + const isConnected = !!installation; + + return ( +
+ + + + + Back to Integrations + + +
+
+ +
+
+
+

GitHub

+ {isConnected ? ( + + + Connected + + ) : ( + Not Connected + )} +
+

+ Connect your GitHub repositories and sync pull requests. Track CI + status and reviews across your team. +

+
+
+ + + + Connection + + Install the Superset GitHub App to connect your repositories. + + + + + {installation && ( +
+ Connected to {installation.accountLogin} ( + {installation.accountType}) + {installation.suspended && ( + + Suspended + + )} +
+ )} +
+
+ + {installation && ( + + + Repositories + + Repositories accessible through the GitHub App installation. + + + + + + + )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/integrations/page.tsx b/apps/web/src/app/(dashboard)/integrations/page.tsx index d85e7bd93a9..ac2aa6a0cd1 100644 --- a/apps/web/src/app/(dashboard)/integrations/page.tsx +++ b/apps/web/src/app/(dashboard)/integrations/page.tsx @@ -22,7 +22,6 @@ const integrations: IntegrationCardProps[] = [ description: "Connect repos and sync pull requests.", category: "Version Control", accentColor: "#FFFFFF", - disabled: true, icon: , }, ]; diff --git a/apps/web/src/app/api/integrations/github/callback/route.ts b/apps/web/src/app/api/integrations/github/callback/route.ts new file mode 100644 index 00000000000..5e4fa6ff46c --- /dev/null +++ b/apps/web/src/app/api/integrations/github/callback/route.ts @@ -0,0 +1,140 @@ +import { db } from "@superset/db/client"; +import { githubInstallations } from "@superset/db/schema"; +import { Client } from "@upstash/qstash"; +import { z } from "zod"; + +import { env } from "@/env"; +import { githubApp } from "../octokit"; + +const qstash = new Client({ token: env.QSTASH_TOKEN }); + +const stateSchema = z.object({ + organizationId: z.string().min(1), + userId: z.string().min(1), +}); + +/** + * Callback handler for GitHub App installation. + * GitHub redirects here after the user installs/configures the app. + */ +export async function GET(request: Request) { + const url = new URL(request.url); + const installationId = url.searchParams.get("installation_id"); + const setupAction = url.searchParams.get("setup_action"); + const state = url.searchParams.get("state"); + + if (setupAction === "cancel") { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=installation_cancelled`, + ); + } + + if (!installationId || !state) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=missing_params`, + ); + } + + const parsed = stateSchema.safeParse( + JSON.parse(Buffer.from(state, "base64url").toString("utf-8")), + ); + + if (!parsed.success) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=invalid_state`, + ); + } + + const { organizationId, userId } = parsed.data; + + try { + const octokit = await githubApp.getInstallationOctokit( + Number(installationId), + ); + + const installationResult = await octokit + .request("GET /app/installations/{installation_id}", { + installation_id: Number(installationId), + }) + .catch((error: Error) => { + console.error("[github/callback] Failed to fetch installation:", error); + return null; + }); + + if (!installationResult) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=installation_fetch_failed`, + ); + } + + const installation = installationResult.data; + + // Extract account info - account can be User or Enterprise + const account = installation.account; + const accountLogin = + account && "login" in account ? account.login : (account?.name ?? ""); + const accountType = + account && "type" in account ? account.type : "Organization"; + + // Save the installation to our database + const [savedInstallation] = await db + .insert(githubInstallations) + .values({ + organizationId, + connectedByUserId: userId, + installationId: String(installation.id), + accountLogin, + accountType, + permissions: installation.permissions as Record, + }) + .onConflictDoUpdate({ + target: [githubInstallations.organizationId], + set: { + connectedByUserId: userId, + installationId: String(installation.id), + accountLogin, + accountType, + permissions: installation.permissions as Record, + suspended: false, + suspendedAt: null, // Clear suspension if reinstalling + updatedAt: new Date(), + }, + }) + .returning(); + + if (!savedInstallation) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=save_failed`, + ); + } + + // Queue initial sync job + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_WEB_URL}/api/integrations/github/jobs/initial-sync`, + body: { + installationDbId: savedInstallation.id, + organizationId, + }, + retries: 3, + }); + } catch (error) { + console.error( + "[github/callback] Failed to queue initial sync job:", + error, + ); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?warning=sync_queue_failed`, + ); + } + + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?success=github_installed`, + ); + } catch (error) { + console.error("[github/callback] Unexpected error:", error); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=unexpected`, + ); + } +} diff --git a/apps/web/src/app/api/integrations/github/install/route.ts b/apps/web/src/app/api/integrations/github/install/route.ts new file mode 100644 index 00000000000..da265f287f5 --- /dev/null +++ b/apps/web/src/app/api/integrations/github/install/route.ts @@ -0,0 +1,56 @@ +import { auth } from "@superset/auth/server"; +import { db } from "@superset/db/client"; +import { members } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { env } from "@/env"; + +export async function GET(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session?.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const organizationId = url.searchParams.get("organizationId"); + + if (!organizationId) { + return Response.json( + { error: "Missing organizationId parameter" }, + { status: 400 }, + ); + } + + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, session.user.id), + ), + }); + + if (!membership) { + return Response.json( + { error: "User is not a member of this organization" }, + { status: 403 }, + ); + } + + if (!env.GITHUB_APP_ID) { + return Response.json( + { error: "GitHub App not configured" }, + { status: 500 }, + ); + } + + const state = Buffer.from( + JSON.stringify({ organizationId, userId: session.user.id }), + ).toString("base64url"); + + const installUrl = new URL( + "https://github.com/apps/superset-app/installations/new", + ); + installUrl.searchParams.set("state", state); + + return Response.redirect(installUrl.toString()); +} diff --git a/apps/web/src/app/api/integrations/github/jobs/initial-sync/route.ts b/apps/web/src/app/api/integrations/github/jobs/initial-sync/route.ts new file mode 100644 index 00000000000..7c1451e5a5c --- /dev/null +++ b/apps/web/src/app/api/integrations/github/jobs/initial-sync/route.ts @@ -0,0 +1,231 @@ +import { db } from "@superset/db/client"; +import { + githubInstallations, + githubPullRequests, + githubRepositories, +} from "@superset/db/schema"; +import { Receiver } from "@upstash/qstash"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { env } from "@/env"; +import { githubApp } from "../../octokit"; + +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const payloadSchema = z.object({ + installationDbId: z.string().uuid(), + organizationId: z.string().uuid(), +}); + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const isValid = await receiver + .verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_WEB_URL}/api/integrations/github/jobs/initial-sync`, + }) + .catch((error) => { + console.error( + "[github/initial-sync] Signature verification failed:", + error, + ); + return false; + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const parsed = payloadSchema.safeParse(JSON.parse(body)); + if (!parsed.success) { + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + const { installationDbId } = parsed.data; + + const [installation] = await db + .select() + .from(githubInstallations) + .where(eq(githubInstallations.id, installationDbId)) + .limit(1); + + if (!installation) { + return Response.json({ error: "Installation not found", skipped: true }); + } + + try { + const octokit = await githubApp.getInstallationOctokit( + Number(installation.installationId), + ); + + // Fetch all repositories + const repos = await octokit.paginate( + octokit.rest.apps.listReposAccessibleToInstallation, + { per_page: 100 }, + ); + + console.log(`[github/initial-sync] Found ${repos.length} repositories`); + + // Upsert repositories + for (const repo of repos) { + await db + .insert(githubRepositories) + .values({ + installationId: installationDbId, + repoId: String(repo.id), + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch ?? "main", + isPrivate: repo.private, + }) + .onConflictDoUpdate({ + target: [githubRepositories.repoId], + set: { + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch ?? "main", + isPrivate: repo.private, + updatedAt: new Date(), + }, + }); + } + + // Fetch PRs for each repository + for (const repo of repos) { + const [dbRepo] = await db + .select() + .from(githubRepositories) + .where(eq(githubRepositories.repoId, String(repo.id))) + .limit(1); + + if (!dbRepo) continue; + + const prs = await octokit.paginate(octokit.rest.pulls.list, { + owner: repo.owner.login, + repo: repo.name, + state: "open", + per_page: 100, + }); + + console.log( + `[github/initial-sync] Found ${prs.length} PRs for ${repo.full_name}`, + ); + + for (const pr of prs) { + // Get CI checks + const { data: checksData } = await octokit.rest.checks.listForRef({ + owner: repo.owner.login, + repo: repo.name, + ref: pr.head.sha, + }); + + const checks = checksData.check_runs.map( + (c: (typeof checksData.check_runs)[number]) => ({ + name: c.name, + status: c.status, + conclusion: c.conclusion, + detailsUrl: c.details_url ?? undefined, + }), + ); + + // Compute checks status + let checksStatus = "none"; + if (checks.length > 0) { + const hasFailure = checks.some( + (c: { + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; + }) => c.conclusion === "failure" || c.conclusion === "timed_out", + ); + const hasPending = checks.some( + (c: { + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; + }) => c.status !== "completed", + ); + + checksStatus = hasFailure + ? "failure" + : hasPending + ? "pending" + : "success"; + } + + await db + .insert(githubPullRequests) + .values({ + repositoryId: dbRepo.id, + prNumber: pr.number, + nodeId: pr.node_id, + headBranch: pr.head.ref, + headSha: pr.head.sha, + baseBranch: pr.base.ref, + title: pr.title, + url: pr.html_url, + authorLogin: pr.user?.login ?? "unknown", + authorAvatarUrl: pr.user?.avatar_url ?? null, + state: pr.state, + isDraft: pr.draft ?? false, + additions: 0, // Not available in list response + deletions: 0, // Not available in list response + changedFiles: 0, // Not available in list response + reviewDecision: null, // Will be updated by webhooks + checksStatus, + checks, + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + }) + .onConflictDoUpdate({ + target: [ + githubPullRequests.repositoryId, + githubPullRequests.prNumber, + ], + set: { + headSha: pr.head.sha, + title: pr.title, + state: pr.state, + isDraft: pr.draft ?? false, + checksStatus, + checks, + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }, + }); + } + } + + // Update installation lastSyncedAt + await db + .update(githubInstallations) + .set({ lastSyncedAt: new Date() }) + .where(eq(githubInstallations.id, installationDbId)); + + console.log("[github/initial-sync] Sync completed successfully"); + return Response.json({ success: true }); + } catch (error) { + console.error("[github/initial-sync] Sync failed:", error); + return Response.json( + { error: error instanceof Error ? error.message : "Sync failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/integrations/github/octokit.ts b/apps/web/src/app/api/integrations/github/octokit.ts new file mode 100644 index 00000000000..1cc078aad93 --- /dev/null +++ b/apps/web/src/app/api/integrations/github/octokit.ts @@ -0,0 +1,11 @@ +import { App } from "@octokit/app"; +import { Octokit } from "@octokit/rest"; + +import { env } from "@/env"; + +export const githubApp = new App({ + appId: env.GITHUB_APP_ID, + privateKey: env.GITHUB_APP_PRIVATE_KEY, + webhooks: { secret: env.GITHUB_WEBHOOK_SECRET }, + Octokit: Octokit, +}); diff --git a/apps/web/src/app/api/integrations/github/webhook/route.ts b/apps/web/src/app/api/integrations/github/webhook/route.ts new file mode 100644 index 00000000000..de31ae1aca3 --- /dev/null +++ b/apps/web/src/app/api/integrations/github/webhook/route.ts @@ -0,0 +1,61 @@ +import { db } from "@superset/db/client"; +import { webhookEvents } from "@superset/db/schema"; +import { eq } from "drizzle-orm"; + +import { webhooks } from "./webhooks"; + +export async function POST(request: Request) { + const body = await request.text(); + const signature = request.headers.get("x-hub-signature-256"); + const eventType = request.headers.get("x-github-event"); + const deliveryId = request.headers.get("x-github-delivery"); + + const [webhookEvent] = await db + .insert(webhookEvents) + .values({ + provider: "github", + eventId: deliveryId ?? `github-${Date.now()}`, + eventType: eventType ?? "unknown", + payload: JSON.parse(body), + status: "pending", + }) + .returning(); + + if (!webhookEvent) { + return Response.json({ error: "Failed to store event" }, { status: 500 }); + } + + try { + await webhooks.verifyAndReceive({ + id: deliveryId ?? "", + name: eventType as Parameters< + typeof webhooks.verifyAndReceive + >[0]["name"], + payload: body, + signature: signature ?? "", + }); + + await db + .update(webhookEvents) + .set({ status: "processed", processedAt: new Date() }) + .where(eq(webhookEvents.id, webhookEvent.id)); + + return Response.json({ success: true }); + } catch (error) { + console.error("[github/webhook] Webhook processing error:", error); + + await db + .update(webhookEvents) + .set({ + status: "failed", + error: error instanceof Error ? error.message : "Unknown error", + retryCount: webhookEvent.retryCount + 1, + }) + .where(eq(webhookEvents.id, webhookEvent.id)); + + const status = + error instanceof Error && error.message.includes("signature") ? 401 : 500; + + return Response.json({ error: "Webhook failed" }, { status }); + } +} diff --git a/apps/web/src/app/api/integrations/github/webhook/webhooks.ts b/apps/web/src/app/api/integrations/github/webhook/webhooks.ts new file mode 100644 index 00000000000..6a82dd95221 --- /dev/null +++ b/apps/web/src/app/api/integrations/github/webhook/webhooks.ts @@ -0,0 +1,377 @@ +import type { EmitterWebhookEvent } from "@octokit/webhooks"; +import { Webhooks } from "@octokit/webhooks"; +import { db } from "@superset/db/client"; +import { + githubInstallations, + githubPullRequests, + githubRepositories, +} from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +import { env } from "@/env"; + +export const webhooks = new Webhooks({ secret: env.GITHUB_WEBHOOK_SECRET }); + +// Installation events +webhooks.on( + "installation.deleted", + async ({ payload }: EmitterWebhookEvent<"installation.deleted">) => { + console.log( + "[github/webhook] Installation deleted:", + payload.installation.id, + ); + await db + .delete(githubInstallations) + .where( + eq(githubInstallations.installationId, String(payload.installation.id)), + ); + }, +); + +webhooks.on( + "installation.suspend", + async ({ payload }: EmitterWebhookEvent<"installation.suspend">) => { + console.log( + "[github/webhook] Installation suspended:", + payload.installation.id, + ); + await db + .update(githubInstallations) + .set({ suspended: true, suspendedAt: new Date() }) + .where( + eq(githubInstallations.installationId, String(payload.installation.id)), + ); + }, +); + +webhooks.on( + "installation.unsuspend", + async ({ payload }: EmitterWebhookEvent<"installation.unsuspend">) => { + console.log( + "[github/webhook] Installation unsuspended:", + payload.installation.id, + ); + await db + .update(githubInstallations) + .set({ suspended: false, suspendedAt: null }) + .where( + eq(githubInstallations.installationId, String(payload.installation.id)), + ); + }, +); + +// Repository events +webhooks.on( + "installation_repositories.added", + async ({ + payload, + }: EmitterWebhookEvent<"installation_repositories.added">) => { + const [installation] = await db + .select() + .from(githubInstallations) + .where( + eq(githubInstallations.installationId, String(payload.installation.id)), + ) + .limit(1); + + if (!installation) { + console.warn( + "[github/webhook] Installation not found:", + payload.installation.id, + ); + return; + } + + for (const repo of payload.repositories_added) { + const [owner, name] = repo.full_name.split("/"); + console.log("[github/webhook] Repository added:", repo.full_name); + + await db + .insert(githubRepositories) + .values({ + installationId: installation.id, + repoId: String(repo.id), + owner: owner ?? "", + name: name ?? repo.name, + fullName: repo.full_name, + defaultBranch: "main", + isPrivate: repo.private, + }) + .onConflictDoNothing(); + } + }, +); + +webhooks.on( + "installation_repositories.removed", + async ({ + payload, + }: EmitterWebhookEvent<"installation_repositories.removed">) => { + for (const repo of payload.repositories_removed) { + console.log("[github/webhook] Repository removed:", repo.full_name); + await db + .delete(githubRepositories) + .where(eq(githubRepositories.repoId, String(repo.id))); + } + }, +); + +// Pull request events +webhooks.on( + [ + "pull_request.opened", + "pull_request.synchronize", + "pull_request.edited", + "pull_request.reopened", + "pull_request.ready_for_review", + "pull_request.converted_to_draft", + ], + async ({ + payload, + }: EmitterWebhookEvent< + | "pull_request.opened" + | "pull_request.synchronize" + | "pull_request.edited" + | "pull_request.reopened" + | "pull_request.ready_for_review" + | "pull_request.converted_to_draft" + >) => { + const { pull_request: pr, repository } = payload; + + const [repo] = await db + .select() + .from(githubRepositories) + .where(eq(githubRepositories.repoId, String(repository.id))) + .limit(1); + + if (!repo) { + console.warn("[github/webhook] Repository not found:", repository.id); + return; + } + + console.log( + `[github/webhook] PR ${payload.action}:`, + `${repository.full_name}#${pr.number}`, + ); + + await db + .insert(githubPullRequests) + .values({ + repositoryId: repo.id, + prNumber: pr.number, + nodeId: pr.node_id, + headBranch: pr.head.ref, + headSha: pr.head.sha, + baseBranch: pr.base.ref, + title: pr.title, + url: pr.html_url, + authorLogin: pr.user?.login ?? "unknown", + authorAvatarUrl: pr.user?.avatar_url ?? null, + state: pr.state, + isDraft: pr.draft ?? false, + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changed_files ?? 0, + checksStatus: "none", // Will be updated by check events + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + }) + .onConflictDoUpdate({ + target: [githubPullRequests.repositoryId, githubPullRequests.prNumber], + set: { + headSha: pr.head.sha, + title: pr.title, + state: pr.state, + isDraft: pr.draft ?? false, + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changed_files ?? 0, + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }, + }); + }, +); + +webhooks.on( + "pull_request.closed", + async ({ payload }: EmitterWebhookEvent<"pull_request.closed">) => { + const { pull_request: pr, repository } = payload; + + const [repo] = await db + .select() + .from(githubRepositories) + .where(eq(githubRepositories.repoId, String(repository.id))) + .limit(1); + + if (!repo) { + console.warn("[github/webhook] Repository not found:", repository.id); + return; + } + + console.log( + "[github/webhook] PR closed:", + `${repository.full_name}#${pr.number}`, + ); + + await db + .update(githubPullRequests) + .set({ + state: pr.state, + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(githubPullRequests.repositoryId, repo.id), + eq(githubPullRequests.prNumber, pr.number), + ), + ); + }, +); + +// Review events +webhooks.on( + "pull_request_review.submitted", + async ({ payload }: EmitterWebhookEvent<"pull_request_review.submitted">) => { + const { review, pull_request: pr, repository } = payload; + + const [repo] = await db + .select() + .from(githubRepositories) + .where(eq(githubRepositories.repoId, String(repository.id))) + .limit(1); + + if (!repo) { + console.warn("[github/webhook] Repository not found:", repository.id); + return; + } + + const reviewDecision = + review.state === "approved" + ? "APPROVED" + : review.state === "changes_requested" + ? "CHANGES_REQUESTED" + : null; + + if (!reviewDecision) return; + + console.log( + `[github/webhook] PR review ${review.state}:`, + `${repository.full_name}#${pr.number}`, + ); + + await db + .update(githubPullRequests) + .set({ + reviewDecision, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and( + eq(githubPullRequests.repositoryId, repo.id), + eq(githubPullRequests.prNumber, pr.number), + ), + ); + }, +); + +// Check run events +webhooks.on( + ["check_run.created", "check_run.completed", "check_run.rerequested"], + async ({ + payload, + }: EmitterWebhookEvent< + "check_run.created" | "check_run.completed" | "check_run.rerequested" + >) => { + const { check_run: checkRun, repository } = payload; + + const [repo] = await db + .select() + .from(githubRepositories) + .where(eq(githubRepositories.repoId, String(repository.id))) + .limit(1); + + if (!repo) { + console.warn("[github/webhook] Repository not found:", repository.id); + return; + } + + for (const pr of checkRun.pull_requests) { + const [currentPr] = await db + .select() + .from(githubPullRequests) + .where( + and( + eq(githubPullRequests.repositoryId, repo.id), + eq(githubPullRequests.prNumber, pr.number), + ), + ) + .limit(1); + + if (!currentPr) continue; + + const currentChecks = + (currentPr.checks as Array<{ + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; + }>) ?? []; + + const checkIndex = currentChecks.findIndex( + (c) => c.name === checkRun.name, + ); + + const newCheck = { + name: checkRun.name, + status: checkRun.status, + conclusion: checkRun.conclusion, + detailsUrl: checkRun.details_url ?? undefined, + }; + + if (checkIndex >= 0) { + currentChecks[checkIndex] = newCheck; + } else { + currentChecks.push(newCheck); + } + + // Compute checks status + const hasFailure = currentChecks.some( + (c) => + c.conclusion === "failure" || + c.conclusion === "timed_out" || + c.conclusion === "action_required", + ); + const hasPending = currentChecks.some((c) => c.status !== "completed"); + + const checksStatus = hasFailure + ? "failure" + : hasPending + ? "pending" + : currentChecks.length > 0 + ? "success" + : "none"; + + console.log( + `[github/webhook] Check ${checkRun.status}/${checkRun.conclusion}:`, + `${repository.full_name}#${pr.number} - ${checkRun.name}`, + ); + + await db + .update(githubPullRequests) + .set({ + checks: currentChecks, + checksStatus, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(githubPullRequests.id, currentPr.id)); + } + }, +); diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 6350a14e738..d24a1f0cd64 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -15,6 +15,14 @@ export const env = createEnv({ DATABASE_URL_UNPOOLED: z.string().url(), BETTER_AUTH_SECRET: z.string(), SENTRY_AUTH_TOKEN: z.string().optional(), + // GitHub App credentials + GITHUB_APP_ID: z.string().min(1), + GITHUB_APP_PRIVATE_KEY: z.string().min(1), + GITHUB_WEBHOOK_SECRET: z.string().min(1), + // QStash for background jobs + QSTASH_TOKEN: z.string().min(1), + QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), + QSTASH_NEXT_SIGNING_KEY: z.string().min(1), }, client: { diff --git a/bun.lock b/bun.lock index 0396673968d..c588f0906b3 100644 --- a/bun.lock +++ b/bun.lock @@ -122,7 +122,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.57", + "version": "0.0.58", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -333,6 +333,9 @@ "name": "@superset/web", "version": "0.1.0", "dependencies": { + "@octokit/app": "^16.1.2", + "@octokit/rest": "^22.0.1", + "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.32.1", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", @@ -346,7 +349,9 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "@uiw/react-md-editor": "^4.0.11", + "@upstash/qstash": "^2.8.4", "better-auth": "^1.4.9", + "drizzle-orm": "0.45.1", "framer-motion": "^12.23.26", "geist": "^1.5.1", "import-in-the-middle": "2.0.1", @@ -922,6 +927,54 @@ "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + "@octokit/app": ["@octokit/app@16.1.2", "", { "dependencies": { "@octokit/auth-app": "^8.1.2", "@octokit/auth-unauthenticated": "^7.0.3", "@octokit/core": "^7.0.6", "@octokit/oauth-app": "^8.0.3", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/types": "^16.0.0", "@octokit/webhooks": "^14.0.0" } }, "sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ=="], + + "@octokit/auth-app": ["@octokit/auth-app@8.1.2", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-db8VO0PqXxfzI6GdjtgEFHY9tzqUql5xMFXYA12juq8TeTgPAuiiP3zid4h50lwlIP457p5+56PnJOgd2GGBuw=="], + + "@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@9.0.3", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/auth-oauth-user": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg=="], + + "@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@8.0.3", "", { "dependencies": { "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw=="], + + "@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@6.0.2", "", { "dependencies": { "@octokit/auth-oauth-device": "^8.0.3", "@octokit/oauth-methods": "^6.0.2", "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A=="], + + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@7.0.3", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-8Jb1mtUdmBHL7lGmop9mU9ArMRUTRhg8vp0T1VtZ4yd9vEm3zcLwmjQkhNEduKawOOORie61xhtYIhTDN+ZQ3g=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.2", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/oauth-app": ["@octokit/oauth-app@8.0.3", "", { "dependencies": { "@octokit/auth-oauth-app": "^9.0.2", "@octokit/auth-oauth-user": "^6.0.1", "@octokit/auth-unauthenticated": "^7.0.2", "@octokit/core": "^7.0.5", "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/oauth-methods": "^6.0.1", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-jnAjvTsPepyUaMu9e69hYBuozEPgYqP4Z3UnpmvoIzHDpf8EXDGvTY1l1jK0RsZ194oRd+k6Hm13oRU8EoDFwg=="], + + "@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@8.0.0", "", {}, "sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ=="], + + "@octokit/oauth-methods": ["@octokit/oauth-methods@6.0.2", "", { "dependencies": { "@octokit/oauth-authorization-url": "^8.0.0", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0" } }, "sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@12.1.0", "", {}, "sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@octokit/webhooks": ["@octokit/webhooks@14.2.0", "", { "dependencies": { "@octokit/openapi-webhooks-types": "12.1.0", "@octokit/request-error": "^7.0.0", "@octokit/webhooks-methods": "^6.0.0" } }, "sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw=="], + + "@octokit/webhooks-methods": ["@octokit/webhooks-methods@6.0.0", "", {}, "sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], @@ -1424,6 +1477,8 @@ "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@types/aws-lambda": ["@types/aws-lambda@8.10.160", "", {}, "sha512-uoO4QVQNWFPJMh26pXtmtrRfGshPUSpMZGUyUQY20FhfHEElEBOPKgVmFs1z+kbpyBsRs2JnoOPT7++Z4GA9pA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -1796,6 +1851,8 @@ "bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="], + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "better-auth": ["better-auth@1.4.13", "", { "dependencies": { "@better-auth/core": "1.4.13", "@better-auth/telemetry": "1.4.13", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.7", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/start-server-core": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/start-server-core", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-frGQmYT0rglidLpx91SP9n4ztaNBFGBb0JrWSdMTAHvhBkmQlUT/43e0IboMK2mPrAZFlvhdcMV8jCnqpYVE9A=="], "better-call": ["better-call@1.1.7", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ=="], @@ -2312,6 +2369,8 @@ "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -3640,6 +3699,8 @@ "to-rotated": ["to-rotated@1.0.0", "", {}, "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q=="], + "toad-cache": ["toad-cache@3.7.0", "", {}, "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tokenlens": ["tokenlens@1.3.1", "", { "dependencies": { "@tokenlens/core": "1.3.0", "@tokenlens/fetch": "1.3.0", "@tokenlens/helpers": "1.3.1", "@tokenlens/models": "1.3.0" } }, "sha512-7oxmsS5PNCX3z+b+z07hL5vCzlgHKkCGrEQjQmWl5l+v5cUrtL7S1cuST4XThaL1XyjbTX8J5hfP0cjDJRkaLA=="], @@ -3734,6 +3795,10 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], diff --git a/package.json b/package.json index b6aeab0d4a3..89320539c26 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "build": "turbo build --filter=@superset/desktop", "test": "turbo test", "db:generate": "turbo db:generate", - "db:push": "cd packages/db && bun db:push", + "db:push": "cd packages/db && bun push", "db:seed": "cd packages/db && bun db:seed", "db:migrate": "cd packages/db && bun db:migrate", "db:studio": "cd packages/db && bun db:studio", diff --git a/packages/db/drizzle/0011_add_github_integration_tables.sql b/packages/db/drizzle/0011_add_github_integration_tables.sql new file mode 100644 index 00000000000..550202252bf --- /dev/null +++ b/packages/db/drizzle/0011_add_github_integration_tables.sql @@ -0,0 +1,70 @@ +ALTER TYPE "public"."integration_provider" ADD VALUE 'github';--> statement-breakpoint +CREATE TABLE "github_installations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "connected_by_user_id" uuid NOT NULL, + "installation_id" text NOT NULL, + "account_login" text NOT NULL, + "account_type" text NOT NULL, + "permissions" jsonb, + "suspended" boolean DEFAULT false NOT NULL, + "suspended_at" timestamp, + "last_synced_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "github_installations_installation_id_unique" UNIQUE("installation_id"), + CONSTRAINT "github_installations_org_unique" UNIQUE("organization_id") +); +--> statement-breakpoint +CREATE TABLE "github_pull_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "repository_id" uuid NOT NULL, + "pr_number" integer NOT NULL, + "node_id" text NOT NULL, + "head_branch" text NOT NULL, + "head_sha" text NOT NULL, + "base_branch" text NOT NULL, + "title" text NOT NULL, + "url" text NOT NULL, + "author_login" text NOT NULL, + "author_avatar_url" text, + "state" text NOT NULL, + "is_draft" boolean DEFAULT false NOT NULL, + "additions" integer DEFAULT 0 NOT NULL, + "deletions" integer DEFAULT 0 NOT NULL, + "changed_files" integer DEFAULT 0 NOT NULL, + "review_decision" text, + "checks_status" text DEFAULT 'none' NOT NULL, + "checks" jsonb DEFAULT '[]'::jsonb, + "merged_at" timestamp, + "closed_at" timestamp, + "last_synced_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "github_pull_requests_repo_pr_unique" UNIQUE("repository_id","pr_number") +); +--> statement-breakpoint +CREATE TABLE "github_repositories" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "installation_id" uuid NOT NULL, + "repo_id" text NOT NULL, + "owner" text NOT NULL, + "name" text NOT NULL, + "full_name" text NOT NULL, + "default_branch" text DEFAULT 'main' NOT NULL, + "is_private" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "github_repositories_repo_id_unique" UNIQUE("repo_id") +); +--> statement-breakpoint +ALTER TABLE "github_installations" ADD CONSTRAINT "github_installations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_installations" ADD CONSTRAINT "github_installations_connected_by_user_id_users_id_fk" FOREIGN KEY ("connected_by_user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_pull_requests" ADD CONSTRAINT "github_pull_requests_repository_id_github_repositories_id_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."github_repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "github_repositories" ADD CONSTRAINT "github_repositories_installation_id_github_installations_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."github_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "github_installations_installation_id_idx" ON "github_installations" USING btree ("installation_id");--> statement-breakpoint +CREATE INDEX "github_pull_requests_repository_id_idx" ON "github_pull_requests" USING btree ("repository_id");--> statement-breakpoint +CREATE INDEX "github_pull_requests_state_idx" ON "github_pull_requests" USING btree ("state");--> statement-breakpoint +CREATE INDEX "github_pull_requests_head_branch_idx" ON "github_pull_requests" USING btree ("head_branch");--> statement-breakpoint +CREATE INDEX "github_repositories_installation_id_idx" ON "github_repositories" USING btree ("installation_id");--> statement-breakpoint +CREATE INDEX "github_repositories_full_name_idx" ON "github_repositories" USING btree ("full_name"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0011_snapshot.json b/packages/db/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000000..98919aef86e --- /dev/null +++ b/packages/db/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2183 @@ +{ + "id": "b2d395c3-681e-4e1c-9d86-b08e75cd5b03", + "prevId": "546993dd-75f0-4d39-9e32-2325aaefd02c", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_organization_id_idx": { + "name": "repositories_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_slug_idx": { + "name": "repositories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_organization_id_organizations_id_fk": { + "name": "repositories_organization_id_organizations_id_fk", + "tableFrom": "repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_org_slug_unique": { + "name": "repositories_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_repository_id_idx": { + "name": "tasks_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_repository_id_repositories_id_fk": { + "name": "tasks_repository_id_repositories_id_fk", + "tableFrom": "tasks", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 869d260783e..910591975a2 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1768446870608, "tag": "0010_add_organization_ids_to_users", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1768871460342, + "tag": "0011_add_github_integration_tables", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index d5cba4044f8..5bd62de50e8 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -23,6 +23,6 @@ export const taskPriorityValues = [ export const taskPriorityEnum = z.enum(taskPriorityValues); export type TaskPriority = z.infer; -export const integrationProviderValues = ["linear"] as const; +export const integrationProviderValues = ["linear", "github"] as const; export const integrationProviderEnum = z.enum(integrationProviderValues); export type IntegrationProvider = z.infer; diff --git a/packages/db/src/schema/github.ts b/packages/db/src/schema/github.ts new file mode 100644 index 00000000000..f1d2f9ca199 --- /dev/null +++ b/packages/db/src/schema/github.ts @@ -0,0 +1,177 @@ +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; + +import { organizations, users } from "./auth"; + +/** + * GitHub App installations linked to Superset organizations. + * One organization can have one GitHub installation. + */ +export const githubInstallations = pgTable( + "github_installations", + { + id: uuid().primaryKey().defaultRandom(), + + // Link to Superset organization + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + connectedByUserId: uuid("connected_by_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + // GitHub installation info + installationId: text("installation_id").notNull().unique(), + accountLogin: text("account_login").notNull(), // GitHub org/user login + accountType: text("account_type").notNull(), // "Organization" | "User" + + // Permissions granted to the app + permissions: jsonb().$type>(), + + // Suspension state + suspended: boolean().notNull().default(false), + suspendedAt: timestamp("suspended_at"), + + // Sync tracking + lastSyncedAt: timestamp("last_synced_at"), + + // Timestamps + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + unique("github_installations_org_unique").on(table.organizationId), + index("github_installations_installation_id_idx").on(table.installationId), + ], +); + +export type InsertGithubInstallation = typeof githubInstallations.$inferInsert; +export type SelectGithubInstallation = typeof githubInstallations.$inferSelect; + +/** + * GitHub repositories accessible via an installation. + */ +export const githubRepositories = pgTable( + "github_repositories", + { + id: uuid().primaryKey().defaultRandom(), + + // Link to installation + installationId: uuid("installation_id") + .notNull() + .references(() => githubInstallations.id, { onDelete: "cascade" }), + + // GitHub repo info + repoId: text("repo_id").notNull().unique(), // GitHub's numeric ID as string + owner: text().notNull(), + name: text().notNull(), + fullName: text("full_name").notNull(), // "owner/name" + defaultBranch: text("default_branch").notNull().default("main"), + isPrivate: boolean("is_private").notNull().default(false), + + // Timestamps + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + index("github_repositories_installation_id_idx").on(table.installationId), + index("github_repositories_full_name_idx").on(table.fullName), + ], +); + +export type InsertGithubRepository = typeof githubRepositories.$inferInsert; +export type SelectGithubRepository = typeof githubRepositories.$inferSelect; + +/** + * GitHub pull requests tracked for synced repositories. + */ +export const githubPullRequests = pgTable( + "github_pull_requests", + { + id: uuid().primaryKey().defaultRandom(), + + // Link to repository + repositoryId: uuid("repository_id") + .notNull() + .references(() => githubRepositories.id, { onDelete: "cascade" }), + + // PR identification + prNumber: integer("pr_number").notNull(), + nodeId: text("node_id").notNull(), // GitHub's GraphQL node ID + + // Branch info + headBranch: text("head_branch").notNull(), + headSha: text("head_sha").notNull(), + baseBranch: text("base_branch").notNull(), + + // PR details + title: text().notNull(), + url: text().notNull(), + authorLogin: text("author_login").notNull(), + authorAvatarUrl: text("author_avatar_url"), + + // PR state + state: text().notNull(), // "open" | "closed" | "merged" + isDraft: boolean("is_draft").notNull().default(false), + + // Stats + additions: integer().notNull().default(0), + deletions: integer().notNull().default(0), + changedFiles: integer("changed_files").notNull().default(0), + + // Review status + reviewDecision: text("review_decision"), // "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null + + // CI/CD checks + checksStatus: text("checks_status").notNull().default("none"), // "none" | "pending" | "success" | "failure" + checks: jsonb() + .$type< + Array<{ + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; + }> + >() + .default([]), + + // Important timestamps + mergedAt: timestamp("merged_at"), + closedAt: timestamp("closed_at"), + lastSyncedAt: timestamp("last_synced_at"), + + // Record timestamps + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + unique("github_pull_requests_repo_pr_unique").on( + table.repositoryId, + table.prNumber, + ), + index("github_pull_requests_repository_id_idx").on(table.repositoryId), + index("github_pull_requests_state_idx").on(table.state), + index("github_pull_requests_head_branch_idx").on(table.headBranch), + ], +); + +export type InsertGithubPullRequest = typeof githubPullRequests.$inferInsert; +export type SelectGithubPullRequest = typeof githubPullRequests.$inferSelect; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 54196ae0b99..0d3a01a8335 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,4 +1,6 @@ export * from "./auth"; +export * from "./enums"; +export * from "./github"; export * from "./ingest"; export * from "./relations"; export * from "./schema"; diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 2e9afd3210f..16e18b2daa5 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -8,6 +8,11 @@ import { sessions, users, } from "./auth"; +import { + githubInstallations, + githubPullRequests, + githubRepositories, +} from "./github"; import { integrationConnections, repositories, @@ -23,6 +28,7 @@ export const usersRelations = relations(users, ({ many }) => ({ createdTasks: many(tasks, { relationName: "creator" }), assignedTasks: many(tasks, { relationName: "assignee" }), connectedIntegrations: many(integrationConnections), + githubInstallations: many(githubInstallations), })); export const sessionsRelations = relations(sessions, ({ one }) => ({ @@ -46,6 +52,7 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ tasks: many(tasks), taskStatuses: many(taskStatuses), integrations: many(integrationConnections), + githubInstallations: many(githubInstallations), })); export const membersRelations = relations(members, ({ one }) => ({ @@ -130,3 +137,40 @@ export const integrationConnectionsRelations = relations( }), }), ); + +// GitHub relations +export const githubInstallationsRelations = relations( + githubInstallations, + ({ one, many }) => ({ + organization: one(organizations, { + fields: [githubInstallations.organizationId], + references: [organizations.id], + }), + connectedBy: one(users, { + fields: [githubInstallations.connectedByUserId], + references: [users.id], + }), + repositories: many(githubRepositories), + }), +); + +export const githubRepositoriesRelations = relations( + githubRepositories, + ({ one, many }) => ({ + installation: one(githubInstallations, { + fields: [githubRepositories.installationId], + references: [githubInstallations.id], + }), + pullRequests: many(githubPullRequests), + }), +); + +export const githubPullRequestsRelations = relations( + githubPullRequests, + ({ one }) => ({ + repository: one(githubRepositories, { + fields: [githubPullRequests.repositoryId], + references: [githubRepositories.id], + }), + }), +); diff --git a/packages/trpc/src/env.ts b/packages/trpc/src/env.ts index f799bc1345f..4ea5faa2b13 100644 --- a/packages/trpc/src/env.ts +++ b/packages/trpc/src/env.ts @@ -11,9 +11,16 @@ export const env = createEnv({ POSTHOG_API_HOST: z.string().url().default("https://us.posthog.com"), POSTHOG_PROJECT_ID: z.string(), QSTASH_TOKEN: z.string().min(1), + QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), + QSTASH_NEXT_SIGNING_KEY: z.string().min(1), NEXT_PUBLIC_API_URL: z.string().url(), + NEXT_PUBLIC_WEB_URL: z.string().url(), KV_REST_API_URL: z.string().url().optional(), KV_REST_API_TOKEN: z.string().optional(), + // GitHub App credentials + GITHUB_APP_ID: z.string().min(1), + GITHUB_APP_PRIVATE_KEY: z.string().min(1), + GITHUB_WEBHOOK_SECRET: z.string().min(1), }, clientPrefix: "PUBLIC_", client: {}, diff --git a/packages/trpc/src/router/integration/github/github.ts b/packages/trpc/src/router/integration/github/github.ts new file mode 100644 index 00000000000..81806595e5b --- /dev/null +++ b/packages/trpc/src/router/integration/github/github.ts @@ -0,0 +1,198 @@ +import { db } from "@superset/db/client"; +import { + githubInstallations, + githubPullRequests, + githubRepositories, +} from "@superset/db/schema"; +import type { TRPCRouterRecord } from "@trpc/server"; +import { and, desc, eq } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../../trpc"; +import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; + +export const githubRouter = { + getInstallation: protectedProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, input.organizationId), + columns: { + id: true, + accountLogin: true, + accountType: true, + suspended: true, + lastSyncedAt: true, + createdAt: true, + }, + }); + + return installation ?? null; + }), + + disconnect: protectedProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + await verifyOrgAdmin(ctx.session.user.id, input.organizationId); + + const result = await db + .delete(githubInstallations) + .where(eq(githubInstallations.organizationId, input.organizationId)) + .returning({ id: githubInstallations.id }); + + if (result.length === 0) { + return { success: false, error: "No installation found" }; + } + + return { success: true }; + }), + + listRepositories: protectedProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, input.organizationId), + columns: { id: true }, + }); + + if (!installation) { + return []; + } + + return db.query.githubRepositories.findMany({ + where: eq(githubRepositories.installationId, installation.id), + orderBy: [desc(githubRepositories.updatedAt)], + }); + }), + + listPullRequests: protectedProcedure + .input( + z.object({ + organizationId: z.string().uuid(), + repositoryId: z.string().uuid().optional(), + state: z.enum(["open", "closed", "all"]).optional().default("open"), + }), + ) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, input.organizationId), + columns: { id: true }, + }); + + if (!installation) { + return []; + } + + // Get repository IDs for this installation + const repos = await db.query.githubRepositories.findMany({ + where: input.repositoryId + ? and( + eq(githubRepositories.installationId, installation.id), + eq(githubRepositories.id, input.repositoryId), + ) + : eq(githubRepositories.installationId, installation.id), + columns: { id: true }, + }); + + if (repos.length === 0) { + return []; + } + + const repoIds = repos.map((r) => r.id); + + // Build query conditions + const conditions = []; + if (repoIds.length === 1 && repoIds[0]) { + conditions.push(eq(githubPullRequests.repositoryId, repoIds[0])); + } + + if (input.state !== "all") { + conditions.push(eq(githubPullRequests.state, input.state)); + } + + return db.query.githubPullRequests.findMany({ + where: conditions.length > 0 ? and(...conditions) : undefined, + with: { + repository: { + columns: { + id: true, + fullName: true, + owner: true, + name: true, + }, + }, + }, + orderBy: [desc(githubPullRequests.updatedAt)], + limit: 100, + }); + }), + + getStats: protectedProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .query(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, input.organizationId), + columns: { id: true }, + }); + + if (!installation) { + return { + repositoryCount: 0, + openPullRequestCount: 0, + pendingChecksCount: 0, + failedChecksCount: 0, + }; + } + + const repos = await db.query.githubRepositories.findMany({ + where: eq(githubRepositories.installationId, installation.id), + columns: { id: true }, + }); + + if (repos.length === 0) { + return { + repositoryCount: 0, + openPullRequestCount: 0, + pendingChecksCount: 0, + failedChecksCount: 0, + }; + } + + const repoIds = repos.map((r) => r.id); + + // Get open PRs + const openPrs = await db.query.githubPullRequests.findMany({ + where: and( + eq(githubPullRequests.state, "open"), + repoIds.length === 1 && repoIds[0] + ? eq(githubPullRequests.repositoryId, repoIds[0]) + : undefined, + ), + columns: { + id: true, + checksStatus: true, + }, + }); + + const pendingChecksCount = openPrs.filter( + (pr) => pr.checksStatus === "pending", + ).length; + const failedChecksCount = openPrs.filter( + (pr) => pr.checksStatus === "failure", + ).length; + + return { + repositoryCount: repos.length, + openPullRequestCount: openPrs.length, + pendingChecksCount, + failedChecksCount, + }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/integration/github/index.ts b/packages/trpc/src/router/integration/github/index.ts new file mode 100644 index 00000000000..4fa29a0d96d --- /dev/null +++ b/packages/trpc/src/router/integration/github/index.ts @@ -0,0 +1 @@ +export { githubRouter } from "./github"; diff --git a/packages/trpc/src/router/integration/github/utils.ts b/packages/trpc/src/router/integration/github/utils.ts new file mode 100644 index 00000000000..fb6c72a6a6a --- /dev/null +++ b/packages/trpc/src/router/integration/github/utils.ts @@ -0,0 +1,31 @@ +import { db } from "@superset/db/client"; +import { members } from "@superset/db/schema"; +import { and, eq } from "drizzle-orm"; + +export async function verifyOrgMembership( + userId: string, + organizationId: string, +) { + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + throw new Error("Not a member of this organization"); + } + + return { membership }; +} + +export async function verifyOrgAdmin(userId: string, organizationId: string) { + const { membership } = await verifyOrgMembership(userId, organizationId); + + if (membership.role !== "admin" && membership.role !== "owner") { + throw new Error("Admin access required"); + } + + return { membership }; +} diff --git a/packages/trpc/src/router/integration/integration.ts b/packages/trpc/src/router/integration/integration.ts index 7d0ba16e5c9..ab62013be8a 100644 --- a/packages/trpc/src/router/integration/integration.ts +++ b/packages/trpc/src/router/integration/integration.ts @@ -4,9 +4,11 @@ import type { TRPCRouterRecord } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../trpc"; +import { githubRouter } from "./github"; import { linearRouter } from "./linear"; export const integrationRouter = { + github: githubRouter, linear: linearRouter, list: protectedProcedure From d777426d048e269bb38fed69547e33408d375bb0 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 17:26:03 -0800 Subject: [PATCH 02/14] Update package.json and routes --- .../components/ConnectionControls/ConnectionControls.tsx | 2 +- .../src/app/api/{integrations => }/github/callback/route.ts | 2 +- .../src/app/api/{integrations => }/github/install/route.ts | 0 .../{integrations => }/github/jobs/initial-sync/route.ts | 2 +- apps/web/src/app/api/{integrations => }/github/octokit.ts | 0 .../src/app/api/{integrations => }/github/webhook/route.ts | 0 .../app/api/{integrations => }/github/webhook/webhooks.ts | 0 package.json | 6 +++--- 8 files changed, 6 insertions(+), 6 deletions(-) rename apps/web/src/app/api/{integrations => }/github/callback/route.ts (97%) rename apps/web/src/app/api/{integrations => }/github/install/route.ts (100%) rename apps/web/src/app/api/{integrations => }/github/jobs/initial-sync/route.ts (98%) rename apps/web/src/app/api/{integrations => }/github/octokit.ts (100%) rename apps/web/src/app/api/{integrations => }/github/webhook/route.ts (100%) rename apps/web/src/app/api/{integrations => }/github/webhook/webhooks.ts (100%) diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx index 9de01e94f4b..074d9f45f32 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx @@ -45,7 +45,7 @@ export function ConnectionControls({ ); const handleConnect = () => { - window.location.href = `${env.NEXT_PUBLIC_WEB_URL}/api/integrations/github/install?organizationId=${organizationId}`; + window.location.href = `${env.NEXT_PUBLIC_WEB_URL}/api/github/install?organizationId=${organizationId}`; }; const handleDisconnect = () => { diff --git a/apps/web/src/app/api/integrations/github/callback/route.ts b/apps/web/src/app/api/github/callback/route.ts similarity index 97% rename from apps/web/src/app/api/integrations/github/callback/route.ts rename to apps/web/src/app/api/github/callback/route.ts index 5e4fa6ff46c..f3c5dd2d298 100644 --- a/apps/web/src/app/api/integrations/github/callback/route.ts +++ b/apps/web/src/app/api/github/callback/route.ts @@ -111,7 +111,7 @@ export async function GET(request: Request) { // Queue initial sync job try { await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_WEB_URL}/api/integrations/github/jobs/initial-sync`, + url: `${env.NEXT_PUBLIC_WEB_URL}/api/github/jobs/initial-sync`, body: { installationDbId: savedInstallation.id, organizationId, diff --git a/apps/web/src/app/api/integrations/github/install/route.ts b/apps/web/src/app/api/github/install/route.ts similarity index 100% rename from apps/web/src/app/api/integrations/github/install/route.ts rename to apps/web/src/app/api/github/install/route.ts diff --git a/apps/web/src/app/api/integrations/github/jobs/initial-sync/route.ts b/apps/web/src/app/api/github/jobs/initial-sync/route.ts similarity index 98% rename from apps/web/src/app/api/integrations/github/jobs/initial-sync/route.ts rename to apps/web/src/app/api/github/jobs/initial-sync/route.ts index 7c1451e5a5c..542a63dfb51 100644 --- a/apps/web/src/app/api/integrations/github/jobs/initial-sync/route.ts +++ b/apps/web/src/app/api/github/jobs/initial-sync/route.ts @@ -33,7 +33,7 @@ export async function POST(request: Request) { .verify({ body, signature, - url: `${env.NEXT_PUBLIC_WEB_URL}/api/integrations/github/jobs/initial-sync`, + url: `${env.NEXT_PUBLIC_WEB_URL}/api/github/jobs/initial-sync`, }) .catch((error) => { console.error( diff --git a/apps/web/src/app/api/integrations/github/octokit.ts b/apps/web/src/app/api/github/octokit.ts similarity index 100% rename from apps/web/src/app/api/integrations/github/octokit.ts rename to apps/web/src/app/api/github/octokit.ts diff --git a/apps/web/src/app/api/integrations/github/webhook/route.ts b/apps/web/src/app/api/github/webhook/route.ts similarity index 100% rename from apps/web/src/app/api/integrations/github/webhook/route.ts rename to apps/web/src/app/api/github/webhook/route.ts diff --git a/apps/web/src/app/api/integrations/github/webhook/webhooks.ts b/apps/web/src/app/api/github/webhook/webhooks.ts similarity index 100% rename from apps/web/src/app/api/integrations/github/webhook/webhooks.ts rename to apps/web/src/app/api/github/webhook/webhooks.ts diff --git a/package.json b/package.json index 89320539c26..9e32ed94c7e 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ "test": "turbo test", "db:generate": "turbo db:generate", "db:push": "cd packages/db && bun push", - "db:seed": "cd packages/db && bun db:seed", - "db:migrate": "cd packages/db && bun db:migrate", - "db:studio": "cd packages/db && bun db:studio", + "db:seed": "cd packages/db && bun seed", + "db:migrate": "cd packages/db && bun migrate", + "db:studio": "cd packages/db && bun studio", "db:reset": "cd apps/backend && bun run reset", "lint": "./scripts/lint.sh", "lint:fix": "biome check --write --unsafe .", From 27e157425da691a0cbe642802083ec3b8a2cf429 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 17:35:28 -0800 Subject: [PATCH 03/14] Use API instead --- apps/api/package.json | 3 +++ apps/{web => api}/src/app/api/github/callback/route.ts | 2 +- apps/{web => api}/src/app/api/github/install/route.ts | 0 .../src/app/api/github/jobs/initial-sync/route.ts | 2 +- apps/{web => api}/src/app/api/github/octokit.ts | 0 apps/{web => api}/src/app/api/github/webhook/route.ts | 0 apps/{web => api}/src/app/api/github/webhook/webhooks.ts | 0 apps/api/src/env.ts | 3 +++ apps/web/package.json | 5 ----- .../components/ConnectionControls/ConnectionControls.tsx | 2 +- apps/web/src/env.ts | 8 -------- bun.lock | 8 +++----- 12 files changed, 12 insertions(+), 21 deletions(-) rename apps/{web => api}/src/app/api/github/callback/route.ts (98%) rename apps/{web => api}/src/app/api/github/install/route.ts (100%) rename apps/{web => api}/src/app/api/github/jobs/initial-sync/route.ts (98%) rename apps/{web => api}/src/app/api/github/octokit.ts (100%) rename apps/{web => api}/src/app/api/github/webhook/route.ts (100%) rename apps/{web => api}/src/app/api/github/webhook/webhooks.ts (100%) diff --git a/apps/api/package.json b/apps/api/package.json index fc47bf2b05a..aa852896258 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,9 @@ "dependencies": { "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", + "@octokit/app": "^16.1.2", + "@octokit/rest": "^22.0.1", + "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.32.1", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", diff --git a/apps/web/src/app/api/github/callback/route.ts b/apps/api/src/app/api/github/callback/route.ts similarity index 98% rename from apps/web/src/app/api/github/callback/route.ts rename to apps/api/src/app/api/github/callback/route.ts index f3c5dd2d298..78a4204fd63 100644 --- a/apps/web/src/app/api/github/callback/route.ts +++ b/apps/api/src/app/api/github/callback/route.ts @@ -111,7 +111,7 @@ export async function GET(request: Request) { // Queue initial sync job try { await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_WEB_URL}/api/github/jobs/initial-sync`, + url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, body: { installationDbId: savedInstallation.id, organizationId, diff --git a/apps/web/src/app/api/github/install/route.ts b/apps/api/src/app/api/github/install/route.ts similarity index 100% rename from apps/web/src/app/api/github/install/route.ts rename to apps/api/src/app/api/github/install/route.ts diff --git a/apps/web/src/app/api/github/jobs/initial-sync/route.ts b/apps/api/src/app/api/github/jobs/initial-sync/route.ts similarity index 98% rename from apps/web/src/app/api/github/jobs/initial-sync/route.ts rename to apps/api/src/app/api/github/jobs/initial-sync/route.ts index 542a63dfb51..489c49281bf 100644 --- a/apps/web/src/app/api/github/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/github/jobs/initial-sync/route.ts @@ -33,7 +33,7 @@ export async function POST(request: Request) { .verify({ body, signature, - url: `${env.NEXT_PUBLIC_WEB_URL}/api/github/jobs/initial-sync`, + url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, }) .catch((error) => { console.error( diff --git a/apps/web/src/app/api/github/octokit.ts b/apps/api/src/app/api/github/octokit.ts similarity index 100% rename from apps/web/src/app/api/github/octokit.ts rename to apps/api/src/app/api/github/octokit.ts diff --git a/apps/web/src/app/api/github/webhook/route.ts b/apps/api/src/app/api/github/webhook/route.ts similarity index 100% rename from apps/web/src/app/api/github/webhook/route.ts rename to apps/api/src/app/api/github/webhook/route.ts diff --git a/apps/web/src/app/api/github/webhook/webhooks.ts b/apps/api/src/app/api/github/webhook/webhooks.ts similarity index 100% rename from apps/web/src/app/api/github/webhook/webhooks.ts rename to apps/api/src/app/api/github/webhook/webhooks.ts diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 5b0ce9a8722..0824e25915f 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -21,6 +21,9 @@ export const env = createEnv({ LINEAR_CLIENT_ID: z.string().min(1), LINEAR_CLIENT_SECRET: z.string().min(1), LINEAR_WEBHOOK_SECRET: z.string().min(1), + GITHUB_APP_ID: z.string().min(1), + GITHUB_APP_PRIVATE_KEY: z.string().min(1), + GITHUB_WEBHOOK_SECRET: z.string().min(1), QSTASH_TOKEN: z.string().min(1), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), QSTASH_NEXT_SIGNING_KEY: z.string().min(1), diff --git a/apps/web/package.json b/apps/web/package.json index 0244953d528..2a169439c00 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,9 +11,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@octokit/app": "^16.1.2", - "@octokit/rest": "^22.0.1", - "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.32.1", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", @@ -27,9 +24,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "@uiw/react-md-editor": "^4.0.11", - "@upstash/qstash": "^2.8.4", "better-auth": "^1.4.9", - "drizzle-orm": "0.45.1", "framer-motion": "^12.23.26", "geist": "^1.5.1", "import-in-the-middle": "2.0.1", diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx index 074d9f45f32..f2f49f3390d 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx @@ -45,7 +45,7 @@ export function ConnectionControls({ ); const handleConnect = () => { - window.location.href = `${env.NEXT_PUBLIC_WEB_URL}/api/github/install?organizationId=${organizationId}`; + window.location.href = `${env.NEXT_PUBLIC_API_URL}/api/github/install?organizationId=${organizationId}`; }; const handleDisconnect = () => { diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index d24a1f0cd64..6350a14e738 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -15,14 +15,6 @@ export const env = createEnv({ DATABASE_URL_UNPOOLED: z.string().url(), BETTER_AUTH_SECRET: z.string(), SENTRY_AUTH_TOKEN: z.string().optional(), - // GitHub App credentials - GITHUB_APP_ID: z.string().min(1), - GITHUB_APP_PRIVATE_KEY: z.string().min(1), - GITHUB_WEBHOOK_SECRET: z.string().min(1), - // QStash for background jobs - QSTASH_TOKEN: z.string().min(1), - QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), - QSTASH_NEXT_SIGNING_KEY: z.string().min(1), }, client: { diff --git a/bun.lock b/bun.lock index c588f0906b3..5b9fa143c7b 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,9 @@ "dependencies": { "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", + "@octokit/app": "^16.1.2", + "@octokit/rest": "^22.0.1", + "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.32.1", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", @@ -333,9 +336,6 @@ "name": "@superset/web", "version": "0.1.0", "dependencies": { - "@octokit/app": "^16.1.2", - "@octokit/rest": "^22.0.1", - "@octokit/webhooks": "^14.2.0", "@sentry/nextjs": "^10.32.1", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", @@ -349,9 +349,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "@uiw/react-md-editor": "^4.0.11", - "@upstash/qstash": "^2.8.4", "better-auth": "^1.4.9", - "drizzle-orm": "0.45.1", "framer-motion": "^12.23.26", "geist": "^1.5.1", "import-in-the-middle": "2.0.1", From 9b49b685b4ee75756d07f1866bf9b4dcaeadec6e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 17:45:38 -0800 Subject: [PATCH 04/14] Add sync --- apps/api/src/app/api/github/sync/route.ts | 216 ++++++++++++++++++ .../RepositoryList/RepositoryList.tsx | 96 +++++--- 2 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/app/api/github/sync/route.ts diff --git a/apps/api/src/app/api/github/sync/route.ts b/apps/api/src/app/api/github/sync/route.ts new file mode 100644 index 00000000000..1d7fa875a0f --- /dev/null +++ b/apps/api/src/app/api/github/sync/route.ts @@ -0,0 +1,216 @@ +import { db } from "@superset/db/client"; +import { + githubInstallations, + githubPullRequests, + githubRepositories, +} from "@superset/db/schema"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { env } from "@/env"; +import { githubApp } from "../octokit"; + +const bodySchema = z.object({ + organizationId: z.string().uuid(), +}); + +/** + * Manual sync endpoint for development. + * In production, use the QStash-triggered initial-sync job. + */ +export async function POST(request: Request) { + // Only allow in development + if (env.NODE_ENV !== "development") { + return Response.json( + { error: "This endpoint is only available in development" }, + { status: 403 }, + ); + } + + const body = await request.json(); + const parsed = bodySchema.safeParse(body); + + if (!parsed.success) { + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + const { organizationId } = parsed.data; + + const [installation] = await db + .select() + .from(githubInstallations) + .where(eq(githubInstallations.organizationId, organizationId)) + .limit(1); + + if (!installation) { + return Response.json({ error: "Installation not found" }, { status: 404 }); + } + + try { + const octokit = await githubApp.getInstallationOctokit( + Number(installation.installationId), + ); + + // Fetch all repositories + const repos = await octokit.paginate( + octokit.rest.apps.listReposAccessibleToInstallation, + { per_page: 100 }, + ); + + console.log(`[github/sync] Found ${repos.length} repositories`); + + // Upsert repositories + for (const repo of repos) { + await db + .insert(githubRepositories) + .values({ + installationId: installation.id, + repoId: String(repo.id), + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch ?? "main", + isPrivate: repo.private, + }) + .onConflictDoUpdate({ + target: [githubRepositories.repoId], + set: { + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + defaultBranch: repo.default_branch ?? "main", + isPrivate: repo.private, + updatedAt: new Date(), + }, + }); + } + + // Fetch PRs for each repository + for (const repo of repos) { + const [dbRepo] = await db + .select() + .from(githubRepositories) + .where(eq(githubRepositories.repoId, String(repo.id))) + .limit(1); + + if (!dbRepo) continue; + + const prs = await octokit.paginate(octokit.rest.pulls.list, { + owner: repo.owner.login, + repo: repo.name, + state: "open", + per_page: 100, + }); + + console.log( + `[github/sync] Found ${prs.length} PRs for ${repo.full_name}`, + ); + + for (const pr of prs) { + // Get CI checks + const { data: checksData } = await octokit.rest.checks.listForRef({ + owner: repo.owner.login, + repo: repo.name, + ref: pr.head.sha, + }); + + const checks = checksData.check_runs.map( + (c: (typeof checksData.check_runs)[number]) => ({ + name: c.name, + status: c.status, + conclusion: c.conclusion, + detailsUrl: c.details_url ?? undefined, + }), + ); + + // Compute checks status + let checksStatus = "none"; + if (checks.length > 0) { + const hasFailure = checks.some( + (c: { + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; + }) => c.conclusion === "failure" || c.conclusion === "timed_out", + ); + const hasPending = checks.some( + (c: { + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; + }) => c.status !== "completed", + ); + + checksStatus = hasFailure + ? "failure" + : hasPending + ? "pending" + : "success"; + } + + await db + .insert(githubPullRequests) + .values({ + repositoryId: dbRepo.id, + prNumber: pr.number, + nodeId: pr.node_id, + headBranch: pr.head.ref, + headSha: pr.head.sha, + baseBranch: pr.base.ref, + title: pr.title, + url: pr.html_url, + authorLogin: pr.user?.login ?? "unknown", + authorAvatarUrl: pr.user?.avatar_url ?? null, + state: pr.state, + isDraft: pr.draft ?? false, + additions: 0, + deletions: 0, + changedFiles: 0, + reviewDecision: null, + checksStatus, + checks, + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + }) + .onConflictDoUpdate({ + target: [ + githubPullRequests.repositoryId, + githubPullRequests.prNumber, + ], + set: { + headSha: pr.head.sha, + title: pr.title, + state: pr.state, + isDraft: pr.draft ?? false, + checksStatus, + checks, + mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, + closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }, + }); + } + } + + // Update installation lastSyncedAt + await db + .update(githubInstallations) + .set({ lastSyncedAt: new Date() }) + .where(eq(githubInstallations.id, installation.id)); + + console.log("[github/sync] Sync completed successfully"); + return Response.json({ + success: true, + repositoriesCount: repos.length, + }); + } catch (error) { + console.error("[github/sync] Sync failed:", error); + return Response.json( + { error: error instanceof Error ? error.message : "Sync failed" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx index 1bbd0f385f0..beb63d6000c 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx @@ -1,8 +1,11 @@ "use client"; import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; import { useQuery } from "@tanstack/react-query"; -import { GitBranch, Lock, Unlock } from "lucide-react"; +import { GitBranch, Lock, RefreshCw, Unlock } from "lucide-react"; +import { useState } from "react"; +import { env } from "@/env"; import { useTRPC } from "@/trpc/react"; interface RepositoryListProps { @@ -11,13 +14,35 @@ interface RepositoryListProps { export function RepositoryList({ organizationId }: RepositoryListProps) { const trpc = useTRPC(); + const [isSyncing, setIsSyncing] = useState(false); - const { data: repositories, isLoading } = useQuery( + const { data: repositories, isLoading, refetch } = useQuery( trpc.integration.github.listRepositories.queryOptions({ organizationId, }), ); + const handleSync = async () => { + setIsSyncing(true); + try { + const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/github/sync`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId }), + }); + const result = await response.json(); + if (result.success) { + await refetch(); + } else { + console.error("[github/sync] Sync failed:", result.error); + } + } catch (error) { + console.error("[github/sync] Sync error:", error); + } finally { + setIsSyncing(false); + } + }; + if (isLoading) { return (
@@ -28,39 +53,56 @@ export function RepositoryList({ organizationId }: RepositoryListProps) { if (!repositories || repositories.length === 0) { return ( -
- No repositories found. Make sure your GitHub App has access to - repositories. +
+

+ No repositories found. Make sure your GitHub App has access to + repositories. +

+
); } return ( -
- {repositories.map((repo) => ( -
-
- {repo.isPrivate ? ( - - ) : ( - - )} -
-

{repo.fullName}

-
- - {repo.defaultBranch} +
+
+

+ {repositories.length} {repositories.length === 1 ? "repository" : "repositories"} +

+ +
+
+ {repositories.map((repo) => ( +
+
+ {repo.isPrivate ? ( + + ) : ( + + )} +
+

{repo.fullName}

+
+ + {repo.defaultBranch} +
+ + {repo.isPrivate ? "Private" : "Public"} +
- - {repo.isPrivate ? "Private" : "Public"} - -
- ))} + ))} +
); } From 16d580b25561187078393c0d274e2f63bc0dd917 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 21:16:22 -0800 Subject: [PATCH 05/14] fix(trpc): scope PR queries by repositoryId to prevent cross-org data leakage Use inArray filter for repositoryId in listPullRequests and getStats queries to ensure PRs are always scoped to the organization's repositories, even when multiple repos exist. --- packages/trpc/src/router/integration/github/github.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/trpc/src/router/integration/github/github.ts b/packages/trpc/src/router/integration/github/github.ts index 81806595e5b..2d590657ad9 100644 --- a/packages/trpc/src/router/integration/github/github.ts +++ b/packages/trpc/src/router/integration/github/github.ts @@ -5,7 +5,7 @@ import { githubRepositories, } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; @@ -107,8 +107,8 @@ export const githubRouter = { // Build query conditions const conditions = []; - if (repoIds.length === 1 && repoIds[0]) { - conditions.push(eq(githubPullRequests.repositoryId, repoIds[0])); + if (repoIds.length > 0) { + conditions.push(inArray(githubPullRequests.repositoryId, repoIds)); } if (input.state !== "all") { @@ -171,9 +171,7 @@ export const githubRouter = { const openPrs = await db.query.githubPullRequests.findMany({ where: and( eq(githubPullRequests.state, "open"), - repoIds.length === 1 && repoIds[0] - ? eq(githubPullRequests.repositoryId, repoIds[0]) - : undefined, + inArray(githubPullRequests.repositoryId, repoIds), ), columns: { id: true, From f70be4f217f04e7437af64460efe09ca575964da Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 21:29:49 -0800 Subject: [PATCH 06/14] fix: add defensive error handling for GitHub integration - Handle query errors in RepositoryList with retry button - Wrap JSON.parse in try-catch in callback route - Wrap JSON.parse in try-catch in initial-sync route --- apps/api/src/app/api/github/callback/route.ts | 12 ++++++++--- .../app/api/github/jobs/initial-sync/route.ts | 9 +++++++- .../RepositoryList/RepositoryList.tsx | 21 ++++++++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/api/github/callback/route.ts b/apps/api/src/app/api/github/callback/route.ts index 78a4204fd63..a3a833c21d0 100644 --- a/apps/api/src/app/api/github/callback/route.ts +++ b/apps/api/src/app/api/github/callback/route.ts @@ -35,10 +35,16 @@ export async function GET(request: Request) { ); } - const parsed = stateSchema.safeParse( - JSON.parse(Buffer.from(state, "base64url").toString("utf-8")), - ); + let stateData: unknown; + try { + stateData = JSON.parse(Buffer.from(state, "base64url").toString("utf-8")); + } catch { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=invalid_state`, + ); + } + const parsed = stateSchema.safeParse(stateData); if (!parsed.success) { return Response.redirect( `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=invalid_state`, diff --git a/apps/api/src/app/api/github/jobs/initial-sync/route.ts b/apps/api/src/app/api/github/jobs/initial-sync/route.ts index 489c49281bf..55ebcc56ab0 100644 --- a/apps/api/src/app/api/github/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/github/jobs/initial-sync/route.ts @@ -47,7 +47,14 @@ export async function POST(request: Request) { return Response.json({ error: "Invalid signature" }, { status: 401 }); } - const parsed = payloadSchema.safeParse(JSON.parse(body)); + let bodyData: unknown; + try { + bodyData = JSON.parse(body); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = payloadSchema.safeParse(bodyData); if (!parsed.success) { return Response.json({ error: "Invalid payload" }, { status: 400 }); } diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx index beb63d6000c..31066159957 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx @@ -16,7 +16,12 @@ export function RepositoryList({ organizationId }: RepositoryListProps) { const trpc = useTRPC(); const [isSyncing, setIsSyncing] = useState(false); - const { data: repositories, isLoading, refetch } = useQuery( + const { + data: repositories, + isLoading, + isError, + refetch, + } = useQuery( trpc.integration.github.listRepositories.queryOptions({ organizationId, }), @@ -51,6 +56,20 @@ export function RepositoryList({ organizationId }: RepositoryListProps) { ); } + if (isError) { + return ( +
+

+ Failed to load repositories. Please try again. +

+ +
+ ); + } + if (!repositories || repositories.length === 0) { return (
From dbed1f65df8e36f0eb2241ad67babb3732dede76 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 21:38:04 -0800 Subject: [PATCH 07/14] fix: address additional PR review feedback - Wrap JSON.parse in try-catch in webhook route - Use crypto.randomUUID for eventId fallback to prevent collisions - Add 404 status for installation not found in initial-sync - Wrap request.json() in try-catch in sync route --- .../src/app/api/github/jobs/initial-sync/route.ts | 5 ++++- apps/api/src/app/api/github/sync/route.ts | 9 +++++++-- apps/api/src/app/api/github/webhook/route.ts | 12 ++++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/api/github/jobs/initial-sync/route.ts b/apps/api/src/app/api/github/jobs/initial-sync/route.ts index 55ebcc56ab0..34876256d8a 100644 --- a/apps/api/src/app/api/github/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/github/jobs/initial-sync/route.ts @@ -68,7 +68,10 @@ export async function POST(request: Request) { .limit(1); if (!installation) { - return Response.json({ error: "Installation not found", skipped: true }); + return Response.json( + { error: "Installation not found", skipped: true }, + { status: 404 }, + ); } try { diff --git a/apps/api/src/app/api/github/sync/route.ts b/apps/api/src/app/api/github/sync/route.ts index 1d7fa875a0f..1d8e8f9fbfa 100644 --- a/apps/api/src/app/api/github/sync/route.ts +++ b/apps/api/src/app/api/github/sync/route.ts @@ -27,9 +27,14 @@ export async function POST(request: Request) { ); } - const body = await request.json(); - const parsed = bodySchema.safeParse(body); + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + const parsed = bodySchema.safeParse(body); if (!parsed.success) { return Response.json({ error: "Invalid payload" }, { status: 400 }); } diff --git a/apps/api/src/app/api/github/webhook/route.ts b/apps/api/src/app/api/github/webhook/route.ts index de31ae1aca3..7fad1adf142 100644 --- a/apps/api/src/app/api/github/webhook/route.ts +++ b/apps/api/src/app/api/github/webhook/route.ts @@ -10,13 +10,21 @@ export async function POST(request: Request) { const eventType = request.headers.get("x-github-event"); const deliveryId = request.headers.get("x-github-delivery"); + let payload: unknown; + try { + payload = JSON.parse(body); + } catch { + console.error("[github/webhook] Invalid JSON payload"); + return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); + } + const [webhookEvent] = await db .insert(webhookEvents) .values({ provider: "github", - eventId: deliveryId ?? `github-${Date.now()}`, + eventId: deliveryId ?? `github-${crypto.randomUUID()}`, eventType: eventType ?? "unknown", - payload: JSON.parse(body), + payload, status: "pending", }) .returning(); From 5dcf82697fd577b3bb858a36472947f48ba1d9b2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 22:10:13 -0800 Subject: [PATCH 08/14] fix: address agent review feedback - Add triggerSync tRPC mutation with proper auth (fixes prod 403) - Update RepositoryList to use tRPC sync with toast notifications - Throw TRPCError in utils for proper 403 responses - Add baseBranch/headBranch to PR webhook upsert - Fix disconnect dialog messaging to be accurate - Verify webhook signature before storing events --- apps/api/src/app/api/github/webhook/route.ts | 27 ++++++----- .../src/app/api/github/webhook/webhooks.ts | 2 + .../ConnectionControls/ConnectionControls.tsx | 5 +- .../RepositoryList/RepositoryList.tsx | 46 +++++++++---------- .../src/router/integration/github/github.ts | 34 ++++++++++++++ .../src/router/integration/github/utils.ts | 11 ++++- 6 files changed, 87 insertions(+), 38 deletions(-) diff --git a/apps/api/src/app/api/github/webhook/route.ts b/apps/api/src/app/api/github/webhook/route.ts index 7fad1adf142..6f9c9821814 100644 --- a/apps/api/src/app/api/github/webhook/route.ts +++ b/apps/api/src/app/api/github/webhook/route.ts @@ -18,6 +18,15 @@ export async function POST(request: Request) { return Response.json({ error: "Invalid JSON payload" }, { status: 400 }); } + // Verify signature BEFORE storing to prevent spam from unverified requests + try { + await webhooks.verify(body, signature ?? ""); + } catch (error) { + console.error("[github/webhook] Signature verification failed:", error); + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + // Store verified event const [webhookEvent] = await db .insert(webhookEvents) .values({ @@ -33,15 +42,14 @@ export async function POST(request: Request) { return Response.json({ error: "Failed to store event" }, { status: 500 }); } + // Process the verified event try { - await webhooks.verifyAndReceive({ + // biome-ignore lint/suspicious/noExplicitAny: GitHub webhook event types are complex unions + await webhooks.receive({ id: deliveryId ?? "", - name: eventType as Parameters< - typeof webhooks.verifyAndReceive - >[0]["name"], - payload: body, - signature: signature ?? "", - }); + name: eventType, + payload, + } as any); await db .update(webhookEvents) @@ -61,9 +69,6 @@ export async function POST(request: Request) { }) .where(eq(webhookEvents.id, webhookEvent.id)); - const status = - error instanceof Error && error.message.includes("signature") ? 401 : 500; - - return Response.json({ error: "Webhook failed" }, { status }); + return Response.json({ error: "Webhook processing failed" }, { status: 500 }); } } diff --git a/apps/api/src/app/api/github/webhook/webhooks.ts b/apps/api/src/app/api/github/webhook/webhooks.ts index 6a82dd95221..4137ea2578e 100644 --- a/apps/api/src/app/api/github/webhook/webhooks.ts +++ b/apps/api/src/app/api/github/webhook/webhooks.ts @@ -179,7 +179,9 @@ webhooks.on( .onConflictDoUpdate({ target: [githubPullRequests.repositoryId, githubPullRequests.prNumber], set: { + headBranch: pr.head.ref, headSha: pr.head.sha, + baseBranch: pr.base.ref, title: pr.title, state: pr.state, isDraft: pr.draft ?? false, diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx index f2f49f3390d..54796adcf93 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx @@ -65,8 +65,9 @@ export function ConnectionControls({ Disconnect GitHub? - This will remove the GitHub App installation for your - organization. You will need to reinstall the app to reconnect. + This will disconnect GitHub from your organization. The GitHub + App will remain installed but will no longer sync data. You can + reconnect at any time. diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx index 31066159957..df1103ef558 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx @@ -2,10 +2,9 @@ import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { GitBranch, Lock, RefreshCw, Unlock } from "lucide-react"; -import { useState } from "react"; -import { env } from "@/env"; +import { toast } from "@superset/ui/sonner"; import { useTRPC } from "@/trpc/react"; interface RepositoryListProps { @@ -14,7 +13,6 @@ interface RepositoryListProps { export function RepositoryList({ organizationId }: RepositoryListProps) { const trpc = useTRPC(); - const [isSyncing, setIsSyncing] = useState(false); const { data: repositories, @@ -27,27 +25,29 @@ export function RepositoryList({ organizationId }: RepositoryListProps) { }), ); - const handleSync = async () => { - setIsSyncing(true); - try { - const response = await fetch(`${env.NEXT_PUBLIC_API_URL}/api/github/sync`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ organizationId }), - }); - const result = await response.json(); - if (result.success) { - await refetch(); - } else { - console.error("[github/sync] Sync failed:", result.error); - } - } catch (error) { - console.error("[github/sync] Sync error:", error); - } finally { - setIsSyncing(false); - } + const syncMutation = useMutation( + trpc.integration.github.triggerSync.mutationOptions({ + onSuccess: () => { + toast.success("Sync started", { + description: "Repositories will be updated shortly.", + }); + // Refetch after a short delay to allow sync to complete + setTimeout(() => refetch(), 3000); + }, + onError: (error) => { + toast.error("Sync failed", { + description: error.message, + }); + }, + }), + ); + + const handleSync = () => { + syncMutation.mutate({ organizationId }); }; + const isSyncing = syncMutation.isPending; + if (isLoading) { return (
diff --git a/packages/trpc/src/router/integration/github/github.ts b/packages/trpc/src/router/integration/github/github.ts index 2d590657ad9..fd00d48db74 100644 --- a/packages/trpc/src/router/integration/github/github.ts +++ b/packages/trpc/src/router/integration/github/github.ts @@ -5,11 +5,16 @@ import { githubRepositories, } from "@superset/db/schema"; import type { TRPCRouterRecord } from "@trpc/server"; +import { TRPCError } from "@trpc/server"; +import { Client } from "@upstash/qstash"; import { and, desc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; +import { env } from "../../../env"; import { protectedProcedure } from "../../../trpc"; import { verifyOrgAdmin, verifyOrgMembership } from "./utils"; +const qstash = new Client({ token: env.QSTASH_TOKEN }); + export const githubRouter = { getInstallation: protectedProcedure .input(z.object({ organizationId: z.string().uuid() })) @@ -48,6 +53,35 @@ export const githubRouter = { return { success: true }; }), + triggerSync: protectedProcedure + .input(z.object({ organizationId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + await verifyOrgMembership(ctx.session.user.id, input.organizationId); + + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, input.organizationId), + columns: { id: true }, + }); + + if (!installation) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "GitHub installation not found", + }); + } + + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, + body: { + installationDbId: installation.id, + organizationId: input.organizationId, + }, + retries: 3, + }); + + return { success: true }; + }), + listRepositories: protectedProcedure .input(z.object({ organizationId: z.string().uuid() })) .query(async ({ ctx, input }) => { diff --git a/packages/trpc/src/router/integration/github/utils.ts b/packages/trpc/src/router/integration/github/utils.ts index fb6c72a6a6a..56e36b3cbed 100644 --- a/packages/trpc/src/router/integration/github/utils.ts +++ b/packages/trpc/src/router/integration/github/utils.ts @@ -1,5 +1,6 @@ import { db } from "@superset/db/client"; import { members } from "@superset/db/schema"; +import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; export async function verifyOrgMembership( @@ -14,7 +15,10 @@ export async function verifyOrgMembership( }); if (!membership) { - throw new Error("Not a member of this organization"); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Not a member of this organization", + }); } return { membership }; @@ -24,7 +28,10 @@ export async function verifyOrgAdmin(userId: string, organizationId: string) { const { membership } = await verifyOrgMembership(userId, organizationId); if (membership.role !== "admin" && membership.role !== "owner") { - throw new Error("Admin access required"); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Admin access required", + }); } return { membership }; From 9b0e40e0cf7c6a586fca3d7e37605ab57d95eaf9 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 19 Jan 2026 23:08:49 -0800 Subject: [PATCH 09/14] Update security concerns --- apps/api/src/app/api/github/callback/route.ts | 38 ++++---- apps/api/src/app/api/github/install/route.ts | 8 +- apps/api/src/app/api/github/webhook/route.ts | 37 ++++++-- .../api/integrations/linear/callback/route.ts | 39 +++++--- .../api/integrations/linear/connect/route.ts | 9 +- .../api/integrations/linear/webhook/route.ts | 29 +++++- apps/api/src/lib/oauth-state.ts | 90 +++++++++++++++++++ .../ConnectionControls/ConnectionControls.tsx | 4 +- .../RepositoryList/RepositoryList.tsx | 20 +++-- 9 files changed, 225 insertions(+), 49 deletions(-) create mode 100644 apps/api/src/lib/oauth-state.ts diff --git a/apps/api/src/app/api/github/callback/route.ts b/apps/api/src/app/api/github/callback/route.ts index a3a833c21d0..94021159083 100644 --- a/apps/api/src/app/api/github/callback/route.ts +++ b/apps/api/src/app/api/github/callback/route.ts @@ -1,18 +1,14 @@ import { db } from "@superset/db/client"; -import { githubInstallations } from "@superset/db/schema"; +import { githubInstallations, members } from "@superset/db/schema"; import { Client } from "@upstash/qstash"; -import { z } from "zod"; +import { and, eq } from "drizzle-orm"; import { env } from "@/env"; +import { verifySignedState } from "@/lib/oauth-state"; import { githubApp } from "../octokit"; const qstash = new Client({ token: env.QSTASH_TOKEN }); -const stateSchema = z.object({ - organizationId: z.string().min(1), - userId: z.string().min(1), -}); - /** * Callback handler for GitHub App installation. * GitHub redirects here after the user installs/configures the app. @@ -35,24 +31,34 @@ export async function GET(request: Request) { ); } - let stateData: unknown; - try { - stateData = JSON.parse(Buffer.from(state, "base64url").toString("utf-8")); - } catch { + // Verify signed state (prevents forgery) + const stateData = verifySignedState(state); + if (!stateData) { return Response.redirect( `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=invalid_state`, ); } - const parsed = stateSchema.safeParse(stateData); - if (!parsed.success) { + const { organizationId, userId } = stateData; + + // Re-verify membership at callback time (defense-in-depth) + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + console.error("[github/callback] Membership verification failed:", { + organizationId, + userId, + }); return Response.redirect( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=invalid_state`, + `${env.NEXT_PUBLIC_WEB_URL}/integrations/github?error=unauthorized`, ); } - const { organizationId, userId } = parsed.data; - try { const octokit = await githubApp.getInstallationOctokit( Number(installationId), diff --git a/apps/api/src/app/api/github/install/route.ts b/apps/api/src/app/api/github/install/route.ts index da265f287f5..037e87ade8b 100644 --- a/apps/api/src/app/api/github/install/route.ts +++ b/apps/api/src/app/api/github/install/route.ts @@ -4,6 +4,7 @@ import { members } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; import { env } from "@/env"; +import { createSignedState } from "@/lib/oauth-state"; export async function GET(request: Request) { const session = await auth.api.getSession({ headers: request.headers }); @@ -43,9 +44,10 @@ export async function GET(request: Request) { ); } - const state = Buffer.from( - JSON.stringify({ organizationId, userId: session.user.id }), - ).toString("base64url"); + const state = createSignedState({ + organizationId, + userId: session.user.id, + }); const installUrl = new URL( "https://github.com/apps/superset-app/installations/new", diff --git a/apps/api/src/app/api/github/webhook/route.ts b/apps/api/src/app/api/github/webhook/route.ts index 6f9c9821814..90993e2c389 100644 --- a/apps/api/src/app/api/github/webhook/route.ts +++ b/apps/api/src/app/api/github/webhook/route.ts @@ -1,6 +1,6 @@ import { db } from "@superset/db/client"; import { webhookEvents } from "@superset/db/schema"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { webhooks } from "./webhooks"; @@ -26,29 +26,53 @@ export async function POST(request: Request) { return Response.json({ error: "Invalid signature" }, { status: 401 }); } - // Store verified event + // Store verified event with idempotent handling + const eventId = deliveryId ?? `github-${crypto.randomUUID()}`; + const [webhookEvent] = await db .insert(webhookEvents) .values({ provider: "github", - eventId: deliveryId ?? `github-${crypto.randomUUID()}`, + eventId, eventType: eventType ?? "unknown", payload, status: "pending", }) + .onConflictDoUpdate({ + target: [webhookEvents.provider, webhookEvents.eventId], + set: { + // Reset for reprocessing only if previously failed + status: sql`CASE WHEN ${webhookEvents.status} = 'failed' THEN 'pending' ELSE ${webhookEvents.status} END`, + retryCount: sql`CASE WHEN ${webhookEvents.status} = 'failed' THEN ${webhookEvents.retryCount} + 1 ELSE ${webhookEvents.retryCount} END`, + error: sql`CASE WHEN ${webhookEvents.status} = 'failed' THEN NULL ELSE ${webhookEvents.error} END`, + }, + }) .returning(); if (!webhookEvent) { return Response.json({ error: "Failed to store event" }, { status: 500 }); } + // Idempotent: skip if already processed or not ready for processing + if (webhookEvent.status === "processed") { + console.log("[github/webhook] Event already processed:", eventId); + return Response.json({ success: true, message: "Already processed" }); + } + if (webhookEvent.status !== "pending") { + console.log( + `[github/webhook] Event in ${webhookEvent.status} state:`, + eventId, + ); + return Response.json({ success: true, message: "Event not ready" }); + } + // Process the verified event try { - // biome-ignore lint/suspicious/noExplicitAny: GitHub webhook event types are complex unions await webhooks.receive({ id: deliveryId ?? "", name: eventType, payload, + // biome-ignore lint/suspicious/noExplicitAny: GitHub webhook event types are complex unions } as any); await db @@ -69,6 +93,9 @@ export async function POST(request: Request) { }) .where(eq(webhookEvents.id, webhookEvent.id)); - return Response.json({ error: "Webhook processing failed" }, { status: 500 }); + return Response.json( + { error: "Webhook processing failed" }, + { status: 500 }, + ); } } diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts index 7eebb8f2de6..eb6e9a0ad39 100644 --- a/apps/api/src/app/api/integrations/linear/callback/route.ts +++ b/apps/api/src/app/api/integrations/linear/callback/route.ts @@ -1,17 +1,14 @@ import { LinearClient } from "@linear/sdk"; import { db } from "@superset/db/client"; -import { integrationConnections } from "@superset/db/schema"; +import { integrationConnections, members } from "@superset/db/schema"; import { Client } from "@upstash/qstash"; -import { z } from "zod"; +import { and, eq } from "drizzle-orm"; + import { env } from "@/env"; +import { verifySignedState } from "@/lib/oauth-state"; const qstash = new Client({ token: env.QSTASH_TOKEN }); -const stateSchema = z.object({ - organizationId: z.string().min(1), - userId: z.string().min(1), -}); - export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get("code"); @@ -30,17 +27,33 @@ export async function GET(request: Request) { ); } - const parsed = stateSchema.safeParse( - JSON.parse(Buffer.from(state, "base64url").toString("utf-8")), - ); - - if (!parsed.success) { + // Verify signed state (prevents forgery) + const stateData = verifySignedState(state); + if (!stateData) { return Response.redirect( `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=invalid_state`, ); } - const { organizationId, userId } = parsed.data; + const { organizationId, userId } = stateData; + + // Re-verify membership at callback time (defense-in-depth) + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organizationId), + eq(members.userId, userId), + ), + }); + + if (!membership) { + console.error("[linear/callback] Membership verification failed:", { + organizationId, + userId, + }); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/integrations/linear?error=unauthorized`, + ); + } const tokenResponse = await fetch("https://api.linear.app/oauth/token", { method: "POST", diff --git a/apps/api/src/app/api/integrations/linear/connect/route.ts b/apps/api/src/app/api/integrations/linear/connect/route.ts index 6a8d123bbf8..a8a59830ad4 100644 --- a/apps/api/src/app/api/integrations/linear/connect/route.ts +++ b/apps/api/src/app/api/integrations/linear/connect/route.ts @@ -2,7 +2,9 @@ import { auth } from "@superset/auth/server"; import { db } from "@superset/db/client"; import { members } from "@superset/db/schema"; import { and, eq } from "drizzle-orm"; + import { env } from "@/env"; +import { createSignedState } from "@/lib/oauth-state"; export async function GET(request: Request) { const session = await auth.api.getSession({ @@ -37,9 +39,10 @@ export async function GET(request: Request) { ); } - const state = Buffer.from( - JSON.stringify({ organizationId, userId: session.user.id }), - ).toString("base64url"); + const state = createSignedState({ + organizationId, + userId: session.user.id, + }); const linearAuthUrl = new URL("https://linear.app/oauth/authorize"); linearAuthUrl.searchParams.set("client_id", env.LINEAR_CLIENT_ID); diff --git a/apps/api/src/app/api/integrations/linear/webhook/route.ts b/apps/api/src/app/api/integrations/linear/webhook/route.ts index cae9af0a514..f15ecdf95e9 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/route.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/route.ts @@ -13,7 +13,7 @@ import { webhookEvents, } from "@superset/db/schema"; import { mapPriorityFromLinear } from "@superset/trpc/integrations/linear"; -import { and, eq } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { env } from "@/env"; const webhookClient = new LinearWebhookClient(env.LINEAR_WEBHOOK_SECRET); @@ -28,21 +28,46 @@ export async function POST(request: Request) { const payload = webhookClient.parseData(Buffer.from(body), signature); + // Store event with idempotent handling + const eventId = `${payload.organizationId}-${payload.webhookTimestamp}`; + const [webhookEvent] = await db .insert(webhookEvents) .values({ provider: "linear", - eventId: `${payload.organizationId}-${payload.webhookTimestamp}`, + eventId, eventType: `${payload.type}.${payload.action}`, payload, status: "pending", }) + .onConflictDoUpdate({ + target: [webhookEvents.provider, webhookEvents.eventId], + set: { + // Reset for reprocessing only if previously failed + status: sql`CASE WHEN ${webhookEvents.status} = 'failed' THEN 'pending' ELSE ${webhookEvents.status} END`, + retryCount: sql`CASE WHEN ${webhookEvents.status} = 'failed' THEN ${webhookEvents.retryCount} + 1 ELSE ${webhookEvents.retryCount} END`, + error: sql`CASE WHEN ${webhookEvents.status} = 'failed' THEN NULL ELSE ${webhookEvents.error} END`, + }, + }) .returning(); if (!webhookEvent) { return Response.json({ error: "Failed to store event" }, { status: 500 }); } + // Idempotent: skip if already processed or not ready for processing + if (webhookEvent.status === "processed") { + console.log("[linear/webhook] Event already processed:", eventId); + return Response.json({ success: true, message: "Already processed" }); + } + if (webhookEvent.status !== "pending") { + console.log( + `[linear/webhook] Event in ${webhookEvent.status} state:`, + eventId, + ); + return Response.json({ success: true, message: "Event not ready" }); + } + const connection = await db.query.integrationConnections.findFirst({ where: and( eq(integrationConnections.externalOrgId, payload.organizationId), diff --git a/apps/api/src/lib/oauth-state.ts b/apps/api/src/lib/oauth-state.ts new file mode 100644 index 00000000000..58aa66bee0d --- /dev/null +++ b/apps/api/src/lib/oauth-state.ts @@ -0,0 +1,90 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { z } from "zod"; + +import { env } from "@/env"; + +const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +const statePayloadSchema = z.object({ + organizationId: z.string().min(1), + userId: z.string().min(1), + timestamp: z.number(), +}); + +/** + * Creates a signed state token for OAuth flows. + * Format: base64url(JSON payload).signature + * + * The signature is an HMAC-SHA256 of the payload, preventing forgery. + * A timestamp is included to prevent replay attacks (10 minute TTL). + */ +export function createSignedState({ + organizationId, + userId, +}: { + organizationId: string; + userId: string; +}): string { + const payload = { organizationId, userId, timestamp: Date.now() }; + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signature = createHmac("sha256", env.BETTER_AUTH_SECRET) + .update(payloadB64) + .digest("base64url"); + return `${payloadB64}.${signature}`; +} + +/** + * Verifies and extracts payload from a signed state token. + * Returns null if invalid, expired, or signature doesn't match. + */ +export function verifySignedState( + state: string, +): { organizationId: string; userId: string } | null { + const [payloadB64, providedSig] = state.split("."); + if (!payloadB64 || !providedSig) { + console.error("[oauth-state] Invalid state format"); + return null; + } + + // Verify signature using timing-safe comparison + const expectedSig = createHmac("sha256", env.BETTER_AUTH_SECRET) + .update(payloadB64) + .digest("base64url"); + const providedBuf = Buffer.from(providedSig, "base64url"); + const expectedBuf = Buffer.from(expectedSig, "base64url"); + + if ( + providedBuf.length !== expectedBuf.length || + !timingSafeEqual(providedBuf, expectedBuf) + ) { + console.error("[oauth-state] Signature verification failed"); + return null; + } + + // Parse and validate payload + let payload: unknown; + try { + payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + } catch { + console.error("[oauth-state] Failed to parse payload"); + return null; + } + + const parsed = statePayloadSchema.safeParse(payload); + if (!parsed.success) { + console.error("[oauth-state] Invalid payload schema"); + return null; + } + + // Check timestamp (replay protection) + const age = Date.now() - parsed.data.timestamp; + if (age < 0 || age > STATE_TTL_MS) { + console.error("[oauth-state] State expired"); + return null; + } + + return { + organizationId: parsed.data.organizationId, + userId: parsed.data.userId, + }; +} diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx index 54796adcf93..9cb10065e33 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/ConnectionControls/ConnectionControls.tsx @@ -65,8 +65,8 @@ export function ConnectionControls({ Disconnect GitHub? - This will disconnect GitHub from your organization. The GitHub - App will remain installed but will no longer sync data. You can + This will disconnect GitHub from your organization. The GitHub App + will remain installed but will no longer sync data. You can reconnect at any time. diff --git a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx index df1103ef558..9528d042be2 100644 --- a/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx +++ b/apps/web/src/app/(dashboard)/integrations/github/components/RepositoryList/RepositoryList.tsx @@ -2,9 +2,9 @@ import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; import { useMutation, useQuery } from "@tanstack/react-query"; import { GitBranch, Lock, RefreshCw, Unlock } from "lucide-react"; -import { toast } from "@superset/ui/sonner"; import { useTRPC } from "@/trpc/react"; interface RepositoryListProps { @@ -78,7 +78,9 @@ export function RepositoryList({ organizationId }: RepositoryListProps) { repositories.

@@ -89,10 +91,18 @@ export function RepositoryList({ organizationId }: RepositoryListProps) {

- {repositories.length} {repositories.length === 1 ? "repository" : "repositories"} + {repositories.length}{" "} + {repositories.length === 1 ? "repository" : "repositories"}

-
From 27bffd0688904eb88e50743cdd33c50c740966db Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 20 Jan 2026 12:18:39 -0800 Subject: [PATCH 10/14] use GH_ instead --- .env.example | 7 +++++++ apps/api/src/app/api/github/install/route.ts | 2 +- apps/api/src/app/api/github/octokit.ts | 6 +++--- apps/api/src/app/api/github/webhook/webhooks.ts | 2 +- apps/api/src/env.ts | 6 +++--- packages/trpc/src/env.ts | 6 +++--- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index e516015f6ec..4ec2a049058 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,13 @@ GOOGLE_CLIENT_SECRET= GH_CLIENT_ID= GH_CLIENT_SECRET= +# ----------------------------------------------------------------------------- +# GitHub App Credentials +# ----------------------------------------------------------------------------- +GH_APP_ID= +GH_APP_PRIVATE_KEY= +GH_WEBHOOK_SECRET= + # ----------------------------------------------------------------------------- # Blob Storage # ----------------------------------------------------------------------------- diff --git a/apps/api/src/app/api/github/install/route.ts b/apps/api/src/app/api/github/install/route.ts index 037e87ade8b..ac2bdb40499 100644 --- a/apps/api/src/app/api/github/install/route.ts +++ b/apps/api/src/app/api/github/install/route.ts @@ -37,7 +37,7 @@ export async function GET(request: Request) { ); } - if (!env.GITHUB_APP_ID) { + if (!env.GH_APP_ID) { return Response.json( { error: "GitHub App not configured" }, { status: 500 }, diff --git a/apps/api/src/app/api/github/octokit.ts b/apps/api/src/app/api/github/octokit.ts index 1cc078aad93..63ee6a1a489 100644 --- a/apps/api/src/app/api/github/octokit.ts +++ b/apps/api/src/app/api/github/octokit.ts @@ -4,8 +4,8 @@ import { Octokit } from "@octokit/rest"; import { env } from "@/env"; export const githubApp = new App({ - appId: env.GITHUB_APP_ID, - privateKey: env.GITHUB_APP_PRIVATE_KEY, - webhooks: { secret: env.GITHUB_WEBHOOK_SECRET }, + appId: env.GH_APP_ID, + privateKey: env.GH_APP_PRIVATE_KEY, + webhooks: { secret: env.GH_WEBHOOK_SECRET }, Octokit: Octokit, }); diff --git a/apps/api/src/app/api/github/webhook/webhooks.ts b/apps/api/src/app/api/github/webhook/webhooks.ts index 4137ea2578e..091e3e98363 100644 --- a/apps/api/src/app/api/github/webhook/webhooks.ts +++ b/apps/api/src/app/api/github/webhook/webhooks.ts @@ -10,7 +10,7 @@ import { and, eq } from "drizzle-orm"; import { env } from "@/env"; -export const webhooks = new Webhooks({ secret: env.GITHUB_WEBHOOK_SECRET }); +export const webhooks = new Webhooks({ secret: env.GH_WEBHOOK_SECRET }); // Installation events webhooks.on( diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 0824e25915f..174a6f306ea 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -21,9 +21,9 @@ export const env = createEnv({ LINEAR_CLIENT_ID: z.string().min(1), LINEAR_CLIENT_SECRET: z.string().min(1), LINEAR_WEBHOOK_SECRET: z.string().min(1), - GITHUB_APP_ID: z.string().min(1), - GITHUB_APP_PRIVATE_KEY: z.string().min(1), - GITHUB_WEBHOOK_SECRET: z.string().min(1), + GH_APP_ID: z.string().min(1), + GH_APP_PRIVATE_KEY: z.string().min(1), + GH_WEBHOOK_SECRET: z.string().min(1), QSTASH_TOKEN: z.string().min(1), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), QSTASH_NEXT_SIGNING_KEY: z.string().min(1), diff --git a/packages/trpc/src/env.ts b/packages/trpc/src/env.ts index 4ea5faa2b13..b0f11862257 100644 --- a/packages/trpc/src/env.ts +++ b/packages/trpc/src/env.ts @@ -18,9 +18,9 @@ export const env = createEnv({ KV_REST_API_URL: z.string().url().optional(), KV_REST_API_TOKEN: z.string().optional(), // GitHub App credentials - GITHUB_APP_ID: z.string().min(1), - GITHUB_APP_PRIVATE_KEY: z.string().min(1), - GITHUB_WEBHOOK_SECRET: z.string().min(1), + GH_APP_ID: z.string().min(1), + GH_APP_PRIVATE_KEY: z.string().min(1), + GH_WEBHOOK_SECRET: z.string().min(1), }, clientPrefix: "PUBLIC_", client: {}, From 5e75fa23aa8eec61af0f78398b1354df591af80b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 20 Jan 2026 12:43:06 -0800 Subject: [PATCH 11/14] Update workflow --- .github/workflows/deploy-preview.yml | 6 ++++++ .github/workflows/deploy-production.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 39550324f63..f44f51a9def 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -187,6 +187,9 @@ jobs: LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} @@ -218,6 +221,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env GH_APP_ID=$GH_APP_ID \ + --env GH_APP_PRIVATE_KEY=$GH_APP_PRIVATE_KEY \ + --env GH_WEBHOOK_SECRET=$GH_WEBHOOK_SECRET \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 3f01e289438..ea21670c02d 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -91,6 +91,9 @@ jobs: LINEAR_CLIENT_ID: ${{ secrets.LINEAR_CLIENT_ID }} LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} LINEAR_WEBHOOK_SECRET: ${{ secrets.LINEAR_WEBHOOK_SECRET }} + GH_APP_ID: ${{ secrets.GH_APP_ID }} + GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} + GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} @@ -122,6 +125,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ + --env GH_APP_ID=$GH_APP_ID \ + --env GH_APP_PRIVATE_KEY=$GH_APP_PRIVATE_KEY \ + --env GH_WEBHOOK_SECRET=$GH_WEBHOOK_SECRET \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ From 11009425101c15a8ad7229be46221a82fc950377 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 20 Jan 2026 12:52:45 -0800 Subject: [PATCH 12/14] fix: quote GH_APP_PRIVATE_KEY to handle PEM newlines The private key contains newlines (-----BEGIN/END RSA PRIVATE KEY-----) that break shell command parsing when passed unquoted. --- .github/workflows/deploy-preview.yml | 6 +++--- .github/workflows/deploy-production.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index f44f51a9def..0d6ca1dc065 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -221,9 +221,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ - --env GH_APP_ID=$GH_APP_ID \ - --env GH_APP_PRIVATE_KEY=$GH_APP_PRIVATE_KEY \ - --env GH_WEBHOOK_SECRET=$GH_WEBHOOK_SECRET \ + --env GH_APP_ID="$GH_APP_ID" \ + --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ + --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index ea21670c02d..5eaa82e655f 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -125,9 +125,9 @@ jobs: --env LINEAR_CLIENT_ID=$LINEAR_CLIENT_ID \ --env LINEAR_CLIENT_SECRET=$LINEAR_CLIENT_SECRET \ --env LINEAR_WEBHOOK_SECRET=$LINEAR_WEBHOOK_SECRET \ - --env GH_APP_ID=$GH_APP_ID \ - --env GH_APP_PRIVATE_KEY=$GH_APP_PRIVATE_KEY \ - --env GH_WEBHOOK_SECRET=$GH_WEBHOOK_SECRET \ + --env GH_APP_ID="$GH_APP_ID" \ + --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ + --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ From 9ea0e2d88c90f924269e95863acbf0364abb7374 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 20 Jan 2026 15:13:06 -0800 Subject: [PATCH 13/14] fix: skip QStash in development (can't reach localhost) QStash rejects URLs that resolve to loopback addresses. In development, call sync endpoints directly via fetch instead of going through QStash. --- .../trpc/src/lib/integrations/sync/tasks.ts | 28 +++++++++++++---- .../src/router/integration/github/github.ts | 30 ++++++++++++++----- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/packages/trpc/src/lib/integrations/sync/tasks.ts b/packages/trpc/src/lib/integrations/sync/tasks.ts index 7082a9713d2..dcaa81c1228 100644 --- a/packages/trpc/src/lib/integrations/sync/tasks.ts +++ b/packages/trpc/src/lib/integrations/sync/tasks.ts @@ -34,11 +34,29 @@ export async function syncTask(taskId: string) { return { provider: conn.provider, skipped: true }; } - await qstash.publishJSON({ - url: `${qstashBaseUrl}${endpoint}`, - body: { taskId }, - retries: 3, - }); + const syncUrl = `${qstashBaseUrl}${endpoint}`; + + // In development, call the sync endpoint directly (QStash can't reach localhost) + if (env.NODE_ENV === "development") { + try { + await fetch(syncUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ taskId }), + }); + } catch (error) { + console.error( + `[sync/tasks] Dev sync failed for ${conn.provider}:`, + error, + ); + } + } else { + await qstash.publishJSON({ + url: syncUrl, + body: { taskId }, + retries: 3, + }); + } return { provider: conn.provider, queued: true }; }), diff --git a/packages/trpc/src/router/integration/github/github.ts b/packages/trpc/src/router/integration/github/github.ts index fd00d48db74..55bcd8cf122 100644 --- a/packages/trpc/src/router/integration/github/github.ts +++ b/packages/trpc/src/router/integration/github/github.ts @@ -70,14 +70,28 @@ export const githubRouter = { }); } - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, - body: { - installationDbId: installation.id, - organizationId: input.organizationId, - }, - retries: 3, - }); + const syncUrl = `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`; + const syncBody = { + installationDbId: installation.id, + organizationId: input.organizationId, + }; + + // In development, call the sync endpoint directly (QStash can't reach localhost) + if (env.NODE_ENV === "development") { + fetch(syncUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(syncBody), + }).catch((error) => { + console.error("[github/triggerSync] Dev sync failed:", error); + }); + } else { + await qstash.publishJSON({ + url: syncUrl, + body: syncBody, + retries: 3, + }); + } return { success: true }; }), From 47082318eb6dbd53323456521d9cceeeb80ad45c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 20 Jan 2026 15:16:31 -0800 Subject: [PATCH 14/14] fix: skip QStash signature verification in development Job routes require QStash signatures, but in dev mode we call them directly via fetch. Skip verification when NODE_ENV=development. --- .../app/api/github/jobs/initial-sync/route.ts | 41 +++++++++++-------- .../linear/jobs/initial-sync/route.ts | 25 ++++++----- .../linear/jobs/sync-task/route.ts | 25 ++++++----- 3 files changed, 53 insertions(+), 38 deletions(-) diff --git a/apps/api/src/app/api/github/jobs/initial-sync/route.ts b/apps/api/src/app/api/github/jobs/initial-sync/route.ts index 34876256d8a..7b615d7a4ee 100644 --- a/apps/api/src/app/api/github/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/github/jobs/initial-sync/route.ts @@ -25,26 +25,31 @@ export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } + // Skip signature verification in development (QStash can't reach localhost) + const isDev = env.NODE_ENV === "development"; - const isValid = await receiver - .verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, - }) - .catch((error) => { - console.error( - "[github/initial-sync] Signature verification failed:", - error, - ); - return false; - }); + if (!isDev) { + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); + const isValid = await receiver + .verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, + }) + .catch((error) => { + console.error( + "[github/initial-sync] Signature verification failed:", + error, + ); + return false; + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } } let bodyData: unknown; diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts index f99ed95025c..0f24fdcef0d 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts @@ -30,18 +30,23 @@ export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } + // Skip signature verification in development (QStash can't reach localhost) + const isDev = env.NODE_ENV === "development"; - const isValid = await receiver.verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, - }); + if (!isDev) { + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); + const isValid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } } const parsed = payloadSchema.safeParse(JSON.parse(body)); diff --git a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts index a06e0e90d69..333effa0743 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts @@ -172,18 +172,23 @@ export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get("upstash-signature"); - if (!signature) { - return Response.json({ error: "Missing signature" }, { status: 401 }); - } + // Skip signature verification in development (QStash can't reach localhost) + const isDev = env.NODE_ENV === "development"; - const isValid = await receiver.verify({ - body, - signature, - url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/sync-task`, - }); + if (!isDev) { + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } - if (!isValid) { - return Response.json({ error: "Invalid signature" }, { status: 401 }); + const isValid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/sync-task`, + }); + + if (!isValid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } } const parsed = payloadSchema.safeParse(JSON.parse(body));