diff --git a/doc/connections/GITHUB-REVIEW-BOT.md b/doc/connections/GITHUB-REVIEW-BOT.md index b947235951..86b38906d3 100644 --- a/doc/connections/GITHUB-REVIEW-BOT.md +++ b/doc/connections/GITHUB-REVIEW-BOT.md @@ -107,6 +107,12 @@ Settings and Access share unsaved edits while you switch connection tabs. Save before reloading or leaving the connection. No permissions change merely by opening a tab or configuring an implicit linked member’s individual event settings. +The repository list loads 20 rows at a time as you scroll. Search covers the +entire connection. **Disable all** and **Enable all** also apply to the entire +connection, including unloaded rows and repositories outside the search results. +Enabling all includes only repositories still available in the App installation; +it cannot restore revoked GitHub access. These changes save immediately. + ## Who can start work Linked members may be allowed together or selected individually. Teammates diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index 27fbd39566..1d8652711c 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -286,6 +286,14 @@ export interface ChatEndpointResource { participants?: string[]; } +export interface GitHubRepositoryPage { + items: ChatEndpointResource[]; + nextOffset: number | null; + totalCount: number; + enabledCount: number; + availableCount: number; +} + export interface ChatExternalPrincipal { id: string; companyId: string; diff --git a/packages/shared/src/validators/chat-channels.ts b/packages/shared/src/validators/chat-channels.ts index dc861e91d9..7a9ad94d22 100644 --- a/packages/shared/src/validators/chat-channels.ts +++ b/packages/shared/src/validators/chat-channels.ts @@ -149,6 +149,16 @@ export const replaceChatEndpointResourcesSchema = z }) .strict(); +export const gitHubRepositoryPageQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).max(2_147_483_647).default(0), + search: z.string().trim().max(200).default(""), +}).strict(); + +export const toggleAllGitHubRepositoriesSchema = z.object({ + enabled: z.boolean(), +}).strict(); + export const publishChatCommentSchema = z .object({ commentId: z.string().uuid(), diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 45c034aa3b..7ab2dcbca9 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -5997,6 +5997,64 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(endpoints).toEqual([]); }); + describe("GitHub repository pagination and bulk access", () => { + it("pages and searches 1000 repositories while toggle all changes unloaded rows atomically", async () => { + const fixture = await seedCompany(); + const context = createService(); + const endpoint = await context.service.create(fixture.companyId, + { provider: "github", assignedAgentId: fixture.assignedAgentId }, "owner-user"); + const foreign = await seedCompany(); + const other = await context.service.create(foreign.companyId, + { provider: "github", assignedAgentId: foreign.assignedAgentId }, "owner-user"); + const all = await db.insert(chatEndpointResources).values(Array.from({ length: 1000 }, (_, index) => ({ + companyId: fixture.companyId, endpointId: endpoint.id, type: "repository", + providerResourceId: `acme/repo-${index}`, label: `Acme/Repo-${String(index).padStart(4, "0")}`, + enabled: true, availability: "available" as const, + }))).returning(); + const [unavailable, channel, unrelated] = await db.insert(chatEndpointResources).values([ + { companyId: fixture.companyId, endpointId: endpoint.id, type: "repository", providerResourceId: "unavailable", label: "Unavailable", enabled: true, availability: "unavailable" }, + { companyId: fixture.companyId, endpointId: endpoint.id, type: "channel", providerResourceId: "channel", label: "Channel", enabled: true, availability: "available" }, + { companyId: foreign.companyId, endpointId: other.id, type: "repository", providerResourceId: "foreign", label: "Foreign", enabled: true, availability: "available" }, + ]).returning(); + const app = routesApp(db, fixture.companyId, context.service); + const path = `/api/chat-endpoints/${endpoint.id}/github/repositories`; + const first = await request(app).get(path).expect(200); + expect(first.body).toMatchObject({ nextOffset: 20, totalCount: 1001, enabledCount: 1001, availableCount: 1000 }); + expect(first.body.items).toHaveLength(20); + const second = await request(app).get(path).query({ offset: 20 }).expect(200); + expect(second.body.items).toHaveLength(20); + expect(second.body.items.some((row: { id: string }) => first.body.items.some((initial: { id: string }) => initial.id === row.id))).toBe(false); + const search = await request(app).get(path).query({ search: "repo-0999" }).expect(200); + expect(search.body.items.map((row: { label: string }) => row.label)).toEqual(["Acme/Repo-0999"]); + expect(search.body.totalCount).toBe(1001); + expect((await request(app).get(path).query({ search: "%" }).expect(200)).body.items).toEqual([]); + await request(app).get(path).query({ limit: 1000 }).expect(400); + await request(app).get(path).query({ offset: -1 }).expect(400); + await request(app).put(`${path}/access`).send({ enabled: false, search: "repo-0999" }).expect(400); + await request(app).put(`${path}/access`).send({ enabled: false }).expect(200); + const afterOff = await context.service.listGitHubRepositories(endpoint.id, { limit: 20, offset: 0, search: "" }); + expect(afterOff.enabledCount).toBe(0); + expect((await db.select().from(chatEndpointResources).where(inArray(chatEndpointResources.id, [channel.id, unrelated.id]))).every((row) => row.enabled)).toBe(true); + const [last] = await db.select().from(chatEndpointResources).where(eq(chatEndpointResources.id, all[999].id)); + expect(last.enabled).toBe(false); + await request(app).put(`${path}/access`).send({ enabled: true }).expect(200); + expect((await context.service.listGitHubRepositories(endpoint.id, { limit: 20, offset: 0, search: "" })).enabledCount).toBe(1000); + expect((await db.select().from(chatEndpointResources).where(eq(chatEndpointResources.id, unavailable.id)))[0].enabled).toBe(false); + const saved = await context.service.get(endpoint.id); + expect(saved.setup.github?.repositorySelectionSaved).toBe(true); + const audits = await db.select().from(activityLog).where(and(eq(activityLog.companyId, fixture.companyId), eq(activityLog.action, "chat_endpoint.resources_updated"))); + expect(audits).toHaveLength(2); + expect(audits.every((audit) => audit.actorId === "owner-user")).toBe(true); + expect((audits[0].details!.changes as unknown[])).toHaveLength(1001); + expect((audits[1].details!.changes as unknown[])).toHaveLength(1000); + const crossCompany = routesApp(db, foreign.companyId, context.service); + expect((await request(crossCompany).get(path)).status).toBeOneOf([403, 404]); + expect((await request(crossCompany).put(`${path}/access`).send({ enabled: false })).status).toBeOneOf([403, 404]); + expect(context.wakeup).not.toHaveBeenCalled(); + await context.service.shutdown(); + }); + }); + describe("resource change auditing", () => { async function setupResourceAudit() { const fixture = await seedCompany(); @@ -6538,6 +6596,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { .expect(200); const deniedMutations = [ + request(memberApp) + .put(`/api/chat-endpoints/${endpoint.id}/github/repositories/access`) + .send({ enabled: false }), request(memberApp) .post(`/api/companies/${fixture.companyId}/chat-endpoints`) .send({ provider: "slack", assignedAgentId: fixture.assignedAgentId }), diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index b5a21f2ddb..04b1df554e 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -17,6 +17,8 @@ import { isUuidLike, publishChatPublicationSchema, replaceChatEndpointResourcesSchema, + gitHubRepositoryPageQuerySchema, + toggleAllGitHubRepositoriesSchema, resolveChatActionSchema, resolveChatPublicationSchema, updateChatEndpointSchema, @@ -248,6 +250,16 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { if (!(await assertEndpointManagementAccess(req, res))) return; res.json(await service.refreshGitHubRepositories(endpointId(req), githubUser(req))); }); + router.get("/chat-endpoints/:endpointId/github/repositories", async (req, res) => { + if (!(await assertEndpointAccess(req, res, service))) return; + const query = gitHubRepositoryPageQuerySchema.safeParse(req.query); + if (!query.success) throw badRequest("Invalid repository search or page"); + res.json(await service.listGitHubRepositories(endpointId(req), query.data)); + }); + router.put("/chat-endpoints/:endpointId/github/repositories/access", validate(toggleAllGitHubRepositoriesSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.json(await service.toggleAllGitHubRepositories(endpointId(req), req.body.enabled, actorUserId(req))); + }); router.patch( "/chat-endpoints/:endpointId", diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index f4d180e031..2ef62a0fd2 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -28002,15 +28002,67 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { })); } + async function listGitHubRepositories( + endpointId: string, + query: { limit: number; offset: number; search: string }, + ) { + const record = await endpointRecord(endpointId); + if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); + const scope = and( + eq(chatEndpointResources.companyId, record.endpoint.companyId), + eq(chatEndpointResources.endpointId, endpointId), + eq(chatEndpointResources.type, "repository"), + ); + // Literal, case-insensitive substring search (%, _ and backslashes are + // ordinary characters). Counts always describe the entire connection. + const search = query.search + ? sql`strpos(lower(${chatEndpointResources.label}), lower(${query.search})) > 0` + : undefined; + const [rows, [counts]] = await Promise.all([ + db.select().from(chatEndpointResources).where(and(scope, search)) + .orderBy(asc(chatEndpointResources.label), asc(chatEndpointResources.id)) + .offset(query.offset).limit(query.limit + 1), + db.select({ + totalCount: sql`count(*)::integer`, + enabledCount: sql`count(*) filter (where ${chatEndpointResources.enabled})::integer`, + availableCount: sql`count(*) filter (where ${chatEndpointResources.availability} = 'available')::integer`, + }).from(chatEndpointResources).where(scope), + ]); + return { + items: rows.slice(0, query.limit), + nextOffset: rows.length > query.limit ? query.offset + query.limit : null, + totalCount: counts!.totalCount, + enabledCount: counts!.enabledCount, + availableCount: counts!.availableCount, + }; + } + async function replaceResources( endpointId: string, updates: Array<{ id: string; enabled: boolean }>, actorUserId?: string | null, options?: { initialGitHubImport?: boolean }, + ) { + await updateResourceSelection(endpointId, updates, actorUserId, options); + return listResources(endpointId); + } + + async function toggleAllGitHubRepositories(endpointId: string, enabled: boolean, actorUserId?: string | null) { + await updateResourceSelection(endpointId, [], actorUserId, { allGitHubRepositoriesEnabled: enabled }); + return { success: true as const }; + } + + async function updateResourceSelection( + endpointId: string, + updates: Array<{ id: string; enabled: boolean }>, + actorUserId?: string | null, + options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean }, ) { const initial = await endpointRecord(endpointId); if (!initial) throw notFound("Chat endpoint not found"); - if (updates.length === 0 && initial.endpoint.provider !== "github") return listResources(endpointId); + const bulk = options?.allGitHubRepositoriesEnabled !== undefined; + if (bulk && initial.endpoint.provider !== "github") throw badRequest("Only GitHub repositories support toggle all"); + if (updates.length === 0 && initial.endpoint.provider !== "github") return; if (initial.endpoint.provider === "imessage-photon" && initial.endpoint.botExternalId?.startsWith("photon-project:") && updates.some((entry) => entry.enabled)) throw unprocessable("Photon shared channels support direct messages only; groups cannot be enabled"); await withCredentialMutationLease( @@ -28048,19 +28100,29 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { and( eq(chatEndpointResources.companyId, endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), - inArray(chatEndpointResources.id, ids), + bulk + ? and( + eq(chatEndpointResources.type, "repository"), + options!.allGitHubRepositoriesEnabled + ? eq(chatEndpointResources.availability, "available") + : undefined, + ) + : inArray(chatEndpointResources.id, ids), ), ) .orderBy(asc(chatEndpointResources.id)) .for("no key update"); - if (rows.length !== new Set(ids).size) + if (!bulk && rows.length !== new Set(ids).size) throw unprocessable("Every resource must belong to this endpoint"); + const selectedUpdates = bulk + ? rows.map((row) => ({ id: row.id, enabled: options!.allGitHubRepositoriesEnabled! })) + : updates; const availabilityById = new Map( rows.map((row) => [row.id, row.availability]), ); // Validate every submitted grant, including intermediate duplicate // entries. Netting below describes the audit, not new authority. - const unavailable = updates.find( + const unavailable = selectedUpdates.find( (entry) => entry.enabled && availabilityById.get(entry.id) !== "available", ); @@ -28070,7 +28132,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { { code: "chat_resource_unavailable", resourceId: unavailable.id }, ); const finalEnabled = new Map( - updates.map((entry) => [entry.id, entry.enabled]), + selectedUpdates.map((entry) => [entry.id, entry.enabled]), ); const changes = rows .filter((row) => row.enabled !== finalEnabled.get(row.id)) @@ -28079,7 +28141,20 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { before: { enabled: row.enabled }, after: { enabled: finalEnabled.get(row.id)! }, })); - for (const entry of updates) + if (bulk) { + // One atomic database update, independent of pagination/search and + // serialized with refresh, reconnect, and individual row changes. + await tx.update(chatEndpointResources) + .set({ enabled: options!.allGitHubRepositoriesEnabled!, updatedAt: new Date() }) + .where(and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpointId), + eq(chatEndpointResources.type, "repository"), + options!.allGitHubRepositoriesEnabled + ? eq(chatEndpointResources.availability, "available") + : undefined, + )); + } else for (const entry of selectedUpdates) await tx .update(chatEndpointResources) .set({ enabled: entry.enabled, updatedAt: new Date() }) @@ -28113,7 +28188,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { for (const publication of publications) publishActivity(publication); }, ); - return listResources(endpointId); } async function listPrincipals(endpointId: string) { @@ -38530,6 +38604,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { setupTestStatus, handleWebhook, listResources, + listGitHubRepositories, + toggleAllGitHubRepositories, replaceResources, listPrincipals, createLinkIntent, diff --git a/ui/src/api/githubChat.ts b/ui/src/api/githubChat.ts index df84000043..c97b972060 100644 --- a/ui/src/api/githubChat.ts +++ b/ui/src/api/githubChat.ts @@ -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(`${path(id)}/repositories?${query}`); + }, + toggleAllRepositories: (id: string, enabled: boolean) => + api.put<{ success: true }>(`${path(id)}/repositories/access`, { enabled }), configuration: (id: string) => api.get(`${path(id)}/configuration`), save: ( diff --git a/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx index 8027b6447a..bef921f25a 100644 --- a/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx +++ b/ui/src/pages/apps/chat/GitHubBotManagement.test.tsx @@ -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", [ diff --git a/ui/src/pages/apps/chat/GitHubBotManagement.tsx b/ui/src/pages/apps/chat/GitHubBotManagement.tsx index 36d413a5bb..f08d9458ba 100644 --- a/ui/src/pages/apps/chat/GitHubBotManagement.tsx +++ b/ui/src/pages/apps/chat/GitHubBotManagement.tsx @@ -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(null); + const more = useRef(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 (
@@ -83,9 +109,29 @@ export function GitHubRepositoryAccess({ Choose where the bot can receive messages and use tools. Repository changes save immediately.

-
- {resources.map((resource) => ( -
+
+
+ + setSearch(event.target.value)} /> +
+ +
+ {summary &&

+ {summary.enabledCount} of {summary.totalCount} repositories enabled. Toggle all applies across the entire connection. +

} + {list.isError &&

+ Could not load repositories. +

} +
+
+ {rows.map((resource) => ( +
))} - {resources.length === 0 && ( +
+ {list.isPending ?

Loading repositories…

: rows.length === 0 && !list.isError && (

- 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."}

)} + {list.hasNextPage &&
+ +
}
); @@ -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(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 (

Could not load the bot configuration.{" "} @@ -172,7 +225,7 @@ export function GitHubBotManagement({

); - if (!record || resources.isPending) + if (!record || (view === "settings" && resources.isPending)) return (

Loading configuration… @@ -189,7 +242,7 @@ export function GitHubBotManagement({ {view === "access" ? ( <> 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] }); + })} /> ({ + ...repos[0], id: `repository-${index}`, providerResourceId: `acme/repository-${String(index).padStart(4, "0")}`, + label: `acme/repository-${String(index).padStart(4, "0")}`, enabled: true, + })); let identities = structuredClone(links); const failedPaths = new Set(); window.fetch = async (input, init) => { @@ -44,13 +48,14 @@ export function FixtureApi({ : input instanceof URL ? input.href : input.url; - const path = new URL(raw, window.location.origin).pathname; + const url = new URL(raw, window.location.origin); + const path = url.pathname; if (!path.includes(`/chat-endpoints/${endpoint.id}`)) return original(input, init); if (state === "loading") return new Promise(() => {}); if ( state === "error" && - (path.endsWith("/configuration") || path.endsWith("/resources")) && + (path.endsWith("/configuration") || path.endsWith("/resources") || path.endsWith("/github/repositories")) && !failedPaths.has(path) ) { failedPaths.add(path); @@ -60,6 +65,22 @@ export function FixtureApi({ ); } const body = init?.body ? JSON.parse(String(init.body)) : {}; + if (path.endsWith("/repositories/access")) { + repos = repos.map((r) => (!body.enabled || r.availability === "available") ? { ...r, enabled: body.enabled } : r); + return Response.json({ success: true }); + } + if (path.endsWith("/github/repositories")) { + const offset = Number(url.searchParams.get("offset") ?? 0); + const limit = Number(url.searchParams.get("limit") ?? 20); + const search = (url.searchParams.get("search") ?? "").toLowerCase(); + const all = state === "empty" ? [] : state === "long" ? repos.map((r) => ({ ...r, + label: `acme/platform-services-production-web-${r.id}-accessibility-improvements` })) : repos; + const filtered = all.filter((r) => r.label.toLowerCase().includes(search)); + return Response.json({ items: filtered.slice(offset, offset + limit), + nextOffset: offset + limit < filtered.length ? offset + limit : null, + totalCount: all.length, enabledCount: all.filter((r) => r.enabled).length, + availableCount: all.filter((r) => r.availability === "available").length }); + } if (path.endsWith("/configuration")) { if (init?.method === "PUT") saved = { diff --git a/ui/storybook/stories/github-bot-management/management.stories.tsx b/ui/storybook/stories/github-bot-management/management.stories.tsx index d9e5bfae1f..ac59c71105 100644 --- a/ui/storybook/stories/github-bot-management/management.stories.tsx +++ b/ui/storybook/stories/github-bot-management/management.stories.tsx @@ -1,4 +1,7 @@ import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { githubChatApi } from "@/api/githubChat"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { Routes, Route } from "@/lib/router"; import { PluginLauncherProvider } from "@/plugins/launchers"; @@ -24,7 +27,6 @@ import { endpoint, agent, configuration, - resources, reviews, conversations, } from "./fixtures"; @@ -46,7 +48,7 @@ const meta = { argTypes: { state: { control: "select", - options: ["populated", "empty", "loading", "error", "long"], + options: ["populated", "empty", "loading", "error", "long", "many"], }, }, render: ({ state }) => ( @@ -109,6 +111,16 @@ export const LongAccess: Story = { args: { state: "long" }, parameters: route("access"), }; +export const ThousandRepositories: Story = { + name: "02 States / 1000 repositories", + args: { state: "many" }, + parameters: route("access"), +}; +export const RepositoryFailure: Story = { + name: "02 States / Repository loading failure", + args: { state: "error" }, + parameters: route("access"), +}; export const Mobile: Story = { name: "02 States / Mobile conversations", args: { state: "long" }, @@ -136,16 +148,28 @@ function People() { ); } function Repositories() { - const [rows, setRows] = useState(resources); + return ; +} +function RepositoryExample() { + const client = useQueryClient(); + const [pending, setPending] = useState(false); + const save = async (fn: () => Promise) => { + setPending(true); + try { + await fn(); + await client.invalidateQueries({ queryKey: ["github-bot-repository-pages", endpoint.id] }); + } finally { setPending(false); } + }; return ( {}} onChange={(id, enabled) => - setRows(rows.map((r) => (r.id === id ? { ...r, enabled } : r))) + void save(() => chatEndpointsApi.updateResources(endpoint.id, [{ id, enabled }])) } + onToggleAll={(enabled) => void save(() => githubChatApi.toggleAllRepositories(endpoint.id, enabled))} /> ); }