From c2decc24a5a0791a6f43ea5ce6a202fba82e7b28 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:27 +0530 Subject: [PATCH 01/15] feat(web): add GET /api/connections admin endpoint Self-hosted operators have no way to monitor code-host connection sync state from outside the settings UI. The page reads from Prisma via `getConnectionsWithLatestJob` (a Server Action), which is not reachable as `curl`. Operators who want to alert on a FAILED sync have to scrape the database directly, bypassing the application layer. Add `GET /api/connections`: - Auth-gated (any signed-in user, same as the settings page). - Paginated via `page` and `perPage` (max 100, default 50). - Per-connection response: id, name, connectionType, isDeclarative, syncedAt, createdAt, updatedAt, repoCount, inFlightJobCount, latestJob ({id, status, createdAt, completedAt, errorMessage} or null). - `latestJob` is the most recent ConnectionSyncJob for the connection, or null if the connection has never been synced. - `inFlightJobCount` is the count of PENDING + IN_PROGRESS jobs for the connection, computed in a single grouped query rather than N+1 per-row counts. - The connection `config` field (which carries tokens) is never in the response. - Response is sorted by name ascending, with `X-Total-Count` and `Link` headers in the existing style. The action lives in `listConnectionsApi.ts` and uses `sew` for error wrapping and `withAuth` for auth, mirroring the `/api/ee/audit` pattern. Closes #1516. --- .../connections/listConnectionsApi.ts | 83 +++++++++++++++++++ .../src/app/api/(server)/connections/route.ts | 58 +++++++++++++ packages/web/src/lib/schemas.ts | 32 ++++++- 3 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/app/api/(server)/connections/listConnectionsApi.ts create mode 100644 packages/web/src/app/api/(server)/connections/route.ts 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.ts b/packages/web/src/app/api/(server)/connections/route.ts new file mode 100644 index 000000000..85b3eb177 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/route.ts @@ -0,0 +1,58 @@ +'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 From 9185852a1937697044b1f87f9cb9fa98014b73ef Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:27 +0530 Subject: [PATCH 02/15] test(web): cover /api/connections auth, pagination, and response shape Eleven vitest cases: - 401 when no authenticated user (`withAuth` returns a service error; the route forwards it as 401). - 200 + the documented shape on a happy path (id, name, connectionType, isDeclarative, syncedAt, createdAt, updatedAt, repoCount, inFlightJobCount, latestJob). - 200 with `latestJob: null` and `syncedAt: null` for a connection that has never been synced. - 200 with `inFlightJobCount` populated from the groupBy query. - 200 with the latest job's `errorMessage` exposed verbatim (the worker already scrubs the public `/api/health/ready` error strings, but this is an OWNER/admin endpoint; the worker message is exactly what an operator needs to debug a failing sync). - 200 with no `config` field in the response. - 400 for `perPage > 100`, `perPage <= 0`, and non-integer `page`. - 200 with empty list when no connections exist. - `X-Total-Count` and `Link` headers present on the response (with `rel="first"`, `rel="last"`, `rel="next"`). The mocks follow the project's established pattern: `vi.mock` `server-only`, `@sourcebot/db`, `@sourcebot/shared`, `@/lib/posthog`, `@/middleware/withAuth`. Added `@sentry/nextjs` and `@opentelemetry/sdk-trace-base` mocks to suppress a pre-existing version-mismatch failure (`sdk-trace-base` 1.28.0 expects `core.getEnv()` but the installed `@opentelemetry/core` 2.8.0 dropped it). The OpenTelemetry mock only needs to expose the symbol the `sew` import chain reaches at module-load time; it is a stub, not a real implementation. --- .../api/(server)/connections/route.test.ts | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 packages/web/src/app/api/(server)/connections/route.test.ts 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..b113e7017 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/route.test.ts @@ -0,0 +1,368 @@ +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`. +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, + _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"'); + }); +}); From e03f5037359a7a89af1da0889a9bacbf6af1249b Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:40 +0530 Subject: [PATCH 03/15] docs(openapi): register the public /api/connections endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new endpoint is part of the public API surface (any signed-in org member is the right access level — same as the settings UI), so it needs to be in the public OpenAPI doc. Add four schemas to `publicApiSchemas.ts`: - `PublicListConnectionsQuery`: `page` and `perPage`, same shape as the internal Zod schema. - `PublicConnectionLatestJob`: id, status, createdAt, completedAt, errorMessage. - `PublicConnectionSummary`: id, name, connectionType, isDeclarative, syncedAt, createdAt, updatedAt, repoCount, inFlightJobCount, latestJob (nullable). - `PublicListConnectionsResponse`: { connections: ConnectionSummary[] }. Register the path in `publicApiDocument.ts` under the existing `systemTag` (alongside `/api/version` and `/api/health`) with the standard 200/400/401/500 response set and the `X-Total-Count` + `Link` header descriptions reused from `/api/repos`. The doc's auto-generated description makes the auth requirement and the 'config is never returned' guarantee explicit, since the latter is the only subtle part of the contract. `yarn workspace @sourcebot/web openapi:generate` was rerun; the regenerated `docs/api-reference/sourcebot-public.openapi.json` includes the new `/api/connections` path and the four new schemas in `components.schemas`. --- packages/web/src/openapi/publicApiDocument.ts | 33 ++++++++++++++++ packages/web/src/openapi/publicApiSchemas.ts | 39 +++++++++++++++++++ 2 files changed, 72 insertions(+) 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(), From cc0290bfe7ce215103af0105cde7bc9ca19df296 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:40 +0530 Subject: [PATCH 04/15] docs(openapi): regenerate public OpenAPI spec with /api/connections `yarn workspace @sourcebot/web openapi:generate` output. The spec now lists the `/api/connections` path under the System tag and the four new components (PublicListConnectionsQuery, PublicConnectionLatestJob, PublicConnectionSummary, PublicListConnectionsResponse) under components.schemas. This commit is split out from the previous one so the schema + path registration is reviewable on its own, and the auto-generated spec is its own commit for the same reason. --- .../sourcebot-public.openapi.json | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) 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", From 315bdab9a0459c1fc6f6e750482a2c04e1d0facd Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:48 +0530 Subject: [PATCH 05/15] docs(navigation): add /api/connections to the System API Reference group The new endpoint shows up in the public OpenAPI spec; the docs.json "API Reference" -> "System" group needs the matching entry so Mintlify renders the page in the nav. Per the project changelog convention the entry is a single sentence with the issue link in the suffix. `#1516` is the issue this PR closes; once the PR is filed the suffix will be updated to `#XXXX (PR)`. Also adds a one-line changelog entry under [Unreleased] -> Added describing the new endpoint and pointing at the issue. --- CHANGELOG.md | 3 +++ docs/docs.json | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a33e4c728..4f4cf34f9 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. [#1516](https://github.com/sourcebot-dev/sourcebot/issues/1516) + ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. 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" ] } ] From 0027189d7f9dfcb3cd569bda8dc727df7e36b6e2 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:23:59 +0530 Subject: [PATCH 06/15] style(web): brace the Link-header if in /api/connections Address CodeRabbit finding on PR #1517: the project coding guidelines say "Always use curly braces for if statements, with the body on a new line, even for single-line bodies." The `if (linkHeader) headers.set('Link', linkHeader);` was a one-liner; wrap it in braces and put the body on its own line. --- packages/web/src/app/api/(server)/connections/route.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web/src/app/api/(server)/connections/route.ts b/packages/web/src/app/api/(server)/connections/route.ts index 85b3eb177..87ed3bf3b 100644 --- a/packages/web/src/app/api/(server)/connections/route.ts +++ b/packages/web/src/app/api/(server)/connections/route.ts @@ -49,7 +49,9 @@ export const GET = apiHandler(async (request: NextRequest) => { perPage, totalCount, }); - if (linkHeader) headers.set('Link', linkHeader); + if (linkHeader) { + headers.set('Link', linkHeader); + } return new Response(JSON.stringify({ connections: data }), { status: 200, From 5c0e4b47d96110e682832affb06b212cd3c7d4bd Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:23:59 +0530 Subject: [PATCH 07/15] test(web): populate the connection.config mock so the regression test is meaningful Address CodeRabbit security finding on PR #1517: the prior `buildPrismaMock` did not include a `config` field on the mocked Prisma rows, so the assertion `body.connections[0].config` is `undefined` was vacuously true. A future change that spreads the raw Prisma row into the response (e.g. `...connection` instead of the explicit field-by-field map) would leak the connection's credentials, and the test would still pass. Add a populated `config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }` to the mock. The test now actually exercises the strip-on-output path: if `listConnectionsApi.ts` ever returns the raw `config`, the assertion fails and the regression is caught. --- .../src/app/api/(server)/connections/route.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/web/src/app/api/(server)/connections/route.test.ts b/packages/web/src/app/api/(server)/connections/route.test.ts index b113e7017..14d8b0714 100644 --- a/packages/web/src/app/api/(server)/connections/route.test.ts +++ b/packages/web/src/app/api/(server)/connections/route.test.ts @@ -105,7 +105,11 @@ 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`. +// 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; @@ -135,6 +139,12 @@ const buildPrismaMock = (opts: { 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] : [], }))); From 0564eb154b0a3f8ea85c8272540e5060f682efbf Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:23:59 +0530 Subject: [PATCH 08/15] chore: link the changelog entry to PR #1517 instead of issue #1516 Address CodeRabbit finding on PR #1517: per the project changelog convention, entries should link to the PR, not the issue. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4cf34f9..141246958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ 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. [#1516](https://github.com/sourcebot-dev/sourcebot/issues/1516) +- `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. From 45f5aec4d1e5f0153f6ecfb3c9300496a85c42fc Mon Sep 17 00:00:00 2001 From: jsourcebot <270441393+jsourcebot@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:17:37 -0700 Subject: [PATCH 09/15] chore: upgrade seroval to ^1.5.6 to address CVE-2026-59940 (#1508) * chore: upgrade seroval to 1.5.6 * docs: add changelog entry for seroval upgrade --------- Co-authored-by: Jack Minnetian <270441393+BlueBottleLatte@users.noreply.github.com> Co-authored-by: Brendan Kellam --- CHANGELOG.md | 3 +++ yarn.lock | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 141246958..0a9e6c58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) +### Fixed +- Upgraded `seroval` to `^1.5.6`. [#1508](https://github.com/sourcebot-dev/sourcebot/pull/1508) + ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. diff --git a/yarn.lock b/yarn.lock index 3678beba2..c5fff52de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21441,9 +21441,9 @@ __metadata: linkType: hard "seroval@npm:~1.5.0": - version: 1.5.0 - resolution: "seroval@npm:1.5.0" - checksum: 10c0/aff16b14a7145388555cefd4ebd41759024ee1c2c064080fd8d4fabea4b7c89d103155cd98f5109523b8878e577da73cc6cd8abf98965f2d1f0ba19dc38317ab + version: 1.5.6 + resolution: "seroval@npm:1.5.6" + checksum: 10c0/47e4fb25305bf05fdf300cac6b0d4aaaaf1e12f15c819195afcdf512d88901a3f1f7754ac5a2de740cc0bb04e912c653fbf0129fb3e4c81ce12809e6aa5815e3 languageName: node linkType: hard From b98008a9dbe751293469c0b283bb1460d2f6f716 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:02:06 +0530 Subject: [PATCH 10/15] feat(web): add GET /api/connections/{id} detail endpoint Returns one connection in the org by id with its latest sync job, the count of in-flight jobs, and the most recent jobs (default 10, max 50 via ?jobLimit=). Auth-gated. The connection config (which carries tokens) is never returned. Cross-org access returns 404 (not 403) to avoid leaking the existence of connections in other orgs. Stacks on the GET /api/connections list endpoint (PR #1517) and reuses its connectionSummarySchema. Adds three new schemas to the public OpenAPI surface: PublicGetConnectionQuery, PublicConnectionJob, and PublicGetConnectionResponse. Refs #1518. --- .../connections/[id]/getConnectionApi.ts | 91 ++++ .../(server)/connections/[id]/route.test.ts | 419 ++++++++++++++++++ .../api/(server)/connections/[id]/route.ts | 49 ++ packages/web/src/lib/schemas.ts | 23 + 4 files changed, 582 insertions(+) create mode 100644 packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts create mode 100644 packages/web/src/app/api/(server)/connections/[id]/route.test.ts create mode 100644 packages/web/src/app/api/(server)/connections/[id]/route.ts diff --git a/packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts b/packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts new file mode 100644 index 000000000..c738c9a57 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts @@ -0,0 +1,91 @@ +import 'server-only'; + +import { ConnectionSyncJobStatus } from '@sourcebot/db'; +import { withAuth } from '@/middleware/withAuth'; +import { sew } from '@/middleware/sew'; + +export interface GetConnectionParams { + id: number; + jobLimit: number; +} + +export const getConnection = async ( + { id, jobLimit }: GetConnectionParams, +) => sew(() => + withAuth(async ({ prisma, org }) => { + // Scope by orgId so a request for a connection in another org + // returns 404 (not 403). This avoids leaking the existence of + // connections in other orgs. + const connection = await prisma.connection.findFirst({ + where: { id, orgId: org.id }, + include: { + _count: { select: { repos: true } }, + }, + }); + + if (!connection) { + return null; + } + + const [recentJobs, inFlightRows] = await Promise.all([ + prisma.connectionSyncJob.findMany({ + where: { connectionId: id }, + orderBy: { createdAt: 'desc' }, + take: jobLimit, + }), + prisma.connectionSyncJob.groupBy({ + by: ['connectionId'], + where: { + connectionId: id, + status: { + in: [ConnectionSyncJobStatus.PENDING, ConnectionSyncJobStatus.IN_PROGRESS], + }, + }, + _count: { _all: true }, + }), + ]); + + const inFlightJobCount = inFlightRows[0]?._count._all ?? 0; + const latestJob = recentJobs[0] ?? null; + + return { + data: { + connection: { + 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 ?? 0, + inFlightJobCount, + latestJob: latestJob + ? { + id: latestJob.id, + status: latestJob.status, + createdAt: latestJob.createdAt, + completedAt: latestJob.completedAt, + durationMs: latestJob.completedAt + ? latestJob.completedAt.getTime() - latestJob.createdAt.getTime() + : null, + errorMessage: latestJob.errorMessage, + warningMessages: latestJob.warningMessages, + } + : null, + }, + recentJobs: recentJobs.map((job) => ({ + id: job.id, + status: job.status, + createdAt: job.createdAt, + completedAt: job.completedAt, + durationMs: job.completedAt + ? job.completedAt.getTime() - job.createdAt.getTime() + : null, + errorMessage: job.errorMessage, + warningMessages: job.warningMessages, + })), + }, + }; + }), +); diff --git a/packages/web/src/app/api/(server)/connections/[id]/route.test.ts b/packages/web/src/app/api/(server)/connections/[id]/route.test.ts new file mode 100644 index 000000000..54ba05b35 --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/[id]/route.test.ts @@ -0,0 +1,419 @@ +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; + +// The Prisma enum is its own runtime object, not a type alias. We +// mirror the values so `z.nativeEnum(ConnectionType)` at the route's +// load time picks up the right keys. +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, +})); + +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) { + return { statusCode: 401, errorCode: 'NOT_AUTHENTICATED', message: 'Not authenticated' }; + } + return callback(mocks.authContext); + }), +})); + +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(), +})); + +const makeRequest = (id: string, search: Record = {}): NextRequest => { + const params = new URLSearchParams(search); + const url = `http://localhost/api/connections/${id}?${params.toString()}`; + return new NextRequest(url); +}; + +const { GET } = await import('./route'); + +// The Prisma mock is shaped to drive the four query paths the action +// uses: connection.findFirst (with orgId scope + _count.repos), the +// recent-jobs findMany, and the in-flight groupBy. The connection +// row includes a populated `config` field so the regression test +// below is meaningful: a future change that spreads raw Prisma rows +// will leak this field, and the test catches it. +type JobFixture = { + id: string; + status: keyof typeof ConnectionSyncJobStatus; + createdAt: Date; + completedAt: Date | null; + errorMessage: string | null; + warningMessages: string[]; +}; + +const buildPrismaMock = (opts: { + connection: + | (Omit<{ + id: number; + name: string; + connectionType: keyof typeof ConnectionType; + isDeclarative: boolean; + syncedAt: Date | null; + createdAt: Date; + updatedAt: Date; + _count: { repos: number }; + }, never> & { config: unknown }) + | null; + recentJobs: JobFixture[]; + inFlightCount: number; +}) => { + const findFirst = vi.fn(async () => opts.connection); + const findMany = vi.fn(async () => opts.recentJobs); + const groupBy = vi.fn(async () => opts.inFlightCount > 0 + ? [{ connectionId: opts.connection?.id ?? 0, _count: { _all: opts.inFlightCount } }] + : []); + return { + connection: { findFirst }, + connectionSyncJob: { findMany, groupBy }, + } as unknown; +}; + +const setAuth = ( + connection: Parameters[0]['connection'], + recentJobs: JobFixture[] = [], + inFlightCount = 0, +) => { + mocks.authContext = { + user: { id: 'user_1' }, + org: { id: 1 }, + prisma: buildPrismaMock({ connection, recentJobs, inFlightCount }), + }; +}; + +const fakeCompletedAt = (createdAt: Date) => new Date(createdAt.getTime() + 30_000); + +describe('GET /api/connections/:id', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authContext = undefined; + }); + + test('returns 401 when no authenticated user', async () => { + mocks.authContext = undefined; + const response = await GET(makeRequest('1'), { params: Promise.resolve({ id: '1' }) }); + expect(response.status).toBe(401); + }); + + test('returns 400 when id is not a positive integer', async () => { + setAuth(null); + const response = await GET(makeRequest('abc'), { params: Promise.resolve({ id: 'abc' }) }); + expect(response.status).toBe(400); + }); + + test('returns 400 when id is zero or negative', async () => { + setAuth(null); + const response = await GET(makeRequest('0'), { params: Promise.resolve({ id: '0' }) }); + expect(response.status).toBe(400); + }); + + test('returns 404 when the connection does not exist in the org', async () => { + setAuth(null); + const response = await GET(makeRequest('999'), { params: Promise.resolve({ id: '999' }) }); + expect(response.status).toBe(404); + }); + + test('returns 200 with the documented shape on a happy path', async () => { + const createdAt = new Date('2026-07-25T13:59:30.000Z'); + const completedAt = fakeCompletedAt(createdAt); + setAuth( + { + id: 1, + name: 'github-public', + connectionType: 'github', + isDeclarative: false, + syncedAt: completedAt, + createdAt: new Date('2026-06-12T08:21:00.000Z'), + updatedAt: completedAt, + _count: { repos: 0 }, + config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }, + }, + [ + { + id: 'job_1', + status: 'COMPLETED', + createdAt, + completedAt, + errorMessage: null, + warningMessages: [], + }, + ], + 0, + ); + + const response = await GET(makeRequest('1'), { params: Promise.resolve({ id: '1' }) }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.connection).toEqual({ + id: 1, + name: 'github-public', + connectionType: 'github', + isDeclarative: false, + syncedAt: completedAt.toISOString(), + createdAt: new Date('2026-06-12T08:21:00.000Z').toISOString(), + updatedAt: completedAt.toISOString(), + repoCount: 0, // _count.repos: 0 + inFlightJobCount: 0, + latestJob: { + id: 'job_1', + status: 'COMPLETED', + createdAt: createdAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMs: 30000, + errorMessage: null, + warningMessages: [], + }, + }); + expect(body.recentJobs).toHaveLength(1); + }); + + test('does NOT include the connection config (which carries tokens)', async () => { + const createdAt = new Date('2026-07-25T13:59:30.000Z'); + setAuth( + { + id: 5, + name: 'token-bearing', + connectionType: 'github', + isDeclarative: false, + syncedAt: null, + createdAt, + updatedAt: createdAt, + _count: { repos: 0 }, + config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }, + }, + ); + + const response = await GET(makeRequest('5'), { params: Promise.resolve({ id: '5' }) }); + const body = await response.json(); + + expect(body.connection.config).toBeUndefined(); + }); + + test('exposes the latest job errorMessage and warningMessages verbatim', async () => { + const createdAt = new Date('2026-07-25T13:59:30.000Z'); + const completedAt = fakeCompletedAt(createdAt); + setAuth( + { + id: 1, + name: 'broken', + connectionType: 'github', + isDeclarative: false, + syncedAt: null, + createdAt, + updatedAt: completedAt, + _count: { repos: 0 }, + config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }, + }, + [ + { + id: 'job_42', + status: 'FAILED', + createdAt, + completedAt, + errorMessage: 'connection refused: host=github.example.com:443', + warningMessages: ['8 repos were skipped: 404 not found'], + }, + ], + ); + + const response = await GET(makeRequest('1'), { params: Promise.resolve({ id: '1' }) }); + const body = await response.json(); + + expect(body.recentJobs[0].errorMessage).toBe( + 'connection refused: host=github.example.com:443', + ); + expect(body.recentJobs[0].warningMessages).toEqual([ + '8 repos were skipped: 404 not found', + ]); + }); + + test('respects the jobLimit query parameter', async () => { + const createdAt = new Date('2026-07-25T13:59:30.000Z'); + const completedAt = fakeCompletedAt(createdAt); + const jobs: JobFixture[] = Array.from({ length: 5 }, (_, i) => ({ + id: `job_${i}`, + status: 'COMPLETED' as const, + createdAt: new Date(createdAt.getTime() - i * 60_000), + completedAt: new Date(createdAt.getTime() - i * 60_000 + 30_000), + errorMessage: null, + warningMessages: [], + })); + setAuth( + { + id: 1, + name: 'busy', + connectionType: 'github', + isDeclarative: false, + syncedAt: null, + createdAt, + updatedAt: completedAt, + _count: { repos: 0 }, + config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }, + }, + jobs, + ); + + const response = await GET( + makeRequest('1', { jobLimit: '2' }), + { params: Promise.resolve({ id: '1' }) }, + ); + const body = await response.json(); + + // The action passes the jobLimit to Prisma's `take`; the mock + // returns whatever we set, so we assert the request body shape + // (recentJobs length) matches what we provided (5). The route + // itself doesn't truncate. + expect(body.recentJobs).toHaveLength(5); + }); + + test('returns 400 for jobLimit=0', async () => { + setAuth(null); + const response = await GET( + makeRequest('1', { jobLimit: '0' }), + { params: Promise.resolve({ id: '1' }) }, + ); + expect(response.status).toBe(400); + }); + + test('returns 400 for jobLimit > 50', async () => { + setAuth(null); + const response = await GET( + makeRequest('1', { jobLimit: '51' }), + { params: Promise.resolve({ id: '1' }) }, + ); + expect(response.status).toBe(400); + }); + + test('returns 200 with empty recentJobs for a connection that has never been synced', async () => { + const createdAt = new Date('2026-07-25T13:59:30.000Z'); + setAuth( + { + id: 2, + name: 'never-synced', + connectionType: 'gitlab', + isDeclarative: false, + syncedAt: null, + createdAt, + updatedAt: createdAt, + _count: { repos: 0 }, + config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }, + }, + [], + ); + + const response = await GET(makeRequest('2'), { params: Promise.resolve({ id: '2' }) }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.connection.syncedAt).toBeNull(); + expect(body.connection.latestJob).toBeNull(); + expect(body.recentJobs).toEqual([]); + }); + + test('populates inFlightJobCount from the groupBy query', async () => { + const createdAt = new Date('2026-07-25T13:59:30.000Z'); + setAuth( + { + id: 3, + name: 'busy', + connectionType: 'github', + isDeclarative: false, + syncedAt: null, + createdAt, + updatedAt: createdAt, + _count: { repos: 0 }, + config: { token: { env: 'SOURCEBOT_TEST_TOKEN' } }, + }, + [], + 2, // inFlightCount + ); + + const response = await GET(makeRequest('3'), { params: Promise.resolve({ id: '3' }) }); + const body = await response.json(); + + expect(body.connection.inFlightJobCount).toBe(2); + }); + + test('cross-org access returns 404 (not 403) to avoid leaking existence', async () => { + // The action scopes by `where: { id, orgId: org.id }`. If the + // connection is in another org, findFirst returns null, and the + // route returns 404 — not 403. This is the security-critical + // assertion: a 403 here would tell an attacker that the id + // exists in some other org. + setAuth(null); + const response = await GET(makeRequest('42'), { params: Promise.resolve({ id: '42' }) }); + expect(response.status).toBe(404); + }); +}); diff --git a/packages/web/src/app/api/(server)/connections/[id]/route.ts b/packages/web/src/app/api/(server)/connections/[id]/route.ts new file mode 100644 index 000000000..b34e6950c --- /dev/null +++ b/packages/web/src/app/api/(server)/connections/[id]/route.ts @@ -0,0 +1,49 @@ +'use server'; + +import { NextRequest } from 'next/server'; +import { z } from 'zod'; + +import { apiHandler } from '@/lib/apiHandler'; +import { getConnectionQueryParamsSchema } from '@/lib/schemas'; +import { + notFound, + queryParamsSchemaValidationError, + serviceErrorResponse, +} from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; + +import { getConnection } from './getConnectionApi'; + +const connectionIdParamSchema = z.coerce.number().int().positive(); + +// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getConnection() which calls withAuth +export const GET = apiHandler(async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + const { id: rawId } = await params; + const idParsed = connectionIdParamSchema.safeParse(rawId); + if (!idParsed.success) { + return serviceErrorResponse(queryParamsSchemaValidationError(idParsed.error)); + } + const id = idParsed.data; + + const jobLimitRaw = request.nextUrl.searchParams.get('jobLimit') ?? undefined; + const paramsParsed = getConnectionQueryParamsSchema.safeParse({ jobLimit: jobLimitRaw }); + if (!paramsParsed.success) { + return serviceErrorResponse(queryParamsSchemaValidationError(paramsParsed.error)); + } + const { jobLimit } = paramsParsed.data; + + const result = await getConnection({ id, jobLimit }); + + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + + if (result === null || result === undefined) { + return serviceErrorResponse(notFound()); + } + + return Response.json(result.data, { status: 200 }); +}); diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 2a3393175..7202d61f9 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -68,4 +68,27 @@ export const connectionSummarySchema = z.object({ export const listConnectionsResponseSchema = z.object({ connections: z.array(connectionSummarySchema), +}); + +export const getConnectionQueryParamsSchema = z.object({ + jobLimit: z.coerce.number().int().positive().max(50).default(10), +}); + +export const connectionJobSchema = z.object({ + id: z.string(), + status: z.nativeEnum(ConnectionSyncJobStatus), + createdAt: z.coerce.date(), + completedAt: z.coerce.date().nullable(), + durationMs: z.number().int().nonnegative().nullable(), + errorMessage: z.string().nullable(), + warningMessages: z.array(z.string()), +}); + +export const getConnectionResponseSchema = z.object({ + connection: connectionSummarySchema.omit({ + latestJob: true, + }).extend({ + latestJob: connectionJobSchema.nullable(), + }), + recentJobs: z.array(connectionJobSchema), }); \ No newline at end of file From 90bd3169fdf102ab7a4c6e7bdd9c713047eda36b Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:02:08 +0530 Subject: [PATCH 11/15] docs(openapi): register GET /api/connections/{id} Adds the detail endpoint to the public OpenAPI spec and to the System API Reference navigation, and adds a one-sentence CHANGELOG entry under [Unreleased] -> Added linking to PR #1518. Refs #1518. --- CHANGELOG.md | 1 + .../sourcebot-public.openapi.json | 167 ++++++++++++++++++ docs/docs.json | 3 +- packages/web/src/openapi/publicApiDocument.ts | 27 +++ packages/web/src/openapi/publicApiSchemas.ts | 21 +++ 5 files changed, 218 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a9e6c58f..2eac3eee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) +- `GET /api/connections/{id}` returns one connection in the org by id with its latest sync job, the count of in-flight jobs, and the most recent jobs (default 10, max 50 via `?jobLimit=`); the connection `config` is never returned. [#1518](https://github.com/sourcebot-dev/sourcebot/pull/1518) ### Fixed - Upgraded `seroval` to `^1.5.6`. [#1508](https://github.com/sourcebot-dev/sourcebot/pull/1508) diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index bc39fc447..3598c1ed2 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -654,6 +654,87 @@ "connections" ] }, + "PublicConnectionJob": { + "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" + }, + "durationMs": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "errorMessage": { + "type": "string", + "nullable": true + }, + "warningMessages": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "status", + "createdAt", + "completedAt", + "durationMs", + "errorMessage", + "warningMessages" + ] + }, + "PublicGetConnectionResponse": { + "type": "object", + "properties": { + "connection": { + "allOf": [ + { + "$ref": "#/components/schemas/PublicConnectionSummary" + }, + { + "type": "object", + "properties": { + "latestJob": { + "$ref": "#/components/schemas/PublicConnectionJob" + } + } + } + ] + }, + "recentJobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicConnectionJob" + } + } + }, + "required": [ + "connection", + "recentJobs" + ] + }, "PublicFileSourceResponse": { "type": "object", "properties": { @@ -1688,6 +1769,92 @@ } } }, + "/api/connections/{id}": { + "get": { + "operationId": "getConnection", + "tags": [ + "System" + ], + "summary": "Get a single code-host connection", + "description": "Returns one connection in the org by id, plus its most recent sync jobs (default 10, max 50 via `?jobLimit=`) and the count of in-flight jobs. The connection `config` is never returned (it carries tokens). Cross-org access returns 404 (not 403) to avoid leaking the existence of connections in other orgs.", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 50, + "default": 10 + }, + "required": false, + "name": "jobLimit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "The connection, its latest job, and recent sync jobs.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicGetConnectionResponse" + } + } + } + }, + "400": { + "description": "Invalid `id` or `jobLimit` query parameter.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "401": { + "description": "Not authenticated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Connection not found in the org.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected connection read 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 597663145..5724d8d17 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -218,7 +218,8 @@ "pages": [ "GET /api/version", "GET /api/health", - "GET /api/connections" + "GET /api/connections", + "GET /api/connections/{id}" ] } ] diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts index a7fa1e498..69fc5bdd7 100644 --- a/packages/web/src/openapi/publicApiDocument.ts +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -28,6 +28,8 @@ import { publicListReposResponseSchema, publicListConnectionsQueryParamsSchema, publicListConnectionsResponseSchema, + publicGetConnectionQueryParamsSchema, + publicGetConnectionResponseSchema, publicSearchRequestSchema, publicSearchResponseSchema, publicServiceErrorSchema, @@ -234,6 +236,31 @@ export function createPublicOpenApiDocument(version: string) { }, }); + registry.registerPath({ + method: 'get', + path: '/api/connections/{id}', + operationId: 'getConnection', + tags: [systemTag.name], + summary: 'Get a single code-host connection', + description: 'Returns one connection in the org by id, plus its most recent sync jobs (default 10, max 50 via `?jobLimit=`) and the count of in-flight jobs. The connection `config` is never returned (it carries tokens). Cross-org access returns 404 (not 403) to avoid leaking the existence of connections in other orgs.', + request: { + params: z.object({ + id: z.coerce.number().int().positive(), + }), + query: publicGetConnectionQueryParamsSchema, + }, + responses: { + 200: { + description: 'The connection, its latest job, and recent sync jobs.', + content: jsonContent(publicGetConnectionResponseSchema), + }, + 400: errorJson('Invalid `id` or `jobLimit` query parameter.'), + 401: errorJson('Not authenticated.'), + 404: errorJson('Connection not found in the org.'), + 500: errorJson('Unexpected connection read 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 a60eec6df..296fa5652 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -103,6 +103,27 @@ export const publicListConnectionsResponseSchema = z.object({ connections: z.array(publicConnectionSummarySchema), }).openapi('PublicListConnectionsResponse'); +export const publicGetConnectionQueryParamsSchema = z.object({ + jobLimit: z.coerce.number().int().positive().max(50).default(10), +}).openapi('PublicGetConnectionQuery'); + +export const publicConnectionJobSchema = z.object({ + id: z.string(), + status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED']), + createdAt: z.string().datetime(), + completedAt: z.string().datetime().nullable(), + durationMs: z.number().int().nonnegative().nullable(), + errorMessage: z.string().nullable(), + warningMessages: z.array(z.string()), +}).openapi('PublicConnectionJob'); + +export const publicGetConnectionResponseSchema = z.object({ + connection: publicConnectionSummarySchema.extend({ + latestJob: publicConnectionJobSchema.nullable(), + }), + recentJobs: z.array(publicConnectionJobSchema), +}).openapi('PublicGetConnectionResponse'); + // EE: User Management export const publicEeUserSchema = z.object({ id: z.string(), From 9c510a8db53972dac840a133915b2e5eb211db35 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:02:53 +0530 Subject: [PATCH 12/15] refactor(web): dedupe connection-job shaping in /api/connections/{id} Extract the job-row-to-public-shape mapping into a single toConnectionJob helper. The latestJob and recentJobs responses now share the same projection, so a future change to the shape lives in one place. Also tightens the jobLimit test to assert that the action actually forwards the parsed jobLimit to Prisma's findMany take, instead of just checking the mock returned its own input. Refs #1518. --- .../connections/[id]/getConnectionApi.ts | 55 +++++++++---------- .../(server)/connections/[id]/route.test.ts | 15 +++-- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts b/packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts index c738c9a57..c1a45ad17 100644 --- a/packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts +++ b/packages/web/src/app/api/(server)/connections/[id]/getConnectionApi.ts @@ -9,6 +9,30 @@ export interface GetConnectionParams { jobLimit: number; } +// Shape a single sync-job row the way the public OpenAPI exposes it: +// an explicit `durationMs` derived from createdAt/completedAt so callers +// don't have to compute it client-side, plus the warning list and +// error verbatim. Used for both the embedded `latestJob` and each +// element of `recentJobs`. +const toConnectionJob = (job: { + id: string; + status: ConnectionSyncJobStatus; + createdAt: Date; + completedAt: Date | null; + errorMessage: string | null; + warningMessages: string[]; +}) => ({ + id: job.id, + status: job.status, + createdAt: job.createdAt, + completedAt: job.completedAt, + durationMs: job.completedAt + ? job.completedAt.getTime() - job.createdAt.getTime() + : null, + errorMessage: job.errorMessage, + warningMessages: job.warningMessages, +}); + export const getConnection = async ( { id, jobLimit }: GetConnectionParams, ) => sew(() => @@ -45,9 +69,6 @@ export const getConnection = async ( }), ]); - const inFlightJobCount = inFlightRows[0]?._count._all ?? 0; - const latestJob = recentJobs[0] ?? null; - return { data: { connection: { @@ -59,32 +80,10 @@ export const getConnection = async ( createdAt: connection.createdAt, updatedAt: connection.updatedAt, repoCount: connection._count?.repos ?? 0, - inFlightJobCount, - latestJob: latestJob - ? { - id: latestJob.id, - status: latestJob.status, - createdAt: latestJob.createdAt, - completedAt: latestJob.completedAt, - durationMs: latestJob.completedAt - ? latestJob.completedAt.getTime() - latestJob.createdAt.getTime() - : null, - errorMessage: latestJob.errorMessage, - warningMessages: latestJob.warningMessages, - } - : null, + inFlightJobCount: inFlightRows[0]?._count._all ?? 0, + latestJob: recentJobs[0] ? toConnectionJob(recentJobs[0]) : null, }, - recentJobs: recentJobs.map((job) => ({ - id: job.id, - status: job.status, - createdAt: job.createdAt, - completedAt: job.completedAt, - durationMs: job.completedAt - ? job.completedAt.getTime() - job.createdAt.getTime() - : null, - errorMessage: job.errorMessage, - warningMessages: job.warningMessages, - })), + recentJobs: recentJobs.map(toConnectionJob), }, }; }), diff --git a/packages/web/src/app/api/(server)/connections/[id]/route.test.ts b/packages/web/src/app/api/(server)/connections/[id]/route.test.ts index 54ba05b35..44efa40d9 100644 --- a/packages/web/src/app/api/(server)/connections/[id]/route.test.ts +++ b/packages/web/src/app/api/(server)/connections/[id]/route.test.ts @@ -299,7 +299,7 @@ describe('GET /api/connections/:id', () => { ]); }); - test('respects the jobLimit query parameter', async () => { + test('forwards jobLimit to Prisma as the findMany `take`', async () => { const createdAt = new Date('2026-07-25T13:59:30.000Z'); const completedAt = fakeCompletedAt(createdAt); const jobs: JobFixture[] = Array.from({ length: 5 }, (_, i) => ({ @@ -331,10 +331,15 @@ describe('GET /api/connections/:id', () => { ); const body = await response.json(); - // The action passes the jobLimit to Prisma's `take`; the mock - // returns whatever we set, so we assert the request body shape - // (recentJobs length) matches what we provided (5). The route - // itself doesn't truncate. + // The mock returns whatever the test sets; the meaningful + // assertion is that the action passed the parsed jobLimit + // through to the recent-jobs findMany. + const prisma = mocks.authContext?.prisma as { + connectionSyncJob: { findMany: ReturnType }; + }; + expect(prisma.connectionSyncJob.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 2, orderBy: { createdAt: 'desc' } }), + ); expect(body.recentJobs).toHaveLength(5); }); From a390fa83ad72480ee49f010d4286aaf14250ad07 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:07:39 +0530 Subject: [PATCH 13/15] fix(web): drop unused _count.syncJobs in /api/connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot finding: the list endpoint fetched a per-connection count of ALL sync jobs but never read it. The endpoint exposes the latest job explicitly via the 'syncJobs: take 1' include, and the in-flight count via a separate groupBy — neither path needs the total count. Drop the unused aggregate so the list hot path doesn't run an unnecessary scan. Refs #1519. --- .../web/src/app/api/(server)/connections/listConnectionsApi.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts b/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts index 6d23a2233..241906ad5 100644 --- a/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts +++ b/packages/web/src/app/api/(server)/connections/listConnectionsApi.ts @@ -25,7 +25,6 @@ export const listConnections = async ( _count: { select: { repos: true, - syncJobs: true, }, }, syncJobs: { From b51e42034e6726c34ab01d421c76ec71c7c31f87 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:13:08 +0530 Subject: [PATCH 14/15] fix(web): address CodeRabbit review on /api/connections/{id} - CHANGELOG: move the seroval upgrade entry into the existing [Unreleased] -> Fixed section (drops a duplicate heading introduced by the rebase). - schemas.ts: move connectionJobSchema above connectionSummarySchema and derive `connectionSummarySchema.latestJob` from it via .omit() so the two job shapes can't drift apart. - publicApiSchemas.ts: apply the same omit-then-extend pattern to publicGetConnectionResponseSchema, and document durationMs as elapsed-from-enqueue time (includes queue wait) and jobLimit in the OpenAPI spec. - route.test.ts: add explicit Prisma-call assertions on the org-scoping filter for both the happy path and the 404 path. The mock returns its fixture regardless of args, so without these assertions a future change that drops `orgId: org.id` from the where clause would still pass the 404 test. All 35 web tests pass; OpenAPI spec regenerated. Refs #1519. --- CHANGELOG.md | 4 +- .../sourcebot-public.openapi.json | 77 ++++++++++++++++--- .../(server)/connections/[id]/route.test.ts | 26 +++++++ packages/web/src/lib/schemas.ts | 33 ++++---- packages/web/src/openapi/publicApiSchemas.ts | 8 +- 5 files changed, 114 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eac3eee8..42d016f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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) - `GET /api/connections/{id}` returns one connection in the org by id with its latest sync job, the count of in-flight jobs, and the most recent jobs (default 10, max 50 via `?jobLimit=`); the connection `config` is never returned. [#1518](https://github.com/sourcebot-dev/sourcebot/pull/1518) -### Fixed -- Upgraded `seroval` to `^1.5.6`. [#1508](https://github.com/sourcebot-dev/sourcebot/pull/1508) - ### Changed - Vulnerability triage now keeps Linear issues synchronized with current security findings. ### Fixed - Fixed vulnerability triage for reusable-workflow callers, repositories without CodeQL, and transient non-JSON Linear query responses. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515) +- Upgraded `seroval` to `^1.5.6`. [#1508](https://github.com/sourcebot-dev/sourcebot/pull/1508) ## [5.1.4] - 2026-07-24 diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 3598c1ed2..3c143c537 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -682,7 +682,8 @@ "durationMs": { "type": "integer", "nullable": true, - "minimum": 0 + "minimum": 0, + "description": "Elapsed time from job enqueue to completion, in milliseconds. Includes queue wait time as well as execution time, not sync execution alone. Null while the job is still running." }, "errorMessage": { "type": "string", @@ -709,18 +710,68 @@ "type": "object", "properties": { "connection": { - "allOf": [ - { - "$ref": "#/components/schemas/PublicConnectionSummary" + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true }, - { - "type": "object", - "properties": { - "latestJob": { - "$ref": "#/components/schemas/PublicConnectionJob" - } - } + "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/PublicConnectionJob" } + }, + "required": [ + "id", + "name", + "connectionType", + "isDeclarative", + "syncedAt", + "createdAt", + "updatedAt", + "repoCount", + "inFlightJobCount", + "latestJob" ] }, "recentJobs": { @@ -1794,9 +1845,11 @@ "minimum": 0, "exclusiveMinimum": true, "maximum": 50, - "default": 10 + "default": 10, + "description": "Maximum number of recent sync jobs to return. Defaults to 10, max 50." }, "required": false, + "description": "Maximum number of recent sync jobs to return. Defaults to 10, max 50.", "name": "jobLimit", "in": "query" } diff --git a/packages/web/src/app/api/(server)/connections/[id]/route.test.ts b/packages/web/src/app/api/(server)/connections/[id]/route.test.ts index 44efa40d9..1bcfef625 100644 --- a/packages/web/src/app/api/(server)/connections/[id]/route.test.ts +++ b/packages/web/src/app/api/(server)/connections/[id]/route.test.ts @@ -216,6 +216,18 @@ describe('GET /api/connections/:id', () => { const body = await response.json(); expect(response.status).toBe(200); + // The action must pass the org id to Prisma so cross-org rows + // are filtered at the query level (the 404 path test above + // already exercises the not-found branch; this asserts the + // happy path also scopes the query). + const prisma = mocks.authContext?.prisma as { + connection: { findFirst: ReturnType }; + }; + expect(prisma.connection.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 1, orgId: 1 }, + }), + ); expect(body.connection).toEqual({ id: 1, name: 'github-public', @@ -420,5 +432,19 @@ describe('GET /api/connections/:id', () => { setAuth(null); const response = await GET(makeRequest('42'), { params: Promise.resolve({ id: '42' }) }); expect(response.status).toBe(404); + + // Beyond the response code, assert that the action actually + // passed the org id to Prisma. A future change that drops + // `orgId: org.id` from the `where` clause would still pass the + // 404 assertion (because the mock returns null regardless of + // args) but would expose cross-org data in production. + const prisma = mocks.authContext?.prisma as { + connection: { findFirst: ReturnType }; + }; + expect(prisma.connection.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 42, orgId: 1 }, + }), + ); }); }); diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 7202d61f9..2c78fe54e 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -47,6 +47,20 @@ export const listConnectionsQueryParamsSchema = z.object({ perPage: z.coerce.number().int().positive().max(100).default(50), }); +// The full sync-job row used by the detail endpoint. The list endpoint's +// `connectionSummarySchema.latestJob` is a strict subset of this (no +// `durationMs`, no `warningMessages`); it's derived via `.omit(...)` below +// to keep the two shapes from drifting. +export const connectionJobSchema = z.object({ + id: z.string(), + status: z.nativeEnum(ConnectionSyncJobStatus), + createdAt: z.coerce.date(), + completedAt: z.coerce.date().nullable(), + durationMs: z.number().int().nonnegative().nullable(), + errorMessage: z.string().nullable(), + warningMessages: z.array(z.string()), +}); + export const connectionSummarySchema = z.object({ id: z.number(), name: z.string(), @@ -57,12 +71,9 @@ export const connectionSummarySchema = z.object({ 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(), + latestJob: connectionJobSchema.omit({ + durationMs: true, + warningMessages: true, }).nullable(), }); @@ -74,16 +85,6 @@ export const getConnectionQueryParamsSchema = z.object({ jobLimit: z.coerce.number().int().positive().max(50).default(10), }); -export const connectionJobSchema = z.object({ - id: z.string(), - status: z.nativeEnum(ConnectionSyncJobStatus), - createdAt: z.coerce.date(), - completedAt: z.coerce.date().nullable(), - durationMs: z.number().int().nonnegative().nullable(), - errorMessage: z.string().nullable(), - warningMessages: z.array(z.string()), -}); - export const getConnectionResponseSchema = z.object({ connection: connectionSummarySchema.omit({ latestJob: true, diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts index 296fa5652..1e8334c54 100644 --- a/packages/web/src/openapi/publicApiSchemas.ts +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -104,7 +104,7 @@ export const publicListConnectionsResponseSchema = z.object({ }).openapi('PublicListConnectionsResponse'); export const publicGetConnectionQueryParamsSchema = z.object({ - jobLimit: z.coerce.number().int().positive().max(50).default(10), + jobLimit: z.coerce.number().int().positive().max(50).default(10).describe('Maximum number of recent sync jobs to return. Defaults to 10, max 50.'), }).openapi('PublicGetConnectionQuery'); export const publicConnectionJobSchema = z.object({ @@ -112,13 +112,15 @@ export const publicConnectionJobSchema = z.object({ status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED']), createdAt: z.string().datetime(), completedAt: z.string().datetime().nullable(), - durationMs: z.number().int().nonnegative().nullable(), + durationMs: z.number().int().nonnegative().nullable().describe('Elapsed time from job enqueue to completion, in milliseconds. Includes queue wait time as well as execution time, not sync execution alone. Null while the job is still running.'), errorMessage: z.string().nullable(), warningMessages: z.array(z.string()), }).openapi('PublicConnectionJob'); export const publicGetConnectionResponseSchema = z.object({ - connection: publicConnectionSummarySchema.extend({ + connection: publicConnectionSummarySchema.omit({ + latestJob: true, + }).extend({ latestJob: publicConnectionJobSchema.nullable(), }), recentJobs: z.array(publicConnectionJobSchema), From 603e0ac7e4b5692cbf5f3fa9e0909690c43ac609 Mon Sep 17 00:00:00 2001 From: Harsh23Kashyap <55448981+Harsh23Kashyap@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:13:38 +0530 Subject: [PATCH 15/15] test(web): assert org-scoping filter in /api/connections Add Prisma-call assertions on the where: { orgId: org.id } filter for both connection.findMany and connection.count. The mock returns its fixture regardless of args, so without these a future change that drops the org filter from the where clause would still pass the response-shape assertions. Refs #1519. --- .../src/app/api/(server)/connections/route.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/web/src/app/api/(server)/connections/route.test.ts b/packages/web/src/app/api/(server)/connections/route.test.ts index 14d8b0714..e4e1aa91e 100644 --- a/packages/web/src/app/api/(server)/connections/route.test.ts +++ b/packages/web/src/app/api/(server)/connections/route.test.ts @@ -208,6 +208,20 @@ describe('GET /api/connections', () => { const body = await response.json(); expect(response.status).toBe(200); + // The action must pass the org id to both Prisma queries so + // cross-org rows are filtered at the query level. The mock + // returns its fixture regardless of args, so a future change + // that drops `orgId: org.id` from the where clause would + // still pass the response-shape assertions above. + const prisma = mocks.authContext?.prisma as { + connection: { findMany: ReturnType; count: ReturnType }; + }; + expect(prisma.connection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId: 1 } }), + ); + expect(prisma.connection.count).toHaveBeenCalledWith( + expect.objectContaining({ where: { orgId: 1 } }), + ); expect(body.connections).toHaveLength(1); expect(body.connections[0]).toEqual({ id: 1,