mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
Paginate GitHub repository access and add global toggles
This commit is contained in:
1 parent
454e6b1c64
commit
c8b0a1bf3c
11 files changed
+391
-34
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number>`count(*)::integer`,
|
||||
enabledCount: sql<number>`count(*) filter (where ${chatEndpointResources.enabled})::integer`,
|
||||
availableCount: sql<number>`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,
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
members,
|
||||
} from "./fixtures";
|
||||
|
||||
export type FixtureState = "populated" | "empty" | "loading" | "error" | "long";
|
||||
export type FixtureState = "populated" | "empty" | "loading" | "error" | "long" | "many";
|
||||
/** Only fixture IDs are intercepted. All shell requests use Storybook's shared API fixtures. */
|
||||
export function FixtureApi({
|
||||
state = "populated",
|
||||
@@ -35,6 +35,10 @@ export function FixtureApi({
|
||||
const original = window.fetch;
|
||||
let saved = { revision: 2, configuration: structuredClone(configuration) };
|
||||
let repos = structuredClone(resources);
|
||||
if (state === "many") repos = Array.from({ length: 1000 }, (_, index) => ({
|
||||
...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<string>();
|
||||
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<Response>(() => {});
|
||||
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 = {
|
||||
|
||||
@@ -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 <FixtureApi><RepositoryExample /></FixtureApi>;
|
||||
}
|
||||
function RepositoryExample() {
|
||||
const client = useQueryClient();
|
||||
const [pending, setPending] = useState(false);
|
||||
const save = async (fn: () => Promise<unknown>) => {
|
||||
setPending(true);
|
||||
try {
|
||||
await fn();
|
||||
await client.invalidateQueries({ queryKey: ["github-bot-repository-pages", endpoint.id] });
|
||||
} finally { setPending(false); }
|
||||
};
|
||||
return (
|
||||
<GitHubRepositoryAccess
|
||||
resources={rows}
|
||||
endpointId={endpoint.id}
|
||||
managementUrl="https://github.com/settings/installations"
|
||||
pending={false}
|
||||
pending={pending}
|
||||
onRefresh={() => {}}
|
||||
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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user