diff --git a/CHANGELOG.md b/CHANGELOG.md index a33e4c728..141246958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `GET /api/connections` returns a paginated, auth-gated list of code-host connections in the org with per-connection sync state (last sync timestamp, latest job status, in-flight job count, repo count) so operators can monitor connection health from a script and pipe into Prometheus / Datadog / Grafana / Slack alerts. [#1517](https://github.com/sourcebot-dev/sourcebot/pull/1517) + ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 7419445de..bc39fc447 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -537,6 +537,123 @@ "status" ] }, + "PublicConnectionLatestJob": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "PENDING", + "IN_PROGRESS", + "COMPLETED", + "FAILED" + ] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "errorMessage": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "status", + "createdAt", + "completedAt", + "errorMessage" + ] + }, + "PublicConnectionSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string", + "enum": [ + "github", + "gitlab", + "gitea", + "gerrit", + "bitbucket-server", + "bitbucket-cloud", + "generic-git-host", + "azuredevops" + ] + }, + "isDeclarative": { + "type": "boolean" + }, + "syncedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "repoCount": { + "type": "integer", + "minimum": 0 + }, + "inFlightJobCount": { + "type": "integer", + "minimum": 0 + }, + "latestJob": { + "$ref": "#/components/schemas/PublicConnectionLatestJob" + } + }, + "required": [ + "id", + "name", + "connectionType", + "isDeclarative", + "syncedAt", + "createdAt", + "updatedAt", + "repoCount", + "inFlightJobCount", + "latestJob" + ] + }, + "PublicListConnectionsResponse": { + "type": "object", + "properties": { + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicConnectionSummary" + } + } + }, + "required": [ + "connections" + ] + }, "PublicFileSourceResponse": { "type": "object", "properties": { @@ -1479,6 +1596,98 @@ } } }, + "/api/connections": { + "get": { + "operationId": "listConnections", + "tags": [ + "System" + ], + "summary": "List code-host connections", + "description": "Returns a paginated list of code-host connections in the org, with per-connection sync state (last sync timestamp, latest job status, in-flight job count, repo count). Auth-gated. The connection `config` is never returned (it carries tokens).", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 1 + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "perPage", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Paginated connection list.", + "headers": { + "X-Total-Count": { + "description": "Total number of connections matching the query across all pages.", + "schema": { + "type": "integer", + "example": 5 + } + }, + "Link": { + "description": "Pagination links formatted per RFC 8288. Includes `rel=\"next\"`, `rel=\"prev\"`, `rel=\"first\"`, and `rel=\"last\"` when applicable.", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicListConnectionsResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "401": { + "description": "Not authenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected connection listing failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, "/api/source": { "get": { "operationId": "getFileSource", diff --git a/docs/docs.json b/docs/docs.json index ad45e7fc8..597663145 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -217,7 +217,8 @@ "icon": "server", "pages": [ "GET /api/version", - "GET /api/health" + "GET /api/health", + "GET /api/connections" ] } ] 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..6d23a2233 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts @@ -0,0 +1,83 @@ +import 'server-only'; + +import { ConnectionSyncJobStatus } from '@sourcebot/db'; +import { withAuth } from '@/middleware/withAuth'; +import { sew } from '@/middleware/sew'; + +export interface ListConnectionsParams { + page: number; + perPage: number; +} + +export const listConnections = async ( + { page, perPage }: ListConnectionsParams, +) => sew(() => + withAuth(async ({ prisma, org }) => { + const skip = (page - 1) * perPage; + + const [connections, totalCount] = await Promise.all([ + prisma.connection.findMany({ + where: { orgId: org.id }, + orderBy: { name: 'asc' }, + skip, + take: perPage, + include: { + _count: { + select: { + repos: true, + syncJobs: true, + }, + }, + syncJobs: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }), + prisma.connection.count({ where: { orgId: org.id } }), + ]); + + // Count in-flight jobs (PENDING + IN_PROGRESS) per connection in + // a single grouped query rather than N+1 small counts. + const inFlightByConnection = await prisma.connectionSyncJob.groupBy({ + by: ['connectionId'], + where: { + connectionId: { in: connections.map((c) => c.id) }, + status: { + in: [ConnectionSyncJobStatus.PENDING, ConnectionSyncJobStatus.IN_PROGRESS], + }, + }, + _count: { _all: true }, + }); + const inFlightMap = new Map( + inFlightByConnection.map((row) => [row.connectionId, row._count._all]), + ); + + return { + data: connections.map((connection) => { + const latestJob = connection.syncJobs[0] ?? null; + return { + id: connection.id, + name: connection.name, + connectionType: connection.connectionType, + isDeclarative: connection.isDeclarative, + syncedAt: connection.syncedAt, + createdAt: connection.createdAt, + updatedAt: connection.updatedAt, + repoCount: connection._count.repos, + inFlightJobCount: inFlightMap.get(connection.id) ?? 0, + latestJob: latestJob + ? { + id: latestJob.id, + status: latestJob.status, + createdAt: latestJob.createdAt, + completedAt: latestJob.completedAt, + errorMessage: latestJob.errorMessage, + } + : null, + }; + }), + totalCount, + }; + }), +); diff --git a/packages/web/src/app/api/(server)/connections/route.test.ts b/packages/web/src/app/api/(server)/connections/route.test.ts new file mode 100644 index 000000000..14d8b0714 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/route.test.ts @@ -0,0 +1,378 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +type AuthContext = { + user: { id: string }; + org: { id: number }; + prisma: unknown; +}; + +// Lightweight stand-ins for the Prisma enums. Re-exported as the same +// names so the route's `z.nativeEnum(...)` validation accepts the test +// values. The real enums live in `@sourcebot/db`; we mock that package +// to keep the OpenTelemetry CJS chain out of the test load path. +const ConnectionSyncJobStatus = { + PENDING: 'PENDING', + IN_PROGRESS: 'IN_PROGRESS', + COMPLETED: 'COMPLETED', + FAILED: 'FAILED', +} as const; + +const CodeHostType = { + github: 'github', + gitlab: 'gitlab', + gitea: 'gitea', + gerrit: 'gerrit', + bitbucketServer: 'bitbucket-server', + bitbucketCloud: 'bitbucket-cloud', + genericGitHost: 'generic-git-host', + azuredevops: 'azuredevops', +} as const; + +const ConnectionType = { + github: 'github', + gitlab: 'gitlab', + gitea: 'gitea', + gerrit: 'gerrit', + bitbucketServer: 'bitbucket-server', + bitbucketCloud: 'bitbucket-cloud', + genericGitHost: 'generic-git-host', + azuredevops: 'azuredevops', +} as const; + +const mocks = vi.hoisted(() => ({ + authContext: undefined as AuthContext | undefined, +})); + +vi.mock('server-only', () => ({})); + +vi.mock('@sourcebot/db', () => ({ + ConnectionSyncJobStatus, + ConnectionType, + CodeHostType, +})); + +// `sew.ts` imports `@sentry/nextjs`, which transitively pulls in +// `@opentelemetry/sdk-trace-base`. The pre-existing version mismatch +// (sdk-trace-base 1.28.0 expects `core.getEnv`, but core 2.8.0 dropped +// it) makes the import fail at test-load time. Stub Sentry and the OTel +// modules so the route file can be loaded without exercising the SDK. +vi.mock('@sentry/nextjs', () => ({ + captureException: vi.fn(), + captureRequestError: vi.fn(), +})); + +vi.mock('@opentelemetry/sdk-trace-base', () => ({ + getEnv: () => ({}), + TraceIdRatioBasedSampler: vi.fn(), + ParentBasedSampler: vi.fn(), + AlwaysOnSampler: vi.fn(), + AlwaysOffSampler: vi.fn(), +})); + +vi.mock('@sourcebot/shared', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('@/lib/posthog', () => ({ + captureEvent: vi.fn(), +})); + +vi.mock('@/middleware/withAuth', () => ({ + withAuth: vi.fn(async (callback: (ctx: AuthContext) => unknown) => { + if (!mocks.authContext) { + // Mirror the production behavior: if no auth, return a + // service error. The route is responsible for translating + // that into a 401. + return { statusCode: 401, errorCode: 'NOT_AUTHENTICATED', message: 'Not authenticated' }; + } + return callback(mocks.authContext); + }), +})); + +const makeRequest = (search: Record = {}): NextRequest => { + const params = new URLSearchParams(search); + const url = `http://localhost/api/connections?${params.toString()}`; + return new NextRequest(url); +}; + +const { GET } = await import('./route'); + +// The Prisma mock is shaped to drive the four query paths the route +// uses: listConnections.findMany, listConnections.count, +// connectionSyncJob.groupBy, plus the relations via `include`. The +// mock includes a populated `config` field on every row so the +// "config is not in the response" regression test can actually +// detect a future change that accidentally spreads raw Prisma rows +// into the response shape. +const buildPrismaMock = (opts: { + connections: Array<{ + id: number; + name: string; + connectionType: typeof ConnectionType[keyof typeof ConnectionType]; + isDeclarative: boolean; + syncedAt: Date | null; + createdAt: Date; + updatedAt: Date; + repoCount: number; + latestJob: { + id: string; + status: typeof ConnectionSyncJobStatus[keyof typeof ConnectionSyncJobStatus]; + createdAt: Date; + completedAt: Date | null; + errorMessage: string | null; + } | null; + }>; + inFlight: Map; + totalCount: number; +}) => { + const findMany = vi.fn(async () => opts.connections.map((c) => ({ + id: c.id, + name: c.name, + connectionType: c.connectionType, + isDeclarative: c.isDeclarative, + syncedAt: c.syncedAt, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + // The config is the actual JSON the user stored (e.g. + // { token: { env: 'GH_TOKEN' } }) for declarative connections. + // It must never appear in the public response. We include it + // here so the regression test below is meaningful: a future + // change that spreads raw Prisma rows will leak this field. + config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }, + _count: { repos: c.repoCount, syncJobs: c.latestJob ? 1 : 0 }, + syncJobs: c.latestJob ? [c.latestJob] : [], + }))); + const count = vi.fn(async () => opts.totalCount); + const groupBy = vi.fn(async () => Array.from(opts.inFlight.entries()).map(([connectionId, count]) => ({ + connectionId, + _count: { _all: count }, + }))); + return { connection: { findMany, count }, connectionSyncJob: { groupBy } } as unknown; +}; + +const setAuth = (connections: Parameters[0]['connections'], opts?: { inFlight?: Map; totalCount?: number }) => { + const allConnections = opts?.inFlight ? new Map(opts.inFlight) : new Map(); + mocks.authContext = { + user: { id: 'user_1' }, + org: { id: 1 }, + prisma: buildPrismaMock({ + connections, + inFlight: allConnections, + totalCount: opts?.totalCount ?? connections.length, + }), + }; +}; + +describe('GET /api/connections', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authContext = undefined; + }); + + test('returns 401 when no authenticated user', async () => { + // withAuth returns a service error; the route returns 401. + mocks.authContext = undefined; + const response = await GET(makeRequest()); + expect(response.status).toBe(401); + }); + + test('returns the documented connection shape on a happy path', async () => { + const now = new Date('2026-07-25T14:00:00.000Z'); + setAuth([ + { + id: 1, + name: 'github-public', + connectionType: ConnectionType.github, + isDeclarative: false, + syncedAt: now, + createdAt: new Date('2026-06-12T08:21:00.000Z'), + updatedAt: now, + repoCount: 137, + latestJob: { + id: 'job_1', + status: ConnectionSyncJobStatus.COMPLETED, + createdAt: new Date('2026-07-25T13:59:30.000Z'), + completedAt: now, + errorMessage: null, + }, + }, + ]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.connections).toHaveLength(1); + expect(body.connections[0]).toEqual({ + id: 1, + name: 'github-public', + connectionType: 'github', + isDeclarative: false, + syncedAt: now.toISOString(), + createdAt: new Date('2026-06-12T08:21:00.000Z').toISOString(), + updatedAt: now.toISOString(), + repoCount: 137, + inFlightJobCount: 0, + latestJob: { + id: 'job_1', + status: 'COMPLETED', + createdAt: new Date('2026-07-25T13:59:30.000Z').toISOString(), + completedAt: now.toISOString(), + errorMessage: null, + }, + }); + }); + + test('returns latestJob:null for a connection that has never been synced', async () => { + setAuth([ + { + id: 2, + name: 'never-synced', + connectionType: ConnectionType.gitlab, + isDeclarative: false, + syncedAt: null, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + repoCount: 0, + latestJob: null, + }, + ]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.connections[0].latestJob).toBeNull(); + expect(body.connections[0].syncedAt).toBeNull(); + expect(body.connections[0].repoCount).toBe(0); + }); + + test('exposes the in-flight job count per connection', async () => { + setAuth( + [ + { + id: 3, + name: 'busy', + connectionType: ConnectionType.github, + isDeclarative: false, + syncedAt: null, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + repoCount: 5, + latestJob: { + id: 'job_3', + status: ConnectionSyncJobStatus.IN_PROGRESS, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + completedAt: null, + errorMessage: null, + }, + }, + ], + { inFlight: new Map([[3, 2]]) }, + ); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.connections[0].inFlightJobCount).toBe(2); + }); + + test('exposes the latest job errorMessage verbatim', async () => { + setAuth([ + { + id: 4, + name: 'broken', + connectionType: ConnectionType.github, + isDeclarative: false, + syncedAt: null, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + repoCount: 0, + latestJob: { + id: 'job_4', + status: ConnectionSyncJobStatus.FAILED, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + completedAt: new Date('2026-07-01T00:00:30.000Z'), + errorMessage: 'connection refused: host=github.example.com:443', + }, + }, + ]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + expect(body.connections[0].latestJob.status).toBe('FAILED'); + expect(body.connections[0].latestJob.errorMessage).toBe( + 'connection refused: host=github.example.com:443', + ); + }); + + test('does NOT include the connection config (which carries tokens)', async () => { + setAuth([ + { + id: 5, + name: 'token-bearing', + connectionType: ConnectionType.github, + isDeclarative: false, + syncedAt: null, + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + repoCount: 0, + latestJob: null, + }, + ]); + + const response = await GET(makeRequest()); + const body = await response.json(); + + // The config is the actual JSON the user stored (e.g. { token: { env: 'GH_TOKEN' } }) + // for declarative connections. It must not be in the public response. + expect(body.connections[0].config).toBeUndefined(); + }); + + test('returns 400 for perPage > 100', async () => { + setAuth([]); + const response = await GET(makeRequest({ perPage: '1000' })); + expect(response.status).toBe(400); + }); + + test('returns 400 for perPage <= 0', async () => { + setAuth([]); + const response = await GET(makeRequest({ perPage: '0' })); + expect(response.status).toBe(400); + }); + + test('returns 400 for non-integer page', async () => { + setAuth([]); + const response = await GET(makeRequest({ page: 'abc' })); + expect(response.status).toBe(400); + }); + + test('defaults page=1 and perPage=50 when no query params are provided', async () => { + setAuth([]); + const response = await GET(makeRequest()); + // Empty list still returns 200 (not 400); defaults are accepted. + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.connections).toEqual([]); + }); + + test('emits X-Total-Count and Link headers on the response', async () => { + setAuth([], { totalCount: 137 }); + + const response = await GET(makeRequest({ page: '1', perPage: '10' })); + + expect(response.headers.get('X-Total-Count')).toBe('137'); + const link = response.headers.get('Link'); + expect(link).toBeTruthy(); + expect(link).toContain('rel="first"'); + expect(link).toContain('rel="last"'); + expect(link).toContain('rel="next"'); + }); +}); 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..87ed3bf3b --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/route.ts @@ -0,0 +1,60 @@ +'use server'; + +import { NextRequest } from 'next/server'; + +import { apiHandler } from '@/lib/apiHandler'; +import { buildLinkHeader } from '@/lib/pagination'; +import { + listConnectionsQueryParamsSchema, +} from '@/lib/schemas'; +import { + queryParamsSchemaValidationError, + 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 withAuth +export const GET = apiHandler(async (request: NextRequest) => { + const rawParams = Object.fromEntries( + Object.keys(listConnectionsQueryParamsSchema.shape).map((key) => [ + key, + request.nextUrl.searchParams.get(key) ?? undefined, + ]), + ); + const parsed = listConnectionsQueryParamsSchema.safeParse(rawParams); + + if (!parsed.success) { + return serviceErrorResponse( + queryParamsSchemaValidationError(parsed.error), + ); + } + + const { page, perPage } = parsed.data; + + const result = await listConnections({ page, perPage }); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + const { data, totalCount } = result; + + const headers = new Headers({ 'Content-Type': 'application/json' }); + headers.set('X-Total-Count', totalCount.toString()); + + const linkHeader = buildLinkHeader(request, { + page, + perPage, + totalCount, + }); + if (linkHeader) { + headers.set('Link', linkHeader); + } + + return new Response(JSON.stringify({ connections: data }), { + status: 200, + headers, + }); +}); diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 4fdae9cad..2a3393175 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { CodeHostType } from "@sourcebot/db"; +import { CodeHostType, ConnectionSyncJobStatus, ConnectionType } from "@sourcebot/db"; export const repositoryQuerySchema = z.object({ codeHostType: z.nativeEnum(CodeHostType), @@ -40,4 +40,32 @@ export const listReposQueryParamsSchema = z.object({ query: z.string().optional(), }); -export const listReposResponseSchema = repositoryQuerySchema.array(); \ No newline at end of file +export const listReposResponseSchema = repositoryQuerySchema.array(); + +export const listConnectionsQueryParamsSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().positive().max(100).default(50), +}); + +export const connectionSummarySchema = z.object({ + id: z.number(), + name: z.string(), + connectionType: z.nativeEnum(ConnectionType), + isDeclarative: z.boolean(), + syncedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + repoCount: z.number().int().nonnegative(), + inFlightJobCount: z.number().int().nonnegative(), + latestJob: z.object({ + id: z.string(), + status: z.nativeEnum(ConnectionSyncJobStatus), + createdAt: z.coerce.date(), + completedAt: z.coerce.date().nullable(), + errorMessage: z.string().nullable(), + }).nullable(), +}); + +export const listConnectionsResponseSchema = z.object({ + connections: z.array(connectionSummarySchema), +}); \ No newline at end of file diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index d0f11ee84..a7fa1e498 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -26,6 +26,8 @@ import { publicListCommitsResponseSchema, publicListReposQueryParamsSchema, publicListReposResponseSchema, + publicListConnectionsQueryParamsSchema, + publicListConnectionsResponseSchema, publicSearchRequestSchema, publicSearchResponseSchema, publicServiceErrorSchema, @@ -201,6 +203,37 @@ export function createPublicOpenApiDocument(version: string) { }, }); + registry.registerPath({ + method: 'get', + path: '/api/connections', + operationId: 'listConnections', + tags: [systemTag.name], + summary: 'List code-host connections', + description: 'Returns a paginated list of code-host connections in the org, with per-connection sync state (last sync timestamp, latest job status, in-flight job count, repo count). Auth-gated. The connection `config` is never returned (it carries tokens).', + request: { + query: publicListConnectionsQueryParamsSchema, + }, + responses: { + 200: { + description: 'Paginated connection list.', + headers: { + 'X-Total-Count': { + description: 'Total number of connections matching the query across all pages.', + schema: { type: 'integer', example: 5 }, + }, + Link: { + description: 'Pagination links formatted per RFC 8288. Includes `rel="next"`, `rel="prev"`, `rel="first"`, and `rel="last"` when applicable.', + schema: { type: 'string' }, + }, + }, + content: jsonContent(publicListConnectionsResponseSchema), + }, + 400: errorJson('Invalid query parameters.'), + 401: errorJson('Not authenticated.'), + 500: errorJson('Unexpected connection listing failure.'), + }, + }); + registry.registerPath({ method: 'get', path: '/api/source', diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index de1c4011f..a60eec6df 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -64,6 +64,45 @@ export const publicHealthResponseSchema = z.object({ status: z.enum(['ok']), }).openapi('PublicHealthResponse'); +export const publicListConnectionsQueryParamsSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().positive().max(100).default(50), +}).openapi('PublicListConnectionsQuery'); + +export const publicConnectionLatestJobSchema = z.object({ + id: z.string(), + status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED']), + createdAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), + errorMessage: z.string().nullable(), +}).openapi('PublicConnectionLatestJob'); + +export const publicConnectionSummarySchema = z.object({ + id: z.number().int().positive(), + name: z.string(), + connectionType: z.enum([ + 'github', + 'gitlab', + 'gitea', + 'gerrit', + 'bitbucket-server', + 'bitbucket-cloud', + 'generic-git-host', + 'azuredevops', + ]), + isDeclarative: z.boolean(), + syncedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + repoCount: z.number().int().nonnegative(), + inFlightJobCount: z.number().int().nonnegative(), + latestJob: publicConnectionLatestJobSchema.nullable(), +}).openapi('PublicConnectionSummary'); + +export const publicListConnectionsResponseSchema = z.object({ + connections: z.array(publicConnectionSummarySchema), +}).openapi('PublicListConnectionsResponse'); + // EE: User Management export const publicEeUserSchema = z.object({ id: z.string(),