diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ce51b7d..56aad09f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [EE] Added one-hour repository-scoped access tokens with public mint and revoke APIs. [#1549](https://github.com/sourcebot-dev/sourcebot/pull/1549) - [EE] Added guided reconnection for MCP connector authentication failures during Ask Sourcebot agent turns. [#1548](https://github.com/sourcebot-dev/sourcebot/pull/1548) +- Added public connection listing and connection-based repository filtering APIs. [#1550](https://github.com/sourcebot-dev/sourcebot/pull/1550) ### Removed - Removed the Langfuse integration. [#1536](https://github.com/sourcebot-dev/sourcebot/pull/1536) diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 216a4f2a4..ecaacf11a 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -10,6 +10,10 @@ "name": "Search & Navigation", "description": "Code search and symbol navigation endpoints." }, + { + "name": "Connections", + "description": "Code host connection metadata." + }, { "name": "Repositories", "description": "Repository listing and metadata endpoints." @@ -516,6 +520,37 @@ ] } }, + "PublicListConnectionsResponse": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string", + "enum": [ + "github", + "gitlab", + "gitea", + "gerrit", + "bitbucket", + "azuredevops", + "git" + ] + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + } + }, "PublicVersionResponse": { "type": "object", "properties": { @@ -1445,6 +1480,18 @@ "required": false, "name": "query", "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections." + }, + "required": false, + "description": "Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections.", + "name": "connectionId", + "in": "query" } ], "responses": { @@ -1497,6 +1544,48 @@ } } }, + "/api/connections": { + "get": { + "operationId": "listConnections", + "tags": [ + "Connections" + ], + "summary": "List connections", + "description": "Returns unique code host connections associated with at least one repository visible to the caller. Connection configuration and credentials are never included.", + "responses": { + "200": { + "description": "Connections associated with visible repositories.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicListConnectionsResponse" + } + } + } + }, + "401": { + "description": "Authentication is required when anonymous access is disabled.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected connection listing failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, "/api/version": { "get": { "operationId": "getVersion", diff --git a/docs/docs.json b/docs/docs.json index 7521cb827..3ce8b359e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -189,6 +189,13 @@ "GET /api/repos" ] }, + { + "group": "Connections", + "icon": "link", + "pages": [ + "GET /api/connections" + ] + }, { "group": "Scoped Access Tokens", "icon": "key", diff --git a/packages/web/src/app/api/(server)/connections/listConnectionsApi.test.ts b/packages/web/src/app/api/(server)/connections/listConnectionsApi.test.ts new file mode 100644 index 000000000..5b7193461 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/listConnectionsApi.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, +})); + +vi.mock('@/middleware/sew', () => ({ + sew: (callback: () => unknown) => callback(), +})); + +vi.mock('@/middleware/withAuth', () => ({ + withOptionalAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); + +const { listConnections } = await import('./listConnectionsApi'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('listConnections', () => { + test('returns unique connections from repositories visible through the scoped Prisma client', async () => { + const findMany = vi.fn().mockResolvedValue([ + { + connections: [ + { + connection: { + id: 2, + name: 'GitLab', + connectionType: 'gitlab', + }, + }, + ], + }, + { + connections: [ + { + connection: { + id: 1, + name: 'GitHub', + connectionType: 'github', + }, + }, + { + connection: { + id: 2, + name: 'GitLab', + connectionType: 'gitlab', + }, + }, + ], + }, + ]); + mocks.authContext = { + org: { id: 1 }, + prisma: { + repo: { findMany }, + }, + }; + + const result = await listConnections(); + + expect(findMany).toHaveBeenCalledWith({ + where: { + orgId: 1, + }, + select: { + connections: { + select: { + connection: { + select: { + id: true, + name: true, + connectionType: true, + }, + }, + }, + }, + }, + }); + expect(result).toEqual([ + { id: 1, name: 'GitHub', connectionType: 'github' }, + { id: 2, name: 'GitLab', connectionType: 'gitlab' }, + ]); + }); + + test('returns an empty list when no visible repositories have connections', async () => { + mocks.authContext = { + org: { id: 1 }, + prisma: { + repo: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + }; + + await expect(listConnections()).resolves.toEqual([]); + }); +}); diff --git a/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts b/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts new file mode 100644 index 000000000..94ed44b6f --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts @@ -0,0 +1,51 @@ +import { sew } from '@/middleware/sew'; +import { withOptionalAuth } from '@/middleware/withAuth'; +import { ConnectionType } from '@sourcebot/db'; +import { z } from 'zod'; + +export const connectionQuerySchema = z.object({ + id: z.number().int(), + name: z.string(), + connectionType: z.nativeEnum(ConnectionType), +}); + +export const listConnectionsResponseSchema = connectionQuerySchema.array(); + +export type ConnectionQuery = z.infer; +export type ListConnectionsResponse = z.infer; + +export const listConnections = async () => sew(() => + withOptionalAuth(async ({ org, prisma }) => { + // Query through repos so the scoped Prisma client applies repository visibility; + // querying Connection directly would expose connections unrelated to visible repos. + const repositories = await prisma.repo.findMany({ + where: { + orgId: org.id, + }, + select: { + connections: { + select: { + connection: { + select: { + id: true, + name: true, + connectionType: true, + }, + }, + }, + }, + }, + }); + + const connectionsById = new Map(); + for (const repository of repositories) { + for (const { connection } of repository.connections) { + connectionsById.set(connection.id, connection); + } + } + + return [...connectionsById.values()].sort((a, b) => + a.name.localeCompare(b.name) || a.id - b.id + ); + }) +); diff --git a/packages/web/src/app/api/(server)/connections/route.ts b/packages/web/src/app/api/(server)/connections/route.ts new file mode 100644 index 000000000..e1aa890ea --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/route.ts @@ -0,0 +1,14 @@ +import { apiHandler } from '@/lib/apiHandler'; +import { serviceErrorResponse } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { listConnections } from './listConnectionsApi'; + +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to listConnections(), which calls withOptionalAuth +export const GET = apiHandler(async () => { + const result = await listConnections(); + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + return Response.json(result); +}); diff --git a/packages/web/src/app/api/(server)/repos/listReposApi.test.ts b/packages/web/src/app/api/(server)/repos/listReposApi.test.ts new file mode 100644 index 000000000..3fded8e0e --- /dev/null +++ b/packages/web/src/app/api/(server)/repos/listReposApi.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as unknown, +})); + +vi.mock('@/middleware/sew', () => ({ + sew: (callback: () => unknown) => callback(), +})); + +vi.mock('@/middleware/withAuth', () => ({ + withOptionalAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)), +})); + +vi.mock('@/ee/features/audit/audit', () => ({ + createAudit: vi.fn(), +})); + +vi.mock('@sourcebot/shared', () => ({ + env: { AUTH_URL: 'https://sourcebot.example.com' }, +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers()), +})); + +const { listRepos } = await import('./listReposApi'); +const { listReposQueryParamsSchema } = await import('@/lib/schemas'); + +function createPrismaMock() { + return { + repo: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('listRepos connection filtering', () => { + test('filters both repositories and the total count by connection', async () => { + const prisma = createPrismaMock(); + mocks.authContext = { + org: { id: 7 }, + user: undefined, + prisma, + }; + + await listRepos({ + page: 2, + perPage: 20, + sort: 'name', + direction: 'asc', + query: 'sourcebot', + connectionId: 42, + }); + + const where = { + orgId: 7, + name: { contains: 'sourcebot', mode: 'insensitive' }, + connections: { + some: { connectionId: 42 }, + }, + }; + expect(prisma.repo.findMany).toHaveBeenCalledWith({ + where, + skip: 20, + take: 20, + orderBy: { name: 'asc' }, + }); + expect(prisma.repo.count).toHaveBeenCalledWith({ where }); + }); + + test('does not add a connection relation filter when none is requested', async () => { + const prisma = createPrismaMock(); + mocks.authContext = { + org: { id: 7 }, + user: undefined, + prisma, + }; + + await listRepos({ + page: 1, + perPage: 30, + sort: 'name', + direction: 'asc', + }); + + expect(prisma.repo.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { orgId: 7 }, + })); + expect(prisma.repo.count).toHaveBeenCalledWith({ + where: { orgId: 7 }, + }); + }); + + test('accepts a positive integer connectionId query parameter', () => { + expect(listReposQueryParamsSchema.parse({ connectionId: '42' }).connectionId).toBe(42); + expect(listReposQueryParamsSchema.safeParse({ connectionId: '0' }).success).toBe(false); + expect(listReposQueryParamsSchema.safeParse({ connectionId: '1.5' }).success).toBe(false); + }); +}); diff --git a/packages/web/src/app/api/(server)/repos/listReposApi.ts b/packages/web/src/app/api/(server)/repos/listReposApi.ts index dbfaf2955..18cc0f2ad 100644 --- a/packages/web/src/app/api/(server)/repos/listReposApi.ts +++ b/packages/web/src/app/api/(server)/repos/listReposApi.ts @@ -6,7 +6,7 @@ import { getBrowsePath } from "@/app/(app)/browse/hooks/utils"; import { env } from "@sourcebot/shared"; import { headers } from "next/headers"; -export const listRepos = async ({ query, page, perPage, sort, direction, source }: ListReposQueryParams & { source?: string }) => sew(() => +export const listRepos = async ({ query, page, perPage, sort, direction, connectionId, source }: ListReposQueryParams & { source?: string }) => sew(() => withOptionalAuth(async ({ org, prisma, user }) => { if (user) { 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 const skip = (page - 1) * perPage; const orderByField = sort === 'pushed' ? 'pushedAt' : 'name'; const baseUrl = env.AUTH_URL; + const where = { + orgId: org.id, + ...(query ? { + name: { contains: query, mode: 'insensitive' as const }, + } : {}), + ...(connectionId !== undefined ? { + connections: { + some: { connectionId }, + }, + } : {}), + }; const [repos, totalCount] = await Promise.all([ prisma.repo.findMany({ - where: { - orgId: org.id, - ...(query ? { - name: { contains: query, mode: 'insensitive' }, - } : {}), - }, + where, skip, take: perPage, orderBy: { [orderByField]: direction }, }), prisma.repo.count({ - where: { - orgId: org.id, - ...(query ? { - name: { contains: query, mode: 'insensitive' }, - } : {}), - }, + where, }), ]); @@ -67,4 +68,4 @@ export const listRepos = async ({ query, page, perPage, sort, direction, source totalCount, }; }) -) \ No newline at end of file +) diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index ae95b3481..5a3a4b082 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -20,7 +20,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return serviceErrorResponse(queryParamsSchemaValidationError(parseResult.error)); } - const { page, perPage, sort, direction, query } = parseResult.data; + const { page, perPage, sort, direction, query, connectionId } = parseResult.data; const response = await listRepos({ page, @@ -28,6 +28,7 @@ export const GET = apiHandler(async (request: NextRequest) => { sort, direction, query, + connectionId, }) if (isServiceError(response)) { @@ -47,6 +48,7 @@ export const GET = apiHandler(async (request: NextRequest) => { sort, direction, ...(query ? { query } : {}), + ...(connectionId !== undefined ? { connectionId: connectionId.toString() } : {}), }, }); if (linkHeader) headers.set('Link', linkHeader); diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 4fdae9cad..110b276f9 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -38,6 +38,8 @@ export const listReposQueryParamsSchema = z.object({ sort: z.enum(['name', 'pushed']).default('name'), direction: z.enum(['asc', 'desc']).default('asc'), query: z.string().optional(), + connectionId: z.coerce.number().int().positive().optional() + .describe('Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections.'), }); -export const listReposResponseSchema = repositoryQuerySchema.array(); \ No newline at end of file +export const listReposResponseSchema = repositoryQuerySchema.array(); diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index 3d1933437..5acee75c6 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { listReposResponseSchema, getVersionResponseSchema, repositoryQuerySchema, searchContextQuerySchema, listReposQueryParamsSchema } from "./schemas"; +import { getVersionResponseSchema, listReposQueryParamsSchema, listReposResponseSchema, repositoryQuerySchema, searchContextQuerySchema } from "./schemas"; export type KeymapType = "default" | "vim"; @@ -28,4 +28,4 @@ export type NewsItem = { export type RepositoryQuery = z.infer; export type SearchContextQuery = z.infer; export type ListReposResponse = z.infer; -export type ListReposQueryParams = z.infer; \ No newline at end of file +export type ListReposQueryParams = z.infer; diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index ea4951aa0..23e88b97b 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -26,6 +26,7 @@ import { publicListCommitAuthorsResponseSchema, publicListCommitsQuerySchema, publicListCommitsResponseSchema, + publicListConnectionsResponseSchema, publicListReposQueryParamsSchema, publicListReposResponseSchema, publicSearchRequestSchema, @@ -36,6 +37,7 @@ import { import dedent from 'dedent'; const searchTag = { name: 'Search & Navigation', description: 'Code search and symbol navigation endpoints.' }; +const connectionsTag = { name: 'Connections', description: 'Code host connection metadata.' }; const reposTag = { name: 'Repositories', description: 'Repository listing and metadata endpoints.' }; const gitTag = { name: 'Git', description: 'Git history, diff, and file content endpoints.' }; const systemTag = { name: 'System', description: 'System health and version endpoints.' }; @@ -175,6 +177,23 @@ export function createPublicOpenApiDocument(version: string) { }, }); + registry.registerPath({ + method: 'get', + path: '/api/connections', + operationId: 'listConnections', + tags: [connectionsTag.name], + summary: 'List connections', + description: 'Returns unique code host connections associated with at least one repository visible to the caller. Connection configuration and credentials are never included.', + responses: { + 200: { + description: 'Connections associated with visible repositories.', + content: jsonContent(publicListConnectionsResponseSchema), + }, + 401: errorJson('Authentication is required when anonymous access is disabled.'), + 500: errorJson('Unexpected connection listing failure.'), + }, + }); + registry.registerPath({ method: 'get', path: '/api/version', @@ -609,7 +628,7 @@ export function createPublicOpenApiDocument(version: string) { version, description: 'OpenAPI description for the public Sourcebot REST endpoints used for search, repository listing, and file browsing. Authentication is instance-dependent: API keys are the standard integration mechanism, OAuth bearer tokens are EE-only, and some instances may allow anonymous access.', }, - tags: [searchTag, reposTag, gitTag, scopedAccessTokensTag, systemTag, eeTag], + tags: [searchTag, connectionsTag, reposTag, gitTag, scopedAccessTokensTag, systemTag, eeTag], security: [ { [securitySchemeNames.bearerToken]: [] }, { [securitySchemeNames.apiKeyHeader]: [] }, diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index 869e29ba5..3a375d529 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -1,4 +1,5 @@ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { ConnectionType } from '@sourcebot/db'; import z from 'zod'; import { findRelatedSymbolsRequestSchema, @@ -45,6 +46,11 @@ export const publicFileSourceResponseSchema = fileSourceResponseSchema.openapi(' export const publicFileBlameRequestSchema = fileBlameRequestSchema.openapi('PublicFileBlameRequest'); export const publicFileBlameResponseSchema = fileBlameResponseSchema.openapi('PublicFileBlameResponse'); export const publicVersionResponseSchema = getVersionResponseSchema.openapi('PublicVersionResponse'); +export const publicListConnectionsResponseSchema = z.array(z.object({ + id: z.number().int(), + name: z.string(), + connectionType: z.nativeEnum(ConnectionType), +})).openapi('PublicListConnectionsResponse'); export const publicListReposQueryParamsSchema = listReposQueryParamsSchema.openapi('PublicListReposQuery'); export const publicListReposResponseSchema = listReposResponseSchema.openapi('PublicListReposResponse'); export const publicGetDiffRequestSchema = getDiffRequestSchema.openapi('PublicGetDiffRequest');