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

@@ -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 }),
+12
View File
@@ -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",
+83 -7
View File
@@ -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,