Skip to content

Commit aa75636

Browse files
feat: filter repositories by connection
1 parent 32d850d commit aa75636

8 files changed

Lines changed: 161 additions & 30 deletions

File tree

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,6 +1480,18 @@
14801480
"required": false,
14811481
"name": "query",
14821482
"in": "query"
1483+
},
1484+
{
1485+
"schema": {
1486+
"type": "integer",
1487+
"minimum": 0,
1488+
"exclusiveMinimum": true,
1489+
"description": "Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections."
1490+
},
1491+
"required": false,
1492+
"description": "Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections.",
1493+
"name": "connectionId",
1494+
"in": "query"
14831495
}
14841496
],
14851497
"responses": {

packages/web/src/app/api/(server)/connections/listConnectionsApi.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1-
import type { ConnectionQuery } from '@/lib/types';
21
import { sew } from '@/middleware/sew';
32
import { withOptionalAuth } from '@/middleware/withAuth';
3+
import { ConnectionType } from '@sourcebot/db';
4+
import { z } from 'zod';
5+
6+
export const connectionQuerySchema = z.object({
7+
id: z.number().int(),
8+
name: z.string(),
9+
connectionType: z.nativeEnum(ConnectionType),
10+
});
11+
12+
export const listConnectionsResponseSchema = connectionQuerySchema.array();
13+
14+
export type ConnectionQuery = z.infer<typeof connectionQuerySchema>;
15+
export type ListConnectionsResponse = z.infer<typeof listConnectionsResponseSchema>;
416

517
export const listConnections = async () => sew(() =>
618
withOptionalAuth(async ({ org, prisma }) => {
19+
// Query through repos so the scoped Prisma client applies repository visibility;
20+
// querying Connection directly would expose connections unrelated to visible repos.
721
const repositories = await prisma.repo.findMany({
822
where: {
923
orgId: org.id,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { beforeEach, describe, expect, test, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
authContext: undefined as unknown,
5+
}));
6+
7+
vi.mock('@/middleware/sew', () => ({
8+
sew: (callback: () => unknown) => callback(),
9+
}));
10+
11+
vi.mock('@/middleware/withAuth', () => ({
12+
withOptionalAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)),
13+
}));
14+
15+
vi.mock('@/ee/features/audit/audit', () => ({
16+
createAudit: vi.fn(),
17+
}));
18+
19+
vi.mock('@sourcebot/shared', () => ({
20+
env: { AUTH_URL: 'https://sourcebot.example.com' },
21+
}));
22+
23+
vi.mock('next/headers', () => ({
24+
headers: vi.fn(async () => new Headers()),
25+
}));
26+
27+
const { listRepos } = await import('./listReposApi');
28+
const { listReposQueryParamsSchema } = await import('@/lib/schemas');
29+
30+
function createPrismaMock() {
31+
return {
32+
repo: {
33+
findMany: vi.fn().mockResolvedValue([]),
34+
count: vi.fn().mockResolvedValue(0),
35+
},
36+
};
37+
}
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
});
42+
43+
describe('listRepos connection filtering', () => {
44+
test('filters both repositories and the total count by connection', async () => {
45+
const prisma = createPrismaMock();
46+
mocks.authContext = {
47+
org: { id: 7 },
48+
user: undefined,
49+
prisma,
50+
};
51+
52+
await listRepos({
53+
page: 2,
54+
perPage: 20,
55+
sort: 'name',
56+
direction: 'asc',
57+
query: 'sourcebot',
58+
connectionId: 42,
59+
});
60+
61+
const where = {
62+
orgId: 7,
63+
name: { contains: 'sourcebot', mode: 'insensitive' },
64+
connections: {
65+
some: { connectionId: 42 },
66+
},
67+
};
68+
expect(prisma.repo.findMany).toHaveBeenCalledWith({
69+
where,
70+
skip: 20,
71+
take: 20,
72+
orderBy: { name: 'asc' },
73+
});
74+
expect(prisma.repo.count).toHaveBeenCalledWith({ where });
75+
});
76+
77+
test('does not add a connection relation filter when none is requested', async () => {
78+
const prisma = createPrismaMock();
79+
mocks.authContext = {
80+
org: { id: 7 },
81+
user: undefined,
82+
prisma,
83+
};
84+
85+
await listRepos({
86+
page: 1,
87+
perPage: 30,
88+
sort: 'name',
89+
direction: 'asc',
90+
});
91+
92+
expect(prisma.repo.findMany).toHaveBeenCalledWith(expect.objectContaining({
93+
where: { orgId: 7 },
94+
}));
95+
expect(prisma.repo.count).toHaveBeenCalledWith({
96+
where: { orgId: 7 },
97+
});
98+
});
99+
100+
test('accepts a positive integer connectionId query parameter', () => {
101+
expect(listReposQueryParamsSchema.parse({ connectionId: '42' }).connectionId).toBe(42);
102+
expect(listReposQueryParamsSchema.safeParse({ connectionId: '0' }).success).toBe(false);
103+
expect(listReposQueryParamsSchema.safeParse({ connectionId: '1.5' }).success).toBe(false);
104+
});
105+
});

packages/web/src/app/api/(server)/repos/listReposApi.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getBrowsePath } from "@/app/(app)/browse/hooks/utils";
66
import { env } from "@sourcebot/shared";
77
import { headers } from "next/headers";
88

9-
export const listRepos = async ({ query, page, perPage, sort, direction, source }: ListReposQueryParams & { source?: string }) => sew(() =>
9+
export const listRepos = async ({ query, page, perPage, sort, direction, connectionId, source }: ListReposQueryParams & { source?: string }) => sew(() =>
1010
withOptionalAuth(async ({ org, prisma, user }) => {
1111
if (user) {
1212
const resolvedSource = source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
@@ -22,26 +22,27 @@ export const listRepos = async ({ query, page, perPage, sort, direction, source
2222
const skip = (page - 1) * perPage;
2323
const orderByField = sort === 'pushed' ? 'pushedAt' : 'name';
2424
const baseUrl = env.AUTH_URL;
25+
const where = {
26+
orgId: org.id,
27+
...(query ? {
28+
name: { contains: query, mode: 'insensitive' as const },
29+
} : {}),
30+
...(connectionId !== undefined ? {
31+
connections: {
32+
some: { connectionId },
33+
},
34+
} : {}),
35+
};
2536

2637
const [repos, totalCount] = await Promise.all([
2738
prisma.repo.findMany({
28-
where: {
29-
orgId: org.id,
30-
...(query ? {
31-
name: { contains: query, mode: 'insensitive' },
32-
} : {}),
33-
},
39+
where,
3440
skip,
3541
take: perPage,
3642
orderBy: { [orderByField]: direction },
3743
}),
3844
prisma.repo.count({
39-
where: {
40-
orgId: org.id,
41-
...(query ? {
42-
name: { contains: query, mode: 'insensitive' },
43-
} : {}),
44-
},
45+
where,
4546
}),
4647
]);
4748

@@ -67,4 +68,4 @@ export const listRepos = async ({ query, page, perPage, sort, direction, source
6768
totalCount,
6869
};
6970
})
70-
)
71+
)

packages/web/src/app/api/(server)/repos/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
2020
return serviceErrorResponse(queryParamsSchemaValidationError(parseResult.error));
2121
}
2222

23-
const { page, perPage, sort, direction, query } = parseResult.data;
23+
const { page, perPage, sort, direction, query, connectionId } = parseResult.data;
2424

2525
const response = await listRepos({
2626
page,
2727
perPage,
2828
sort,
2929
direction,
3030
query,
31+
connectionId,
3132
})
3233

3334
if (isServiceError(response)) {
@@ -47,6 +48,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
4748
sort,
4849
direction,
4950
...(query ? { query } : {}),
51+
...(connectionId !== undefined ? { connectionId: connectionId.toString() } : {}),
5052
},
5153
});
5254
if (linkHeader) headers.set('Link', linkHeader);

packages/web/src/lib/schemas.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
11
import { z } from "zod";
2-
import { CodeHostType, ConnectionType } from "@sourcebot/db";
3-
4-
export const connectionQuerySchema = z.object({
5-
id: z.number().int(),
6-
name: z.string(),
7-
connectionType: z.nativeEnum(ConnectionType),
8-
});
9-
10-
export const listConnectionsResponseSchema = connectionQuerySchema.array();
2+
import { CodeHostType } from "@sourcebot/db";
113

124
export const repositoryQuerySchema = z.object({
135
codeHostType: z.nativeEnum(CodeHostType),
@@ -46,6 +38,8 @@ export const listReposQueryParamsSchema = z.object({
4638
sort: z.enum(['name', 'pushed']).default('name'),
4739
direction: z.enum(['asc', 'desc']).default('asc'),
4840
query: z.string().optional(),
41+
connectionId: z.coerce.number().int().positive().optional()
42+
.describe('Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections.'),
4943
});
5044

5145
export const listReposResponseSchema = repositoryQuerySchema.array();

packages/web/src/lib/types.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from "zod";
2-
import { connectionQuerySchema, getVersionResponseSchema, listConnectionsResponseSchema, listReposQueryParamsSchema, listReposResponseSchema, repositoryQuerySchema, searchContextQuerySchema } from "./schemas";
2+
import { getVersionResponseSchema, listReposQueryParamsSchema, listReposResponseSchema, repositoryQuerySchema, searchContextQuerySchema } from "./schemas";
33

44
export type KeymapType = "default" | "vim";
55

@@ -26,8 +26,6 @@ export type NewsItem = {
2626
}
2727

2828
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
29-
export type ConnectionQuery = z.infer<typeof connectionQuerySchema>;
3029
export type SearchContextQuery = z.infer<typeof searchContextQuerySchema>;
31-
export type ListConnectionsResponse = z.infer<typeof listConnectionsResponseSchema>;
3230
export type ListReposResponse = z.infer<typeof listReposResponseSchema>;
3331
export type ListReposQueryParams = z.infer<typeof listReposQueryParamsSchema>;

packages/web/src/openapi/publicApiSchemas.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
2+
import { ConnectionType } from '@sourcebot/db';
23
import z from 'zod';
34
import {
45
findRelatedSymbolsRequestSchema,
@@ -24,7 +25,7 @@ import {
2425
searchResponseSchema,
2526
} from '../features/search/types.js';
2627
import { serviceErrorSchema } from '../lib/serviceError.js';
27-
import { getVersionResponseSchema, listConnectionsResponseSchema, listReposQueryParamsSchema, listReposResponseSchema } from '../lib/schemas.js';
28+
import { getVersionResponseSchema, listReposQueryParamsSchema, listReposResponseSchema } from '../lib/schemas.js';
2829

2930
let hasExtendedZod = false;
3031

@@ -45,7 +46,11 @@ export const publicFileSourceResponseSchema = fileSourceResponseSchema.openapi('
4546
export const publicFileBlameRequestSchema = fileBlameRequestSchema.openapi('PublicFileBlameRequest');
4647
export const publicFileBlameResponseSchema = fileBlameResponseSchema.openapi('PublicFileBlameResponse');
4748
export const publicVersionResponseSchema = getVersionResponseSchema.openapi('PublicVersionResponse');
48-
export const publicListConnectionsResponseSchema = listConnectionsResponseSchema.openapi('PublicListConnectionsResponse');
49+
export const publicListConnectionsResponseSchema = z.array(z.object({
50+
id: z.number().int(),
51+
name: z.string(),
52+
connectionType: z.nativeEnum(ConnectionType),
53+
})).openapi('PublicListConnectionsResponse');
4954
export const publicListReposQueryParamsSchema = listReposQueryParamsSchema.openapi('PublicListReposQuery');
5055
export const publicListReposResponseSchema = listReposResponseSchema.openapi('PublicListReposResponse');
5156
export const publicGetDiffRequestSchema = getDiffRequestSchema.openapi('PublicGetDiffRequest');

0 commit comments

Comments
 (0)