Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [EE] Added one-hour repository-scoped access tokens with public mint and revoke APIs. [#1549](https://github.com/sourcebot-dev/sourcebot/pull/1549)
- [EE] Added guided reconnection for MCP connector authentication failures during Ask Sourcebot agent turns. [#1548](https://github.com/sourcebot-dev/sourcebot/pull/1548)
- Added public connection listing and connection-based repository filtering APIs. [#1550](https://github.com/sourcebot-dev/sourcebot/pull/1550)

### Removed
- Removed the Langfuse integration. [#1536](https://github.com/sourcebot-dev/sourcebot/pull/1536)
Expand Down
89 changes: 89 additions & 0 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
"name": "Search & Navigation",
"description": "Code search and symbol navigation endpoints."
},
{
"name": "Connections",
"description": "Code host connection metadata."
},
{
"name": "Repositories",
"description": "Repository listing and metadata endpoints."
Expand Down Expand Up @@ -516,6 +520,37 @@
]
}
},
"PublicListConnectionsResponse": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"connectionType": {
"type": "string",
"enum": [
"github",
"gitlab",
"gitea",
"gerrit",
"bitbucket",
"azuredevops",
"git"
]
}
},
"required": [
"id",
"name",
"connectionType"
]
}
},
"PublicVersionResponse": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1445,6 +1480,18 @@
"required": false,
"name": "query",
"in": "query"
},
{
"schema": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"description": "Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections."
},
"required": false,
"description": "Filter repositories to those associated with this connection ID. IDs are returned by GET /api/connections.",
"name": "connectionId",
"in": "query"
}
],
"responses": {
Expand Down Expand Up @@ -1497,6 +1544,48 @@
}
}
},
"/api/connections": {
"get": {
"operationId": "listConnections",
"tags": [
"Connections"
],
"summary": "List connections",
"description": "Returns unique code host connections associated with at least one repository visible to the caller. Connection configuration and credentials are never included.",
"responses": {
"200": {
"description": "Connections associated with visible repositories.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicListConnectionsResponse"
}
}
}
},
"401": {
"description": "Authentication is required when anonymous access is disabled.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"500": {
"description": "Unexpected connection listing failure.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
}
}
}
},
"/api/version": {
"get": {
"operationId": "getVersion",
Expand Down
7 changes: 7 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@
"GET /api/repos"
]
},
{
"group": "Connections",
"icon": "link",
"pages": [
"GET /api/connections"
]
},
{
"group": "Scoped Access Tokens",
"icon": "key",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
authContext: undefined as unknown,
}));

vi.mock('@/middleware/sew', () => ({
sew: (callback: () => unknown) => callback(),
}));

vi.mock('@/middleware/withAuth', () => ({
withOptionalAuth: vi.fn((callback: (context: unknown) => unknown) => callback(mocks.authContext)),
}));

const { listConnections } = await import('./listConnectionsApi');

beforeEach(() => {
vi.clearAllMocks();
});

describe('listConnections', () => {
test('returns unique connections from repositories visible through the scoped Prisma client', async () => {
const findMany = vi.fn().mockResolvedValue([
{
connections: [
{
connection: {
id: 2,
name: 'GitLab',
connectionType: 'gitlab',
},
},
],
},
{
connections: [
{
connection: {
id: 1,
name: 'GitHub',
connectionType: 'github',
},
},
{
connection: {
id: 2,
name: 'GitLab',
connectionType: 'gitlab',
},
},
],
},
]);
mocks.authContext = {
org: { id: 1 },
prisma: {
repo: { findMany },
},
};

const result = await listConnections();

expect(findMany).toHaveBeenCalledWith({
where: {
orgId: 1,
},
select: {
connections: {
select: {
connection: {
select: {
id: true,
name: true,
connectionType: true,
},
},
},
},
},
});
expect(result).toEqual([
{ id: 1, name: 'GitHub', connectionType: 'github' },
{ id: 2, name: 'GitLab', connectionType: 'gitlab' },
]);
});

test('returns an empty list when no visible repositories have connections', async () => {
mocks.authContext = {
org: { id: 1 },
prisma: {
repo: {
findMany: vi.fn().mockResolvedValue([]),
},
},
};

await expect(listConnections()).resolves.toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { sew } from '@/middleware/sew';
import { withOptionalAuth } from '@/middleware/withAuth';
import { ConnectionType } from '@sourcebot/db';
import { z } from 'zod';

export const connectionQuerySchema = z.object({
id: z.number().int(),
name: z.string(),
connectionType: z.nativeEnum(ConnectionType),
});

export const listConnectionsResponseSchema = connectionQuerySchema.array();

export type ConnectionQuery = z.infer<typeof connectionQuerySchema>;
export type ListConnectionsResponse = z.infer<typeof listConnectionsResponseSchema>;

export const listConnections = async () => sew(() =>
withOptionalAuth(async ({ org, prisma }) => {
// Query through repos so the scoped Prisma client applies repository visibility;
// querying Connection directly would expose connections unrelated to visible repos.
const repositories = await prisma.repo.findMany({
where: {
orgId: org.id,
},
select: {
connections: {
select: {
connection: {
select: {
id: true,
name: true,
connectionType: true,
},
},
},
},
},
});

const connectionsById = new Map<number, ConnectionQuery>();
for (const repository of repositories) {
for (const { connection } of repository.connections) {
connectionsById.set(connection.id, connection);
}
}

return [...connectionsById.values()].sort((a, b) =>
a.name.localeCompare(b.name) || a.id - b.id
);
})
);
14 changes: 14 additions & 0 deletions packages/web/src/app/api/(server)/connections/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { apiHandler } from '@/lib/apiHandler';
import { serviceErrorResponse } from '@/lib/serviceError';
import { isServiceError } from '@/lib/utils';
import { listConnections } from './listConnectionsApi';

// eslint-disable-next-line authz/require-auth-wrapper -- delegates to listConnections(), which calls withOptionalAuth
export const GET = apiHandler(async () => {
const result = await listConnections();
if (isServiceError(result)) {
return serviceErrorResponse(result);
}

return Response.json(result);
});
Loading
Loading