diff --git a/apps/web/src/components/layout/app/integrations/integrations-page.tsx b/apps/web/src/components/layout/app/integrations/integrations-page.tsx index 62f5ead4..96782373 100644 --- a/apps/web/src/components/layout/app/integrations/integrations-page.tsx +++ b/apps/web/src/components/layout/app/integrations/integrations-page.tsx @@ -1,6 +1,15 @@ import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query" import { useCallback, useState } from "react" import { Button } from "@tripwire/ui/button" +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPopup, + DialogTitle, +} from "@tripwire/ui/dialog" import { Pagination, PaginationContent, @@ -52,6 +61,7 @@ export function IntegrationsPage() { const installations = installationsQuery.data ?? [] const isConnected = installations.length > 0 + const [manageDialogOpen, setManageDialogOpen] = useState(false) const [confirmingId, setConfirmingId] = useState(null) const disconnect = useMutation( trpc.orgs.disconnectInstallation.mutationOptions({ @@ -156,54 +166,24 @@ export function IntegrationsPage() { - {confirmingId === install.id ? ( -
- - Remove from Tripwire? - - - -
- ) : ( -
- -
- )} +
+ + +
))} )} + + + + Manage GitHub repos + + Removing a repo from Tripwire's GitHub App installation will also + delete its related Tripwire data, including rules, workflows, + events, lists, requests, and reputation records. + + + + + Cancel + + + + + + {isConnected && ( <>
diff --git a/apps/web/src/integrations/trpc/routers/requests.ts b/apps/web/src/integrations/trpc/routers/requests.ts index 5fc79e41..50e3db4e 100644 --- a/apps/web/src/integrations/trpc/routers/requests.ts +++ b/apps/web/src/integrations/trpc/routers/requests.ts @@ -391,10 +391,7 @@ async function notifyDecisionOnGithub( // decision is already committed, so record the miss in the ledger instead // of failing silently: a maintainer can see the reopen/notify didn't land // and re-run it, rather than assuming "approved" reopened the PR. - console.error( - `[requests] notify/reopen failed for ${req.githubRef}:`, - err - ) + console.error(`[requests] notify/reopen failed for ${req.githubRef}:`, err) await logEvent({ repoId: req.repoId, action: "request_notify_failed", diff --git a/apps/web/src/lib/github/install.ts b/apps/web/src/lib/github/install.ts index ebf908ce..2c77ee79 100644 --- a/apps/web/src/lib/github/install.ts +++ b/apps/web/src/lib/github/install.ts @@ -77,6 +77,89 @@ async function fetchInstallationMeta( } } +interface GitHubRepo { + id: number + name: string + full_name: string + private: boolean + owner: { + id: number + login: string + type?: string + avatar_url?: string + } +} + +async function fetchInstallationRepos( + installationId: number +): Promise { + const token = await getInstallationToken(installationId) + const all: GitHubRepo[] = [] + const perPage = 100 + + for (let page = 1; ; page++) { + const reposRes = await fetch( + `https://api.github.com/installation/repositories?per_page=${perPage}&page=${page}`, + { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", + }, + } + ) + + if (!reposRes.ok) { + console.error("[Callback] Failed to fetch repos:", reposRes.status) + return null + } + + const { repositories: repos } = (await reposRes.json()) as { + repositories: GitHubRepo[] + } + if (!repos?.length) break + all.push(...repos) + if (repos.length < perPage) break + } + + return all +} + +async function applyRepoSync( + orgId: string, + repos: GitHubRepo[] +): Promise { + const currentRepoIds = new Set(repos.map((r) => r.id)) + + for (const repo of repos) { + const [existingRepo] = await db + .select() + .from(repositories) + .where(eq(repositories.githubRepoId, repo.id)) + + if (!existingRepo) { + await db.insert(repositories).values({ + orgId, + githubRepoId: repo.id, + name: repo.name, + fullName: repo.full_name, + isPrivate: repo.private, + }) + } + } + + const existingRepos = await db + .select() + .from(repositories) + .where(eq(repositories.orgId, orgId)) + + for (const repo of existingRepos) { + if (!currentRepoIds.has(repo.githubRepoId)) { + await db.delete(repositories).where(eq(repositories.id, repo.id)) + console.log(`[Callback] Removed repo ${repo.fullName}`) + } + } +} + /** * Decide which Better Auth org a new installation attaches to. Prefers the * org the user was viewing at install time (`preferredBaOrgId`) once we @@ -124,12 +207,18 @@ export async function ensureInstallation( userId: string, preferredBaOrgId?: string | null ): Promise<"ok" | "installer_mismatch"> { - const [existing] = await db + const [existingOrg] = await db .select() .from(organizations) .where(eq(organizations.githubInstallationId, installationId)) - if (existing) return "ok" + if (existingOrg) { + const repos = await fetchInstallationRepos(installationId) + if (repos) { + await applyRepoSync(existingOrg.id, repos) + } + return "ok" + } const meta = await fetchInstallationMeta(installationId) if (!meta) return "installer_mismatch" @@ -158,36 +247,7 @@ export async function ensureInstallation( // require the session user to be GH-linked (above) and rely on GitHub's own // install UI to gate org-admin permission. Stricter membership check TODO. - const token = await getInstallationToken(installationId) - const reposRes = await fetch( - "https://api.github.com/installation/repositories?per_page=100", - { - headers: { - Authorization: `token ${token}`, - Accept: "application/vnd.github.v3+json", - }, - } - ) - - if (!reposRes.ok) { - console.error("[Callback] Failed to fetch repos:", reposRes.status) - return "ok" - } - - const { repositories: repos } = (await reposRes.json()) as { - repositories: Array<{ - id: number - name: string - full_name: string - private: boolean - owner: { - id: number - login: string - type?: string - avatar_url?: string - } - }> - } + const repos = await fetchInstallationRepos(installationId) if (!repos || repos.length === 0) return "ok" const ghAccount = repos[0].owner @@ -209,22 +269,7 @@ export async function ensureInstallation( console.log(`[Callback] Created org "${ghAccount.login}" (ID: ${org.id})`) - for (const repo of repos) { - const [existingRepo] = await db - .select() - .from(repositories) - .where(eq(repositories.githubRepoId, repo.id)) - - if (!existingRepo) { - await db.insert(repositories).values({ - orgId: org.id, - githubRepoId: repo.id, - name: repo.name, - fullName: repo.full_name, - isPrivate: repo.private, - }) - } - } + await applyRepoSync(org.id, repos) return "ok" } diff --git a/apps/web/src/lib/github/webhook.ts b/apps/web/src/lib/github/webhook.ts index c7846829..6a54a321 100644 --- a/apps/web/src/lib/github/webhook.ts +++ b/apps/web/src/lib/github/webhook.ts @@ -173,32 +173,28 @@ export async function handleInstallationRepositories( return } - if (payload.action === "added" && payload.repositories_added) { - for (const repo of payload.repositories_added) { - const existing = await db - .select() - .from(repositories) - .where(eq(repositories.githubRepoId, repo.id)) - - if (existing.length === 0) { - await db.insert(repositories).values({ - orgId: org.id, - githubRepoId: repo.id, - name: repo.name, - fullName: repo.full_name, - isPrivate: repo.private, - }) - console.log(`[RepoChange] ✓ Added repo ${repo.full_name}`) - } + const added = payload.repositories_added ?? [] + for (const repo of added) { + const existing = await db + .select() + .from(repositories) + .where(eq(repositories.githubRepoId, repo.id)) + + if (existing.length === 0) { + await db.insert(repositories).values({ + orgId: org.id, + githubRepoId: repo.id, + name: repo.name, + fullName: repo.full_name, + isPrivate: repo.private, + }) + console.log(`[RepoChange] ✓ Added repo ${repo.full_name}`) } } - if (payload.action === "removed" && payload.repositories_removed) { - for (const repo of payload.repositories_removed) { - await db - .delete(repositories) - .where(eq(repositories.githubRepoId, repo.id)) - console.log(`[RepoChange] ✓ Removed repo ${repo.id}`) - } + const removed = payload.repositories_removed ?? [] + for (const repo of removed) { + await db.delete(repositories).where(eq(repositories.githubRepoId, repo.id)) + console.log(`[RepoChange] ✓ Removed repo ${repo.id}`) } } diff --git a/apps/web/src/routes/api/github/callback.ts b/apps/web/src/routes/api/github/callback.ts index 33347698..39d5cc11 100644 --- a/apps/web/src/routes/api/github/callback.ts +++ b/apps/web/src/routes/api/github/callback.ts @@ -20,7 +20,10 @@ async function handler({ request }: { request: Request }) { const ctx = await createContext({ headers: request.headers }) - if (installationId && setupAction === "install") { + if ( + installationId && + (setupAction === "install" || setupAction === "update") + ) { if (!ctx.user) return redirectToIntegrations("not_authenticated") const cookieState = readCookie( diff --git a/apps/web/src/routes/request.$owner.$repo.tsx b/apps/web/src/routes/request.$owner.$repo.tsx index 9cf12a2b..603b977c 100644 --- a/apps/web/src/routes/request.$owner.$repo.tsx +++ b/apps/web/src/routes/request.$owner.$repo.tsx @@ -193,9 +193,9 @@ function RequestPage() { You're vouched. {" "} You have {vouch.vouchCount} vouch - {vouch.vouchCount !== 1 ? "es" : ""} from - Tripwire maintainers. Some repositories may auto-approve - your contributions. + {vouch.vouchCount !== 1 ? "es" : ""} from Tripwire + maintainers. Some repositories may auto-approve your + contributions.
)} diff --git a/packages/core/src/pr-comment.ts b/packages/core/src/pr-comment.ts index 26664641..b8b1516d 100644 --- a/packages/core/src/pr-comment.ts +++ b/packages/core/src/pr-comment.ts @@ -188,7 +188,9 @@ export function renderDecisionComment(input: RenderDecisionInput): string { if (input.decision === "approve") { if (input.reopened === false) { const branchHint = - input.kind === "pull_request" ? " (its branch may have been deleted)" : "" + input.kind === "pull_request" + ? " (its branch may have been deleted)" + : "" return `Good news, ${mention}! A maintainer approved your review request. We couldn't reopen this ${subject} automatically${branchHint}, but you're welcome to reopen it yourself.` } return `Good news, ${mention}! A maintainer approved your review request — this ${subject} is back open. Thanks for your patience 🎉`