Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions app/(app)/settings/integrations/github/bind-button.tsx
Original file line number Diff line number Diff line change
@@ -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<Binding[]>(initialBindings);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(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 (
<span className="text-xs text-[var(--color-muted)]">
Create a project first
</span>
);
}

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 (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="btn btn-secondary text-xs"
disabled={pending}
>
{label} ▾
</button>
{open && (
<>
{/* Click-away catcher */}
<button
type="button"
aria-hidden
tabIndex={-1}
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 cursor-default bg-transparent"
/>
<div className="absolute right-0 z-50 mt-1 w-64 rounded-md border border-[var(--color-border)] bg-[var(--color-card)] p-2 shadow-lg">
<p className="px-2 py-1 text-xs text-[var(--color-muted)]">
Click a project to bind / unbind. Bound repos become
the default in Apply Fix &amp; Install Tracker.
</p>
<ul className="mt-1 max-h-60 overflow-y-auto">
{projects.map((p) => {
const isBound = boundProjectIds.has(p.id);
return (
<li key={p.id}>
<button
type="button"
onClick={() => toggle(p.id)}
disabled={pending}
className="flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-[var(--color-bg)]"
>
<span className="truncate">{p.name}</span>
<span
className={
isBound
? "text-green-600"
: "text-[var(--color-muted)]"
}
>
{isBound ? "✓ bound" : "+ bind"}
</span>
</button>
</li>
);
})}
</ul>
{error && (
<p className="mt-2 px-2 text-xs text-red-600">{error}</p>
)}
</div>
</>
)}
</div>
);
}
29 changes: 28 additions & 1 deletion app/(app)/settings/integrations/github/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -204,7 +224,14 @@ export default async function GithubSettingsPage({
</p>
) : (
<div className="mt-4">
<ReposFilter repos={inst.repos ?? []} />
<ReposFilter
repos={inst.repos ?? []}
installationId={inst.installation_id}
projects={projects}
bindings={bindings.filter(
(b) => b.installation_id === inst.installation_id,
)}
/>
</div>
)}
</section>
Expand Down
113 changes: 82 additions & 31 deletions app/(app)/settings/integrations/github/repos-filter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useMemo, useState } from "react";
import { BindButton } from "./bind-button";

interface Repo {
full_name: string;
Expand All @@ -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();
Expand All @@ -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<string, Binding[]>();
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 (
<div>
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
Expand All @@ -49,37 +84,53 @@ export function ReposFilter({ repos }: ReposFilterProps) {
</p>
) : (
<ul className="divide-y divide-[var(--color-border)]">
{filtered.map((r) => (
<li
key={r.full_name}
className="flex flex-wrap items-baseline justify-between gap-2 py-2"
>
<div className="min-w-0">
<a
href={r.html_url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium hover:underline"
>
{r.full_name}
</a>
{r.description && (
<p className="text-xs text-[var(--color-muted)] line-clamp-1">
{r.description}
{filtered.map((r) => {
const [owner, name] = r.full_name.split("/");
const repoBindings = bindingsByRepo.get(r.full_name) ?? [];
return (
<li
key={r.full_name}
className="flex flex-wrap items-center justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<a
href={r.html_url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium hover:underline"
>
{r.full_name}
</a>
{r.private && (
<span className="rounded bg-[var(--color-border)] px-1.5 py-0.5 text-xs text-[var(--color-muted)]">
private
</span>
)}
</div>
{r.description && (
<p className="text-xs text-[var(--color-muted)] line-clamp-1">
{r.description}
</p>
)}
<p className="mt-0.5 text-xs text-[var(--color-muted)]">
{r.language && <>{r.language} · </>}
{r.default_branch}
</p>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2 text-xs text-[var(--color-muted)]">
{r.private && (
<span className="rounded bg-[var(--color-border)] px-1.5 py-0.5">
private
</span>
)}
{r.language && <span>{r.language}</span>}
<span>{r.default_branch}</span>
</div>
</li>
))}
</div>
<div className="flex-shrink-0">
<BindButton
projects={projects}
bindings={repoBindings}
installationId={installationId}
owner={owner}
repo={name}
defaultBranch={r.default_branch}
/>
</div>
</li>
);
})}
</ul>
)}
</div>
Expand Down
Loading