Skip to content
Merged
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
### Fixed
- Fixed Bitbucket Cloud pagination not working beyond first page. [#295](https://github.com/sourcebot-dev/sourcebot/issues/295)
- Fixed search bar line wrapping. [#501](https://github.com/sourcebot-dev/sourcebot/pull/501)
- Fixed carousel perf issues. [#507](https://github.com/sourcebot-dev/sourcebot/pull/507)

## [4.6.7] - 2025-09-08

Expand Down
4 changes: 2 additions & 2 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ server.tool(
};
}

const content: TextContent[] = response.repos.map(repo => {
const content: TextContent[] = response.map(repo => {
return {
type: "text",
text: `id: ${repo.name}\nurl: ${repo.webUrl}`,
text: `id: ${repo.repoName}\nurl: ${repo.webUrl}`,
}
});

Expand Down
32 changes: 25 additions & 7 deletions packages/mcp/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,34 @@ export const searchResponseSchema = z.object({
isBranchFilteringEnabled: z.boolean(),
});

export const repositorySchema = z.object({
name: z.string(),
branches: z.array(z.string()),
enum RepoIndexingStatus {
NEW = 'NEW',
IN_INDEX_QUEUE = 'IN_INDEX_QUEUE',
INDEXING = 'INDEXING',
INDEXED = 'INDEXED',
FAILED = 'FAILED',
IN_GC_QUEUE = 'IN_GC_QUEUE',
GARBAGE_COLLECTING = 'GARBAGE_COLLECTING',
GARBAGE_COLLECTION_FAILED = 'GARBAGE_COLLECTION_FAILED'
}

export const repositoryQuerySchema = z.object({
codeHostType: z.string(),
repoId: z.number(),
repoName: z.string(),
repoDisplayName: z.string().optional(),
repoCloneUrl: z.string(),
webUrl: z.string().optional(),
rawConfig: z.record(z.string(), z.string()).optional(),
linkedConnections: z.array(z.object({
id: z.number(),
name: z.string(),
})),
imageUrl: z.string().optional(),
indexedAt: z.coerce.date().optional(),
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
});

export const listRepositoriesResponseSchema = z.object({
repos: z.array(repositorySchema),
});
export const listRepositoriesResponseSchema = repositoryQuerySchema.array();

export const fileSourceRequestSchema = z.object({
fileName: z.string(),
Expand Down
1 change: 0 additions & 1 deletion packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export type SearchResultChunk = SearchResultFile["chunks"][number];
export type SearchSymbol = z.infer<typeof symbolSchema>;

export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
export type Repository = ListRepositoriesResponse["repos"][number];

export type FileSourceRequest = z.infer<typeof fileSourceRequestSchema>;
export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
Expand Down
5 changes: 3 additions & 2 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "cross-env SKIP_ENV_VALIDATION=1 next build",
"start": "next start",
"lint": "cross-env SKIP_ENV_VALIDATION=1 eslint .",
"test": "vitest",
"test": "cross-env SKIP_ENV_VALIDATION=1 vitest",
"dev:emails": "email dev --dir ./src/emails",
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
},
Expand Down Expand Up @@ -212,7 +212,8 @@
"tsx": "^4.19.2",
"typescript": "^5",
"vite-tsconfig-paths": "^5.1.3",
"vitest": "^2.1.5"
"vitest": "^2.1.5",
"vitest-mock-extended": "^3.1.0"
},
"resolutions": {
"@types/react": "19.1.10",
Expand Down
48 changes: 48 additions & 0 deletions packages/web/src/__mocks__/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
import { ApiKey, Org, PrismaClient, User } from '@prisma/client';
import { beforeEach } from 'vitest';
import { mockDeep, mockReset } from 'vitest-mock-extended';

beforeEach(() => {
mockReset(prisma);
});

export const prisma = mockDeep<PrismaClient>();

export const MOCK_ORG: Org = {
id: SINGLE_TENANT_ORG_ID,
name: SINGLE_TENANT_ORG_NAME,
domain: SINGLE_TENANT_ORG_DOMAIN,
createdAt: new Date(),
updatedAt: new Date(),
isOnboarded: true,
imageUrl: null,
metadata: null,
memberApprovalRequired: false,
stripeCustomerId: null,
stripeSubscriptionStatus: null,
stripeLastUpdatedAt: null,
inviteLinkEnabled: false,
inviteLinkId: null
}

export const MOCK_API_KEY: ApiKey = {
name: 'Test API Key',
hash: 'apikey',
createdAt: new Date(),
lastUsedAt: new Date(),
orgId: 1,
createdById: '1',
}

export const MOCK_USER: User = {
id: '1',
name: 'Test User',
email: 'test@test.com',
createdAt: new Date(),
updatedAt: new Date(),
hashedPassword: null,
emailVerified: null,
image: null
}

75 changes: 37 additions & 38 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { getAuditService } from "@/ee/features/audit/factory";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { getOrgMetadata } from "@/lib/utils";
import { getOrgFromDomain } from "./data/org";
import { withOptionalAuthV2 } from "./withAuthV2";

const ajv = new Ajv({
validateFormats: false,
Expand Down Expand Up @@ -637,49 +638,47 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
}
})));

export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const repos = await prisma.repo.findMany({
where: {
orgId: org.id,
...(filter.status ? {
repoIndexingStatus: { in: filter.status }
} : {}),
...(filter.connectionId ? {
connections: {
some: {
connectionId: filter.connectionId
}
}
} : {}),
},
include: {
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
withOptionalAuthV2(async ({ org }) => {
const repos = await prisma.repo.findMany({
where: {
orgId: org.id,
...(filter.status ? {
repoIndexingStatus: { in: filter.status }
} : {}),
...(filter.connectionId ? {
connections: {
include: {
connection: true,
some: {
connectionId: filter.connectionId
}
}
} : {}),
},
include: {
connections: {
include: {
connection: true,
}
}
});
}
});

return repos.map((repo) => repositoryQuerySchema.parse({
codeHostType: repo.external_codeHostType,
repoId: repo.id,
repoName: repo.name,
repoDisplayName: repo.displayName ?? undefined,
repoCloneUrl: repo.cloneUrl,
webUrl: repo.webUrl ?? undefined,
linkedConnections: repo.connections.map(({ connection }) => ({
id: connection.id,
name: connection.name,
})),
imageUrl: repo.imageUrl ?? undefined,
indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus,
}));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));
return repos.map((repo) => repositoryQuerySchema.parse({
codeHostType: repo.external_codeHostType,
repoId: repo.id,
repoName: repo.name,
repoDisplayName: repo.displayName ?? undefined,
repoCloneUrl: repo.cloneUrl,
webUrl: repo.webUrl ?? undefined,
linkedConnections: repo.connections.map(({ connection }) => ({
id: connection.id,
name: connection.name,
})),
imageUrl: repo.imageUrl ?? undefined,
indexedAt: repo.indexedAt ?? undefined,
repoIndexingStatus: repo.repoIndexingStatus,
}))
}));

export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
withAuth((userId) =>
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/[domain]/chat/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface PageProps {
export default async function Page(props: PageProps) {
const params = await props.params;
const languageModels = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(params.domain);
const repos = await getRepos();
const searchContexts = await getSearchContexts(params.domain);
const chatInfo = await getChatInfo({ chatId: params.id }, params.domain);
const session = await auth();
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/[domain]/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface PageProps {
export default async function Page(props: PageProps) {
const params = await props.params;
const languageModels = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(params.domain);
const repos = await getRepos();
const searchContexts = await getSearchContexts(params.domain);
const session = await auth();
const chatHistory = session ? await getUserChatHistory(params.domain) : [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ import { env } from "@/env.mjs";
import { useQuery } from "@tanstack/react-query";
import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db";
import { getConnections } from "@/actions";
import { getRepos } from "@/actions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { getRepos } from "@/app/api/(client)/client";

export const ErrorNavIndicator = () => {
const domain = useDomain();
const captureEvent = useCaptureEvent();

const { data: repos, isPending: isPendingRepos, isError: isErrorRepos } = useQuery({
queryKey: ['repos', domain],
queryFn: () => unwrapServiceError(getRepos(domain)),
queryFn: () => unwrapServiceError(getRepos()),
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.FAILED),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { RepositoryCarousel } from "./repositoryCarousel";
import { useDomain } from "@/hooks/useDomain";
import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils";
import { getRepos } from "@/actions";
import { getRepos } from "@/app/api/(client)/client";
import { env } from "@/env.mjs";
import { Skeleton } from "@/components/ui/skeleton";
import {
Expand All @@ -22,14 +22,16 @@ interface RepositorySnapshotProps {
repos: RepositoryQuery[];
}

const MAX_REPOS_TO_DISPLAY_IN_CAROUSEL = 15;

export function RepositorySnapshot({
repos: initialRepos,
}: RepositorySnapshotProps) {
const domain = useDomain();

const { data: repos, isPending, isError } = useQuery({
queryKey: ['repos', domain],
queryFn: () => unwrapServiceError(getRepos(domain)),
queryFn: () => unwrapServiceError(getRepos()),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
placeholderData: initialRepos,
});
Expand Down Expand Up @@ -78,7 +80,9 @@ export function RepositorySnapshot({
</Link>
{` indexed`}
</span>
<RepositoryCarousel repos={indexedRepos} />
<RepositoryCarousel
repos={indexedRepos.slice(0, MAX_REPOS_TO_DISPLAY_IN_CAROUSEL)}
/>
{process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo" && (
<p className="text-sm text-muted-foreground text-center">
Interested in using Sourcebot on your code? Check out our{' '}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { getRepos } from "@/actions";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useDomain } from "@/hooks/useDomain";
Expand All @@ -10,14 +9,15 @@ import { RepoIndexingStatus } from "@prisma/client";
import { useQuery } from "@tanstack/react-query";
import { Loader2Icon } from "lucide-react";
import Link from "next/link";
import { getRepos } from "@/app/api/(client)/client";

export const ProgressNavIndicator = () => {
const domain = useDomain();
const captureEvent = useCaptureEvent();

const { data: inProgressRepos, isPending, isError } = useQuery({
queryKey: ['repos', domain],
queryFn: () => unwrapServiceError(getRepos(domain)),
queryKey: ['repos'],
queryFn: () => unwrapServiceError(getRepos()),
select: (data) => data.filter(repo => repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE || repo.repoIndexingStatus === RepoIndexingStatus.INDEXING),
refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
VscSymbolVariable
} from "react-icons/vsc";
import { useSearchHistory } from "@/hooks/useSearchHistory";
import { getDisplayTime, isServiceError } from "@/lib/utils";
import { getDisplayTime, isServiceError, unwrapServiceError } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain";


Expand All @@ -37,12 +37,12 @@ export const useSuggestionsData = ({
}: Props) => {
const domain = useDomain();
const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({
queryKey: ["repoSuggestions", domain],
queryFn: () => getRepos(domain),
queryKey: ["repoSuggestions"],
queryFn: () => unwrapServiceError(getRepos()),
select: (data): Suggestion[] => {
return data.repos
return data
.map(r => ({
value: r.name,
value: r.repoName,
}));
},
enabled: suggestionMode === "repo",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
const { data: unfilteredRepos, isPending: isReposPending, error: reposError, refetch: refetchRepos } = useQuery({
queryKey: ['repos', domain, connectionId],
queryFn: async () => {
const repos = await unwrapServiceError(getRepos(domain, { connectionId }));
const repos = await unwrapServiceError(getRepos({ connectionId }));
return repos.sort((a, b) => {
const priorityA = getPriority(a.repoIndexingStatus);
const priorityB = getPriority(b.repoIndexingStatus);
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/app/[domain]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default async function Home(props: { params: Promise<{ domain: string }>
const session = await auth();

const models = await getConfiguredLanguageModelsInfo();
const repos = await getRepos(domain);
const repos = await getRepos();
const searchContexts = await getSearchContexts(domain);
const chatHistory = session ? await getUserChatHistory(domain) : [];

Expand Down
Loading
Loading