Skip to content
150 changes: 102 additions & 48 deletions apps/web/src/components/layout/app/integrations/integrations-page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string | null>(null)
const disconnect = useMutation(
trpc.orgs.disconnectInstallation.mutationOptions({
Expand Down Expand Up @@ -156,54 +166,24 @@ export function IntegrationsPage() {
</span>
</div>
</div>
{confirmingId === install.id ? (
<div className="flex shrink-0 items-center gap-2">
<span className="text-[12px] text-tw-text-secondary">
Remove from Tripwire?
</span>
<Button
size="xs"
variant="destructive"
disabled={disconnect.isPending}
onClick={() =>
disconnect.mutate({ installationId: install.id })
}
>
Uninstall
</Button>
<Button
size="xs"
variant="ghost"
onClick={() => setConfirmingId(null)}
>
Cancel
</Button>
</div>
) : (
<div className="flex shrink-0 items-center gap-1.5">
<Button
size="xs"
variant="outline"
render={
<a
href={installHref}
target="_blank"
rel="noopener noreferrer"
>
Manage
</a>
}
/>
<Button
size="xs"
variant="ghost"
className="text-tw-text-muted hover:text-tw-error"
onClick={() => setConfirmingId(install.id)}
>
Uninstall
</Button>
</div>
)}
<div className="flex shrink-0 items-center gap-1.5">
<Button
size="xs"
variant="outline"
type="button"
onClick={() => setManageDialogOpen(true)}
>
Manage
</Button>
<Button
size="xs"
variant="ghost"
className="text-tw-text-muted hover:text-tw-error"
onClick={() => setConfirmingId(install.id)}
>
Uninstall
</Button>
</div>
</div>
))}
<a
Expand All @@ -217,6 +197,80 @@ export function IntegrationsPage() {
</div>
)}

<Dialog open={manageDialogOpen} onOpenChange={setManageDialogOpen}>
<DialogPopup
showCloseButton={false}
bottomStickOnMobile={false}
className="fixed top-1/2 left-1/2 row-auto w-[min(460px,calc(100vw-2rem))] max-w-none -translate-x-1/2 -translate-y-1/2"
>
<DialogHeader className="px-6 pt-6 pb-5">
<DialogTitle>Manage GitHub repos</DialogTitle>
<DialogDescription className="text-[14px]">
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.
</DialogDescription>
</DialogHeader>
<DialogFooter variant="bare" className="px-6 pt-3 pb-6">
<DialogClose className="flex h-8 items-center rounded-lg border border-tw-border px-3 text-[13px] font-medium text-tw-text-secondary transition-colors hover:bg-tw-hover">
Cancel
</DialogClose>
<Button
size="xs"
className="bg-white text-black hover:bg-white/90"
render={
<a
href={installHref}
target="_blank"
rel="noopener noreferrer"
onClick={() => setManageDialogOpen(false)}
>
Continue to GitHub
</a>
}
/>
</DialogFooter>
</DialogPopup>
</Dialog>

<Dialog
open={confirmingId !== null}
onOpenChange={(open) => {
if (!open) setConfirmingId(null)
}}
>
<DialogPopup
showCloseButton={false}
bottomStickOnMobile={false}
className="fixed top-1/2 left-1/2 row-auto w-[min(460px,calc(100vw-2rem))] max-w-none -translate-x-1/2 -translate-y-1/2"
>
<DialogHeader className="px-6 pt-6 pb-5">
<DialogTitle>Remove from Tripwire?</DialogTitle>
<DialogDescription className="text-[14px]">
Uninstalling this GitHub App will also delete its related Tripwire
data, including rules, workflows, events, lists, requests, and
reputation records.
</DialogDescription>
</DialogHeader>
<DialogFooter variant="bare" className="px-6 pt-3 pb-6">
<DialogClose className="flex h-8 items-center rounded-lg border border-tw-border px-3 text-[13px] font-medium text-tw-text-secondary transition-colors hover:bg-tw-hover">
Cancel
</DialogClose>
<Button
size="xs"
variant="destructive"
disabled={disconnect.isPending}
onClick={() => {
if (confirmingId)
disconnect.mutate({ installationId: confirmingId })
}}
>
Uninstall
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>

Comment thread
OpeOginni marked this conversation as resolved.
{isConnected && (
<>
<div className="mb-2 flex items-baseline justify-between">
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/integrations/trpc/routers/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
141 changes: 93 additions & 48 deletions apps/web/src/lib/github/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHubRepo[] | null> {
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
}
Comment thread
OpeOginni marked this conversation as resolved.

async function applyRepoSync(
orgId: string,
repos: GitHubRepo[]
): Promise<void> {
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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
}
Loading
Loading