diff --git a/app/(app)/settings/integrations/github/bind-button.tsx b/app/(app)/settings/integrations/github/bind-button.tsx new file mode 100644 index 00000000..1d02c0d2 --- /dev/null +++ b/app/(app)/settings/integrations/github/bind-button.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +interface Project { + id: string; + name: string; +} + +interface Binding { + id: string; + project_id: string; + installation_id: number; +} + +interface BindButtonProps { + /** All projects owned by the current user. */ + projects: Project[]; + /** Current bindings for THIS repo, keyed by project_id. */ + bindings: Binding[]; + installationId: number; + owner: string; + repo: string; + defaultBranch: string; +} + +export function BindButton({ + projects, + bindings: initialBindings, + installationId, + owner, + repo, + defaultBranch, +}: BindButtonProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [bindings, setBindings] = useState(initialBindings); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const boundProjectIds = new Set(bindings.map((b) => b.project_id)); + + async function toggle(projectId: string) { + setError(null); + const existing = bindings.find((b) => b.project_id === projectId); + + if (existing) { + // Unbind. + startTransition(async () => { + const res = await fetch( + `/api/projects/${projectId}/repos/${existing.id}`, + { method: "DELETE" }, + ); + if (!res.ok) { + const j = await res.json().catch(() => ({})); + setError(j.error || "Failed to unbind"); + return; + } + setBindings((bs) => bs.filter((b) => b.id !== existing.id)); + router.refresh(); + }); + return; + } + + // Bind. + startTransition(async () => { + const res = await fetch(`/api/projects/${projectId}/repos`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + installation_id: installationId, + owner, + repo, + default_branch: defaultBranch, + }), + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) { + setError(j.error || "Failed to bind"); + return; + } + const newBinding = j.data as Binding; + setBindings((bs) => [...bs, newBinding]); + router.refresh(); + }); + } + + if (projects.length === 0) { + return ( + + Create a project first + + ); + } + + const label = + bindings.length === 0 + ? "Bind to project" + : bindings.length === 1 + ? `Bound to ${ + projects.find((p) => p.id === bindings[0].project_id)?.name ?? "1 project" + }` + : `Bound to ${bindings.length} projects`; + + return ( +
+ + {open && ( + <> + {/* Click-away catcher */} + + + ); + })} + + {error && ( +

{error}

+ )} +
+ + )} + + ); +} diff --git a/app/(app)/settings/integrations/github/page.tsx b/app/(app)/settings/integrations/github/page.tsx index b52287a0..f695cc88 100644 --- a/app/(app)/settings/integrations/github/page.tsx +++ b/app/(app)/settings/integrations/github/page.tsx @@ -77,6 +77,26 @@ export default async function GithubSettingsPage({ .order("account_login", { ascending: true }); const installations: InstallationView[] = (rows ?? []) as InstallationRow[]; + // Projects this user owns — fuels the per-repo "Bind to project" picker. + const { data: projectsData } = await supabase + .from("projects") + .select("id, name") + .eq("owner_id", user.id) + .order("name", { ascending: true }); + const projects = (projectsData ?? []) as Array<{ id: string; name: string }>; + + // Every project_repos row this user can see (RLS scopes to their projects). + const { data: bindingsData } = await supabase + .from("project_repos") + .select("id, project_id, installation_id, repo_owner, repo_name"); + const bindings = (bindingsData ?? []) as Array<{ + id: string; + project_id: string; + installation_id: number; + repo_owner: string; + repo_name: string; + }>; + // For each installation, pull the live repo list. Use the service // client for token caching writes; safe because we already verified // the rows belong to this user via the auth-scoped read above. @@ -204,7 +224,14 @@ export default async function GithubSettingsPage({

) : (
- + b.installation_id === inst.installation_id, + )} + />
)} diff --git a/app/(app)/settings/integrations/github/repos-filter.tsx b/app/(app)/settings/integrations/github/repos-filter.tsx index aee9c1a7..f1239d3f 100644 --- a/app/(app)/settings/integrations/github/repos-filter.tsx +++ b/app/(app)/settings/integrations/github/repos-filter.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { BindButton } from "./bind-button"; interface Repo { full_name: string; @@ -12,11 +13,33 @@ interface Repo { html_url: string; } +interface Project { + id: string; + name: string; +} + +interface Binding { + id: string; + project_id: string; + installation_id: number; + repo_owner: string; + repo_name: string; +} + interface ReposFilterProps { repos: Repo[]; + installationId: number; + projects: Project[]; + /** All bindings across this installation's repos — we slice per row. */ + bindings: Binding[]; } -export function ReposFilter({ repos }: ReposFilterProps) { +export function ReposFilter({ + repos, + installationId, + projects, + bindings, +}: ReposFilterProps) { const [q, setQ] = useState(""); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); @@ -29,6 +52,18 @@ export function ReposFilter({ repos }: ReposFilterProps) { ); }, [q, repos]); + // Group bindings by "owner/name" so per-row lookup is O(1). + const bindingsByRepo = useMemo(() => { + const map = new Map(); + for (const b of bindings) { + const k = `${b.repo_owner}/${b.repo_name}`; + const cur = map.get(k); + if (cur) cur.push(b); + else map.set(k, [b]); + } + return map; + }, [bindings]); + return (
@@ -49,37 +84,53 @@ export function ReposFilter({ repos }: ReposFilterProps) {

) : (
    - {filtered.map((r) => ( -
  • -
    - - {r.full_name} - - {r.description && ( -

    - {r.description} + {filtered.map((r) => { + const [owner, name] = r.full_name.split("/"); + const repoBindings = bindingsByRepo.get(r.full_name) ?? []; + return ( +

  • +
    +
    + + {r.full_name} + + {r.private && ( + + private + + )} +
    + {r.description && ( +

    + {r.description} +

    + )} +

    + {r.language && <>{r.language} · } + {r.default_branch}

    - )} -
    -
    - {r.private && ( - - private - - )} - {r.language && {r.language}} - {r.default_branch} -
    -
  • - ))} +
+
+ +
+ + ); + })} )}