Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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)),
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