mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user