Paginate GitHub repository access and add global toggles

This commit is contained in:
Dotta committed 2026-10-08 06:12:46 -05:00
1 parent 454e6b1c64
commit c8b0a1bf3c
11 files changed
+391 -34

No files matched your search

+7
View File
@@ -3,6 +3,7 @@ import type {
GitHubChatConfiguration,
GitHubTaskReview,
GitHubAppRegistrationInput, GitHubAppWizardState,
GitHubRepositoryPage,
} from "@paperclipai/shared";
import { api } from "./client";
import type { ChatEndpoint, ChatEndpointResource } from "./chatEndpoints";
@@ -22,6 +23,12 @@ export type GitHubVerification = {
};
const path = (endpointId: string) => `/chat-endpoints/${endpointId}/github`;
export const githubChatApi = {
repositories: (id: string, options: { offset?: number; search?: string; limit?: number } = {}) => {
const query = new URLSearchParams({ limit: String(options.limit ?? 20), offset: String(options.offset ?? 0), search: options.search ?? "" });
return api.get<GitHubRepositoryPage>(`${path(id)}/repositories?${query}`);
},
toggleAllRepositories: (id: string, enabled: boolean) =>
api.put<{ success: true }>(`${path(id)}/repositories/access`, { enabled }),
configuration: (id: string) =>
api.get<GitHubConfigurationRecord>(`${path(id)}/configuration`),
save: (
@@ -24,6 +24,8 @@ const mocks = vi.hoisted(() => ({
config: vi.fn(),
save: vi.fn(),
resources: vi.fn(),
repositoryPage: vi.fn(),
toggleAll: vi.fn(),
updateResources: vi.fn(),
reviews: vi.fn(),
members: vi.fn(),
@@ -35,6 +37,8 @@ vi.mock("@/api/githubChat", () => ({
configuration: mocks.config,
save: mocks.save,
reviews: mocks.reviews,
repositories: mocks.repositoryPage,
toggleAllRepositories: mocks.toggleAll,
},
}));
vi.mock("@/api/chatEndpoints", () => ({
@@ -131,6 +135,10 @@ describe("GitHub bot management", () => {
metadata: { providerRepositoryId: "100" },
},
]);
mocks.repositoryPage.mockImplementation(async () => ({
items: await mocks.resources(), nextOffset: null, totalCount: 1, enabledCount: 1, availableCount: 1,
}));
mocks.toggleAll.mockResolvedValue({ success: true });
mocks.links.mockResolvedValue([
{
id: "link",
@@ -173,6 +181,7 @@ describe("GitHub bot management", () => {
await act(async () => root.unmount());
client.clear();
container.remove();
vi.unstubAllGlobals();
});
async function render(tab = mocks.tab) {
mocks.tab = tab;
@@ -202,14 +211,14 @@ describe("GitHub bot management", () => {
await act(async () => target!.click());
}
async function input(
element: HTMLTextAreaElement | HTMLSelectElement,
element: HTMLTextAreaElement | HTMLSelectElement | HTMLInputElement,
value: string,
) {
await act(async () => {
Object.getOwnPropertyDescriptor(
element instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLSelectElement.prototype,
: element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype,
"value",
)!.set!.call(element, value);
element.dispatchEvent(
@@ -219,6 +228,68 @@ describe("GitHub bot management", () => {
);
});
}
it("loads repositories 20 at a time, searches all pages, and toggles the full connection", async () => {
let enabled = true;
const all = Array.from({ length: 1000 }, (_, index) => ({
id: `repo-${index}`, type: "repository", providerResourceId: `acme/repo-${index}`,
label: `acme/repo-${String(index).padStart(4, "0")}`, availability: "available", enabled: true,
}));
mocks.repositoryPage.mockImplementation(async (_id, { offset, search, limit }) => {
const filtered = all.filter((row) => row.label.includes(search));
return { items: filtered.slice(offset, offset + limit).map((row) => ({ ...row, enabled })),
nextOffset: offset + limit < filtered.length ? offset + limit : null,
totalCount: 1000, enabledCount: enabled ? 1000 : 0, availableCount: 1000 };
});
mocks.toggleAll.mockImplementation(async (_id, value) => { enabled = value; return { success: true }; });
await render("access");
await vi.waitFor(() => expect(container.querySelectorAll('[role="region"] [role="switch"]')).toHaveLength(20));
expect(mocks.repositoryPage).toHaveBeenCalledWith("bot", { offset: 0, limit: 20, search: "" });
await click("Load more repositories");
await vi.waitFor(() => expect(container.querySelectorAll('[role="region"] [role="switch"]')).toHaveLength(40));
const search = container.querySelector('input[aria-label="Search repositories"]') as HTMLInputElement;
await input(search, "0999");
await vi.waitFor(() => expect(container.querySelectorAll('[role="region"] [role="switch"]')).toHaveLength(1));
expect(container.textContent).toContain("acme/repo-0999");
expect(container.textContent).toContain("1000 of 1000 repositories enabled");
await click("Disable all repositories");
await vi.waitFor(() => expect(container.textContent).toContain("0 of 1000 repositories enabled"));
expect(mocks.toggleAll).toHaveBeenCalledWith("bot", false);
expect(mocks.updateResources).not.toHaveBeenCalled();
await click("Enable all repositories");
await vi.waitFor(() => expect(container.textContent).toContain("1000 of 1000 repositories enabled"));
expect(mocks.toggleAll).toHaveBeenLastCalledWith("bot", true);
await input(search, "does-not-exist");
await vi.waitFor(() => expect(container.textContent).toContain("No repositories match your search"));
expect(container.querySelector('button[aria-label="Disable all repositories"]')?.hasAttribute("disabled")).toBe(false);
});
it("keeps repository failures visible and allows a retry", async () => {
mocks.repositoryPage.mockRejectedValueOnce(new Error("Unavailable"));
await render("access");
await vi.waitFor(() => expect(container.textContent).toContain("Could not load repositories"));
await click("Try again");
await vi.waitFor(() => expect(container.querySelectorAll('[role="region"] [role="switch"]')).toHaveLength(1));
expect(container.textContent).not.toContain("Could not load repositories");
});
it("automatically loads the next 20 repositories when the scroll sentinel becomes visible", async () => {
let intersect!: IntersectionObserverCallback;
vi.stubGlobal("IntersectionObserver", class {
constructor(callback: IntersectionObserverCallback) { intersect = callback; }
observe() {}
disconnect() {}
});
mocks.repositoryPage.mockImplementation(async (_id, { offset }) => ({
items: Array.from({ length: 20 }, (_, index) => ({
id: `repo-${offset + index}`, label: `acme/repo-${offset + index}`,
enabled: true, availability: "available", type: "repository",
})),
nextOffset: offset === 0 ? 20 : null, totalCount: 40, enabledCount: 40, availableCount: 40,
}));
await render("access");
await vi.waitFor(() => expect(intersect).toBeDefined());
await act(async () => intersect([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver));
await vi.waitFor(() => expect(container.querySelectorAll('[role="region"] [role="switch"]')).toHaveLength(40));
expect(mocks.repositoryPage).toHaveBeenLastCalledWith("bot", { offset: 20, limit: 20, search: "" });
});
it("shows the verified custom App mention and its own organization branding settings", async () => {
const branded: ChatEndpoint = {
...endpoint,
@@ -322,6 +393,7 @@ describe("GitHub bot management", () => {
await render();
await input(container.querySelector("textarea")!, "Unsaved behavior");
await render("access");
await vi.waitFor(() => expect(container.querySelector('button[aria-label="acme/web"]')).not.toBeNull());
await click("acme/web");
await vi.waitFor(() =>
expect(mocks.updateResources).toHaveBeenCalledWith("bot", [
+76 -16
View File
@@ -1,10 +1,11 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ExternalLink,
GitPullRequest,
MoreHorizontal,
RefreshCw,
Search,
} from "lucide-react";
import type {
GitHubChatConfiguration,
@@ -17,9 +18,9 @@ import {
import {
chatEndpointsApi,
type ChatEndpoint,
type ChatEndpointResource,
} from "@/api/chatEndpoints";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/EmptyState";
import { MarkdownBody } from "@/components/MarkdownBody";
import {
@@ -39,18 +40,43 @@ import {
import { GitHubAppBranding } from "./GitHubAppIdentity";
export function GitHubRepositoryAccess({
resources,
endpointId,
managementUrl,
pending,
onRefresh,
onChange,
onToggleAll,
}: {
resources: ChatEndpointResource[];
endpointId: string;
managementUrl: string;
pending: boolean;
onRefresh: () => void;
onChange: (id: string, enabled: boolean) => void;
onToggleAll: (enabled: boolean) => void;
}) {
const [search, setSearch] = useState("");
const scroller = useRef<HTMLDivElement>(null);
const more = useRef<HTMLDivElement>(null);
const list = useInfiniteQuery({
queryKey: ["github-bot-repository-pages", endpointId, search],
queryFn: ({ pageParam }) => githubChatApi.repositories(endpointId, { limit: 20, offset: pageParam, search }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
});
const rows = list.data?.pages.flatMap((page) => page.items) ?? [];
const summary = list.data?.pages[0];
const disableAll = (summary?.enabledCount ?? 0) > 0;
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = 0;
}, [search]);
useEffect(() => {
if (!list.hasNextPage || list.isFetching || list.isError || pending || !more.current || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) void list.fetchNextPage({ cancelRefetch: false });
}, { root: scroller.current });
observer.observe(more.current);
return () => observer.disconnect();
}, [list.hasNextPage, list.isFetching, list.isError, list.fetchNextPage, pending]);
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
@@ -83,9 +109,29 @@ export function GitHubRepositoryAccess({
Choose where the bot can receive messages and use tools. Repository
changes save immediately.
</p>
<div className="divide-y divide-border border-y border-border">
{resources.map((resource) => (
<div key={resource.id} className="py-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-0 flex-1 basis-48">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input aria-label="Search repositories" placeholder="Search repositories" className="pl-9"
value={search} maxLength={200} onChange={(event) => setSearch(event.target.value)} />
</div>
<Button variant="outline" size="sm" disabled={pending || list.isFetching || !summary || !summary.totalCount || (!disableAll && !summary.availableCount)}
aria-label={`${disableAll ? "Disable" : "Enable"} all repositories`}
onClick={() => onToggleAll(!disableAll)}>
{disableAll ? "Disable all" : "Enable all"}
</Button>
</div>
{summary && <p className="text-xs text-muted-foreground">
{summary.enabledCount} of {summary.totalCount} repositories enabled. Toggle all applies across the entire connection.
</p>}
{list.isError && <p role="alert" className="text-sm text-destructive">
Could not load repositories. <Button variant="link" size="sm" onClick={() => void list.refetch()}>Try again</Button>
</p>}
<div ref={scroller} role="region" aria-label="Repositories" tabIndex={0}
className="max-h-96 overflow-y-auto overscroll-contain border-y border-border">
<div className="divide-y divide-border">
{rows.map((resource) => (
<div key={resource.id} className="px-1 py-1">
<GitHubToggle
label={resource.label ?? resource.providerResourceId}
checked={resource.enabled}
@@ -102,12 +148,18 @@ export function GitHubRepositoryAccess({
/>
</div>
))}
{resources.length === 0 && (
</div>
{list.isPending ? <p role="status" className="py-4 text-sm text-muted-foreground">Loading repositories…</p> : rows.length === 0 && !list.isError && (
<p className="py-4 text-sm text-muted-foreground">
No repositories available. Add repository access on GitHub, then
refresh.
{search ? "No repositories match your search." : "No repositories available. Add repository access on GitHub, then refresh."}
</p>
)}
{list.hasNextPage && <div ref={more} className="flex justify-center py-2">
<Button variant="ghost" size="sm" disabled={pending || list.isFetching}
onClick={() => void list.fetchNextPage({ cancelRefetch: false })}>
{list.isFetchingNextPage ? "Loading repositories…" : "Load more repositories"}
</Button>
</div>}
</div>
</section>
);
@@ -130,6 +182,7 @@ export function GitHubBotManagement({
const resources = useQuery({
queryKey: ["github-bot-repositories", endpoint.id],
queryFn: () => chatEndpointsApi.listResources(endpoint.id),
enabled: view === "settings",
});
const [draft, setDraft] = useState<GitHubConfigurationRecord | null>(null);
const [repository, setRepository] = useState("");
@@ -157,7 +210,7 @@ export function GitHubBotManagement({
setPending(false);
}
};
if (query.isError || resources.isError)
if (query.isError || (view === "settings" && resources.isError))
return (
<p role="alert" className="text-sm text-destructive">
Could not load the bot configuration.{" "}
@@ -172,7 +225,7 @@ export function GitHubBotManagement({
</Button>
</p>
);
if (!record || resources.isPending)
if (!record || (view === "settings" && resources.isPending))
return (
<p role="status" className="text-sm text-muted-foreground">
Loading configuration…
@@ -189,7 +242,7 @@ export function GitHubBotManagement({
{view === "access" ? (
<>
<GitHubRepositoryAccess
resources={repositories}
endpointId={endpoint.id}
pending={pending}
managementUrl={
endpoint.setup?.github?.managementUrl ??
@@ -199,7 +252,8 @@ export function GitHubBotManagement({
onRefresh={() =>
void act(async () => {
await githubChatApi.refreshRepositories(endpoint.id);
await resources.refetch();
await client.invalidateQueries({ queryKey: ["github-bot-repositories", endpoint.id] });
await client.invalidateQueries({ queryKey: ["github-bot-repository-pages", endpoint.id] });
setNotice(
"Repository access refreshed. New repositories stay disabled.",
);
@@ -210,9 +264,15 @@ export function GitHubBotManagement({
await chatEndpointsApi.updateResources(endpoint.id, [
{ id, enabled },
]);
await resources.refetch();
await client.invalidateQueries({ queryKey: ["github-bot-repositories", endpoint.id] });
await client.invalidateQueries({ queryKey: ["github-bot-repository-pages", endpoint.id] });
})
}
onToggleAll={(enabled) => void act(async () => {
await githubChatApi.toggleAllRepositories(endpoint.id, enabled);
await client.invalidateQueries({ queryKey: ["github-bot-repositories", endpoint.id] });
await client.invalidateQueries({ queryKey: ["github-bot-repository-pages", endpoint.id] });
})}
/>
<GitHubAccessEditor
endpointId={endpoint.id}