diff --git a/apps/web/.env.example b/apps/web/.env.example index 2c859222..3498225c 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -61,6 +61,24 @@ ANTHROPIC_API_KEY=your_anthropic_api_key # INNGEST_EVENT_KEY=your_inngest_event_key # INNGEST_SIGNING_KEY=your_inngest_signing_key +# ────────────────────────────────────────────── +# GitHub Webhooks (optional) +# ────────────────────────────────────────────── +# Secret for verifying GitHub webhook signatures +# Configure in your GitHub repo/org webhook settings +# Generate with: openssl rand -hex 20 +# GITHUB_WEBHOOK_SECRET=your_webhook_secret + +# ────────────────────────────────────────────── +# GitHub App (optional — enables webhook-driven PR conflict detection) +# ────────────────────────────────────────────── +# Create a GitHub App: https://docs.github.com/en/apps/creating-github-apps +# Required permissions: Pull Requests (read) +# Subscribe to events: pull_request, installation, installation_repositories +# GITHUB_APP_ID=your_github_app_id +# GITHUB_APP_PRIVATE_KEY=base64_encoded_pem_private_key +# GITHUB_APP_WEBHOOK_SECRET=your_github_app_webhook_secret + # ────────────────────────────────────────────── # Search & Memory (optional) # ────────────────────────────────────────────── diff --git a/apps/web/package.json b/apps/web/package.json index 492f936a..6cb6f068 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@better-auth/infra": "^0.1.7", "@better-auth/utils": "^0.3.1", "@mixedbread-ai/sdk": "^2.2.11", + "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^22.0.1", "@openrouter/ai-sdk-provider": "^2.2.3", "@prisma/adapter-pg": "^7.4.1", diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 32d0871c..137cae6a 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -234,3 +234,59 @@ model PinnedItem { @@index([userId, owner, repo]) @@map("pinned_items") } + +model RepoSystemPin { + id String @id @default(cuid()) + owner String + repo String + kind String // e.g. "pr_conflict" + resourceKey String // e.g. "pr:123" + url String + title String + status String @default("active") // "active" | "cleared" + payloadJson String? + createdAt String + updatedAt String + clearedAt String? + + @@unique([owner, repo, kind, resourceKey]) + @@index([owner, repo, status]) + @@map("repo_system_pins") +} + +model GitHubAppInstallation { + id String @id @default(cuid()) + installationId Int @unique // GitHub's installation ID + accountLogin String // GitHub org or user login + accountType String // "Organization" | "User" + appSlug String + status String @default("active") // "active" | "suspended" | "removed" + permissions String? // JSON of granted permissions + events String? // JSON of subscribed events + createdAt String + updatedAt String + + repos GitHubAppInstallationRepo[] + + @@index([accountLogin, status]) + @@map("github_app_installations") +} + +model GitHubAppInstallationRepo { + id String @id @default(cuid()) + installationId Int // FK to GitHubAppInstallation.installationId + owner String + repo String + status String @default("active") // "active" | "removed" + lastWebhookAt String? + lastPolledAt String? + createdAt String + updatedAt String + + installation GitHubAppInstallation @relation(fields: [installationId], references: [installationId], onDelete: Cascade) + + @@unique([installationId, owner, repo]) + @@index([owner, repo]) + @@index([status]) + @@map("github_app_installation_repos") +} diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/pin-actions.ts b/apps/web/src/app/(app)/repos/[owner]/[repo]/pin-actions.ts index e6a60b21..0956ae8b 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/pin-actions.ts +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/pin-actions.ts @@ -9,6 +9,10 @@ import { getPinnedItems, type PinnedItem, } from "@/lib/pinned-items-store"; +import { + getActiveSystemPins, + type SystemPin, +} from "@/lib/system-pins-store"; import { revalidatePath } from "next/cache"; import { invalidateRepoCache } from "@/lib/repo-data-cache-vc"; @@ -49,3 +53,17 @@ export async function getPinnedUrlsForRepo(owner: string, repo: string): Promise if (!session?.user?.id) return []; return getPinnedItemUrls(session.user.id, owner, repo); } + +// ── System Pins (repo-global, server-managed) ──────────────── + +export async function fetchSystemPinsForRepo( + owner: string, + repo: string, + kind?: "pr_conflict", +): Promise { + // System pins are repo-global, no user scoping needed + // But still require auth to prevent unauthenticated reads + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) return []; + return getActiveSystemPins(owner, repo, kind); +} diff --git a/apps/web/src/app/api/github/webhook/route.ts b/apps/web/src/app/api/github/webhook/route.ts new file mode 100644 index 00000000..454f51e0 --- /dev/null +++ b/apps/web/src/app/api/github/webhook/route.ts @@ -0,0 +1,243 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { redis } from "@/lib/redis"; +import { inngest } from "@/lib/inngest"; +import { getWebhookSecret } from "@/lib/github-app"; +import { + upsertInstallation, + removeInstallation, + suspendInstallation, + unsuspendInstallation, + syncInstallationRepos, + addInstallationRepos, + removeInstallationRepos, + touchRepoWebhook, +} from "@/lib/github-app-store"; + +// ── Signature Verification ─────────────────────────────────── + +function verifySignature(payload: string, signature: string | null, secret: string): boolean { + if (!signature) return false; + const expected = `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`; + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); + } catch { + return false; + } +} + +// ── Delivery Deduplication ─────────────────────────────────── + +const DEDUPE_TTL_SECONDS = 60 * 60; // 1 hour + +async function isDuplicateDelivery(deliveryId: string): Promise { + const result = await redis.set(`webhook:delivery:${deliveryId}`, "1", { + nx: true, + ex: DEDUPE_TTL_SECONDS, + }); + return result === null; +} + +// ── PR Actions We Care About ───────────────────────────────── + +const PR_ACTIONS_EVALUATE = new Set([ + "opened", + "reopened", + "synchronize", + "edited", + "ready_for_review", +]); +const PR_ACTIONS_CLOSE = new Set(["closed"]); + +// ── Webhook Payload Types ──────────────────────────────────── + +interface WebhookRepo { + id: number; + name: string; + full_name: string; +} + +// ── Route Handler ──────────────────────────────────────────── + +export async function POST(request: NextRequest) { + const secret = getWebhookSecret(); + if (!secret) { + console.error("[webhook] GitHub App webhook secret not configured"); + return NextResponse.json({ error: "Webhook secret not configured" }, { status: 500 }); + } + + // Read raw body for signature verification + const rawBody = await request.text(); + const signature = request.headers.get("x-hub-signature-256"); + + if (!verifySignature(rawBody, signature, secret)) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + // Deduplicate + const deliveryId = request.headers.get("x-github-delivery"); + if (deliveryId && (await isDuplicateDelivery(deliveryId))) { + return NextResponse.json({ status: "duplicate", deliveryId }); + } + + const eventType = request.headers.get("x-github-event"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let payload: any; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + // ── Installation lifecycle events ──────────────────────── + if (eventType === "installation") { + return handleInstallationEvent(payload); + } + + if (eventType === "installation_repositories") { + return handleInstallationRepositoriesEvent(payload); + } + + // ── Pull request events ────────────────────────────────── + if (eventType === "pull_request") { + return handlePullRequestEvent(payload); + } + + return NextResponse.json({ status: "ignored", event: eventType }); +} + +// ── Installation Event Handler ─────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleInstallationEvent(payload: any) { + const { action, installation, repositories } = payload; + const installationId: number = installation.id; + const accountLogin: string = installation.account.login; + const accountType: string = installation.account.type; + const appSlug: string = installation.app_slug; + + if (action === "created") { + await upsertInstallation({ + installationId, + accountLogin, + accountType, + appSlug, + permissions: installation.permissions, + events: installation.events, + }); + + // Sync initial repos + if (repositories && Array.isArray(repositories)) { + const repos = (repositories as WebhookRepo[]).map((r) => { + const [owner, repo] = r.full_name.split("/"); + return { owner, repo }; + }); + await syncInstallationRepos(installationId, repos); + } + + console.log(`[webhook] Installation created: ${installationId} for ${accountLogin}`); + return NextResponse.json({ status: "processed", action: "installation.created" }); + } + + if (action === "deleted") { + await removeInstallation(installationId); + console.log(`[webhook] Installation removed: ${installationId} for ${accountLogin}`); + return NextResponse.json({ status: "processed", action: "installation.deleted" }); + } + + if (action === "suspend") { + await suspendInstallation(installationId); + console.log(`[webhook] Installation suspended: ${installationId}`); + return NextResponse.json({ status: "processed", action: "installation.suspend" }); + } + + if (action === "unsuspend") { + await unsuspendInstallation(installationId); + console.log(`[webhook] Installation unsuspended: ${installationId}`); + return NextResponse.json({ status: "processed", action: "installation.unsuspend" }); + } + + return NextResponse.json({ status: "ignored", action: `installation.${action}` }); +} + +// ── Installation Repositories Event Handler ────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handleInstallationRepositoriesEvent(payload: any) { + const { action, installation, repositories_added, repositories_removed } = payload; + const installationId: number = installation.id; + + if (action === "added" && repositories_added) { + const repos = (repositories_added as WebhookRepo[]).map((r) => { + const [owner, repo] = r.full_name.split("/"); + return { owner, repo }; + }); + await addInstallationRepos(installationId, repos); + console.log(`[webhook] Repos added to installation ${installationId}: ${repos.map((r) => `${r.owner}/${r.repo}`).join(", ")}`); + return NextResponse.json({ status: "processed", action: "repos.added", count: repos.length }); + } + + if (action === "removed" && repositories_removed) { + const repos = (repositories_removed as WebhookRepo[]).map((r) => { + const [owner, repo] = r.full_name.split("/"); + return { owner, repo }; + }); + await removeInstallationRepos(installationId, repos); + console.log(`[webhook] Repos removed from installation ${installationId}: ${repos.map((r) => `${r.owner}/${r.repo}`).join(", ")}`); + return NextResponse.json({ status: "processed", action: "repos.removed", count: repos.length }); + } + + return NextResponse.json({ status: "ignored", action: `repos.${action}` }); +} + +// ── Pull Request Event Handler ─────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function handlePullRequestEvent(payload: any) { + const { action, number: pullNumber, pull_request: pr, repository: repo, installation } = payload; + const owner: string = repo.owner.login; + const repoName: string = repo.name; + const installationId: number | undefined = installation?.id; + + // Track webhook activity + if (installationId) { + touchRepoWebhook(installationId, owner, repoName).catch((err) => { + console.error("[webhook] Failed to touch repo webhook timestamp:", err); + }); + } + + if (PR_ACTIONS_CLOSE.has(action)) { + await inngest.send({ + name: "app/pr.conflict.clear", + data: { + owner, + repo: repoName, + pullNumber, + reason: pr.merged ? "merged" : "closed", + }, + }); + return NextResponse.json({ status: "processed", action: "clear", pullNumber }); + } + + if (PR_ACTIONS_EVALUATE.has(action)) { + await inngest.send({ + name: "app/pr.conflict.evaluate", + data: { + owner, + repo: repoName, + pullNumber, + installationId: installationId ?? null, + title: pr.title, + url: pr.html_url, + headRef: pr.head.ref, + baseRef: pr.base.ref, + webhookAction: action, + source: "github_app_webhook", + }, + }); + return NextResponse.json({ status: "processed", action: "evaluate", pullNumber }); + } + + return NextResponse.json({ status: "ignored", action }); +} diff --git a/apps/web/src/app/api/inngest/route.ts b/apps/web/src/app/api/inngest/route.ts index 7daa6146..9c44f80a 100644 --- a/apps/web/src/app/api/inngest/route.ts +++ b/apps/web/src/app/api/inngest/route.ts @@ -1,7 +1,7 @@ import { serve } from "inngest/next"; -import { inngest, embedContent } from "@/lib/inngest"; +import { inngest, embedContent, evaluatePRConflict, clearPRConflict, pollConflicts } from "@/lib/inngest"; export const { GET, POST, PUT } = serve({ client: inngest, - functions: [embedContent], + functions: [embedContent, evaluatePRConflict, clearPRConflict, pollConflicts], }); diff --git a/apps/web/src/components/repo/repo-overview.tsx b/apps/web/src/components/repo/repo-overview.tsx index 6b1939b3..e4621621 100644 --- a/apps/web/src/components/repo/repo-overview.tsx +++ b/apps/web/src/components/repo/repo-overview.tsx @@ -18,13 +18,16 @@ import { X, Eye, LayoutDashboard, + AlertTriangle, } from "lucide-react"; import { CheckStatusBadge } from "@/components/pr/check-status-badge"; import { unpinFromOverview, fetchPinnedItemsForRepo, + fetchSystemPinsForRepo, } from "@/app/(app)/repos/[owner]/[repo]/pin-actions"; import type { PinnedItem } from "@/lib/pinned-items-store"; +import type { SystemPin } from "@/lib/system-pins-store"; import { useMutationSubscription } from "@/hooks/use-mutation-subscription"; import { useMutationEvents } from "@/components/shared/mutation-event-provider"; import { isRepoEvent, type MutationEvent } from "@/lib/mutation-events"; @@ -793,6 +796,51 @@ function PinnedItemsSection({ ); } +// --- Conflict Pins Section (server-managed, repo-global) --- +function ConflictPinsSection({ + items, + base, +}: { + items: SystemPin[]; + base: string; +}) { + if (items.length === 0) return null; + + return ( +
+
+ +

+ Merge Conflicts +

+ + {items.length} + +
+
+ {items.map((pin) => { + const prNumber = pin.resourceKey.replace("pr:", ""); + return ( + + + + + #{prNumber} + + {pin.title} + + + ); + })} +
+
+ ); +} + function CIStatusCard({ ciStatus, owner, @@ -1008,6 +1056,14 @@ export function RepoOverview({ gcTime: 10 * 60 * 1000, }); + const { data: conflictPins = [] } = useQuery({ + queryKey: ["system-pins", owner, repo, "pr_conflict"], + queryFn: () => fetchSystemPinsForRepo(owner, repo, "pr_conflict"), + enabled: isMaintainer, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + }); + // True when we have initialData OR the queries have finished fetching const hasInitialData = !!(initialPRs || initialIssues || initialEvents); const dataReady = hasInitialData || (prsFetched && issuesFetched && eventsFetched); @@ -1091,6 +1147,12 @@ export function RepoOverview({ defaultBranch={branch} /> )} + {conflictPins.length > 0 && ( + + )} {pinnedItems && pinnedItems.length > 0 && ( ; + events?: string[]; +}): Promise { + const now = new Date().toISOString(); + const permissionsJson = input.permissions ? JSON.stringify(input.permissions) : null; + const eventsJson = input.events ? JSON.stringify(input.events) : null; + + const result = await prisma.gitHubAppInstallation.upsert({ + where: { installationId: input.installationId }, + create: { + installationId: input.installationId, + accountLogin: input.accountLogin, + accountType: input.accountType, + appSlug: input.appSlug, + status: "active", + permissions: permissionsJson, + events: eventsJson, + createdAt: now, + updatedAt: now, + }, + update: { + accountLogin: input.accountLogin, + accountType: input.accountType, + appSlug: input.appSlug, + status: "active", + permissions: permissionsJson, + events: eventsJson, + updatedAt: now, + }, + }); + return result as unknown as AppInstallation; +} + +export async function suspendInstallation(installationId: number): Promise { + const now = new Date().toISOString(); + await prisma.gitHubAppInstallation.update({ + where: { installationId }, + data: { status: "suspended", updatedAt: now }, + }); +} + +export async function unsuspendInstallation(installationId: number): Promise { + const now = new Date().toISOString(); + await prisma.gitHubAppInstallation.update({ + where: { installationId }, + data: { status: "active", updatedAt: now }, + }); +} + +export async function removeInstallation(installationId: number): Promise { + const now = new Date().toISOString(); + // Mark installation and all its repos as removed (cascade will handle repos on delete, + // but we soft-delete for audit trail) + await prisma.$transaction([ + prisma.gitHubAppInstallationRepo.updateMany({ + where: { installationId }, + data: { status: "removed", updatedAt: now }, + }), + prisma.gitHubAppInstallation.update({ + where: { installationId }, + data: { status: "removed", updatedAt: now }, + }), + ]); +} + +// ── Repo CRUD ──────────────────────────────────────────────── + +export async function syncInstallationRepos( + installationId: number, + repos: Array<{ owner: string; repo: string }>, +): Promise { + const now = new Date().toISOString(); + + await prisma.$transaction( + repos.map(({ owner, repo }) => + prisma.gitHubAppInstallationRepo.upsert({ + where: { + installationId_owner_repo: { installationId, owner, repo }, + }, + create: { + installationId, + owner, + repo, + status: "active", + createdAt: now, + updatedAt: now, + }, + update: { + status: "active", + updatedAt: now, + }, + }), + ), + ); +} + +export async function addInstallationRepos( + installationId: number, + repos: Array<{ owner: string; repo: string }>, +): Promise { + const now = new Date().toISOString(); + + await prisma.$transaction( + repos.map(({ owner, repo }) => + prisma.gitHubAppInstallationRepo.upsert({ + where: { + installationId_owner_repo: { installationId, owner, repo }, + }, + create: { + installationId, + owner, + repo, + status: "active", + createdAt: now, + updatedAt: now, + }, + update: { + status: "active", + updatedAt: now, + }, + }), + ), + ); +} + +export async function removeInstallationRepos( + installationId: number, + repos: Array<{ owner: string; repo: string }>, +): Promise { + const now = new Date().toISOString(); + + await prisma.$transaction( + repos.map(({ owner, repo }) => + prisma.gitHubAppInstallationRepo.updateMany({ + where: { installationId, owner, repo }, + data: { status: "removed", updatedAt: now }, + }), + ), + ); +} + +// ── Queries ────────────────────────────────────────────────── + +/** + * Get the active installation ID for a specific repo. + * Returns null if no GitHub App is installed for this repo. + */ +export async function getInstallationForRepo( + owner: string, + repo: string, +): Promise { + const record = await prisma.gitHubAppInstallationRepo.findFirst({ + where: { owner, repo, status: "active" }, + include: { installation: { select: { status: true } } }, + }); + + if (!record || record.installation.status !== "active") return null; + return record.installationId; +} + +/** + * Check if a repo has an active GitHub App installation. + */ +export async function hasActiveInstallation( + owner: string, + repo: string, +): Promise { + return (await getInstallationForRepo(owner, repo)) !== null; +} + +/** + * Get all active repos that do NOT have a GitHub App installation. + * These are the repos that need polling fallback. + * We derive this from repos that users have visited (have system pins or pinned items) + * but lack an app installation. + */ +export async function getReposNeedingPolling(): Promise< + Array<{ owner: string; repo: string }> +> { + // Get all distinct repos from system pins that are active + const pinnedRepos = await prisma.repoSystemPin.findMany({ + where: { status: "active" }, + select: { owner: true, repo: true }, + distinct: ["owner", "repo"], + }); + + // Get repos from user pinned items + const userPinnedRepos = await prisma.pinnedItem.findMany({ + select: { owner: true, repo: true }, + distinct: ["owner", "repo"], + }); + + // Combine unique repos + const allRepos = new Map(); + for (const r of [...pinnedRepos, ...userPinnedRepos]) { + allRepos.set(`${r.owner}/${r.repo}`, { owner: r.owner, repo: r.repo }); + } + + // Filter out repos that have active installations + const results: Array<{ owner: string; repo: string }> = []; + for (const repo of allRepos.values()) { + const installed = await hasActiveInstallation(repo.owner, repo.repo); + if (!installed) { + results.push(repo); + } + } + + return results; +} + +/** + * Update the lastWebhookAt timestamp for a repo. + */ +export async function touchRepoWebhook( + installationId: number, + owner: string, + repo: string, +): Promise { + const now = new Date().toISOString(); + await prisma.gitHubAppInstallationRepo.updateMany({ + where: { installationId, owner, repo }, + data: { lastWebhookAt: now, updatedAt: now }, + }); +} + +/** + * Update the lastPolledAt timestamp for a repo. + */ +export async function touchRepoPolled( + owner: string, + repo: string, +): Promise { + const now = new Date().toISOString(); + // For polled repos, update the first matching record (if any) + await prisma.gitHubAppInstallationRepo.updateMany({ + where: { owner, repo }, + data: { lastPolledAt: now, updatedAt: now }, + }); +} diff --git a/apps/web/src/lib/github-app.ts b/apps/web/src/lib/github-app.ts new file mode 100644 index 00000000..e8d5f092 --- /dev/null +++ b/apps/web/src/lib/github-app.ts @@ -0,0 +1,104 @@ +import { Octokit } from "@octokit/rest"; +import { createAppAuth } from "@octokit/auth-app"; +import { redis } from "@/lib/redis"; + +// ── GitHub App Configuration ───────────────────────────────── + +function getAppConfig() { + const appId = process.env.GITHUB_APP_ID; + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY; + const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET; + + if (!appId || !privateKey) { + return null; + } + + return { + appId, + // Private key may be base64-encoded in env vars (common for multi-line PEM) + privateKey: privateKey.includes("BEGIN") + ? privateKey + : Buffer.from(privateKey, "base64").toString("utf-8"), + webhookSecret, + }; +} + +/** + * Check if the GitHub App is configured. + */ +export function isGitHubAppConfigured(): boolean { + return getAppConfig() !== null; +} + +/** + * Get the webhook secret for verifying GitHub App webhook signatures. + */ +export function getWebhookSecret(): string | undefined { + return getAppConfig()?.webhookSecret; +} + +// ── Installation Token Management ──────────────────────────── + +const TOKEN_CACHE_PREFIX = "github_app_token:"; +const TOKEN_TTL_SECONDS = 55 * 60; // 55 min (tokens last 60 min) + +/** + * Get an Octokit instance authenticated as a specific installation. + * Tokens are cached in Redis for reuse. + */ +export async function getInstallationOctokit( + installationId: number, +): Promise { + const config = getAppConfig(); + if (!config) return null; + + // Check cached token first + const cacheKey = `${TOKEN_CACHE_PREFIX}${installationId}`; + const cachedToken = await redis.get(cacheKey); + + if (cachedToken) { + return new Octokit({ auth: cachedToken }); + } + + // Generate new installation token + try { + const auth = createAppAuth({ + appId: config.appId, + privateKey: config.privateKey, + }); + + const { token, expiresAt } = await auth({ + type: "installation", + installationId, + }); + + // Cache the token (expire slightly before actual expiry) + const expiresIn = expiresAt + ? Math.max( + Math.floor( + (new Date(expiresAt).getTime() - Date.now()) / 1000 - 300, + ), + 60, + ) + : TOKEN_TTL_SECONDS; + + await redis.set(cacheKey, token, { ex: expiresIn }); + + return new Octokit({ auth: token }); + } catch (error) { + console.error( + `[github-app] Failed to get installation token for ${installationId}:`, + error, + ); + return null; + } +} + +/** + * Invalidate a cached installation token. + */ +export async function invalidateInstallationToken( + installationId: number, +): Promise { + await redis.del(`${TOKEN_CACHE_PREFIX}${installationId}`); +} diff --git a/apps/web/src/lib/inngest.ts b/apps/web/src/lib/inngest.ts index 7bb39349..36ebc3e3 100644 --- a/apps/web/src/lib/inngest.ts +++ b/apps/web/src/lib/inngest.ts @@ -1,4 +1,5 @@ import { Inngest } from "inngest"; +import { Octokit } from "@octokit/rest"; import { embedText, embedTexts } from "@/lib/mixedbread"; import { getExistingContentHash, @@ -6,6 +7,18 @@ import { upsertEmbedding, type ContentType, } from "@/lib/embedding-store"; +import { prisma } from "@/lib/db"; +import { symmetricDecrypt } from "better-auth/crypto"; +import { + activateSystemPin, + clearSystemPin, +} from "@/lib/system-pins-store"; +import { getInstallationOctokit } from "@/lib/github-app"; +import { + getInstallationForRepo, + getReposNeedingPolling, + touchRepoPolled, +} from "@/lib/github-app-store"; export const inngest = new Inngest({ id: "better-github" }); @@ -187,9 +200,330 @@ export const embedContent = inngest.createFunction( }); } - return { - contentKey, - commentCount: allCommentItems.length, - }; + return { + contentKey, + commentCount: allCommentItems.length, + }; + }, +); + +// ── Auth Helpers ────────────────────────────────────────────── + +/** + * Get an Octokit instance for a repo. + * Priority: + * 1. Installation token (if installationId provided or discoverable) + * 2. Any user's OAuth token (fallback for repos without app installation) + */ +async function getOctokitForRepo( + owner: string, + repo: string, + installationId?: number | null, +): Promise { + // Try installation token first + const instId = installationId ?? (await getInstallationForRepo(owner, repo)); + if (instId) { + const octokit = await getInstallationOctokit(instId); + if (octokit) return octokit; + } + + // Fallback: decrypt any user's OAuth token + return getFallbackOctokit(); +} + +async function getFallbackOctokit(): Promise { + const account = await prisma.account.findFirst({ + where: { providerId: "github" }, + select: { accessToken: true }, + orderBy: { updatedAt: "desc" }, + }); + + if (!account?.accessToken) return null; + + const secret = process.env.BETTER_AUTH_SECRET; + if (!secret) return null; + + try { + const decrypted = await symmetricDecrypt({ + key: secret, + data: account.accessToken, + }); + return new Octokit({ auth: decrypted }); + } catch { + return null; + } +} + +// ── Conflict Evaluate ──────────────────────────────────────── + +interface ConflictEvaluateData { + owner: string; + repo: string; + pullNumber: number; + installationId?: number | null; + title: string; + url: string; + headRef: string; + baseRef: string; + webhookAction?: string; + source: "github_app_webhook" | "polling"; +} + +export const evaluatePRConflict = inngest.createFunction( + { + id: "evaluate-pr-conflict", + concurrency: [{ limit: 10 }], + retries: 2, + }, + { event: "app/pr.conflict.evaluate" }, + async ({ event, step }) => { + const data = event.data as ConflictEvaluateData; + const { owner, repo, pullNumber, installationId, title, url, source } = data; + const resourceKey = `pr:${pullNumber}`; + + // Step 1: Wait briefly for GitHub to compute mergeability + await step.sleep("wait-for-mergeability", "5s"); + + // Step 2: Fetch PR detail and check mergeability + const mergeResult = await step.run("check-mergeability", async () => { + const octokit = await getOctokitForRepo(owner, repo, installationId); + if (!octokit) { + return { error: "no_auth" as const }; + } + + const { data: pr } = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + + return { + mergeable: pr.mergeable, + mergeable_state: pr.mergeable_state, + state: pr.state, + merged: pr.merged, + }; + }); + + if ("error" in mergeResult) { + return { status: "skipped", reason: mergeResult.error }; + } + + // If PR is closed/merged, clear any existing pin + if (mergeResult.state === "closed" || mergeResult.merged) { + const { transitioned } = await step.run("clear-closed-pr", async () => { + return clearSystemPin(owner, repo, "pr_conflict", resourceKey); + }); + return { status: "cleared", reason: "closed_or_merged", transitioned }; + } + + // If mergeability is still null, retry once after a longer delay + if (mergeResult.mergeable === null) { + await step.sleep("retry-mergeability-delay", "15s"); + + const retryResult = await step.run("retry-mergeability", async () => { + const octokit = await getOctokitForRepo(owner, repo, installationId); + if (!octokit) return { error: "no_auth" as const }; + + const { data: pr } = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + + return { + mergeable: pr.mergeable, + mergeable_state: pr.mergeable_state, + }; + }); + + if ("error" in retryResult) { + return { status: "skipped", reason: "no_auth_retry" }; + } + + // Still null — give up, next webhook/poll will retry + if (retryResult.mergeable === null) { + return { status: "skipped", reason: "mergeability_unknown" }; + } + + const hasConflict = + retryResult.mergeable_state === "dirty" || retryResult.mergeable === false; + + return applyTransition(step, { + owner, + repo, + pullNumber, + resourceKey, + title, + url, + hasConflict, + source, + }); + } + + // We have a definitive mergeability answer + const hasConflict = + mergeResult.mergeable_state === "dirty" || mergeResult.mergeable === false; + + return applyTransition(step, { + owner, + repo, + pullNumber, + resourceKey, + title, + url, + hasConflict, + source, + }); + }, +); + +// ── State Transition Helper ────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function applyTransition( + step: any, + params: { + owner: string; + repo: string; + pullNumber: number; + resourceKey: string; + title: string; + url: string; + hasConflict: boolean; + source: string; + }, +) { + const { owner, repo, pullNumber, resourceKey, title, url, hasConflict, source } = params; + + if (hasConflict) { + const { transitioned } = await step.run("activate-conflict-pin", async () => { + return activateSystemPin({ + owner, + repo, + kind: "pr_conflict", + resourceKey, + url, + title, + payload: { + pullNumber, + detectedAt: new Date().toISOString(), + source, + }, + }); + }); + return { status: "conflict_detected", transitioned, pullNumber }; + } + + // No conflict — clear pin if one existed + const { transitioned } = await step.run("clear-conflict-pin", async () => { + return clearSystemPin(owner, repo, "pr_conflict", resourceKey); + }); + return { status: "no_conflict", transitioned, pullNumber }; +} + +// ── Conflict Clear (for closed/merged PRs) ─────────────────── + +interface ConflictClearData { + owner: string; + repo: string; + pullNumber: number; + reason: string; +} + +export const clearPRConflict = inngest.createFunction( + { + id: "clear-pr-conflict", + retries: 3, + }, + { event: "app/pr.conflict.clear" }, + async ({ event, step }) => { + const data = event.data as ConflictClearData; + const { owner, repo, pullNumber, reason } = data; + const resourceKey = `pr:${pullNumber}`; + + const { transitioned } = await step.run("clear-pin", async () => { + return clearSystemPin(owner, repo, "pr_conflict", resourceKey); + }); + + return { status: "cleared", reason, transitioned, pullNumber }; + }, +); + +// ── Polling Fallback (for repos without GitHub App) ────────── + +export const pollConflicts = inngest.createFunction( + { + id: "poll-pr-conflicts", + concurrency: [{ limit: 3 }], + retries: 1, + }, + { cron: "*/15 * * * *" }, // Every 15 minutes + async ({ step }) => { + // Step 1: Get repos that need polling (no active app installation) + const repos = await step.run("get-repos-needing-polling", async () => { + return getReposNeedingPolling(); + }); + + if (repos.length === 0) { + return { status: "no_repos_to_poll" }; + } + + let totalEvaluated = 0; + + // Step 2: For each repo, fetch open PRs and evaluate conflicts + for (const { owner, repo } of repos) { + const prs = await step.run(`fetch-open-prs-${owner}-${repo}`, async () => { + const octokit = await getOctokitForRepo(owner, repo); + if (!octokit) return []; + + try { + const { data } = await octokit.rest.pulls.list({ + owner, + repo, + state: "open", + per_page: 100, + }); + + await touchRepoPolled(owner, repo); + + return data.map((pr) => ({ + number: pr.number, + title: pr.title, + url: pr.html_url, + headRef: pr.head.ref, + baseRef: pr.base.ref, + })); + } catch (error) { + console.error(`[poll-conflicts] Failed to fetch PRs for ${owner}/${repo}:`, error); + return []; + } + }); + + // Enqueue evaluate events for each open PR + if (prs.length > 0) { + await step.run(`enqueue-evaluations-${owner}-${repo}`, async () => { + await inngest.send( + prs.map((pr) => ({ + name: "app/pr.conflict.evaluate" as const, + data: { + owner, + repo, + pullNumber: pr.number, + installationId: null, + title: pr.title, + url: pr.url, + headRef: pr.headRef, + baseRef: pr.baseRef, + source: "polling" as const, + }, + })), + ); + }); + totalEvaluated += prs.length; + } + } + + return { status: "polled", reposChecked: repos.length, prsEvaluated: totalEvaluated }; }, ); diff --git a/apps/web/src/lib/system-pins-store.ts b/apps/web/src/lib/system-pins-store.ts new file mode 100644 index 00000000..b3e32847 --- /dev/null +++ b/apps/web/src/lib/system-pins-store.ts @@ -0,0 +1,159 @@ +import { prisma } from "@/lib/db"; + +// ── Types ──────────────────────────────────────────────────── + +export type SystemPinKind = "pr_conflict"; +export type SystemPinStatus = "active" | "cleared"; + +export interface SystemPin { + id: string; + owner: string; + repo: string; + kind: SystemPinKind; + resourceKey: string; + url: string; + title: string; + status: SystemPinStatus; + payloadJson: string | null; + createdAt: string; + updatedAt: string; + clearedAt: string | null; +} + +export interface UpsertSystemPinInput { + owner: string; + repo: string; + kind: SystemPinKind; + resourceKey: string; // e.g. "pr:123" + url: string; + title: string; + payload?: Record; +} + +// ── Queries ────────────────────────────────────────────────── + +export async function getActiveSystemPins( + owner: string, + repo: string, + kind?: SystemPinKind, +): Promise { + const where: Record = { owner, repo, status: "active" }; + if (kind) where.kind = kind; + + return prisma.repoSystemPin.findMany({ + where, + orderBy: { createdAt: "desc" }, + }) as unknown as SystemPin[]; +} + +export async function getSystemPin( + owner: string, + repo: string, + kind: SystemPinKind, + resourceKey: string, +): Promise { + return prisma.repoSystemPin.findUnique({ + where: { + owner_repo_kind_resourceKey: { owner, repo, kind, resourceKey }, + }, + }) as unknown as SystemPin | null; +} + +// ── Mutations ──────────────────────────────────────────────── + +/** + * Create or re-activate a system pin. Returns the pin and whether + * a state transition occurred (i.e. it was newly created or went + * from cleared -> active). + */ +export async function activateSystemPin( + input: UpsertSystemPinInput, +): Promise<{ pin: SystemPin; transitioned: boolean }> { + const now = new Date().toISOString(); + const payloadJson = input.payload ? JSON.stringify(input.payload) : null; + + const existing = await getSystemPin(input.owner, input.repo, input.kind, input.resourceKey); + + if (existing && existing.status === "active") { + // Already active — update title/payload but no transition + const pin = await prisma.repoSystemPin.update({ + where: { id: existing.id }, + data: { title: input.title, payloadJson, updatedAt: now }, + }); + return { pin: pin as unknown as SystemPin, transitioned: false }; + } + + if (existing && existing.status === "cleared") { + // Re-activate + const pin = await prisma.repoSystemPin.update({ + where: { id: existing.id }, + data: { + title: input.title, + url: input.url, + payloadJson, + status: "active", + clearedAt: null, + updatedAt: now, + }, + }); + return { pin: pin as unknown as SystemPin, transitioned: true }; + } + + // Brand new + const pin = await prisma.repoSystemPin.create({ + data: { + owner: input.owner, + repo: input.repo, + kind: input.kind, + resourceKey: input.resourceKey, + url: input.url, + title: input.title, + status: "active", + payloadJson, + createdAt: now, + updatedAt: now, + }, + }); + return { pin: pin as unknown as SystemPin, transitioned: true }; +} + +/** + * Clear (deactivate) a system pin. Returns whether a transition occurred. + */ +export async function clearSystemPin( + owner: string, + repo: string, + kind: SystemPinKind, + resourceKey: string, +): Promise<{ transitioned: boolean }> { + const now = new Date().toISOString(); + + const existing = await getSystemPin(owner, repo, kind, resourceKey); + + if (!existing || existing.status === "cleared") { + return { transitioned: false }; + } + + await prisma.repoSystemPin.update({ + where: { id: existing.id }, + data: { status: "cleared", clearedAt: now, updatedAt: now }, + }); + return { transitioned: true }; +} + +/** + * Clear all active pins of a given kind for a repo. + * Useful when a repo is removed or all PRs should be re-evaluated. + */ +export async function clearAllSystemPins( + owner: string, + repo: string, + kind: SystemPinKind, +): Promise { + const now = new Date().toISOString(); + const result = await prisma.repoSystemPin.updateMany({ + where: { owner, repo, kind, status: "active" }, + data: { status: "cleared", clearedAt: now, updatedAt: now }, + }); + return result.count; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4dd6e529..2d611061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@mixedbread-ai/sdk': specifier: ^2.2.11 version: 2.2.11 + '@octokit/auth-app': + specifier: ^8.2.0 + version: 8.2.0 '@octokit/rest': specifier: ^22.0.1 version: 22.0.1 @@ -1076,6 +1079,22 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@octokit/auth-app@8.2.0': + resolution: {integrity: sha512-vVjdtQQwomrZ4V46B9LaCsxsySxGoHsyw6IYBov/TqJVROrlYdyNgw5q6tQbB7KZt53v1l1W53RiqTvpzL907g==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-app@9.0.3': + resolution: {integrity: sha512-+yoFQquaF8OxJSxTb7rnytBIC2ZLbLqA/yb71I4ZXT9+Slw4TziV9j/kyGhUFRRTF2+7WlnIWsePZCWHs+OGjg==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-device@8.0.3': + resolution: {integrity: sha512-zh2W0mKKMh/VWZhSqlaCzY7qFyrgd9oTWmTmHaXnHNeQRCZr/CXy2jCgHo4e4dJVTiuxP5dLa0YM5p5QVhJHbw==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-user@6.0.2': + resolution: {integrity: sha512-qLoPPc6E6GJoz3XeDG/pnDhJpTkODTGG4kY0/Py154i/I003O9NazkrwJwRuzgCalhzyIeWQ+6MDvkUmKXjg/A==} + engines: {node: '>= 20'} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1092,6 +1111,14 @@ packages: resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} engines: {node: '>= 20'} + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.2': + resolution: {integrity: sha512-HiNOO3MqLxlt5Da5bZbLV8Zarnphi4y9XehrbaFMkcoJ+FL7sMxH/UlUsCVxpddVu4qvNDrBdaTVE2o4ITK8ng==} + engines: {node: '>= 20'} + '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} @@ -5651,6 +5678,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + toidentifier@1.0.0: resolution: {integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==} engines: {node: '>=0.6'} @@ -5742,6 +5773,9 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-github-app-jwt@2.2.2: + resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} + universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -6279,7 +6313,7 @@ snapshots: '@better-auth/infra@0.1.7(@better-auth/utils@0.3.1)(better-auth@1.5.0-beta.18(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(@types/pg@8.15.6)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.7)(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(mongodb@7.1.0(socks@2.8.7))(mysql2@3.15.3)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(kysely@0.28.11)(nanostores@1.1.0)': dependencies: '@better-auth/core': 1.5.0-beta.18(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.19-beta.1)(better-call@1.1.0-beta.2)(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) - '@better-auth/sso': 1.5.0-beta.18(c3dfdb99e3980a1851db697a277fc4a4) + '@better-auth/sso': 1.5.0-beta.18(1ed2b4f0ea91ad386e6360051b461399) '@better-fetch/fetch': 1.1.19-beta.1 better-auth: 1.5.0-beta.18(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(@types/pg@8.15.6)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.7)(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(mongodb@7.1.0(socks@2.8.7))(mysql2@3.15.3)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) better-call: 1.1.0-beta.2 @@ -6339,9 +6373,9 @@ snapshots: '@prisma/client': 7.4.1(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) prisma: 7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) - '@better-auth/sso@1.5.0-beta.18(c3dfdb99e3980a1851db697a277fc4a4)': + '@better-auth/sso@1.5.0-beta.18(1ed2b4f0ea91ad386e6360051b461399)': dependencies: - '@better-auth/core': 1.5.0-beta.18(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.19-beta.1)(better-call@1.1.0-beta.2)(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/core': 1.5.0-beta.18(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 better-auth: 1.5.0-beta.18(@prisma/client@7.4.1(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(@types/pg@8.15.6)(kysely@0.28.11)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.7)(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)))(mongodb@7.1.0(socks@2.8.7))(mysql2@3.15.3)(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -6789,6 +6823,40 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@octokit/auth-app@8.2.0': + dependencies: + '@octokit/auth-oauth-app': 9.0.3 + '@octokit/auth-oauth-user': 6.0.2 + '@octokit/request': 10.0.7 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + toad-cache: 3.7.0 + universal-github-app-jwt: 2.2.2 + universal-user-agent: 7.0.3 + + '@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.7 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-device@8.0.3': + dependencies: + '@octokit/oauth-methods': 6.0.2 + '@octokit/request': 10.0.7 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@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.7 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.6': @@ -6812,6 +6880,15 @@ snapshots: '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.2': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.7 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + '@octokit/openapi-types@27.0.0': {} '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': @@ -11834,6 +11911,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + toidentifier@1.0.0: {} tr46@0.0.3: {} @@ -11921,6 +12000,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-github-app-jwt@2.2.2: {} + universal-user-agent@7.0.3: {} universalify@2.0.1: {}