From 2d178e4550ac577865443ac40e2ab62abb43ff81 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 2 Apr 2025 23:26:28 -0700 Subject: [PATCH 01/14] wip impl on search contexts --- .../migration.sql | 32 ++++ packages/db/prisma/schema.prisma | 17 ++ packages/schemas/src/v3/index.schema.ts | 33 ++++ packages/schemas/src/v3/index.type.ts | 15 ++ packages/web/package.json | 2 + packages/web/src/actions.ts | 16 ++ .../components/searchBar/constants.ts | 18 +- .../searchBar/searchSuggestionsBox.tsx | 13 +- .../searchBar/useSuggestionsData.ts | 34 +++- .../searchBar/zoektLanguageExtension.ts | 2 +- packages/web/src/app/[domain]/search/page.tsx | 19 +- packages/web/src/app/api/(client)/client.ts | 8 +- packages/web/src/initialize.ts | 174 ++++++++++++++---- packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/schemas.ts | 1 + packages/web/src/lib/server/searchService.ts | 99 +++++++--- schemas/v3/index.json | 34 +++- yarn.lock | 2 + 18 files changed, 447 insertions(+), 73 deletions(-) create mode 100644 packages/db/prisma/migrations/20250403044104_add_search_contexts/migration.sql diff --git a/packages/db/prisma/migrations/20250403044104_add_search_contexts/migration.sql b/packages/db/prisma/migrations/20250403044104_add_search_contexts/migration.sql new file mode 100644 index 000000000..87bdb2dd5 --- /dev/null +++ b/packages/db/prisma/migrations/20250403044104_add_search_contexts/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "SearchContext" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "SearchContext_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_RepoToSearchContext" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL, + + CONSTRAINT "_RepoToSearchContext_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SearchContext_name_orgId_key" ON "SearchContext"("name", "orgId"); + +-- CreateIndex +CREATE INDEX "_RepoToSearchContext_B_index" ON "_RepoToSearchContext"("B"); + +-- AddForeignKey +ALTER TABLE "SearchContext" ADD CONSTRAINT "SearchContext_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RepoToSearchContext" ADD CONSTRAINT "_RepoToSearchContext_A_fkey" FOREIGN KEY ("A") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RepoToSearchContext" ADD CONSTRAINT "_RepoToSearchContext_B_fkey" FOREIGN KEY ("B") REFERENCES "SearchContext"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 303510d93..967ae38db 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -61,9 +61,24 @@ model Repo { org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int + searchContexts SearchContext[] + @@unique([external_id, external_codeHostUrl, orgId]) } +model SearchContext { + id Int @id @default(autoincrement()) + + name String + description String? + repos Repo[] + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@unique([name, orgId]) +} + model Connection { id Int @id @default(autoincrement()) name String @@ -138,6 +153,8 @@ model Org { /// List of pending invites to this organization invites Invite[] + + searchContexts SearchContext[] } enum OrgRole { diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index f8b9258c8..bacd51e70 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -65,6 +65,30 @@ const schema = { } }, "additionalProperties": false + }, + "SearchContext": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + } + }, + "required": [ + "include" + ], + "additionalProperties": false } }, "properties": { @@ -74,6 +98,15 @@ const schema = { "settings": { "$ref": "#/definitions/Settings" }, + "contexts": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "$ref": "#/definitions/SearchContext" + } + }, + "additionalProperties": false + }, "connections": { "type": "object", "description": "Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode.", diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 01c6c6682..d2b837714 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -13,6 +13,9 @@ export type ConnectionConfig = export interface SourcebotConfig { $schema?: string; settings?: Settings; + contexts?: { + [k: string]: SearchContext; + }; /** * Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode. */ @@ -72,6 +75,18 @@ export interface Settings { */ repoIndexTimeoutMs?: number; } +/** + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` "^[a-zA-Z0-9_-]+$". + * + * This interface was referenced by `SourcebotConfig`'s JSON-Schema + * via the `definition` "SearchContext". + */ +export interface SearchContext { + include: string[]; + exclude?: string[]; + description?: string; +} export interface GithubConnectionConfig { /** * GitHub Configuration diff --git a/packages/web/package.json b/packages/web/package.json index 68790d382..33bd60558 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -113,6 +113,7 @@ "http-status-codes": "^2.3.0", "input-otp": "^1.4.2", "lucide-react": "^0.435.0", + "micromatch": "^4.0.8", "next": "14.2.25", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.3.0", @@ -137,6 +138,7 @@ "zod": "^3.24.2" }, "devDependencies": { + "@types/micromatch": "^4.0.9", "@types/node": "^20", "@types/nodemailer": "^6.4.17", "@types/psl": "^1.1.3", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 0d98daffb..9d79bcc88 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1443,6 +1443,22 @@ export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => return true; }); +export const getSearchContexts = async (domain: string) => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const searchContexts = await prisma.searchContext.findMany({ + where: { + orgId, + }, + }); + + return searchContexts.map((context) => ({ + name: context.name, + description: context.description ?? undefined, + })); + } + ), /* allowSingleTenantUnauthedAccess = */ true)); + ////// Helpers /////// diff --git a/packages/web/src/app/[domain]/components/searchBar/constants.ts b/packages/web/src/app/[domain]/components/searchBar/constants.ts index e08a03fe5..a89852db0 100644 --- a/packages/web/src/app/[domain]/components/searchBar/constants.ts +++ b/packages/web/src/app/[domain]/components/searchBar/constants.ts @@ -18,7 +18,8 @@ enum SearchPrefix { archived = "archived:", case = "case:", fork = "fork:", - public = "public:" + public = "public:", + context = "context:", } const negate = (prefix: SearchPrefix) => { @@ -99,6 +100,13 @@ export const suggestionModeMappings: SuggestionModeMapping[] = [ prefixes: [ SearchPrefix.public ] + }, + { + suggestionMode: "context", + prefixes: [ + SearchPrefix.context, + negate(SearchPrefix.context), + ] } ]; @@ -172,6 +180,14 @@ export const refineModeSuggestions: Suggestion[] = [ value: SearchPrefix.public, description: "Filter on repository visibility." }, + { + value: SearchPrefix.context, + description: "Include only results from the given search context." + }, + { + value: negate(SearchPrefix.context), + description: "Exclude results from the given search context." + }, ]; export const publicModeSuggestions: Suggestion[] = [ diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index 54d48a2b9..de6962a42 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -39,7 +39,8 @@ export type SuggestionMode = "symbol" | "content" | "repo" | - "searchHistory"; + "searchHistory" | + "context"; interface SearchSuggestionsBoxProps { query: string; @@ -59,6 +60,7 @@ interface SearchSuggestionsBoxProps { symbolSuggestions: Suggestion[]; languageSuggestions: Suggestion[]; searchHistorySuggestions: Suggestion[]; + searchContextSuggestions: Suggestion[]; } const SearchSuggestionsBox = forwardRef(({ @@ -78,6 +80,7 @@ const SearchSuggestionsBox = forwardRef(({ symbolSuggestions, languageSuggestions, searchHistorySuggestions, + searchContextSuggestions, }: SearchSuggestionsBoxProps, ref: Ref) => { const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0); const { onOpenChanged } = useSyntaxGuide(); @@ -198,6 +201,12 @@ const SearchSuggestionsBox = forwardRef(({ }, descriptionPlacement: "right", } + case "context": + return { + list: searchContextSuggestions, + onSuggestionClicked: createOnSuggestionClickedHandler(), + descriptionPlacement: "right", + } case "none": case "revision": case "content": @@ -287,6 +296,8 @@ const SearchSuggestionsBox = forwardRef(({ return "Languages"; case "searchHistory": return "Search history" + case "context": + return "Search contexts" default: return ""; } diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index a8ed1eb6b..ac13d4f8a 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; import { getRepos, search } from "@/app/api/(client)/client"; +import { getSearchContexts } from "@/actions"; import { useMemo } from "react"; import { Symbol } from "@/lib/types"; import { languageMetadataMap } from "@/lib/languageMetadata"; @@ -18,7 +19,7 @@ import { VscSymbolVariable } from "react-icons/vsc"; import { useSearchHistory } from "@/hooks/useSearchHistory"; -import { getDisplayTime } from "@/lib/utils"; +import { getDisplayTime, isServiceError } from "@/lib/utils"; import { useDomain } from "@/hooks/useDomain"; @@ -56,6 +57,10 @@ export const useSuggestionsData = ({ maxMatchDisplayCount: 15, }, domain), select: (data): Suggestion[] => { + if (isServiceError(data)) { + return []; + } + return data.Result.Files?.map((file) => ({ value: file.FileName })) ?? []; @@ -71,6 +76,10 @@ export const useSuggestionsData = ({ maxMatchDisplayCount: 15, }, domain), select: (data): Suggestion[] => { + if (isServiceError(data)) { + return []; + } + const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []); if (!symbols) { return []; @@ -89,6 +98,24 @@ export const useSuggestionsData = ({ }); const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]); + const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({ + queryKey: ["searchContexts"], + queryFn: () => getSearchContexts(domain), + select: (data): Suggestion[] => { + if (isServiceError(data)) { + return []; + } + + return data.map((context) => ({ + value: context.name, + description: context.description, + })); + + }, + enabled: suggestionMode === "context", + }); + const isLoadingSearchContexts = useMemo(() => suggestionMode === "context" && _isLoadingSearchContexts, [_isLoadingSearchContexts, suggestionMode]); + const languageSuggestions = useMemo((): Suggestion[] => { return Object.keys(languageMetadataMap).map((lang) => { const spotlight = [ @@ -116,13 +143,14 @@ export const useSuggestionsData = ({ }, [searchHistory]); const isLoadingSuggestions = useMemo(() => { - return isLoadingSymbols || isLoadingFiles || isLoadingRepos; - }, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]); + return isLoadingSymbols || isLoadingFiles || isLoadingRepos || isLoadingSearchContexts; + }, [isLoadingFiles, isLoadingRepos, isLoadingSymbols, isLoadingSearchContexts]); return { repoSuggestions: repoSuggestions ?? [], fileSuggestions: fileSuggestions ?? [], symbolSuggestions: symbolSuggestions ?? [], + searchContextSuggestions: searchContextSuggestions ?? [], languageSuggestions, searchHistorySuggestions, isLoadingSuggestions, diff --git a/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts b/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts index 6fa0f4c76..1dad70bc7 100644 --- a/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts +++ b/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts @@ -47,7 +47,7 @@ export const zoekt = () => { // Check for prefixes first // If these match, we return 'keyword' - if (stream.match(/(archived:|branch:|b:|rev:|c:|case:|content:|f:|file:|fork:|public:|r:|repo:|regex:|lang:|sym:|t:|type:)/)) { + if (stream.match(/(archived:|branch:|b:|rev:|c:|case:|content:|f:|file:|fork:|public:|r:|repo:|regex:|lang:|sym:|t:|type:|context:)/)) { return t.keyword.toString(); } diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index 0b5879038..134b6b029 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -10,7 +10,7 @@ import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useSearchHistory } from "@/hooks/useSearchHistory"; import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types"; -import { createPathWithQueryParams, measure } from "@/lib/utils"; +import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils"; import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; @@ -22,6 +22,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; +import { useToast } from "@/components/hooks/use-toast"; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; @@ -44,21 +45,31 @@ const SearchPageInternal = () => { const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const domain = useDomain(); + const { toast } = useToast(); - const { data: searchResponse, isLoading } = useQuery({ + const { data: searchResponse, isLoading, error } = useQuery({ queryKey: ["search", searchQuery, maxMatchDisplayCount], - queryFn: () => measure(() => search({ + queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, maxMatchDisplayCount, - }, domain), "client.search"), + }, domain)), "client.search"), select: ({ data, durationMs }) => ({ ...data, durationMs, }), enabled: searchQuery.length > 0, refetchOnWindowFocus: false, + retry: false, }); + useEffect(() => { + if (error) { + toast({ + description: `❌ Search failed. Reason: ${error.message}`, + }); + } + }, [error, toast]); + // Write the query to the search history useEffect(() => { diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 987a0ad7f..44349cb64 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -1,9 +1,11 @@ 'use client'; import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas"; +import { ServiceError } from "@/lib/serviceError"; import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types"; +import { isServiceError } from "@/lib/utils"; -export const search = async (body: SearchRequest, domain: string): Promise => { +export const search = async (body: SearchRequest, domain: string): Promise => { const result = await fetch("/api/search", { method: "POST", headers: { @@ -13,6 +15,10 @@ export const search = async (body: SearchRequest, domain: string): Promise response.json()); + if (isServiceError(result)) { + return result; + } + return searchResponseSchema.parse(result); } diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 0d97404e1..f8b13f992 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -5,10 +5,11 @@ import { SINGLE_TENANT_USER_ID, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, import { readFile } from 'fs/promises'; import { watch } from 'fs'; import stripJsonComments from 'strip-json-comments'; -import { SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; +import { SearchContext, SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { indexSchema } from '@sourcebot/schemas/v3/index.schema'; import Ajv from 'ajv'; +import micromatch from 'micromatch'; const ajv = new Ajv({ validateFormats: false, @@ -22,29 +23,9 @@ const isRemotePath = (path: string) => { return path.startsWith('https://') || path.startsWith('http://'); } -const scheduleDeclarativeConfigSync = async (configPath: string) => { - const configContent = await (async () => { - if (isRemotePath(configPath)) { - const response = await fetch(configPath); - if (!response.ok) { - throw new Error(`Failed to fetch config file ${configPath}: ${response.statusText}`); - } - return response.text(); - } else { - return readFile(configPath, { - encoding: 'utf-8', - }); - } - })(); - - const config = JSON.parse(stripJsonComments(configContent)) as SourcebotConfig; - const isValidConfig = ajv.validate(indexSchema, config); - if (!isValidConfig) { - throw new Error(`Config file '${configPath}' is invalid: ${ajv.errorsText(ajv.errors)}`); - } - - if (config.connections) { - for (const [key, newConnectionConfig] of Object.entries(config.connections)) { +const syncConnections = async (connections?: { [key: string]: ConnectionConfig }) => { + if (connections) { + for (const [key, newConnectionConfig] of Object.entries(connections)) { const currentConnection = await prisma.connection.findUnique({ where: { name_orgId: { @@ -108,26 +89,145 @@ const scheduleDeclarativeConfigSync = async (configPath: string) => { }) } } + } + + // Delete any connections that are no longer in the config. + const deletedConnections = await prisma.connection.findMany({ + where: { + isDeclarative: true, + name: { + notIn: Object.keys(connections ?? {}), + }, + orgId: SINGLE_TENANT_ORG_ID, + } + }); - const deletedConnections = await prisma.connection.findMany({ + for (const connection of deletedConnections) { + console.log(`Deleting connection with name '${connection.name}'. Connection ID: ${connection.id}`); + await prisma.connection.delete({ where: { - isDeclarative: true, - name: { - notIn: Object.keys(config.connections), + id: connection.id, + } + }) + } +} + +const syncSearchContexts = async (contexts?: { [key: string]: SearchContext }) => { + if (contexts) { + for (const [key, newContextConfig] of Object.entries(contexts)) { + const allRepos = await prisma.repo.findMany({ + where: { + orgId: SINGLE_TENANT_ORG_ID, }, - orgId: SINGLE_TENANT_ORG_ID, + select: { + id: true, + name: true, + } + }); + + let newReposInContext = allRepos.filter(repo => { + return micromatch.isMatch(repo.name, newContextConfig.include); + }); + + if (newContextConfig.exclude) { + const exclude = newContextConfig.exclude; + newReposInContext = newReposInContext.filter(repo => { + return !micromatch.isMatch(repo.name, exclude); + }); } - }); - for (const connection of deletedConnections) { - console.log(`Deleting connection with name '${connection.name}'. Connection ID: ${connection.id}`); - await prisma.connection.delete({ + const currentReposInContext = (await prisma.searchContext.findUnique({ where: { - id: connection.id, + name_orgId: { + name: key, + orgId: SINGLE_TENANT_ORG_ID, + } + }, + include: { + repos: true, } - }) + }))?.repos ?? []; + + await prisma.searchContext.upsert({ + where: { + name_orgId: { + name: key, + orgId: SINGLE_TENANT_ORG_ID, + } + }, + update: { + repos: { + connect: newReposInContext.map(repo => ({ + id: repo.id, + })), + disconnect: currentReposInContext + .filter(repo => !newReposInContext.map(r => r.id).includes(repo.id)) + .map(repo => ({ + id: repo.id, + })), + }, + description: newContextConfig.description, + }, + create: { + name: key, + description: newContextConfig.description, + org: { + connect: { + id: SINGLE_TENANT_ORG_ID, + } + }, + repos: { + connect: newReposInContext.map(repo => ({ + id: repo.id, + })), + } + } + }); } } + + const deletedContexts = await prisma.searchContext.findMany({ + where: { + name: { + notIn: Object.keys(contexts ?? {}), + }, + orgId: SINGLE_TENANT_ORG_ID, + } + }); + + for (const context of deletedContexts) { + console.log(`Deleting search context with name '${context.name}'. ID: ${context.id}`); + await prisma.searchContext.delete({ + where: { + id: context.id, + } + }) + } +} + +const syncDeclarativeConfig = async (configPath: string) => { + const configContent = await (async () => { + if (isRemotePath(configPath)) { + const response = await fetch(configPath); + if (!response.ok) { + throw new Error(`Failed to fetch config file ${configPath}: ${response.statusText}`); + } + return response.text(); + } else { + return readFile(configPath, { + encoding: 'utf-8', + }); + } + })(); + + const config = JSON.parse(stripJsonComments(configContent)) as SourcebotConfig; + const isValidConfig = ajv.validate(indexSchema, config); + if (!isValidConfig) { + throw new Error(`Config file '${configPath}' is invalid: ${ajv.errorsText(ajv.errors)}`); + } + + await syncConnections(config.connections); + await syncSearchContexts(config.contexts); } const initSingleTenancy = async () => { @@ -186,13 +286,13 @@ const initSingleTenancy = async () => { // Load any connections defined declaratively in the config file. const configPath = env.CONFIG_PATH; if (configPath) { - await scheduleDeclarativeConfigSync(configPath); + await syncDeclarativeConfig(configPath); // watch for changes assuming it is a local file if (!isRemotePath(configPath)) { watch(configPath, () => { console.log(`Config file ${configPath} changed. Re-syncing...`); - scheduleDeclarativeConfigSync(configPath); + syncDeclarativeConfig(configPath); }); } } diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 66229e7b0..56702cd5e 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -22,4 +22,5 @@ export enum ErrorCode { SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', STRIPE_CLIENT_NOT_INITIALIZED = 'STRIPE_CLIENT_NOT_INITIALIZED', ACTION_DISALLOWED_IN_TENANCY_MODE = 'ACTION_DISALLOWED_IN_TENANCY_MODE', + SEARCH_CONTEXT_NOT_FOUND = 'SEARCH_CONTEXT_NOT_FOUND', } diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index a8a35cd68..a0fc7975a 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -2,6 +2,7 @@ import { checkIfOrgDomainExists } from "@/actions"; import { RepoIndexingStatus } from "@sourcebot/db"; import { z } from "zod"; import { isServiceError } from "./utils"; + export const searchRequestSchema = z.object({ query: z.string(), maxMatchDisplayCount: z.number(), diff --git a/packages/web/src/lib/server/searchService.ts b/packages/web/src/lib/server/searchService.ts index c5b807ea1..7648e1b0b 100644 --- a/packages/web/src/lib/server/searchService.ts +++ b/packages/web/src/lib/server/searchService.ts @@ -5,40 +5,91 @@ import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, Search import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError"; import { isServiceError } from "../utils"; import { zoektFetch } from "./zoektClient"; +import { prisma } from "@/prisma"; +import { ErrorCode } from "../errorCodes"; +import { StatusCodes } from "http-status-codes"; // List of supported query prefixes in zoekt. // @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417 enum zoektPrefixes { archived = "archived:", branchShort = "b:", - branch = "branch:", - caseShort = "c:", - case = "case:", - content = "content:", - fileShort = "f:", - file = "file:", - fork = "fork:", - public = "public:", - repoShort = "r:", - repo = "repo:", - regex = "regex:", - lang = "lang:", - sym = "sym:", - typeShort = "t:", - type = "type:", + branch = "branch:", + caseShort = "c:", + case = "case:", + content = "content:", + fileShort = "f:", + file = "file:", + fork = "fork:", + public = "public:", + repoShort = "r:", + repo = "repo:", + regex = "regex:", + lang = "lang:", + sym = "sym:", + typeShort = "t:", + type = "type:", + reposet = "reposet:", } -// Mapping of additional "alias" prefixes to zoekt prefixes. -const aliasPrefixMappings: Record = { - "rev:": zoektPrefixes.branch, - "revision:": zoektPrefixes.branch, +const transformZoektQuery = async (query: string, orgId: number): Promise => { + const prevQueryParts = query.split(" "); + const newQueryParts = []; + + for (const part of prevQueryParts) { + + // Handle mapping `rev:` and `revision:` to `branch:` + if (part.match(/^-?(rev|revision):.+$/)) { + const isNegated = part.startsWith("-"); + const revisionName = part.slice(part.indexOf(":") + 1); + newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.branch}${revisionName}`); + } + + // Expand `context:` into `reposet:` atom. + else if (part.match(/^-?context:.+$/)) { + const isNegated = part.startsWith("-"); + const contextName = part.slice(part.indexOf(":") + 1); + + const context = await prisma.searchContext.findUnique({ + where: { + name_orgId: { + name: contextName, + orgId, + } + }, + include: { + repos: true, + } + }); + + // If the context doesn't exist, return an error. + if (!context) { + return { + errorCode: ErrorCode.SEARCH_CONTEXT_NOT_FOUND, + message: `Search context "${contextName}" not found`, + statusCode: StatusCodes.NOT_FOUND, + } satisfies ServiceError; + } + + const names = context.repos.map((repo) => repo.name); + newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.reposet}${names.join(",")}`); + } + + // no-op: add the original part to the new query parts. + else { + newQueryParts.push(part); + } + } + + return newQueryParts.join(" "); } -export const search = async ({ query, maxMatchDisplayCount, whole}: SearchRequest, orgId: number): Promise => { - // Replace any alias prefixes with their corresponding zoekt prefixes. - for (const [prefix, zoektPrefix] of Object.entries(aliasPrefixMappings)) { - query = query.replaceAll(prefix, zoektPrefix); +export const search = async ({ query, maxMatchDisplayCount, whole }: SearchRequest, orgId: number): Promise => { + const transformedQuery = await transformZoektQuery(query, orgId); + if (isServiceError(transformedQuery)) { + return transformedQuery; } + query = transformedQuery; const isBranchFilteringEnabled = ( query.includes(zoektPrefixes.branch) || @@ -100,7 +151,7 @@ export const search = async ({ query, maxMatchDisplayCount, whole}: SearchReques export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, orgId: number): Promise => { const escapedFileName = escapeStringRegexp(fileName); const escapedRepository = escapeStringRegexp(repository); - + let query = `file:${escapedFileName} repo:^${escapedRepository}$`; if (branch) { query = query.concat(` branch:${branch}`); diff --git a/schemas/v3/index.json b/schemas/v3/index.json index 45a3b029b..c736f1d9d 100644 --- a/schemas/v3/index.json +++ b/schemas/v3/index.json @@ -11,7 +11,6 @@ "type": "number", "description": "The maximum size of a file (in bytes) to be indexed. Files that exceed this maximum will not be indexed. Defaults to 2MB.", "minimum": 1 - }, "maxTrigramCount": { "type": "number", @@ -65,6 +64,30 @@ } }, "additionalProperties": false + }, + "SearchContext": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "string" + } + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": "string" + } + }, + "required": [ + "include" + ], + "additionalProperties": false } }, "properties": { @@ -74,6 +97,15 @@ "settings": { "$ref": "#/definitions/Settings" }, + "contexts": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "$ref": "#/definitions/SearchContext" + } + }, + "additionalProperties": false + }, "connections": { "type": "object", "description": "Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode.", diff --git a/yarn.lock b/yarn.lock index faa5a8643..2ca4f10a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5298,6 +5298,7 @@ __metadata: "@tanstack/react-query": "npm:^5.53.3" "@tanstack/react-table": "npm:^8.20.5" "@tanstack/react-virtual": "npm:^3.10.8" + "@types/micromatch": "npm:^4.0.9" "@types/node": "npm:^20" "@types/nodemailer": "npm:^6.4.17" "@types/psl": "npm:^1.1.3" @@ -5344,6 +5345,7 @@ __metadata: input-otp: "npm:^1.4.2" jsdom: "npm:^25.0.1" lucide-react: "npm:^0.435.0" + micromatch: "npm:^4.0.8" next: "npm:14.2.25" next-auth: "npm:^5.0.0-beta.25" next-themes: "npm:^0.3.0" From 3d61a54261f99e7b6ae4226c9100e3c9440c27a5 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 3 Apr 2025 11:30:14 -0700 Subject: [PATCH 02/14] wip --- packages/web/src/ee/README.md | 0 .../src/ee/features/searchContexts/README.md | 0 .../searchContexts/syncSearchContexts.ts | 103 ++++++++++++++++++ packages/web/src/env.mjs | 3 + .../src/features/entitlements/constants.ts | 21 ++++ packages/web/src/initialize.ts | 97 +---------------- 6 files changed, 129 insertions(+), 95 deletions(-) create mode 100644 packages/web/src/ee/README.md create mode 100644 packages/web/src/ee/features/searchContexts/README.md create mode 100644 packages/web/src/ee/features/searchContexts/syncSearchContexts.ts create mode 100644 packages/web/src/features/entitlements/constants.ts diff --git a/packages/web/src/ee/README.md b/packages/web/src/ee/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/web/src/ee/features/searchContexts/README.md b/packages/web/src/ee/features/searchContexts/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts new file mode 100644 index 000000000..9193e6628 --- /dev/null +++ b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts @@ -0,0 +1,103 @@ +import { env } from "@/env.mjs"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { prisma } from "@/prisma"; +import { SearchContext } from "@sourcebot/schemas/v3/index.type"; +import micromatch from "micromatch"; + + +export const syncSearchContexts = async (contexts?: { [key: string]: SearchContext }) => { + if (env.SOURCEBOT_TENANCY_MODE !== 'single') { + throw new Error("Search contexts are not supported in this tenancy mode"); + } + + if (contexts) { + for (const [key, newContextConfig] of Object.entries(contexts)) { + const allRepos = await prisma.repo.findMany({ + where: { + orgId: SINGLE_TENANT_ORG_ID, + }, + select: { + id: true, + name: true, + } + }); + + let newReposInContext = allRepos.filter(repo => { + return micromatch.isMatch(repo.name, newContextConfig.include); + }); + + if (newContextConfig.exclude) { + const exclude = newContextConfig.exclude; + newReposInContext = newReposInContext.filter(repo => { + return !micromatch.isMatch(repo.name, exclude); + }); + } + + const currentReposInContext = (await prisma.searchContext.findUnique({ + where: { + name_orgId: { + name: key, + orgId: SINGLE_TENANT_ORG_ID, + } + }, + include: { + repos: true, + } + }))?.repos ?? []; + + await prisma.searchContext.upsert({ + where: { + name_orgId: { + name: key, + orgId: SINGLE_TENANT_ORG_ID, + } + }, + update: { + repos: { + connect: newReposInContext.map(repo => ({ + id: repo.id, + })), + disconnect: currentReposInContext + .filter(repo => !newReposInContext.map(r => r.id).includes(repo.id)) + .map(repo => ({ + id: repo.id, + })), + }, + description: newContextConfig.description, + }, + create: { + name: key, + description: newContextConfig.description, + org: { + connect: { + id: SINGLE_TENANT_ORG_ID, + } + }, + repos: { + connect: newReposInContext.map(repo => ({ + id: repo.id, + })), + } + } + }); + } + } + + const deletedContexts = await prisma.searchContext.findMany({ + where: { + name: { + notIn: Object.keys(contexts ?? {}), + }, + orgId: SINGLE_TENANT_ORG_ID, + } + }); + + for (const context of deletedContexts) { + console.log(`Deleting search context with name '${context.name}'. ID: ${context.id}`); + await prisma.searchContext.delete({ + where: { + id: context.id, + } + }) + } +} \ No newline at end of file diff --git a/packages/web/src/env.mjs b/packages/web/src/env.mjs index 11e77174d..e75cc5d6d 100644 --- a/packages/web/src/env.mjs +++ b/packages/web/src/env.mjs @@ -49,6 +49,9 @@ export const env = createEnv({ // Misc UI flags SECURITY_CARD_ENABLED: booleanSchema.default('false'), + + // EE License + SOURCEBOT_EE_LICENSE_KEY: z.string().optional(), }, // @NOTE: Please make sure of the following: // - Make sure you destructure all client variables in diff --git a/packages/web/src/features/entitlements/constants.ts b/packages/web/src/features/entitlements/constants.ts new file mode 100644 index 000000000..6c24e1f4c --- /dev/null +++ b/packages/web/src/features/entitlements/constants.ts @@ -0,0 +1,21 @@ + +const planLabels = { + oss: "OSS", + "cloud:team": "Team", + "cloud:enterprise": "Enterprise", + "self-hosted:enterprise": "Enterprise (Self-Hosted)", +} as const; +export type Plan = keyof typeof planLabels; + + +const entitlements = [ + "search-contexts" +] as const; +export type Entitlement = (typeof entitlements)[number]; + +export const entitlementsByPlan: Record = { + oss: [], + "cloud:team": [], + "cloud:enterprise": ["search-contexts"], + "self-hosted:enterprise": ["search-contexts"], +} as const; diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index f8b13f992..b9fedb305 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -5,11 +5,11 @@ import { SINGLE_TENANT_USER_ID, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, import { readFile } from 'fs/promises'; import { watch } from 'fs'; import stripJsonComments from 'strip-json-comments'; -import { SearchContext, SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; +import { SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { indexSchema } from '@sourcebot/schemas/v3/index.schema'; import Ajv from 'ajv'; -import micromatch from 'micromatch'; +import { syncSearchContexts } from '@/ee/features/searchContexts/syncSearchContexts'; const ajv = new Ajv({ validateFormats: false, @@ -112,99 +112,6 @@ const syncConnections = async (connections?: { [key: string]: ConnectionConfig } } } -const syncSearchContexts = async (contexts?: { [key: string]: SearchContext }) => { - if (contexts) { - for (const [key, newContextConfig] of Object.entries(contexts)) { - const allRepos = await prisma.repo.findMany({ - where: { - orgId: SINGLE_TENANT_ORG_ID, - }, - select: { - id: true, - name: true, - } - }); - - let newReposInContext = allRepos.filter(repo => { - return micromatch.isMatch(repo.name, newContextConfig.include); - }); - - if (newContextConfig.exclude) { - const exclude = newContextConfig.exclude; - newReposInContext = newReposInContext.filter(repo => { - return !micromatch.isMatch(repo.name, exclude); - }); - } - - const currentReposInContext = (await prisma.searchContext.findUnique({ - where: { - name_orgId: { - name: key, - orgId: SINGLE_TENANT_ORG_ID, - } - }, - include: { - repos: true, - } - }))?.repos ?? []; - - await prisma.searchContext.upsert({ - where: { - name_orgId: { - name: key, - orgId: SINGLE_TENANT_ORG_ID, - } - }, - update: { - repos: { - connect: newReposInContext.map(repo => ({ - id: repo.id, - })), - disconnect: currentReposInContext - .filter(repo => !newReposInContext.map(r => r.id).includes(repo.id)) - .map(repo => ({ - id: repo.id, - })), - }, - description: newContextConfig.description, - }, - create: { - name: key, - description: newContextConfig.description, - org: { - connect: { - id: SINGLE_TENANT_ORG_ID, - } - }, - repos: { - connect: newReposInContext.map(repo => ({ - id: repo.id, - })), - } - } - }); - } - } - - const deletedContexts = await prisma.searchContext.findMany({ - where: { - name: { - notIn: Object.keys(contexts ?? {}), - }, - orgId: SINGLE_TENANT_ORG_ID, - } - }); - - for (const context of deletedContexts) { - console.log(`Deleting search context with name '${context.name}'. ID: ${context.id}`); - await prisma.searchContext.delete({ - where: { - id: context.id, - } - }) - } -} - const syncDeclarativeConfig = async (configPath: string) => { const configContent = await (async () => { if (isRemotePath(configPath)) { From 9a1b6c46e83cfb3e15d841c886747c0f2fe3c0bc Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 3 Apr 2025 15:25:04 -0700 Subject: [PATCH 03/14] Add entitlements system --- packages/web/src/ee/README.md => ee/LICENSE | 0 .../components/searchBar/constants.ts | 172 +----------------- .../searchBar/searchSuggestionsBox.tsx | 3 +- .../searchBar/useRefineModeSuggestions.ts | 101 ++++++++++ .../searchBar/useSuggestionModeAndQuery.ts | 4 +- .../searchBar/useSuggestionModeMappings.ts | 104 +++++++++++ packages/web/src/app/layout.tsx | 32 ++-- .../src/ee/features/searchContexts/README.md | 0 .../searchContexts/syncSearchContexts.ts | 12 +- .../web/src/features/entitlements/README.md | 8 + .../src/features/entitlements/constants.ts | 2 - .../features/entitlements/planProvider.tsx | 21 +++ .../web/src/features/entitlements/server.ts | 49 +++++ .../entitlements/useHasEntitlement.ts | 10 + .../web/src/features/entitlements/usePlan.ts | 7 + 15 files changed, 335 insertions(+), 190 deletions(-) rename packages/web/src/ee/README.md => ee/LICENSE (100%) create mode 100644 packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts create mode 100644 packages/web/src/app/[domain]/components/searchBar/useSuggestionModeMappings.ts delete mode 100644 packages/web/src/ee/features/searchContexts/README.md create mode 100644 packages/web/src/features/entitlements/README.md create mode 100644 packages/web/src/features/entitlements/planProvider.tsx create mode 100644 packages/web/src/features/entitlements/server.ts create mode 100644 packages/web/src/features/entitlements/useHasEntitlement.ts create mode 100644 packages/web/src/features/entitlements/usePlan.ts diff --git a/packages/web/src/ee/README.md b/ee/LICENSE similarity index 100% rename from packages/web/src/ee/README.md rename to ee/LICENSE diff --git a/packages/web/src/app/[domain]/components/searchBar/constants.ts b/packages/web/src/app/[domain]/components/searchBar/constants.ts index a89852db0..c637bee9f 100644 --- a/packages/web/src/app/[domain]/components/searchBar/constants.ts +++ b/packages/web/src/app/[domain]/components/searchBar/constants.ts @@ -1,10 +1,10 @@ -import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; +import { Suggestion } from "./searchSuggestionsBox"; /** * List of search prefixes that can be used while the * `refine` suggestion mode is active. */ -enum SearchPrefix { +export enum SearchPrefix { repo = "repo:", r = "r:", lang = "lang:", @@ -22,174 +22,6 @@ enum SearchPrefix { context = "context:", } -const negate = (prefix: SearchPrefix) => { - return `-${prefix}`; -} - -type SuggestionModeMapping = { - suggestionMode: SuggestionMode, - prefixes: string[], -} - -/** - * Maps search prefixes to a suggestion mode. When a query starts - * with a prefix, the corresponding suggestion mode is enabled. - * @see [searchSuggestionsBox.tsx](./searchSuggestionsBox.tsx) - */ -export const suggestionModeMappings: SuggestionModeMapping[] = [ - { - suggestionMode: "repo", - prefixes: [ - SearchPrefix.repo, negate(SearchPrefix.repo), - SearchPrefix.r, negate(SearchPrefix.r), - ] - }, - { - suggestionMode: "language", - prefixes: [ - SearchPrefix.lang, negate(SearchPrefix.lang), - ] - }, - { - suggestionMode: "file", - prefixes: [ - SearchPrefix.file, negate(SearchPrefix.file), - ] - }, - { - suggestionMode: "content", - prefixes: [ - SearchPrefix.content, negate(SearchPrefix.content), - ] - }, - { - suggestionMode: "revision", - prefixes: [ - SearchPrefix.rev, negate(SearchPrefix.rev), - SearchPrefix.revision, negate(SearchPrefix.revision), - SearchPrefix.branch, negate(SearchPrefix.branch), - SearchPrefix.b, negate(SearchPrefix.b), - ] - }, - { - suggestionMode: "symbol", - prefixes: [ - SearchPrefix.sym, negate(SearchPrefix.sym), - ] - }, - { - suggestionMode: "archived", - prefixes: [ - SearchPrefix.archived - ] - }, - { - suggestionMode: "case", - prefixes: [ - SearchPrefix.case - ] - }, - { - suggestionMode: "fork", - prefixes: [ - SearchPrefix.fork - ] - }, - { - suggestionMode: "public", - prefixes: [ - SearchPrefix.public - ] - }, - { - suggestionMode: "context", - prefixes: [ - SearchPrefix.context, - negate(SearchPrefix.context), - ] - } -]; - -export const refineModeSuggestions: Suggestion[] = [ - { - value: SearchPrefix.repo, - description: "Include only results from the given repository.", - spotlight: true, - }, - { - value: negate(SearchPrefix.repo), - description: "Exclude results from the given repository." - }, - { - value: SearchPrefix.lang, - description: "Include only results from the given language.", - spotlight: true, - }, - { - value: negate(SearchPrefix.lang), - description: "Exclude results from the given language." - }, - { - value: SearchPrefix.file, - description: "Include only results from filepaths matching the given search pattern.", - spotlight: true, - }, - { - value: negate(SearchPrefix.file), - description: "Exclude results from file paths matching the given search pattern." - }, - { - value: SearchPrefix.rev, - description: "Search a given branch or tag instead of the default branch.", - spotlight: true, - }, - { - value: negate(SearchPrefix.rev), - description: "Exclude results from the given branch or tag." - }, - { - value: SearchPrefix.sym, - description: "Include only symbols matching the given search pattern.", - spotlight: true, - }, - { - value: negate(SearchPrefix.sym), - description: "Exclude results from symbols matching the given search pattern." - }, - { - value: SearchPrefix.content, - description: "Include only results from files if their content matches the given search pattern." - }, - { - value: negate(SearchPrefix.content), - description: "Exclude results from files if their content matches the given search pattern." - }, - { - value: SearchPrefix.archived, - description: "Include results from archived repositories.", - }, - { - value: SearchPrefix.case, - description: "Control case-sensitivity of search patterns." - }, - { - value: SearchPrefix.fork, - description: "Include only results from forked repositories." - }, - { - value: SearchPrefix.public, - description: "Filter on repository visibility." - }, - { - value: SearchPrefix.context, - description: "Include only results from the given search context." - }, - { - value: negate(SearchPrefix.context), - description: "Exclude results from the given search context." - }, -]; - export const publicModeSuggestions: Suggestion[] = [ { value: "yes", diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index de6962a42..19ab75383 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -10,7 +10,6 @@ import { caseModeSuggestions, forkModeSuggestions, publicModeSuggestions, - refineModeSuggestions, } from "./constants"; import { IconType } from "react-icons/lib"; import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc"; @@ -18,6 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; import { KeyboardShortcutHint } from "../keyboardShortcutHint"; import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider"; +import { useRefineModeSuggestions } from "./useRefineModeSuggestions"; export type Suggestion = { value: string; @@ -84,6 +84,7 @@ const SearchSuggestionsBox = forwardRef(({ }: SearchSuggestionsBoxProps, ref: Ref) => { const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0); const { onOpenChanged } = useSyntaxGuide(); + const refineModeSuggestions = useRefineModeSuggestions(); const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => { if (!isEnabled) { diff --git a/packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts b/packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts new file mode 100644 index 000000000..fdc16d504 --- /dev/null +++ b/packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts @@ -0,0 +1,101 @@ +'use client'; + +import { useMemo } from "react"; +import { Suggestion } from "./searchSuggestionsBox"; +import { SearchPrefix } from "./constants"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; + +const negate = (prefix: SearchPrefix) => { + return `-${prefix}`; +} + +export const useRefineModeSuggestions = () => { + const isSearchContextsEnabled = useHasEntitlement('search-contexts'); + + const suggestions = useMemo((): Suggestion[] => { + return [ + ...(isSearchContextsEnabled ? [ + { + value: SearchPrefix.context, + description: "Include only results from the given search context.", + spotlight: true, + }, + { + value: negate(SearchPrefix.context), + description: "Exclude results from the given search context." + }, + ] : []), + { + value: SearchPrefix.public, + description: "Filter on repository visibility." + }, + { + value: SearchPrefix.repo, + description: "Include only results from the given repository.", + spotlight: true, + }, + { + value: negate(SearchPrefix.repo), + description: "Exclude results from the given repository." + }, + { + value: SearchPrefix.lang, + description: "Include only results from the given language.", + spotlight: true, + }, + { + value: negate(SearchPrefix.lang), + description: "Exclude results from the given language." + }, + { + value: SearchPrefix.file, + description: "Include only results from filepaths matching the given search pattern.", + spotlight: true, + }, + { + value: negate(SearchPrefix.file), + description: "Exclude results from file paths matching the given search pattern." + }, + { + value: SearchPrefix.rev, + description: "Search a given branch or tag instead of the default branch.", + spotlight: true, + }, + { + value: negate(SearchPrefix.rev), + description: "Exclude results from the given branch or tag." + }, + { + value: SearchPrefix.sym, + description: "Include only symbols matching the given search pattern.", + spotlight: true, + }, + { + value: negate(SearchPrefix.sym), + description: "Exclude results from symbols matching the given search pattern." + }, + { + value: SearchPrefix.content, + description: "Include only results from files if their content matches the given search pattern." + }, + { + value: negate(SearchPrefix.content), + description: "Exclude results from files if their content matches the given search pattern." + }, + { + value: SearchPrefix.archived, + description: "Include results from archived repositories.", + }, + { + value: SearchPrefix.case, + description: "Control case-sensitivity of search patterns." + }, + { + value: SearchPrefix.fork, + description: "Include only results from forked repositories." + }, + ]; + }, [isSearchContextsEnabled]); + + return suggestions; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts index 555b4c22f..6aa4ff9dd 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { splitQuery, SuggestionMode } from "./searchSuggestionsBox"; -import { suggestionModeMappings } from "./constants"; +import { useSuggestionModeMappings } from "./useSuggestionModeMappings"; interface Props { isSuggestionsEnabled: boolean; @@ -18,6 +18,8 @@ export const useSuggestionModeAndQuery = ({ query, }: Props) => { + const suggestionModeMappings = useSuggestionModeMappings(); + const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => { // When suggestions are not enabled, fallback to using a sentinal // suggestion mode of "none". diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeMappings.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeMappings.ts new file mode 100644 index 000000000..da03fd6b5 --- /dev/null +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeMappings.ts @@ -0,0 +1,104 @@ +'use client'; + +import { useMemo } from "react"; +import { SearchPrefix } from "./constants"; +import { SuggestionMode } from "./searchSuggestionsBox"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; + +const negate = (prefix: SearchPrefix) => { + return `-${prefix}`; +} + +type SuggestionModeMapping = { + suggestionMode: SuggestionMode, + prefixes: string[], +} + +/** + * Maps search prefixes to a suggestion mode. When a query starts + * with a prefix, the corresponding suggestion mode is enabled. + * @see [searchSuggestionsBox.tsx](./searchSuggestionsBox.tsx) + */ +export const useSuggestionModeMappings = () => { + const isSearchContextsEnabled = useHasEntitlement('search-contexts'); + + const mappings = useMemo((): SuggestionModeMapping[] => { + return [ + { + suggestionMode: "repo", + prefixes: [ + SearchPrefix.repo, negate(SearchPrefix.repo), + SearchPrefix.r, negate(SearchPrefix.r), + ] + }, + { + suggestionMode: "language", + prefixes: [ + SearchPrefix.lang, negate(SearchPrefix.lang), + ] + }, + { + suggestionMode: "file", + prefixes: [ + SearchPrefix.file, negate(SearchPrefix.file), + ] + }, + { + suggestionMode: "content", + prefixes: [ + SearchPrefix.content, negate(SearchPrefix.content), + ] + }, + { + suggestionMode: "revision", + prefixes: [ + SearchPrefix.rev, negate(SearchPrefix.rev), + SearchPrefix.revision, negate(SearchPrefix.revision), + SearchPrefix.branch, negate(SearchPrefix.branch), + SearchPrefix.b, negate(SearchPrefix.b), + ] + }, + { + suggestionMode: "symbol", + prefixes: [ + SearchPrefix.sym, negate(SearchPrefix.sym), + ] + }, + { + suggestionMode: "archived", + prefixes: [ + SearchPrefix.archived + ] + }, + { + suggestionMode: "case", + prefixes: [ + SearchPrefix.case + ] + }, + { + suggestionMode: "fork", + prefixes: [ + SearchPrefix.fork + ] + }, + { + suggestionMode: "public", + prefixes: [ + SearchPrefix.public + ] + }, + ...(isSearchContextsEnabled ? [ + { + suggestionMode: "context", + prefixes: [ + SearchPrefix.context, + negate(SearchPrefix.context), + ] + } satisfies SuggestionModeMapping, + ] : []), + ] + }, [isSearchContextsEnabled]); + + return mappings; +} \ No newline at end of file diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 859cd8b60..fa4309306 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -7,6 +7,8 @@ import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { SessionProvider } from "next-auth/react"; import { env } from "@/env.mjs"; +import { PlanProvider } from "@/features/entitlements/planProvider"; +import { getPlan } from "@/features/entitlements/server"; export const metadata: Metadata = { title: "Sourcebot", @@ -27,20 +29,22 @@ export default function RootLayout({ - - - - - {children} - - - - + + + + + + {children} + + + + + diff --git a/packages/web/src/ee/features/searchContexts/README.md b/packages/web/src/ee/features/searchContexts/README.md deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts index 9193e6628..772c2c37b 100644 --- a/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts +++ b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts @@ -1,13 +1,21 @@ import { env } from "@/env.mjs"; +import { getPlan, hasEntitlement } from "@/features/entitlements/server"; import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { prisma } from "@/prisma"; import { SearchContext } from "@sourcebot/schemas/v3/index.type"; import micromatch from "micromatch"; - export const syncSearchContexts = async (contexts?: { [key: string]: SearchContext }) => { if (env.SOURCEBOT_TENANCY_MODE !== 'single') { - throw new Error("Search contexts are not supported in this tenancy mode"); + throw new Error("Search contexts are not supported in this tenancy mode. Set SOURCEBOT_TENANCY_MODE=single in your environment variables."); + } + + if (!hasEntitlement("search-contexts")) { + if (contexts) { + const plan = getPlan(); + console.error(`Search contexts are not supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact team@sourcebot.dev.`); + } + return; } if (contexts) { diff --git a/packages/web/src/features/entitlements/README.md b/packages/web/src/features/entitlements/README.md new file mode 100644 index 000000000..5991640cd --- /dev/null +++ b/packages/web/src/features/entitlements/README.md @@ -0,0 +1,8 @@ +# Entitlements + +Entitlements control the availability of certain features dependent on the current plan. Entitlements are managed at the **instance** level. + +Some definitions: + +- `Plan`: A plan is a tier of features. Examples: `oss`, `cloud:team`, `self-hosted:enterprise`. +- `Entitlement`: An entitlement is a feature that is available to a instance. Examples: `search-contexts`, `billing`. diff --git a/packages/web/src/features/entitlements/constants.ts b/packages/web/src/features/entitlements/constants.ts index 6c24e1f4c..bdb6625dc 100644 --- a/packages/web/src/features/entitlements/constants.ts +++ b/packages/web/src/features/entitlements/constants.ts @@ -2,7 +2,6 @@ const planLabels = { oss: "OSS", "cloud:team": "Team", - "cloud:enterprise": "Enterprise", "self-hosted:enterprise": "Enterprise (Self-Hosted)", } as const; export type Plan = keyof typeof planLabels; @@ -16,6 +15,5 @@ export type Entitlement = (typeof entitlements)[number]; export const entitlementsByPlan: Record = { oss: [], "cloud:team": [], - "cloud:enterprise": ["search-contexts"], "self-hosted:enterprise": ["search-contexts"], } as const; diff --git a/packages/web/src/features/entitlements/planProvider.tsx b/packages/web/src/features/entitlements/planProvider.tsx new file mode 100644 index 000000000..728eccc71 --- /dev/null +++ b/packages/web/src/features/entitlements/planProvider.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { createContext } from "react"; +import { Plan } from "./constants"; + +export const PlanContext = createContext('oss'); + +interface PlanProviderProps { + children: React.ReactNode; + plan: Plan; +} + +export const PlanProvider = ({ children, plan }: PlanProviderProps) => { + return ( + + {children} + + ) +}; diff --git a/packages/web/src/features/entitlements/server.ts b/packages/web/src/features/entitlements/server.ts new file mode 100644 index 000000000..3df594a80 --- /dev/null +++ b/packages/web/src/features/entitlements/server.ts @@ -0,0 +1,49 @@ +import { env } from "@/env.mjs" +import { Entitlement, entitlementsByPlan, Plan } from "./constants" +import { base64Decode } from "@/lib/utils"; +import { z } from "zod"; +const eeLicenseKeyPrefix = "sourcebot_ee_"; + +const eeLicenseKeyPayloadSchema = z.object({ + // ISO 8601 date string + expiryDate: z.string().datetime(), +}); + +const decodeLicenseKeyPayload = (payload: string) => { + const decodedPayload = base64Decode(payload); + const payloadJson = JSON.parse(decodedPayload); + return eeLicenseKeyPayloadSchema.parse(payloadJson); +} + +export const getPlan = (): Plan => { + if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT) { + return "cloud:team"; + } + + const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY; + if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) { + const payload = licenseKey.substring(eeLicenseKeyPrefix.length); + + try { + const { expiryDate } = decodeLicenseKeyPayload(payload); + + if (new Date(expiryDate).getTime() < new Date().getTime()) { + console.error("The provided license key has expired. Falling back to oss plan. Please contact team@sourcebot.dev for support."); + return "oss"; + } + + return "self-hosted:enterprise"; + } catch (error) { + console.error(`Failed to decode license key payload: ${error}`); + return "oss"; + } + } + + return "oss"; +} + +export const hasEntitlement = (entitlement: Entitlement) => { + const plan = getPlan(); + const entitlements = entitlementsByPlan[plan]; + return entitlements.includes(entitlement); +} diff --git a/packages/web/src/features/entitlements/useHasEntitlement.ts b/packages/web/src/features/entitlements/useHasEntitlement.ts new file mode 100644 index 000000000..c629c2c90 --- /dev/null +++ b/packages/web/src/features/entitlements/useHasEntitlement.ts @@ -0,0 +1,10 @@ +'use client'; + +import { Entitlement, entitlementsByPlan } from "./constants"; +import { usePlan } from "./usePlan"; + +export const useHasEntitlement = (entitlement: Entitlement) => { + const plan = usePlan(); + const entitlements = entitlementsByPlan[plan]; + return entitlements.includes(entitlement); +} \ No newline at end of file diff --git a/packages/web/src/features/entitlements/usePlan.ts b/packages/web/src/features/entitlements/usePlan.ts new file mode 100644 index 000000000..126c060e2 --- /dev/null +++ b/packages/web/src/features/entitlements/usePlan.ts @@ -0,0 +1,7 @@ +import { useContext } from "react"; +import { PlanContext } from "./planProvider"; + +export const usePlan = () => { + const plan = useContext(PlanContext); + return plan; +} \ No newline at end of file From c7d9a79fcfdce0c06ef96cbafcf01f716953983f Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 3 Apr 2025 15:25:23 -0700 Subject: [PATCH 04/14] cleanup support email --- .../app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx | 4 ++-- packages/web/src/app/components/securityCard.tsx | 3 ++- packages/web/src/app/error.tsx | 3 ++- packages/web/src/app/login/verify/page.tsx | 3 ++- packages/web/src/app/login/verify/verificationFailed.tsx | 3 ++- .../web/src/ee/features/searchContexts/syncSearchContexts.ts | 4 ++-- packages/web/src/features/entitlements/server.ts | 4 +++- packages/web/src/lib/constants.ts | 4 +++- 8 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx b/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx index f18dd7c2e..74de5cf08 100644 --- a/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx +++ b/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ENTERPRISE_FEATURES } from "@/lib/constants"; +import { ENTERPRISE_FEATURES, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; import { UpgradeCard } from "./upgradeCard"; import Link from "next/link"; import useCaptureEvent from "@/hooks/useCaptureEvent"; @@ -14,7 +14,7 @@ export const EnterpriseUpgradeCard = () => { } return ( - + Have questions? diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx index 69f503e88..44b5dabe1 100644 --- a/packages/web/src/app/error.tsx +++ b/packages/web/src/app/error.tsx @@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Button } from "@/components/ui/button" import { serviceErrorSchema } from '@/lib/serviceError'; import { SourcebotLogo } from './components/sourcebotLogo'; +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) { useEffect(() => { @@ -76,7 +77,7 @@ function ErrorCard({ message, errorCode, statusCode, onReloadButtonClicked }: Er Unexpected Error - An unexpected error occurred. Please reload the page and try again. If the issue persists, please contact us. + An unexpected error occurred. Please reload the page and try again. If the issue persists, please contact us. diff --git a/packages/web/src/app/login/verify/page.tsx b/packages/web/src/app/login/verify/page.tsx index 8b1c3bc01..a096f5f8b 100644 --- a/packages/web/src/app/login/verify/page.tsx +++ b/packages/web/src/app/login/verify/page.tsx @@ -13,6 +13,7 @@ import VerificationFailed from "./verificationFailed" import { SourcebotLogo } from "@/app/components/sourcebotLogo" import useCaptureEvent from "@/hooks/useCaptureEvent" import { Footer } from "@/app/components/footer" +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" function VerifyPageContent() { const [value, setValue] = useState("") @@ -89,7 +90,7 @@ function VerifyPageContent() {

Having trouble?{" "} - + Contact support

diff --git a/packages/web/src/app/login/verify/verificationFailed.tsx b/packages/web/src/app/login/verify/verificationFailed.tsx index 5fd46cae5..98aeda111 100644 --- a/packages/web/src/app/login/verify/verificationFailed.tsx +++ b/packages/web/src/app/login/verify/verificationFailed.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button" import { AlertCircle } from "lucide-react" import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { useRouter } from "next/navigation" +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" export default function VerificationFailed() { const router = useRouter() @@ -34,7 +35,7 @@ export default function VerificationFailed() { About - + Contact Us
diff --git a/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts index 772c2c37b..e662896d0 100644 --- a/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts +++ b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts @@ -1,6 +1,6 @@ import { env } from "@/env.mjs"; import { getPlan, hasEntitlement } from "@/features/entitlements/server"; -import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { SINGLE_TENANT_ORG_ID, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; import { prisma } from "@/prisma"; import { SearchContext } from "@sourcebot/schemas/v3/index.type"; import micromatch from "micromatch"; @@ -13,7 +13,7 @@ export const syncSearchContexts = async (contexts?: { [key: string]: SearchConte if (!hasEntitlement("search-contexts")) { if (contexts) { const plan = getPlan(); - console.error(`Search contexts are not supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact team@sourcebot.dev.`); + console.error(`Search contexts are not supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); } return; } diff --git a/packages/web/src/features/entitlements/server.ts b/packages/web/src/features/entitlements/server.ts index 3df594a80..510724f38 100644 --- a/packages/web/src/features/entitlements/server.ts +++ b/packages/web/src/features/entitlements/server.ts @@ -2,6 +2,8 @@ import { env } from "@/env.mjs" import { Entitlement, entitlementsByPlan, Plan } from "./constants" import { base64Decode } from "@/lib/utils"; import { z } from "zod"; +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; + const eeLicenseKeyPrefix = "sourcebot_ee_"; const eeLicenseKeyPayloadSchema = z.object({ @@ -28,7 +30,7 @@ export const getPlan = (): Plan => { const { expiryDate } = decodeLicenseKeyPayload(payload); if (new Date(expiryDate).getTime() < new Date().getTime()) { - console.error("The provided license key has expired. Falling back to oss plan. Please contact team@sourcebot.dev for support."); + console.error(`The provided license key has expired. Falling back to oss plan. Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); return "oss"; } diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts index faee5be97..889133352 100644 --- a/packages/web/src/lib/constants.ts +++ b/packages/web/src/lib/constants.ts @@ -28,4 +28,6 @@ export const SINGLE_TENANT_USER_ID = '1'; export const SINGLE_TENANT_USER_EMAIL = 'default@sourcebot.dev'; export const SINGLE_TENANT_ORG_ID = 1; export const SINGLE_TENANT_ORG_DOMAIN = '~'; -export const SINGLE_TENANT_ORG_NAME = 'default'; \ No newline at end of file +export const SINGLE_TENANT_ORG_NAME = 'default'; + +export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev'; \ No newline at end of file From cfd316284cd2190df031a98b8a980f92d34e4c29 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 3 Apr 2025 15:59:43 -0700 Subject: [PATCH 05/14] docs wip --- docs/docs.json | 6 ++- docs/self-hosting/license-key.mdx | 21 ++++++++ docs/self-hosting/more/search-contexts.mdx | 58 ++++++++++++++++++++++ packages/schemas/src/v3/index.schema.ts | 23 +++++++-- packages/schemas/src/v3/index.type.ts | 14 ++++++ schemas/v3/index.json | 23 +++++++-- 6 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 docs/self-hosting/license-key.mdx create mode 100644 docs/self-hosting/more/search-contexts.mdx diff --git a/docs/docs.json b/docs/docs.json index 643588769..5d6711f40 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -52,7 +52,8 @@ "group": "Getting Started", "pages": [ "self-hosting/overview", - "self-hosting/configuration" + "self-hosting/configuration", + "self-hosting/license-key" ] }, { @@ -61,7 +62,8 @@ "self-hosting/more/authentication", "self-hosting/more/tenancy", "self-hosting/more/transactional-emails", - "self-hosting/more/declarative-config" + "self-hosting/more/declarative-config", + "self-hosting/more/search-contexts" ] }, { diff --git a/docs/self-hosting/license-key.mdx b/docs/self-hosting/license-key.mdx new file mode 100644 index 000000000..fe4a267fe --- /dev/null +++ b/docs/self-hosting/license-key.mdx @@ -0,0 +1,21 @@ +--- +title: License key +sidebarTitle: License key +--- + +All core Sourcebot features are available in Sourcebot OSS (MIT Licensed). Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details. + + +## Activating a license key + +```sh +SOURCEBOT_EE_LICENSE_KEY= +``` + +## Feature availability + +todo + +## Questions? + +If you have any questions regarding licensing, please [contact us](mailto:team@sourcebot.dev). \ No newline at end of file diff --git a/docs/self-hosting/more/search-contexts.mdx b/docs/self-hosting/more/search-contexts.mdx new file mode 100644 index 000000000..8b35acfee --- /dev/null +++ b/docs/self-hosting/more/search-contexts.mdx @@ -0,0 +1,58 @@ +--- +title: Search contexts +sidebarTitle: Search contexts (EE) +--- + + +This is only available in the Enteprise Edition. Please add your [license key](/self-hosting/license-key) to activate it. + + +todo + + +## Schema reference + + +```json +{ + "type": "object", + "description": "Search context", + "properties": { + "include": { + "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "exclude": { + "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "description": { + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." + } + }, + "required": [ + "include" + ], + "additionalProperties": false +} +``` + diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index bacd51e70..d78811b2c 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -68,21 +68,37 @@ const schema = { }, "SearchContext": { "type": "object", + "description": "Search context", "properties": { "include": { "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", "items": { "type": "string" - } + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] }, "exclude": { "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", "items": { "type": "string" - } + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] }, "description": { - "type": "string" + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." } }, "required": [ @@ -100,6 +116,7 @@ const schema = { }, "contexts": { "type": "object", + "description": "[Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/self-hosting/more/search-contexts", "patternProperties": { "^[a-zA-Z0-9_-]+$": { "$ref": "#/definitions/SearchContext" diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index d2b837714..54cb4a8ea 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -13,6 +13,9 @@ export type ConnectionConfig = export interface SourcebotConfig { $schema?: string; settings?: Settings; + /** + * [Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/self-hosting/more/search-contexts + */ contexts?: { [k: string]: SearchContext; }; @@ -76,6 +79,8 @@ export interface Settings { repoIndexTimeoutMs?: number; } /** + * Search context + * * This interface was referenced by `undefined`'s JSON-Schema definition * via the `patternProperty` "^[a-zA-Z0-9_-]+$". * @@ -83,8 +88,17 @@ export interface Settings { * via the `definition` "SearchContext". */ export interface SearchContext { + /** + * List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported. + */ include: string[]; + /** + * List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported. + */ exclude?: string[]; + /** + * Optional description of the search context that surfaces in the UI. + */ description?: string; } export interface GithubConnectionConfig { diff --git a/schemas/v3/index.json b/schemas/v3/index.json index c736f1d9d..587f9c4fb 100644 --- a/schemas/v3/index.json +++ b/schemas/v3/index.json @@ -67,21 +67,37 @@ }, "SearchContext": { "type": "object", + "description": "Search context", "properties": { "include": { "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", "items": { "type": "string" - } + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] }, "exclude": { "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", "items": { "type": "string" - } + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] }, "description": { - "type": "string" + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." } }, "required": [ @@ -99,6 +115,7 @@ }, "contexts": { "type": "object", + "description": "[Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/self-hosting/more/search-contexts", "patternProperties": { "^[a-zA-Z0-9_-]+$": { "$ref": "#/definitions/SearchContext" From d64932ca7e885ddb350d3f566d5b73833dedc0b8 Mon Sep 17 00:00:00 2001 From: bkellam Date: Tue, 22 Apr 2025 14:03:48 -0700 Subject: [PATCH 06/14] minor nit to wipe DB on soft-reset --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index abae628da..00fbbb1e4 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,7 @@ clean: soft-reset: rm -rf .sourcebot redis-cli FLUSHALL + yarn dev:prisma:migrate:reset .PHONY: bin From bc2872b758ac9f5d3ca235fd7c1b1e6c74fa7447 Mon Sep 17 00:00:00 2001 From: msukkari Date: Tue, 22 Apr 2025 14:25:38 -0700 Subject: [PATCH 07/14] add ee license --- LICENSE | 8 ++++++-- ee/LICENSE | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 83bbbcd63..04fbff3ad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,10 @@ -MIT License +Copyright (c) 2025 Taqla Inc. -Copyright (c) Taqla, Inc. +Portions of this software are licensed as follows: + +- All content that resides under the "ee/" and "packages/web/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE". +- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component. +- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/ee/LICENSE b/ee/LICENSE index e69de29bb..928443d86 100644 --- a/ee/LICENSE +++ b/ee/LICENSE @@ -0,0 +1,27 @@ +Sourcebot Enterprise license (the “Enterprise License” or "EE license") +Copyright (c) 2025 Taqla Inc. + +With regard to the Sourcebot Enterprise Software: + +This software and associated documentation files (the "Software") may only be used for +internal business purposes if you (and any entity that you represent) are in compliance +with an agreement governing the use of the Software, as agreed by you and Sourcebot, and otherwise +have a valid Sourcebot Enterprise license for the correct number of user seats. Subject to the foregoing +sentence, you are free to modify this Software and publish patches to the Software. You agree that Sourcebot +and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications +and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, +distributed, or otherwise exploited with a valid Sourcebot Enterprise license for the correct number of user seats. +Notwithstanding the foregoing, you may copy and modify the Software for non-production evaluation or internal +experimentation purposes, without requiring a subscription. You agree that Sourcebot and/or +its licensors (as applicable) retain all right, title and interest in and to all such modifications. +You are not granted any other rights beyond what is expressly stated herein. Subject to the +foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For all third party components incorporated into the Sourcebot Software, those components are +licensed under the original license provided by the owner of the applicable component. \ No newline at end of file From 0f091046bf6a0be62195b51a9e1dbe9497e269f3 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 23 Apr 2025 14:41:44 -0700 Subject: [PATCH 08/14] wip on docs --- docs/docs.json | 1 + docs/docs/more/syntax-reference.mdx | 35 +++++++ docs/images/search_contexts_example.png | Bin 0 -> 67346 bytes docs/self-hosting/license-key.mdx | 4 - docs/self-hosting/more/declarative-config.mdx | 4 + docs/self-hosting/more/search-contexts.mdx | 88 +++++++++++++++++- .../searchBar/searchSuggestionsBox.tsx | 3 +- 7 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 docs/docs/more/syntax-reference.mdx create mode 100644 docs/images/search_contexts_example.png diff --git a/docs/docs.json b/docs/docs.json index 5d6711f40..75af3813c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,6 +39,7 @@ { "group": "More", "pages": [ + "docs/more/syntax-reference", "docs/more/roles-and-permissions" ] } diff --git a/docs/docs/more/syntax-reference.mdx b/docs/docs/more/syntax-reference.mdx new file mode 100644 index 000000000..82a5d3856 --- /dev/null +++ b/docs/docs/more/syntax-reference.mdx @@ -0,0 +1,35 @@ +--- +title: Writing search queries +--- + +Sourcebot uses a powerful regex-based query language that enabled precise code search within large codebases. + + +## Syntax reference guide + +Queries consist of space-separated regular expressions. Wrapping expressions in `""` combines them. By default, a file must have at least one match for each expression to be included. + +| Example | Explanation | +| :--- | :--- | +| `foo` | Match files with regex `/foo/` | +| `foo bar` | Match files with regex `/foo/` **and** `/bar/` | +| `"foo bar"` | Match files with regex `/foo bar/` | + +Multiple expressions can be or'd together with `or`, negated with `-`, or grouped with `()`. + +| Example | Explanation | +| :--- | :--- | +| `foo or bar` | Match files with regex `/foo/` **or** `/bar/` | +| `foo -bar` | Match files with regex `/foo/` but **not** `/bar/` | +| `foo (bar or baz)` | Match files with regex `/foo/` **and** either `/bar/` **or** `/baz/` | + +Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the `-` prefix. + +| Prefix | Description | Example | +| :--- | :--- | :--- | +| `file:` | Filter results from filepaths that match the regex. By default all files are searched. | `file:README` - Filter results to filepaths that match regex `/README/`
`file:"my file"` - Filter results to filepaths that match regex `/my file/`
`-file:test\.ts$` - Ignore results from filepaths match regex `/test\.ts$/` | +| `repo:` | Filter results from repos that match the regex. By default all repos are searched. | `repo:linux` - Filter results to repos that match regex `/linux/`
`-repo:^web/.*` - Ignore results from repos that match regex `/^web\/.*` | +| `rev:` | Filter results from a specific branch or tag. By default **only** the default branch is searched. | `rev:beta` - Filter results to branches that match regex `/beta/` | +| `lang:` | Filter results by language (as defined by [linguist](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml)). By default all languages are searched. | `lang:TypeScript` - Filter results to TypeScript files
`-lang:YAML` - Ignore results from YAML files | +| `sym:` | Match symbol definitions created by [universal ctags](https://ctags.io/) at index time. | `sym:\bmain\b` - Filter results to symbols that match regex `/\bmain\b/` | +| `context:` | Filter results to a predefined [search context](/self-hosting/more/search-contexts). | `context:web` - Filter results to the web context
`-context:pipelines` - Ignore results from the pipelines context | \ No newline at end of file diff --git a/docs/images/search_contexts_example.png b/docs/images/search_contexts_example.png new file mode 100644 index 0000000000000000000000000000000000000000..f63eae827fc494b28f59f7c32dde1f7f4a685da8 GIT binary patch literal 67346 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 ``` -## Feature availability - -todo - ## Questions? If you have any questions regarding licensing, please [contact us](mailto:team@sourcebot.dev). \ No newline at end of file diff --git a/docs/self-hosting/more/declarative-config.mdx b/docs/self-hosting/more/declarative-config.mdx index 2e67d2844..43e75a02f 100644 --- a/docs/self-hosting/more/declarative-config.mdx +++ b/docs/self-hosting/more/declarative-config.mdx @@ -3,6 +3,10 @@ title: Configuring Sourcebot from a file (declarative config) sidebarTitle: Declarative config --- + +Declaratively defining `connections` is not available when [multi-tenancy](/self-hosting/more/tenancy) is enabled. + + Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/overview). diff --git a/docs/self-hosting/more/search-contexts.mdx b/docs/self-hosting/more/search-contexts.mdx index 8b35acfee..0dab3fa67 100644 --- a/docs/self-hosting/more/search-contexts.mdx +++ b/docs/self-hosting/more/search-contexts.mdx @@ -4,11 +4,95 @@ sidebarTitle: Search contexts (EE) --- -This is only available in the Enteprise Edition. Please add your [license key](/self-hosting/license-key) to activate it. +This is only available in the Enterprise Edition. Please add your [license key](/self-hosting/license-key) to activate it. -todo +A **search context** is a user-defined grouping of repositories that helps focus searches on specific areas of your codebase, like frontend, backend, or infrastructure code. This reduces noise in search results and improves developer productivity. +## Getting started + + +The following will walkthrough an example of how search contexts can be used. For a full reference, see [here](#schema-reference). + + +Let's assume we have a GitLab instance hosted at `https://gitlab.example.com` with three top-level projects, `web`, `backend`, and `shared`: + +```sh +web/ +├─ admin_panel/ +├─ customer_portal/ +├─ pipelines/ +├─ ... +backend/ +├─ billing_server/ +├─ auth_server/ +├─ db_migrations/ +├─ pipelines/ +├─ ... +shared/ +├─ protobufs/ +├─ react/ +├─ pipelines/ +├─ ... +``` + +To make searching easier, we can create three search contexts: +- `web`: For all frontend-related code +- `backend`: For backend services and shared APIs +- `pipelines`: For all CI/CD configurations + +Add these contexts to your [config.json](/self-hosting/more/declarative-config): + +```json +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "connections": { + "gitlab-connection": { + "type": "gitlab", + "url": "https://gitlab.example.com", + "groups": ["web", "backend", "shared"] + } + }, + "contexts": { + "web": { + "description": "Frontend related repos.", + "include": [ + "gitlab.example.com/web/**", + "gitlab.example.com/shared/react/**", + "gitlab.example.com/shared/protobufs/**" + ] + }, + "backend": { + "description": "Backend related repos.", + "include": [ + "gitlab.example.com/backend/**", + "gitlab.example.com/shared/protobufs/**" + ] + }, + "pipelines": { + "description": "Devops related repos.", + "include": [ + "gitlab.example.com/web/pipelines/**", + "gitlab.example.com/backend/pipelines/**", + "gitlab.example.com/shared/pipelines/**" + ] + } + } +} +``` + +Once configured, you can use these contexts in the search bar by prefixing your query with the context name. For example: +- `context:web login form` searches for login form code in frontend repositories +- `context:backend auth` searches for authentication code in backend services +- `context:pipelines deploy` searches for deployment configurations + +![Example](/images/search_contexts_example.png) + +Like other prefixes, contexts can be negated using `-` or combined using `or`: +- `-context:web` excludes frontend repositories from results +- `( context:web or context:backend )` searches across both frontend and backend code + +See [this doc](/docs/more/syntax-reference) for more details on the search query syntax. ## Schema reference diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index 19ab75383..0efff1b86 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -206,7 +206,8 @@ const SearchSuggestionsBox = forwardRef(({ return { list: searchContextSuggestions, onSuggestionClicked: createOnSuggestionClickedHandler(), - descriptionPlacement: "right", + descriptionPlacement: "left", + DefaultIcon: VscFilter, } case "none": case "revision": From 7eaaea5588529bd5190deff74761bc1058016396 Mon Sep 17 00:00:00 2001 From: bkellam Date: Wed, 23 Apr 2025 14:49:36 -0700 Subject: [PATCH 09/14] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563cd0108..4e0b6c93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- [Sourcebot EE] Added search contexts, user-defined groupings of repositories that help focus searches on specific areas of a codebase. [#273](https://github.com/sourcebot-dev/sourcebot/pull/273) + + ## [3.0.4] - 2025-04-12 ### Fixes From d8daccf383b68f17da70424de1aae52f734a2ac6 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 24 Apr 2025 12:56:52 -0700 Subject: [PATCH 10/14] Make license key expiry date optional & add required id field to key payload --- packages/web/src/features/entitlements/server.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/web/src/features/entitlements/server.ts b/packages/web/src/features/entitlements/server.ts index 510724f38..9e08df4c2 100644 --- a/packages/web/src/features/entitlements/server.ts +++ b/packages/web/src/features/entitlements/server.ts @@ -7,8 +7,9 @@ import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; const eeLicenseKeyPrefix = "sourcebot_ee_"; const eeLicenseKeyPayloadSchema = z.object({ + id: z.string(), // ISO 8601 date string - expiryDate: z.string().datetime(), + expiryDate: z.string().datetime().optional(), }); const decodeLicenseKeyPayload = (payload: string) => { @@ -29,14 +30,15 @@ export const getPlan = (): Plan => { try { const { expiryDate } = decodeLicenseKeyPayload(payload); - if (new Date(expiryDate).getTime() < new Date().getTime()) { + if (expiryDate && new Date(expiryDate).getTime() < new Date().getTime()) { console.error(`The provided license key has expired. Falling back to oss plan. Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); return "oss"; } return "self-hosted:enterprise"; } catch (error) { - console.error(`Failed to decode license key payload: ${error}`); + console.error(`Failed to decode license key payload with error: ${error}`); + console.info('Falling back to oss plan.'); return "oss"; } } From 44fceda2dcfdf60d475210cff57ca157a2f9358c Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 24 Apr 2025 14:55:59 -0700 Subject: [PATCH 11/14] docs improvement --- docs/self-hosting/more/search-contexts.mdx | 75 +++++++++++++--------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/docs/self-hosting/more/search-contexts.mdx b/docs/self-hosting/more/search-contexts.mdx index 0dab3fa67..f730725fc 100644 --- a/docs/self-hosting/more/search-contexts.mdx +++ b/docs/self-hosting/more/search-contexts.mdx @@ -7,13 +7,16 @@ sidebarTitle: Search contexts (EE) This is only available in the Enterprise Edition. Please add your [license key](/self-hosting/license-key) to activate it. -A **search context** is a user-defined grouping of repositories that helps focus searches on specific areas of your codebase, like frontend, backend, or infrastructure code. This reduces noise in search results and improves developer productivity. +A **search context** is a user-defined grouping of repositories that helps focus searches on specific areas of your codebase, like frontend, backend, or infrastructure code. Some example queries using search contexts: -## Getting started +- `context:data_engineering userId` - search for `userId` across all repos related to Data Engineering. +- `context:k8s ingress` - search for anything related to ingresses in your k8's configs. +- `( context:project1 or context:project2 ) logger\.debug` - search for debug log calls in project1 and project2 - -The following will walkthrough an example of how search contexts can be used. For a full reference, see [here](#schema-reference). - + +Search contexts are defined in the `context` object inside of a [declarative config](/self-hosting/more/declarative-config). Repositories can be included / excluded from a search context by specifying the repo's URL in either the `include` array or `exclude` array. Glob patterns are supported. + +## Example Let's assume we have a GitLab instance hosted at `https://gitlab.example.com` with three top-level projects, `web`, `backend`, and `shared`: @@ -36,51 +39,59 @@ shared/ ├─ ... ``` -To make searching easier, we can create three search contexts: +To make searching easier, we can create three search contexts in our [config.json](/self-hosting/more/declarative-config): - `web`: For all frontend-related code - `backend`: For backend services and shared APIs - `pipelines`: For all CI/CD configurations -Add these contexts to your [config.json](/self-hosting/more/declarative-config): ```json { "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", - "connections": { - "gitlab-connection": { - "type": "gitlab", - "url": "https://gitlab.example.com", - "groups": ["web", "backend", "shared"] - } - }, "contexts": { "web": { - "description": "Frontend related repos.", + // To include repositories in a search context, + // you can reference them... "include": [ - "gitlab.example.com/web/**", + // ... individually by specifying the repo URL. + "gitlab.example.com/web/admin_panel/core", + + + // ... or as groups using glob patterns. This is + // particularly useful for including entire "sub-folders" + // of repositories in one go. + "gitlab.example.com/web/customer_portal/**", "gitlab.example.com/shared/react/**", "gitlab.example.com/shared/protobufs/**" - ] - }, - "backend": { - "description": "Backend related repos.", - "include": [ - "gitlab.example.com/backend/**", - "gitlab.example.com/shared/protobufs/**" - ] + ], + + // Same with excluding repositories. + "exclude": [ + "gitlab.example.com/web/customer_portal/pipelines", + "gitlab.example.com/shared/react/hooks/**", + ], + + // Optional description of the search context + // that surfaces in the UI. + "description": "Web related repos." }, - "pipelines": { - "description": "Devops related repos.", - "include": [ - "gitlab.example.com/web/pipelines/**", - "gitlab.example.com/backend/pipelines/**", - "gitlab.example.com/shared/pipelines/**" - ] - } + "backend": { /* ... specifies backend replated repos ... */}, + "pipelines": { /* ... specifies pipeline related repos ... */ } + }, + "connections": { + /* ...connection definitions... */ } } ``` + + Repo URLs are expected to be formatted without the leading http(s):// prefix. For example: + - `github.com/sourcebot-dev/sourcebot` ([link](https://github.com/sourcebot-dev/sourcebot)) + - `gitlab.com/gitlab-org/gitlab` ([link](https://gitlab.com/gitlab-org/gitlab)) + - `chromium.googlesource.com/chromium` ([link](https://chromium-review.googlesource.com/admin/repos/chromium,general)) + + + Once configured, you can use these contexts in the search bar by prefixing your query with the context name. For example: - `context:web login form` searches for login form code in frontend repositories - `context:backend auth` searches for authentication code in backend services From ab5ce391f21ee7d8d0d4af2e00258c7812b2ff74 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 24 Apr 2025 16:50:58 -0700 Subject: [PATCH 12/14] move billing under ee --- package.json | 4 +- packages/web/src/actions.ts | 399 ++---------------- .../[domain]/components/navigationMenu.tsx | 7 +- packages/web/src/app/[domain]/layout.tsx | 7 +- .../web/src/app/[domain]/onboard/page.tsx | 4 +- .../app/[domain]/settings/billing/page.tsx | 18 +- .../web/src/app/[domain]/settings/layout.tsx | 2 +- .../app/[domain]/settings/members/page.tsx | 2 +- .../web/src/app/[domain]/upgrade/page.tsx | 10 +- .../web/src/app/api/(server)/stripe/route.ts | 2 +- .../app/onboard/components/onboardHeader.tsx | 2 +- .../web/src/ee/features/billing/actions.ts | 279 ++++++++++++ .../components}/changeBillingEmailCard.tsx | 2 +- .../features/billing}/components/checkout.tsx | 2 +- .../components/enterpriseUpgradeCard.tsx | 0 .../components}/manageSubscriptionButton.tsx | 2 +- .../billing}/components/teamUpgradeCard.tsx | 2 +- .../billing}/components/upgradeCard.tsx | 0 .../src/ee/features/billing/serverUtils.ts | 80 ++++ .../{lib => ee/features/billing}/stripe.ts | 3 +- .../src/features/entitlements/constants.ts | 5 +- 21 files changed, 428 insertions(+), 404 deletions(-) create mode 100644 packages/web/src/ee/features/billing/actions.ts rename packages/web/src/{app/[domain]/settings/billing => ee/features/billing/components}/changeBillingEmailCard.tsx (98%) rename packages/web/src/{app/[domain]/onboard => ee/features/billing}/components/checkout.tsx (98%) rename packages/web/src/{app/[domain]/upgrade => ee/features/billing}/components/enterpriseUpgradeCard.tsx (100%) rename packages/web/src/{app/[domain]/settings/billing => ee/features/billing/components}/manageSubscriptionButton.tsx (96%) rename packages/web/src/{app/[domain]/upgrade => ee/features/billing}/components/teamUpgradeCard.tsx (97%) rename packages/web/src/{app/[domain]/upgrade => ee/features/billing}/components/upgradeCard.tsx (100%) create mode 100644 packages/web/src/ee/features/billing/serverUtils.ts rename packages/web/src/{lib => ee/features/billing}/stripe.ts (53%) diff --git a/package.json b/package.json index 4ad48cb2c..778f70848 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "packages/*" ], "scripts": { - "build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces run build", - "test": "yarn workspaces run test", + "build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build", + "test": "yarn workspaces foreach -A run test", "dev": "yarn dev:prisma:migrate:dev && npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web", "with-env": "cross-env PATH=\"$PWD/bin:$PATH\" dotenv -e .env.development -c --", "dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 9d79bcc88..0e220753a 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,33 +1,32 @@ 'use server'; -import Ajv from "ajv"; -import * as Sentry from '@sentry/nextjs'; -import { auth } from "./auth"; -import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists, stripeClientNotInitialized } from "@/lib/serviceError"; -import { prisma } from "@/prisma"; -import { StatusCodes } from "http-status-codes"; +import { env } from "@/env.mjs"; import { ErrorCode } from "@/lib/errorCodes"; +import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; +import { prisma } from "@/prisma"; +import { render } from "@react-email/components"; +import * as Sentry from '@sentry/nextjs'; +import { decrypt, encrypt } from "@sourcebot/crypto"; +import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; +import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; +import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; import { githubSchema } from "@sourcebot/schemas/v3/github.schema"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; -import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; -import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; -import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; -import { decrypt, encrypt } from "@sourcebot/crypto" -import { getConnection } from "./data/connection"; -import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; -import { cookies, headers } from "next/headers" +import Ajv from "ajv"; +import { StatusCodes } from "http-status-codes"; import { Session } from "next-auth"; -import { env } from "@/env.mjs"; -import Stripe from "stripe"; -import { render } from "@react-email/components"; -import InviteUserEmail from "./emails/inviteUserEmail"; +import { cookies, headers } from "next/headers"; import { createTransport } from "nodemailer"; +import { auth } from "./auth"; +import { getConnection } from "./data/connection"; +import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe"; +import InviteUserEmail from "./emails/inviteUserEmail"; +import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; import { TenancyMode } from "./lib/types"; -import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants"; -import { stripeClient } from "./lib/stripe"; -import { IS_BILLING_ENABLED } from "./lib/stripe"; +import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils"; const ajv = new Ajv({ validateFormats: false, @@ -230,7 +229,7 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo // Else, validate that the org has an active subscription. } else { - const subscriptionOrError = await fetchSubscription(domain); + const subscriptionOrError = await getSubscriptionForOrg(orgId, prisma); if (isServiceError(subscriptionOrError)) { return subscriptionOrError; } @@ -832,22 +831,10 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean const res = await prisma.$transaction(async (tx) => { if (IS_BILLING_ENABLED) { - // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check. - const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx); - if (isServiceError(subscription)) { - return subscription; + const result = await incrementOrgSeatCount(invite.orgId, tx); + if (isServiceError(result)) { + return result; } - - const existingSeatCount = subscription.items.data[0].quantity; - const newSeatCount = (existingSeatCount || 1) + 1 - - await stripeClient?.subscriptionItems.update( - subscription.items.data[0].id, - { - quantity: newSeatCount, - proration_behavior: 'create_prorations', - } - ) } await tx.userToOrg.create({ @@ -977,261 +964,6 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro }, /* minRequiredRole = */ OrgRole.OWNER) )); -export const createOnboardingSubscription = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - - const user = await getMe(); - if (isServiceError(user)) { - return user; - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({ - frozen_time: Math.floor(Date.now() / 1000) - }) : null; - - // Use the existing customer if it exists, otherwise create a new one. - const customerId = await (async () => { - if (org.stripeCustomerId) { - return org.stripeCustomerId; - } - - const customer = await stripeClient.customers.create({ - name: org.name, - email: user.email ?? undefined, - test_clock: test_clock?.id, - description: `Created by ${user.email} on ${domain} (id: ${org.id})`, - }); - - await prisma.org.update({ - where: { - id: org.id, - }, - data: { - stripeCustomerId: customer.id, - } - }); - - return customer.id; - })(); - - const existingSubscription = await fetchSubscription(domain); - if (!isServiceError(existingSubscription)) { - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS, - message: "Attemped to create a trial subscription for an organization that already has an active subscription", - } satisfies ServiceError; - } - - - const prices = await stripeClient.prices.list({ - product: env.STRIPE_PRODUCT_ID, - expand: ['data.product'], - }); - - try { - const subscription = await stripeClient.subscriptions.create({ - customer: customerId, - items: [{ - price: prices.data[0].id, - }], - trial_period_days: 14, - trial_settings: { - end_behavior: { - missing_payment_method: 'cancel', - }, - }, - payment_settings: { - save_default_payment_method: 'on_subscription', - }, - }); - - if (!subscription) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create subscription", - } satisfies ServiceError; - } - - return { - subscriptionId: subscription.id, - } - } catch (e) { - console.error(e); - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create subscription", - } satisfies ServiceError; - } - - - }, /* minRequiredRole = */ OrgRole.OWNER) - )); - -export const createStripeCheckoutSession = async (domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const orgMembers = await prisma.userToOrg.findMany({ - where: { - orgId, - }, - select: { - userId: true, - } - }); - const numOrgMembers = orgMembers.length; - - const origin = (await headers()).get('origin')!; - const prices = await stripeClient.prices.list({ - product: env.STRIPE_PRODUCT_ID, - expand: ['data.product'], - }); - - const stripeSession = await stripeClient.checkout.sessions.create({ - customer: org.stripeCustomerId as string, - payment_method_types: ['card'], - line_items: [ - { - price: prices.data[0].id, - quantity: numOrgMembers - } - ], - mode: 'subscription', - payment_method_collection: 'always', - success_url: `${origin}/${domain}/settings/billing`, - cancel_url: `${origin}/${domain}`, - }); - - if (!stripeSession.url) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create checkout session", - } satisfies ServiceError; - } - - return { - url: stripeSession.url, - } - }) - )); - -export const getCustomerPortalSessionLink = async (domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const origin = (await headers()).get('origin')!; - const portalSession = await stripeClient.billingPortal.sessions.create({ - customer: org.stripeCustomerId as string, - return_url: `${origin}/${domain}/settings/billing`, - }); - - return portalSession.url; - }, /* minRequiredRole = */ OrgRole.OWNER) - )); - -export const fetchSubscription = (domain: string): Promise => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - return _fetchSubscriptionForOrg(orgId, prisma); - }) - )); - -export const getSubscriptionBillingEmail = async (domain: string): Promise => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const customer = await stripeClient.customers.retrieve(org.stripeCustomerId); - if (!('email' in customer) || customer.deleted) { - return notFound(); - } - return customer.email!; - }) - )); - -export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org || !org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - await stripeClient.customers.update(org.stripeCustomerId, { - email: newEmail, - }); - - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); - export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => withAuth(async () => { const org = await prisma.org.findFirst({ @@ -1270,21 +1002,10 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro } if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); - if (isServiceError(subscription)) { - return subscription; + const result = await decrementOrgSeatCount(orgId, prisma); + if (isServiceError(result)) { + return result; } - - const existingSeatCount = subscription.items.data[0].quantity; - const newSeatCount = (existingSeatCount || 1) - 1; - - await stripeClient?.subscriptionItems.update( - subscription.items.data[0].id, - { - quantity: newSeatCount, - proration_behavior: 'create_prorations', - } - ) } await prisma.userToOrg.delete({ @@ -1324,21 +1045,10 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S } if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); - if (isServiceError(subscription)) { - return subscription; + const result = await decrementOrgSeatCount(orgId, prisma); + if (isServiceError(result)) { + return result; } - - const existingSeatCount = subscription.items.data[0].quantity; - const newSeatCount = (existingSeatCount || 1) - 1; - - await stripeClient?.subscriptionItems.update( - subscription.items.data[0].id, - { - quantity: newSeatCount, - proration_behavior: 'create_prorations', - } - ) } await prisma.userToOrg.delete({ @@ -1356,28 +1066,6 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S }) )); -export const getSubscriptionData = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async () => { - const subscription = await fetchSubscription(domain); - if (isServiceError(subscription)) { - return subscription; - } - - if (!subscription) { - return null; - } - - return { - plan: "Team", - seats: subscription.items.data[0].quantity!, - perSeatPrice: subscription.items.data[0].price.unit_amount! / 100, - nextBillingDate: subscription.current_period_end!, - status: subscription.status, - } - }) - )); - export const getOrgMembership = async (domain: string) => sew(() => withAuth(async (session) => withOrgMembership(session, domain, async ({ orgId }) => { @@ -1462,35 +1150,6 @@ export const getSearchContexts = async (domain: string) => sew(() => ////// Helpers /////// -const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - - if (!org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const subscriptions = await stripeClient.subscriptions.list({ - customer: org.stripeCustomerId - }); - - if (subscriptions.data.length === 0) { - return orgInvalidSubscription(); - } - return subscriptions.data[0]; -} - const parseConnectionConfig = (connectionType: string, config: string) => { let parsedConfig: ConnectionConfig; try { diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index a0da71dc7..75ff020cf 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -6,14 +6,15 @@ import { SettingsDropdown } from "./settingsDropdown"; import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { redirect } from "next/navigation"; import { OrgSelector } from "./orgSelector"; -import { getSubscriptionData } from "@/actions"; import { ErrorNavIndicator } from "./errorNavIndicator"; import { WarningNavIndicator } from "./warningNavIndicator"; import { ProgressNavIndicator } from "./progressNavIndicator"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { TrialNavIndicator } from "./trialNavIndicator"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { env } from "@/env.mjs"; +import { getSubscriptionInfo } from "@/ee/features/billing/actions"; + const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; @@ -24,7 +25,7 @@ interface NavigationMenuProps { export const NavigationMenu = async ({ domain, }: NavigationMenuProps) => { - const subscription = IS_BILLING_ENABLED ? await getSubscriptionData(domain) : null; + const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null; return (
diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 693496baa..8d712266c 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -3,7 +3,6 @@ import { auth } from "@/auth"; import { getOrgFromDomain } from "@/data/org"; import { isServiceError } from "@/lib/utils"; import { OnboardGuard } from "./components/onboardGuard"; -import { fetchSubscription } from "@/actions"; import { UpgradeGuard } from "./components/upgradeGuard"; import { cookies, headers } from "next/headers"; import { getSelectorsByUserAgent } from "react-device-detect"; @@ -11,9 +10,11 @@ import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSpl import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants"; import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { env } from "@/env.mjs"; import { notFound, redirect } from "next/navigation"; +import { getSubscriptionInfo } from "@/ee/features/billing/actions"; + interface LayoutProps { children: React.ReactNode, params: { domain: string } @@ -58,7 +59,7 @@ export default async function Layout({ } if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); + const subscription = await getSubscriptionInfo(domain); if ( subscription && ( diff --git a/packages/web/src/app/[domain]/onboard/page.tsx b/packages/web/src/app/[domain]/onboard/page.tsx index 768244eef..a62770e17 100644 --- a/packages/web/src/app/[domain]/onboard/page.tsx +++ b/packages/web/src/app/[domain]/onboard/page.tsx @@ -5,9 +5,9 @@ import { notFound, redirect } from "next/navigation"; import { ConnectCodeHost } from "./components/connectCodeHost"; import { InviteTeam } from "./components/inviteTeam"; import { CompleteOnboarding } from "./components/completeOnboarding"; -import { Checkout } from "./components/checkout"; +import { Checkout } from "@/ee/features/billing/components/checkout"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { env } from "@/env.mjs"; interface OnboardProps { diff --git a/packages/web/src/app/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 5f66e6ccb..87fccb7c8 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -1,13 +1,15 @@ -import type { Metadata } from "next" -import { CalendarIcon, DollarSign, Users } from "lucide-react" +import { getCurrentUserRole } from "@/actions" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { ManageSubscriptionButton } from "./manageSubscriptionButton" -import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" +import { getSubscriptionBillingEmail, getSubscriptionInfo } from "@/ee/features/billing/actions" +import { ChangeBillingEmailCard } from "@/ee/features/billing/components/changeBillingEmailCard" +import { ManageSubscriptionButton } from "@/ee/features/billing/components/manageSubscriptionButton" +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe" +import { ServiceErrorException } from "@/lib/serviceError" import { isServiceError } from "@/lib/utils" -import { ChangeBillingEmailCard } from "./changeBillingEmailCard" +import { CalendarIcon, DollarSign, Users } from "lucide-react" +import type { Metadata } from "next" import { notFound } from "next/navigation" -import { IS_BILLING_ENABLED } from "@/lib/stripe" -import { ServiceErrorException } from "@/lib/serviceError" + export const metadata: Metadata = { title: "Billing | Settings", description: "Manage your subscription and billing information", @@ -26,7 +28,7 @@ export default async function BillingPage({ notFound(); } - const subscription = await getSubscriptionData(domain) + const subscription = await getSubscriptionInfo(domain) if (isServiceError(subscription)) { throw new ServiceErrorException(subscription); diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 0f15f9bcb..ce0238319 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -2,7 +2,7 @@ import { Metadata } from "next" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" import { Header } from "./components/header"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index cab223e69..7fb161231 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -7,7 +7,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs"; import { TabSwitcher } from "@/components/ui/tab-switcher"; import { InvitesList } from "./components/invitesList"; import { getOrgInvites, getMe } from "@/actions"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { ServiceErrorException } from "@/lib/serviceError"; interface MembersSettingsPageProps { params: { diff --git a/packages/web/src/app/[domain]/upgrade/page.tsx b/packages/web/src/app/[domain]/upgrade/page.tsx index cd8f238b9..8a51aa090 100644 --- a/packages/web/src/app/[domain]/upgrade/page.tsx +++ b/packages/web/src/app/[domain]/upgrade/page.tsx @@ -1,23 +1,23 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { Footer } from "@/app/components/footer"; import { OrgSelector } from "../components/orgSelector"; -import { EnterpriseUpgradeCard } from "./components/enterpriseUpgradeCard"; -import { TeamUpgradeCard } from "./components/teamUpgradeCard"; -import { fetchSubscription } from "@/actions"; +import { EnterpriseUpgradeCard } from "@/ee/features/billing/components/enterpriseUpgradeCard"; +import { TeamUpgradeCard } from "@/ee/features/billing/components/teamUpgradeCard"; import { redirect } from "next/navigation"; import { isServiceError } from "@/lib/utils"; import Link from "next/link"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { env } from "@/env.mjs"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; +import { getSubscriptionInfo } from "@/ee/features/billing/actions"; export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) { if (!IS_BILLING_ENABLED) { redirect(`/${domain}`); } - const subscription = await fetchSubscription(domain); + const subscription = await getSubscriptionInfo(domain); if (!subscription) { redirect(`/${domain}`); } diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 9219c463b..8a466b7a7 100644 --- a/packages/web/src/app/api/(server)/stripe/route.ts +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -3,7 +3,7 @@ import { NextRequest } from 'next/server'; import Stripe from 'stripe'; import { prisma } from '@/prisma'; import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; -import { stripeClient } from '@/lib/stripe'; +import { stripeClient } from '@/ee/features/billing/stripe'; import { env } from '@/env.mjs'; export async function POST(req: NextRequest) { diff --git a/packages/web/src/app/onboard/components/onboardHeader.tsx b/packages/web/src/app/onboard/components/onboardHeader.tsx index b40d60c75..17281d762 100644 --- a/packages/web/src/app/onboard/components/onboardHeader.tsx +++ b/packages/web/src/app/onboard/components/onboardHeader.tsx @@ -1,6 +1,6 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { OnboardingSteps } from "@/lib/constants"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; interface OnboardHeaderProps { title: string diff --git a/packages/web/src/ee/features/billing/actions.ts b/packages/web/src/ee/features/billing/actions.ts new file mode 100644 index 000000000..b10689444 --- /dev/null +++ b/packages/web/src/ee/features/billing/actions.ts @@ -0,0 +1,279 @@ +'use server'; + +import { getMe, sew, withAuth } from "@/actions"; +import { ServiceError, stripeClientNotInitialized, notFound } from "@/lib/serviceError"; +import { withOrgMembership } from "@/actions"; +import { prisma } from "@/prisma"; +import { OrgRole } from "@sourcebot/db"; +import { stripeClient } from "./stripe"; +import { isServiceError } from "@/lib/utils"; +import { env } from "@/env.mjs"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; +import { headers } from "next/headers"; +import { getSubscriptionForOrg } from "./serverUtils"; + +export const createOnboardingSubscription = async (domain: string) => sew(() => + withAuth(async (session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org) { + return notFound(); + } + + const user = await getMe(); + if (isServiceError(user)) { + return user; + } + + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({ + frozen_time: Math.floor(Date.now() / 1000) + }) : null; + + // Use the existing customer if it exists, otherwise create a new one. + const customerId = await (async () => { + if (org.stripeCustomerId) { + return org.stripeCustomerId; + } + + const customer = await stripeClient.customers.create({ + name: org.name, + email: user.email ?? undefined, + test_clock: test_clock?.id, + description: `Created by ${user.email} on ${domain} (id: ${org.id})`, + }); + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + stripeCustomerId: customer.id, + } + }); + + return customer.id; + })(); + + const existingSubscription = await getSubscriptionForOrg(orgId, prisma); + if (!isServiceError(existingSubscription)) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS, + message: "Attemped to create a trial subscription for an organization that already has an active subscription", + } satisfies ServiceError; + } + + + const prices = await stripeClient.prices.list({ + product: env.STRIPE_PRODUCT_ID, + expand: ['data.product'], + }); + + try { + const subscription = await stripeClient.subscriptions.create({ + customer: customerId, + items: [{ + price: prices.data[0].id, + }], + trial_period_days: 14, + trial_settings: { + end_behavior: { + missing_payment_method: 'cancel', + }, + }, + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + }); + + if (!subscription) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Failed to create subscription", + } satisfies ServiceError; + } + + return { + subscriptionId: subscription.id, + } + } catch (e) { + console.error(e); + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Failed to create subscription", + } satisfies ServiceError; + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + +export const createStripeCheckoutSession = async (domain: string) => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + const orgMembers = await prisma.userToOrg.findMany({ + where: { + orgId, + }, + select: { + userId: true, + } + }); + const numOrgMembers = orgMembers.length; + + const origin = (await headers()).get('origin')!; + const prices = await stripeClient.prices.list({ + product: env.STRIPE_PRODUCT_ID, + expand: ['data.product'], + }); + + const stripeSession = await stripeClient.checkout.sessions.create({ + customer: org.stripeCustomerId as string, + payment_method_types: ['card'], + line_items: [ + { + price: prices.data[0].id, + quantity: numOrgMembers + } + ], + mode: 'subscription', + payment_method_collection: 'always', + success_url: `${origin}/${domain}/settings/billing`, + cancel_url: `${origin}/${domain}`, + }); + + if (!stripeSession.url) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Failed to create checkout session", + } satisfies ServiceError; + } + + return { + url: stripeSession.url, + } + }) + )); + +export const getCustomerPortalSessionLink = async (domain: string): Promise => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + const origin = (await headers()).get('origin')!; + const portalSession = await stripeClient.billingPortal.sessions.create({ + customer: org.stripeCustomerId as string, + return_url: `${origin}/${domain}/settings/billing`, + }); + + return portalSession.url; + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + +export const getSubscriptionBillingEmail = async (domain: string): Promise => sew(() => + withAuth(async (session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + const customer = await stripeClient.customers.retrieve(org.stripeCustomerId); + if (!('email' in customer) || customer.deleted) { + return notFound(); + } + return customer.email!; + }) + )); + +export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth((session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org || !org.stripeCustomerId) { + return notFound(); + } + + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + await stripeClient.customers.update(org.stripeCustomerId, { + email: newEmail, + }); + + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + +export const getSubscriptionInfo = async (domain: string) => sew(() => + withAuth(async (session) => + withOrgMembership(session, domain, async ({ orgId }) => { + const subscription = await getSubscriptionForOrg(orgId, prisma); + + if (isServiceError(subscription)) { + return subscription; + } + + return { + status: subscription.status, + plan: "Team", + seats: subscription.items.data[0].quantity!, + perSeatPrice: subscription.items.data[0].price.unit_amount! / 100, + nextBillingDate: subscription.current_period_end!, + } + }) + )); diff --git a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx b/packages/web/src/ee/features/billing/components/changeBillingEmailCard.tsx similarity index 98% rename from packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx rename to packages/web/src/ee/features/billing/components/changeBillingEmailCard.tsx index 878ef20ef..a4febbe52 100644 --- a/packages/web/src/app/[domain]/settings/billing/changeBillingEmailCard.tsx +++ b/packages/web/src/ee/features/billing/components/changeBillingEmailCard.tsx @@ -1,11 +1,11 @@ "use client" -import { changeSubscriptionBillingEmail } from "@/actions" import { useToast } from "@/components/hooks/use-toast" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form" import { Input } from "@/components/ui/input" +import { changeSubscriptionBillingEmail } from "@/ee/features/billing/actions" import useCaptureEvent from "@/hooks/useCaptureEvent" import { useDomain } from "@/hooks/useDomain" import { isServiceError } from "@/lib/utils" diff --git a/packages/web/src/app/[domain]/onboard/components/checkout.tsx b/packages/web/src/ee/features/billing/components/checkout.tsx similarity index 98% rename from packages/web/src/app/[domain]/onboard/components/checkout.tsx rename to packages/web/src/ee/features/billing/components/checkout.tsx index d38032a7f..980c6cf25 100644 --- a/packages/web/src/app/[domain]/onboard/components/checkout.tsx +++ b/packages/web/src/ee/features/billing/components/checkout.tsx @@ -1,6 +1,5 @@ 'use client'; -import { createOnboardingSubscription } from "@/actions"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; @@ -13,6 +12,7 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { OnboardingSteps, TEAM_FEATURES } from "@/lib/constants"; import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { createOnboardingSubscription } from "../actions"; export const Checkout = () => { const domain = useDomain(); diff --git a/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx b/packages/web/src/ee/features/billing/components/enterpriseUpgradeCard.tsx similarity index 100% rename from packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx rename to packages/web/src/ee/features/billing/components/enterpriseUpgradeCard.tsx diff --git a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx b/packages/web/src/ee/features/billing/components/manageSubscriptionButton.tsx similarity index 96% rename from packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx rename to packages/web/src/ee/features/billing/components/manageSubscriptionButton.tsx index 9a6af18f1..a2c2701bf 100644 --- a/packages/web/src/app/[domain]/settings/billing/manageSubscriptionButton.tsx +++ b/packages/web/src/ee/features/billing/components/manageSubscriptionButton.tsx @@ -4,11 +4,11 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { isServiceError } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { getCustomerPortalSessionLink } from "@/actions" import { useDomain } from "@/hooks/useDomain"; import { OrgRole } from "@sourcebot/db"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { ExternalLink, Loader2 } from "lucide-react"; +import { getCustomerPortalSessionLink } from "@/ee/features/billing/actions" export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) { const [isLoading, setIsLoading] = useState(false) diff --git a/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx b/packages/web/src/ee/features/billing/components/teamUpgradeCard.tsx similarity index 97% rename from packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx rename to packages/web/src/ee/features/billing/components/teamUpgradeCard.tsx index 159b5efeb..db1551764 100644 --- a/packages/web/src/app/[domain]/upgrade/components/teamUpgradeCard.tsx +++ b/packages/web/src/ee/features/billing/components/teamUpgradeCard.tsx @@ -1,7 +1,6 @@ 'use client'; import { UpgradeCard } from "./upgradeCard"; -import { createStripeCheckoutSession } from "@/actions"; import { useToast } from "@/components/hooks/use-toast"; import { useDomain } from "@/hooks/useDomain"; import { isServiceError } from "@/lib/utils"; @@ -9,6 +8,7 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { TEAM_FEATURES } from "@/lib/constants"; import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { createStripeCheckoutSession } from "../actions"; interface TeamUpgradeCardProps { buttonText: string; diff --git a/packages/web/src/app/[domain]/upgrade/components/upgradeCard.tsx b/packages/web/src/ee/features/billing/components/upgradeCard.tsx similarity index 100% rename from packages/web/src/app/[domain]/upgrade/components/upgradeCard.tsx rename to packages/web/src/ee/features/billing/components/upgradeCard.tsx diff --git a/packages/web/src/ee/features/billing/serverUtils.ts b/packages/web/src/ee/features/billing/serverUtils.ts new file mode 100644 index 000000000..bf2eb6a8c --- /dev/null +++ b/packages/web/src/ee/features/billing/serverUtils.ts @@ -0,0 +1,80 @@ +import 'server-only'; + +import { notFound, orgInvalidSubscription, ServiceError, stripeClientNotInitialized } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { Prisma } from "@sourcebot/db"; +import Stripe from "stripe"; +import { stripeClient } from "./stripe"; + +export const incrementOrgSeatCount = async (orgId: number, prisma: Prisma.TransactionClient) => { + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + const subscription = await getSubscriptionForOrg(orgId, prisma); + if (isServiceError(subscription)) { + return subscription; + } + + const existingSeatCount = subscription.items.data[0].quantity; + const newSeatCount = (existingSeatCount || 1) + 1; + + await stripeClient.subscriptionItems.update( + subscription.items.data[0].id, + { + quantity: newSeatCount, + proration_behavior: 'create_prorations', + } + ); +} + +export const decrementOrgSeatCount = async (orgId: number, prisma: Prisma.TransactionClient) => { + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + const subscription = await getSubscriptionForOrg(orgId, prisma); + if (isServiceError(subscription)) { + return subscription; + } + + const existingSeatCount = subscription.items.data[0].quantity; + const newSeatCount = (existingSeatCount || 1) - 1; + + await stripeClient.subscriptionItems.update( + subscription.items.data[0].id, + { + quantity: newSeatCount, + proration_behavior: 'create_prorations', + } + ); +} + +export const getSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org) { + return notFound(); + } + + if (!org.stripeCustomerId) { + return notFound(); + } + + if (!stripeClient) { + return stripeClientNotInitialized(); + } + + const subscriptions = await stripeClient.subscriptions.list({ + customer: org.stripeCustomerId + }); + + if (subscriptions.data.length === 0) { + return orgInvalidSubscription(); + } + return subscriptions.data[0]; +} \ No newline at end of file diff --git a/packages/web/src/lib/stripe.ts b/packages/web/src/ee/features/billing/stripe.ts similarity index 53% rename from packages/web/src/lib/stripe.ts rename to packages/web/src/ee/features/billing/stripe.ts index fd65253d1..2a9995711 100644 --- a/packages/web/src/lib/stripe.ts +++ b/packages/web/src/ee/features/billing/stripe.ts @@ -1,8 +1,9 @@ import 'server-only'; import { env } from '@/env.mjs' import Stripe from "stripe"; +import { hasEntitlement } from '@/features/entitlements/server'; -export const IS_BILLING_ENABLED = env.STRIPE_SECRET_KEY !== undefined; +export const IS_BILLING_ENABLED = hasEntitlement('billing') && env.STRIPE_SECRET_KEY !== undefined; export const stripeClient = IS_BILLING_ENABLED diff --git a/packages/web/src/features/entitlements/constants.ts b/packages/web/src/features/entitlements/constants.ts index bdb6625dc..1701913ae 100644 --- a/packages/web/src/features/entitlements/constants.ts +++ b/packages/web/src/features/entitlements/constants.ts @@ -8,12 +8,13 @@ export type Plan = keyof typeof planLabels; const entitlements = [ - "search-contexts" + "search-contexts", + "billing" ] as const; export type Entitlement = (typeof entitlements)[number]; export const entitlementsByPlan: Record = { oss: [], - "cloud:team": [], + "cloud:team": ["billing"], "self-hosted:enterprise": ["search-contexts"], } as const; From 83f1c5efef2a9504d79af7d711c0f4dc9b2e9abc Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 24 Apr 2025 22:16:32 -0700 Subject: [PATCH 13/14] feedback --- docs/self-hosting/license-key.mdx | 9 ++- docs/self-hosting/more/search-contexts.mdx | 2 +- packages/web/src/actions.ts | 62 ++++++++++--------- .../searchBar/searchSuggestionsBox.tsx | 1 + 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/docs/self-hosting/license-key.mdx b/docs/self-hosting/license-key.mdx index 56f884425..ea7c99fad 100644 --- a/docs/self-hosting/license-key.mdx +++ b/docs/self-hosting/license-key.mdx @@ -8,8 +8,13 @@ All core Sourcebot features are available in Sourcebot OSS (MIT Licensed). Some ## Activating a license key -```sh -SOURCEBOT_EE_LICENSE_KEY= +After purchasing a license key, you can activate it by setting the `SOURCEBOT_EE_LICENSE_KEY` environment variable. + +```bash +docker run \ + -e SOURCEBOT_EE_LICENSE_KEY= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest ``` ## Questions? diff --git a/docs/self-hosting/more/search-contexts.mdx b/docs/self-hosting/more/search-contexts.mdx index f730725fc..9ac7fba4e 100644 --- a/docs/self-hosting/more/search-contexts.mdx +++ b/docs/self-hosting/more/search-contexts.mdx @@ -18,7 +18,7 @@ Search contexts are defined in the `context` object inside of a [declarative con ## Example -Let's assume we have a GitLab instance hosted at `https://gitlab.example.com` with three top-level projects, `web`, `backend`, and `shared`: +Let's assume we have a GitLab instance hosted at `https://gitlab.example.com` with three top-level groups, `web`, `backend`, and `shared`: ```sh web/ diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 0e220753a..0db41d627 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -830,13 +830,6 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } const res = await prisma.$transaction(async (tx) => { - if (IS_BILLING_ENABLED) { - const result = await incrementOrgSeatCount(invite.orgId, tx); - if (isServiceError(result)) { - return result; - } - } - await tx.userToOrg.create({ data: { userId: user.id, @@ -850,6 +843,13 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean id: invite.id, } }); + + if (IS_BILLING_ENABLED) { + const result = await incrementOrgSeatCount(invite.orgId, tx); + if (isServiceError(result)) { + throw result; + } + } }); if (isServiceError(res)) { @@ -1001,18 +1001,20 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro return notFound(); } - if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(orgId, prisma); - if (isServiceError(result)) { - return result; - } - } + await prisma.$transaction(async (tx) => { + await tx.userToOrg.delete({ + where: { + orgId_userId: { + orgId, + userId: memberId, + } + } + }); - await prisma.userToOrg.delete({ - where: { - orgId_userId: { - orgId, - userId: memberId, + if (IS_BILLING_ENABLED) { + const result = await decrementOrgSeatCount(orgId, tx); + if (isServiceError(result)) { + throw result; } } }); @@ -1044,18 +1046,20 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S return notFound(); } - if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(orgId, prisma); - if (isServiceError(result)) { - return result; - } - } + await prisma.$transaction(async (tx) => { + await tx.userToOrg.delete({ + where: { + orgId_userId: { + orgId, + userId: session.user.id, + } + } + }); - await prisma.userToOrg.delete({ - where: { - orgId_userId: { - orgId, - userId: session.user.id, + if (IS_BILLING_ENABLED) { + const result = await decrementOrgSeatCount(orgId, tx); + if (isServiceError(result)) { + throw result; } } }); diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index 0efff1b86..f95a926e4 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -274,6 +274,7 @@ const SearchSuggestionsBox = forwardRef(({ symbolSuggestions, searchHistorySuggestions, languageSuggestions, + searchContextSuggestions, ]); // When the list of suggestions change, reset the highlight index From 5d684510a73a0678c876f069147202af6afff838 Mon Sep 17 00:00:00 2001 From: bkellam Date: Thu, 24 Apr 2025 22:18:23 -0700 Subject: [PATCH 14/14] bump zoekt version --- vendor/zoekt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/zoekt b/vendor/zoekt index cf4563940..7d1896215 160000 --- a/vendor/zoekt +++ b/vendor/zoekt @@ -1 +1 @@ -Subproject commit cf456394003dd9bfc9a885fdfcc8cc80230a261d +Subproject commit 7d1896215eea6f97af66c9549c9ec70436356b51