From 749bcd3bbe98b077151a123cd2b6e7bb92446a7a Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Fri, 22 May 2026 13:47:00 +0000
Subject: [PATCH] feat(github): per-repo "Bind to project" button on settings
page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The settings repo list was read-only — users could see all 200+ repos
but the only click action was an external link to github.com. Now each
row has a project picker dropdown so users can bind/unbind repos to
their Crawlproof projects without leaving the page.
Pieces:
- New BindButton client component: dropdown of the user's projects with
checkmarks for currently-bound ones; click toggles via POST/DELETE on
/api/projects/[id]/repos.
- ReposFilter now receives projects + bindings and renders BindButton
per row. Label shows the bound project name (1 project), the count
(2+ projects), or "Bind to project" when none.
- The settings page pre-loads the user's projects and all project_repos
rows once, then slices per-installation when rendering. RLS keeps the
data scoped to the owner.
Same data primitives the /projects/[id]/repos tab uses — this just
makes the binding action available from the GitHub-side surface too,
so a user who arrives via "Install on GitHub" never gets stuck.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../integrations/github/bind-button.tsx | 165 ++++++++++++++++++
.../settings/integrations/github/page.tsx | 29 ++-
.../integrations/github/repos-filter.tsx | 113 ++++++++----
3 files changed, 275 insertions(+), 32 deletions(-)
create mode 100644 app/(app)/settings/integrations/github/bind-button.tsx
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 */}
+
+ );
+}
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.description && (
+
+ {r.description}
+
+ )}
+
+ {r.language && <>{r.language} · >}
+ {r.default_branch}
- )}
-
-
- {r.private && (
-
- private
-
- )}
- {r.language && {r.language}}
- {r.default_branch}
-
-
- ))}
+
+
+
+
+
+ );
+ })}
)}