diff --git a/apps/api/package.json b/apps/api/package.json index 023421e13..d1d88477b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,9 @@ "dependencies": { "@electric-sql/client": "^1.3.1", "@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/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index ba2b1d05f..f72b40632 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -1,5 +1,8 @@ import { db } from "@superset/db/client"; import { + githubInstallations, + githubPullRequests, + githubRepositories, members, organizations, repositories, @@ -13,6 +16,8 @@ import { QueryBuilder } from "drizzle-orm/pg-core"; export type AllowedTable = | "tasks" | "repositories" + | "github_repositories" + | "github_pull_requests" | "auth.members" | "auth.organizations" | "auth.users"; @@ -45,6 +50,60 @@ export async function buildWhereClause( case "repositories": return build(repositories, repositories.organizationId, organizationId); + case "github_repositories": { + // Get the GitHub installation for this organization + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, organizationId), + columns: { id: true }, + }); + + if (!installation) { + return { fragment: "1 = 0", params: [] }; + } + + return build( + githubRepositories, + githubRepositories.installationId, + installation.id, + ); + } + + case "github_pull_requests": { + // Get the GitHub installation for this organization + const installation = await db.query.githubInstallations.findFirst({ + where: eq(githubInstallations.organizationId, organizationId), + columns: { id: true }, + }); + + if (!installation) { + return { fragment: "1 = 0", params: [] }; + } + + // Get all repositories for this installation + const repos = await db.query.githubRepositories.findMany({ + where: eq(githubRepositories.installationId, installation.id), + columns: { id: true }, + }); + + if (repos.length === 0) { + return { fragment: "1 = 0", params: [] }; + } + + const repoIds = repos.map((r) => r.id); + const whereExpr = inArray( + sql`${sql.identifier(githubPullRequests.repositoryId.name)}`, + repoIds, + ); + const qb = new QueryBuilder(); + const { sql: query, params } = qb + .select() + .from(githubPullRequests) + .where(whereExpr) + .toSQL(); + const fragment = query.replace(/^select .* from .* where\s+/i, ""); + return { fragment, params }; + } + case "auth.members": return build(members, members.organizationId, organizationId); diff --git a/apps/api/src/app/api/integrations/github/callback/route.ts b/apps/api/src/app/api/integrations/github/callback/route.ts new file mode 100644 index 000000000..83e1f9919 --- /dev/null +++ b/apps/api/src/app/api/integrations/github/callback/route.ts @@ -0,0 +1,137 @@ +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}/settings/integrations?error=installation_cancelled`, + ); + } + + if (!installationId || !state) { + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?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}/settings/integrations?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}/settings/integrations?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}/settings/integrations?error=save_failed`, + ); + } + + // Queue initial sync job + try { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_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}/settings/integrations?warning=sync_queue_failed`, + ); + } + + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?success=github_installed`, + ); + } catch (error) { + console.error("[github/callback] Unexpected error:", error); + return Response.redirect( + `${env.NEXT_PUBLIC_WEB_URL}/settings/integrations?error=unexpected`, + ); + } +} diff --git a/apps/api/src/app/api/integrations/github/install/route.ts b/apps/api/src/app/api/integrations/github/install/route.ts new file mode 100644 index 000000000..b56a6beb2 --- /dev/null +++ b/apps/api/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/api/src/app/api/integrations/github/jobs/initial-sync/route.ts b/apps/api/src/app/api/integrations/github/jobs/initial-sync/route.ts new file mode 100644 index 000000000..650cb34f6 --- /dev/null +++ b/apps/api/src/app/api/integrations/github/jobs/initial-sync/route.ts @@ -0,0 +1,220 @@ +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_API_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, organizationId } = 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, + // Note: additions, deletions, changedFiles not updated here + // as they're not available in list response + 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/api/src/app/api/integrations/github/octokit.ts b/apps/api/src/app/api/integrations/github/octokit.ts new file mode 100644 index 000000000..1cc078aad --- /dev/null +++ b/apps/api/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/api/src/app/api/integrations/github/webhook/route.ts b/apps/api/src/app/api/integrations/github/webhook/route.ts new file mode 100644 index 000000000..de31ae1ac --- /dev/null +++ b/apps/api/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/api/src/app/api/integrations/github/webhook/webhooks.ts b/apps/api/src/app/api/integrations/github/webhook/webhooks.ts new file mode 100644 index 000000000..c7959d18a --- /dev/null +++ b/apps/api/src/app/api/integrations/github/webhook/webhooks.ts @@ -0,0 +1,318 @@ +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/api/src/env.ts b/apps/api/src/env.ts index 5b0ce9a87..0824e2591 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/desktop/src/renderer/contexts/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts index 7cbff9ec9..136f6aea1 100644 --- a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts @@ -1,5 +1,7 @@ import { snakeCamelMapper } from "@electric-sql/client"; import type { + SelectGithubPullRequest, + SelectGithubRepository, SelectMember, SelectOrganization, SelectRepository, @@ -139,5 +141,45 @@ export function createCollections({ }), ); - return { tasks, repositories, members, users, organizations }; + const githubRepositories = createCollection( + electricCollectionOptions({ + id: `github-repositories-${activeOrgId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "github_repositories", + org: activeOrgId, + }, + headers, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + + const githubPullRequests = createCollection( + electricCollectionOptions({ + id: `github-pull-requests-${activeOrgId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "github_pull_requests", + org: activeOrgId, + }, + headers, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + + return { + tasks, + repositories, + members, + users, + organizations, + githubRepositories, + githubPullRequests, + }; } diff --git a/bun.lock b/bun.lock index 37cdedcf2..218c4809d 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,9 @@ "dependencies": { "@electric-sql/client": "^1.3.1", "@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:*", @@ -121,7 +124,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.47", + "version": "0.0.48", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -922,6 +925,54 @@ "@npmcli/move-file": ["@npmcli/move-file@2.0.1", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ=="], + "@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=="], @@ -1368,6 +1419,8 @@ "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], + "@types/aws-lambda": ["@types/aws-lambda@8.10.159", "", {}, "sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg=="], + "@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=="], @@ -1726,6 +1779,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.9", "", { "dependencies": { "@better-auth/core": "1.4.9", "@better-auth/telemetry": "1.4.9", "@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/react-start": "^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/react-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-usSdjuyTzZwIvM8fjF8YGhPncxV3MAg3dHUO9uPUnf0yklXUSYISiH1+imk6/Z+UBqsscyyPRnbIyjyK97p7YA=="], "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=="], @@ -2188,6 +2243,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-equals": ["fast-equals@5.3.3", "", {}, "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw=="], @@ -3470,6 +3527,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=="], @@ -3574,6 +3633,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/packages/db/drizzle/0008_add_github_integration_tables.sql b/packages/db/drizzle/0008_add_github_integration_tables.sql new file mode 100644 index 000000000..b71dbf4e2 --- /dev/null +++ b/packages/db/drizzle/0008_add_github_integration_tables.sql @@ -0,0 +1,76 @@ +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 NOT NULL, + "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, + "title" text NOT NULL, + "state" text NOT NULL, + "is_draft" boolean NOT NULL, + "url" text NOT NULL, + "head_branch" text NOT NULL, + "base_branch" text NOT NULL, + "head_sha" text NOT NULL, + "author_login" text NOT NULL, + "author_avatar_url" text, + "additions" integer NOT NULL, + "deletions" integer NOT NULL, + "changed_files" integer NOT NULL, + "review_decision" text, + "checks_status" text NOT NULL, + "checks" jsonb DEFAULT '[]'::jsonb, + "merged_at" timestamp, + "closed_at" timestamp, + "last_synced_at" timestamp DEFAULT now() NOT NULL, + "etag" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "github_pull_requests_node_id_unique" UNIQUE("node_id"), + 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, + "full_name" text NOT NULL, + "name" text NOT NULL, + "owner" text NOT NULL, + "default_branch" text DEFAULT 'main' NOT NULL, + "is_private" boolean NOT NULL, + "enabled" boolean DEFAULT true 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_installations_org_idx" ON "github_installations" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "github_pull_requests_repo_idx" ON "github_pull_requests" USING btree ("repository_id");--> statement-breakpoint +CREATE INDEX "github_pull_requests_head_branch_idx" ON "github_pull_requests" USING btree ("head_branch");--> statement-breakpoint +CREATE INDEX "github_pull_requests_state_idx" ON "github_pull_requests" USING btree ("state");--> statement-breakpoint +CREATE INDEX "github_pull_requests_checks_status_idx" ON "github_pull_requests" USING btree ("checks_status");--> statement-breakpoint +CREATE INDEX "github_pull_requests_synced_at_idx" ON "github_pull_requests" USING btree ("last_synced_at");--> statement-breakpoint +CREATE INDEX "github_repositories_installation_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/0008_snapshot.json b/packages/db/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..44fb2ff81 --- /dev/null +++ b/packages/db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2101 @@ +{ + "id": "c8d82f70-d52d-4460-9ae3-9d62a748fc76", + "prevId": "fa87a301-b667-4c9c-9e1f-15b9d9130bb0", + "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 + }, + "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": true + }, + "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": {} + }, + "github_installations_org_idx": { + "name": "github_installations_org_idx", + "columns": [ + { + "expression": "organization_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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "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 + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": true, + "default": "now()" + }, + "etag": { + "name": "etag", + "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": { + "github_pull_requests_repo_idx": { + "name": "github_pull_requests_repo_idx", + "columns": [ + { + "expression": "repository_id", + "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": {} + }, + "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_checks_status_idx": { + "name": "github_pull_requests_checks_status_idx", + "columns": [ + { + "expression": "checks_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_synced_at_idx": { + "name": "github_pull_requests_synced_at_idx", + "columns": [ + { + "expression": "last_synced_at", + "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_node_id_unique": { + "name": "github_pull_requests_node_id_unique", + "nullsNotDistinct": false, + "columns": [ + "node_id" + ] + }, + "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 + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "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 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "github_repositories_installation_idx": { + "name": "github_repositories_installation_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.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": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_position": { + "name": "status_position", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "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_idx": { + "name": "tasks_status_idx", + "columns": [ + { + "expression": "status", + "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_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": [ + "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 b6e6ceabf..f3738a53f 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1767049518603, "tag": "0007_add_created_at_default", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1767797350444, + "tag": "0008_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 d5cba4044..5bd62de50 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 000000000..623cb278e --- /dev/null +++ b/packages/db/src/schema/github.ts @@ -0,0 +1,174 @@ +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; + +import { organizations, users } from "./auth"; + +// GitHub App installations (one per organization) +export const githubInstallations = pgTable( + "github_installations", + { + id: uuid().primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + connectedByUserId: uuid("connected_by_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + // GitHub App installation data + installationId: text("installation_id").notNull().unique(), + accountLogin: text("account_login").notNull(), + accountType: text("account_type").notNull(), // "Organization" | "User" + + // Permissions granted to the app + permissions: jsonb().$type>().notNull(), + + // Suspension status + suspended: boolean().notNull().default(false), + suspendedAt: timestamp("suspended_at"), + + // Sync tracking + lastSyncedAt: timestamp("last_synced_at"), + + 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, + ), + index("github_installations_org_idx").on(table.organizationId), + ], +); + +export type InsertGithubInstallation = typeof githubInstallations.$inferInsert; +export type SelectGithubInstallation = typeof githubInstallations.$inferSelect; + +// Repositories accessible via GitHub installation +export const githubRepositories = pgTable( + "github_repositories", + { + id: uuid().primaryKey().defaultRandom(), + installationId: uuid("installation_id") + .notNull() + .references(() => githubInstallations.id, { onDelete: "cascade" }), + + // GitHub repo identifiers + repoId: text("repo_id").notNull(), // GitHub's repo ID (immutable) + fullName: text("full_name").notNull(), // "owner/repo" + name: text("name").notNull(), + owner: text("owner").notNull(), + + defaultBranch: text("default_branch").notNull().default("main"), + isPrivate: boolean("is_private").notNull(), + + // Sync control + enabled: boolean().notNull().default(true), // User can disable specific repos + + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + unique("github_repositories_repo_id_unique").on(table.repoId), + index("github_repositories_installation_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; + +// Pull request metadata cache +export const githubPullRequests = pgTable( + "github_pull_requests", + { + id: uuid().primaryKey().defaultRandom(), + repositoryId: uuid("repository_id") + .notNull() + .references(() => githubRepositories.id, { onDelete: "cascade" }), + + // PR identifiers + prNumber: integer("pr_number").notNull(), + nodeId: text("node_id").notNull().unique(), // GitHub's global node ID + + // PR metadata + title: text().notNull(), + state: text().notNull(), // "open", "draft", "merged", "closed" + isDraft: boolean("is_draft").notNull(), + url: text().notNull(), + + // Branch info + headBranch: text("head_branch").notNull(), + baseBranch: text("base_branch").notNull(), + headSha: text("head_sha").notNull(), // For detecting updates + + // Author + authorLogin: text("author_login").notNull(), + authorAvatarUrl: text("author_avatar_url"), + + // Stats + additions: integer().notNull(), + deletions: integer().notNull(), + changedFiles: integer("changed_files").notNull(), + + // Review status + reviewDecision: text("review_decision"), // "APPROVED", "CHANGES_REQUESTED", null + + // Check status rollup + checksStatus: text("checks_status").notNull(), // "success", "failure", "pending", "none" + checks: jsonb() + .$type< + Array<{ + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; + }> + >() + .default([]), + + // Timestamps + mergedAt: timestamp("merged_at"), + closedAt: timestamp("closed_at"), + + // Sync metadata + lastSyncedAt: timestamp("last_synced_at").notNull().defaultNow(), + etag: text(), // For conditional requests + + 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_repo_idx").on(table.repositoryId), + index("github_pull_requests_head_branch_idx").on(table.headBranch), + index("github_pull_requests_state_idx").on(table.state), + index("github_pull_requests_checks_status_idx").on(table.checksStatus), + index("github_pull_requests_synced_at_idx").on(table.lastSyncedAt), + ], +); + +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 54196ae0b..66d40cf91 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,4 +1,5 @@ export * from "./auth"; +export * from "./github"; export * from "./ingest"; export * from "./relations"; export * from "./schema"; diff --git a/plans/github-integration-review.md b/plans/github-integration-review.md new file mode 100644 index 000000000..e96c95bae --- /dev/null +++ b/plans/github-integration-review.md @@ -0,0 +1,365 @@ +# GitHub App Integration Review + +## Overall Rating: 6.5/10 + +**What's good:** +- ✅ Solid database schema with proper indexes +- ✅ Webhook handlers are comprehensive and well-structured +- ✅ Using GitHub App (not OAuth/PAT) - correct architecture choice +- ✅ Initial sync job logic is sound +- ✅ Electric SQL collections configured in desktop app +- ✅ Type-safe webhook event handlers + +**What needs work:** +- ❌ Storing unused access tokens +- ❌ Electric SQL not configured to sync GitHub tables +- ❌ No replacement for `gh` CLI queries +- ❌ Missing repository-to-PR lookup logic +- ❌ No UI for connecting the integration + +--- + +## Critical Issues + +### 1. **Unnecessary Token Storage** (Priority: Medium) + +**Problem:** We're fetching and storing installation access tokens in the database but never using them. + +**Location:** `apps/api/src/app/api/integrations/github/callback/route.ts:64-72, 100-104, 114-117` + +```typescript +// ❌ Current: Fetching token we don't need +const tokenResult = await octokit.request("POST /app/installations/{installation_id}/access_tokens", { + installation_id: Number(installationId), +}); +// ... storing it in DB +accessToken: token.token, +tokenExpiresAt: token.expires_at ? new Date(token.expires_at) : null, +``` + +**Why it's wrong:** `githubApp.getInstallationOctokit()` generates fresh tokens on-demand using the app's private key. These tokens expire in 1 hour and we'd need token refresh logic to keep them valid. + +**Solution:** Remove token fetching and storage. The schema fields `accessToken`, `tokenExpiresAt`, `refreshToken` can be removed entirely. + +**Impact:** Simplifies code, removes unnecessary API call, eliminates token expiry concerns. + +--- + +### 2. **Electric SQL Not Configured for GitHub Tables** (Priority: CRITICAL) + +**Problem:** `github_repositories` and `github_pull_requests` tables won't sync to desktop because they're not in the Electric SQL proxy configuration. + +**Location:** `apps/api/src/app/api/electric/[...path]/utils.ts:13-18, 41-114` + +```typescript +// ❌ Missing cases +export type AllowedTable = + | "tasks" + | "repositories" + | "auth.members" + | "auth.organizations" + | "auth.users"; + // Missing: "github_repositories" | "github_pull_requests" + +// buildWhereClause() has no cases for GitHub tables +``` + +**Solution:** Add cases to filter by organization: + +```typescript +export type AllowedTable = + | "tasks" + | "repositories" + | "github_repositories" + | "github_pull_requests" + | "auth.members" + | "auth.organizations" + | "auth.users"; + +// In buildWhereClause: +case "github_repositories": { + // Find installations for this org, then filter repos by those installations + const [installation] = await db + .select({ id: githubInstallations.id }) + .from(githubInstallations) + .where(eq(githubInstallations.organizationId, organizationId)) + .limit(1); + + if (!installation) { + return { fragment: "1 = 0", params: [] }; + } + + return build(githubRepositories, githubRepositories.installationId, installation.id); +} + +case "github_pull_requests": { + // Filter PRs by repos belonging to org's installation + // More complex - need to join through repositories + const [installation] = await db + .select({ id: githubInstallations.id }) + .from(githubInstallations) + .where(eq(githubInstallations.organizationId, organizationId)) + .limit(1); + + if (!installation) { + return { fragment: "1 = 0", params: [] }; + } + + const repos = await db + .select({ id: githubRepositories.id }) + .from(githubRepositories) + .where(eq(githubRepositories.installationId, installation.id)); + + if (repos.length === 0) { + return { fragment: "1 = 0", params: [] }; + } + + const repoIds = repos.map((r) => r.id); + const whereExpr = inArray( + sql`${sql.identifier(githubPullRequests.repositoryId.name)}`, + repoIds, + ); + const qb = new QueryBuilder(); + const { sql: query, params } = qb + .select() + .from(githubPullRequests) + .where(whereExpr) + .toSQL(); + const fragment = query.replace(/^select .* from .* where\s+/i, ""); + return { fragment, params }; +} +``` + +**Impact:** Without this, the desktop app can't access GitHub data at all. Collections will remain empty. + +--- + +### 3. **No Replacement for `gh` CLI Logic** (Priority: CRITICAL) + +**Problem:** Desktop app still uses `gh` CLI to fetch PR status via tRPC. We built the GitHub App integration to replace this, but didn't replace the consumer. + +**Location:** +- `apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts:1451-1490` - tRPC procedure +- `apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts:23-72` - CLI implementation + +**Current flow:** +``` +usePRStatus hook → trpc.workspaces.getGitHubStatus → fetchGitHubPRStatus → gh CLI +``` + +**Target flow:** +``` +usePRStatus hook → Electric SQL query → githubPullRequests collection +``` + +**Solution:** Replace the tRPC procedure with a hook that queries the Electric SQL collection: + +```typescript +// New: apps/desktop/src/renderer/hooks/useWorkspacePR.ts +import { useCollections } from "renderer/contexts/CollectionsProvider"; +import { useWorkspace } from "./useWorkspace"; + +export function useWorkspacePR(workspaceId: string) { + const { githubPullRequests, githubRepositories } = useCollections(); + const workspace = useWorkspace(workspaceId); + + // Get git remote URL and branch from workspace + const repoFullName = extractRepoFromRemote(workspace?.gitRemoteUrl); + const branchName = workspace?.currentBranch; + + // Find repository + const repo = githubRepositories.rows.find( + (r) => r.fullName === repoFullName + ); + + // Find PR by repo + branch + const pr = githubPullRequests.rows.find( + (pr) => pr.repositoryId === repo?.id && pr.headBranch === branchName + ); + + return { pr, repo, isLoading: !workspace }; +} +``` + +**Challenges:** +1. Need to store `gitRemoteUrl` and `currentBranch` in workspace (local SQLite) +2. Need helper to extract `owner/repo` from git remote URL +3. Need to handle case where workspace's repo isn't connected to GitHub App + +--- + +### 4. **Missing Repository Identification Logic** (Priority: HIGH) + +**Problem:** No way to map a workspace's git remote URL to a `githubRepository` record. + +**What we have:** +- Workspace has: git directory → can get remote URL +- `githubRepositories` has: `fullName` (e.g., "superset-sh/superset") + +**What we need:** +```typescript +function extractRepoFromRemote(remoteUrl: string): string | null { + // Input: "git@github.com:superset-sh/superset.git" + // Output: "superset-sh/superset" + + // Input: "https://github.com/superset-sh/superset.git" + // Output: "superset-sh/superset" + + const sshMatch = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/); + const httpsMatch = remoteUrl.match(/github\.com\/(.+?)(?:\.git)?$/); + + return sshMatch?.[1] || httpsMatch?.[1] || null; +} +``` + +**Location to add:** `apps/desktop/src/lib/git-utils.ts` (or similar) + +--- + +### 5. **Webhook Payload Fields Not Always Available** (Priority: LOW) + +**Problem:** Webhook handlers assume `additions`, `deletions`, `changedFiles` exist on all PR webhook events. They don't - only available on individual PR GET requests. + +**Location:** `apps/api/src/app/api/integrations/github/webhook/webhooks.ts:115-120` + +```typescript +// ❌ These fields don't exist in webhook payloads +additions: pr.additions ?? 0, +deletions: pr.deletions ?? 0, +changedFiles: pr.changed_files ?? 0, +``` + +**Solution:** Set to 0 in webhooks (they'll be populated by initial sync which uses the full PR endpoint). + +**Status:** Already fixed in initial sync (we set to 0), just need to verify webhooks do the same. + +--- + +### 6. **No Periodic Sync Mechanism** (Priority: MEDIUM) + +**Problem:** We only sync on initial install. If webhooks fail or get out of sync, data gets stale. + +**Solution:** Add a cron job that runs every 5-10 minutes: +```typescript +// apps/api/src/app/api/cron/github-sync/route.ts +export async function GET(request: Request) { + // Verify cron secret + if (request.headers.get("Authorization") !== `Bearer ${env.CRON_SECRET}`) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Find installations that haven't synced in 10+ minutes + const staleInstallations = await db + .select() + .from(githubInstallations) + .where( + and( + eq(githubInstallations.suspended, false), + // lastSyncedAt < 10 minutes ago + ) + ); + + // Queue sync jobs + for (const installation of staleInstallations) { + await qstash.publishJSON({ + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/github/jobs/sync`, + body: { installationId: installation.id }, + }); + } + + return Response.json({ synced: staleInstallations.length }); +} +``` + +**Note:** Need to add `lastSyncedAt` to `githubInstallations` table. + +--- + +### 7. **No UI for Managing Integration** (Priority: HIGH) + +**Problem:** Users can't connect/disconnect the GitHub App from the UI. + +**Needed components:** +1. Settings page at `/settings/integrations` +2. "Connect GitHub" button → redirects to install route +3. Connected state showing: + - Account name + - Number of repos synced + - Last sync time + - "Disconnect" button +4. Repository selection (enable/disable specific repos) + +**Location to create:** `apps/web/src/app/(dashboard)/settings/integrations/github/page.tsx` + +--- + +## Schema Issues + +### 8. **Unused Fields in Schema** (Priority: LOW) + +**Fields to remove from `githubInstallations`:** +- `accessToken` - Never used (we generate tokens on-demand) +- `tokenExpiresAt` - Never used +- `refreshToken` - GitHub App tokens don't have refresh tokens +- `webhookId` - Not set anywhere +- `webhookSecret` - We use a global webhook secret, not per-installation + +**Fields to add:** +- `lastSyncedAt` - For tracking sync freshness + +--- + +## Missing Features + +### 9. **No Support for Multiple Installations per Org** (By design) + +**Current:** One installation per organization (enforced by unique constraint on `organizationId`) + +**Is this correct?** Yes, for most cases. A GitHub App can only be installed once per GitHub account. However, if users have personal repos AND org repos, they'd need separate installations. + +**Decision:** Keep current design. Document that users should install to their organization, not personal account. + +--- + +## Testing Gaps + +### 10. **No Test Coverage** (Priority: MEDIUM) + +**Missing tests:** +- OAuth flow (install → callback → sync) +- Webhook handling (all event types) +- Token generation +- Electric SQL query building +- Repository identification logic + +**Recommendation:** Add integration tests for critical flows before deploying. + +--- + +## Summary of Required Changes + +### Must Fix Before Deploy: +1. ✅ Add Electric SQL configuration for GitHub tables +2. ✅ Replace `gh` CLI logic with Electric SQL queries +3. ✅ Add repository identification helper +4. ✅ Create UI for connecting/managing integration + +### Should Fix Soon: +5. Remove unused token storage +6. Add periodic sync cron job +7. Add comprehensive error handling + +### Nice to Have: +8. Add test coverage +9. Add repository enable/disable UI +10. Add sync status indicators + +--- + +## Estimated Effort + +- **Critical fixes (1-4):** 4-6 hours +- **Should fix (5-7):** 2-3 hours +- **Nice to have (8-10):** 4-6 hours + +**Total:** 10-15 hours to production-ready