diff --git a/CHANGELOG.md b/CHANGELOG.md index 563cd010..4e0b6c93 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 diff --git a/LICENSE b/LICENSE index 83bbbcd6..04fbff3a 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/Makefile b/Makefile index abae628d..00fbbb1e 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 diff --git a/docs/docs.json b/docs/docs.json index 64358876..75af3813 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" ] } @@ -52,7 +53,8 @@ "group": "Getting Started", "pages": [ "self-hosting/overview", - "self-hosting/configuration" + "self-hosting/configuration", + "self-hosting/license-key" ] }, { @@ -61,7 +63,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/docs/more/syntax-reference.mdx b/docs/docs/more/syntax-reference.mdx new file mode 100644 index 00000000..82a5d385 --- /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 00000000..f63eae82 Binary files /dev/null and b/docs/images/search_contexts_example.png differ diff --git a/docs/self-hosting/license-key.mdx b/docs/self-hosting/license-key.mdx new file mode 100644 index 00000000..ea7c99fa --- /dev/null +++ b/docs/self-hosting/license-key.mdx @@ -0,0 +1,22 @@ +--- +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 + +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? + +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 2e67d284..43e75a02 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 new file mode 100644 index 00000000..9ac7fba4 --- /dev/null +++ b/docs/self-hosting/more/search-contexts.mdx @@ -0,0 +1,153 @@ +--- +title: Search contexts +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. Some example queries using search contexts: + +- `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 + + +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 groups, `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 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 + + +```json +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "contexts": { + "web": { + // To include repositories in a search context, + // you can reference them... + "include": [ + // ... 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/**" + ], + + // 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." + }, + "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 +- `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 + + +```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/ee/LICENSE b/ee/LICENSE new file mode 100644 index 00000000..928443d8 --- /dev/null +++ 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 diff --git a/package.json b/package.json index 4ad48cb2..778f7084 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/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 00000000..87bdb2dd --- /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 303510d9..967ae38d 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 f8b9258c..d78811b2 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -65,6 +65,46 @@ const schema = { } }, "additionalProperties": false + }, + "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", + "description": "Optional description of the search context that surfaces in the UI." + } + }, + "required": [ + "include" + ], + "additionalProperties": false } }, "properties": { @@ -74,6 +114,16 @@ const schema = { "settings": { "$ref": "#/definitions/Settings" }, + "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" + } + }, + "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 01c6c668..54cb4a8e 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -13,6 +13,12 @@ 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; + }; /** * Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode. */ @@ -72,6 +78,29 @@ export interface Settings { */ repoIndexTimeoutMs?: number; } +/** + * Search context + * + * 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 { + /** + * 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 { /** * GitHub Configuration diff --git a/packages/web/package.json b/packages/web/package.json index 68790d38..33bd6055 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 0d98daff..0db41d62 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; } @@ -831,25 +830,6 @@ 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 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({ data: { userId: user.id, @@ -863,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)) { @@ -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({ @@ -1269,29 +1001,20 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro return notFound(); } - if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); - 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', + 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; } } }); @@ -1323,29 +1046,20 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S return notFound(); } - if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); - 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', + 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; } } }); @@ -1356,28 +1070,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 }) => { @@ -1443,37 +1135,24 @@ 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, + }, + }); -////// 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(); - } + return searchContexts.map((context) => ({ + name: context.name, + description: context.description ?? undefined, + })); + } + ), /* allowSingleTenantUnauthedAccess = */ true)); - const subscriptions = await stripeClient.subscriptions.list({ - customer: org.stripeCustomerId - }); - if (subscriptions.data.length === 0) { - return orgInvalidSubscription(); - } - return subscriptions.data[0]; -} +////// Helpers /////// const parseConnectionConfig = (connectionType: string, config: string) => { let parsedConfig: ConnectionConfig; diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index a0da71dc..75ff020c 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]/components/searchBar/constants.ts b/packages/web/src/app/[domain]/components/searchBar/constants.ts index e08a03fe..c637bee9 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:", @@ -18,162 +18,10 @@ enum SearchPrefix { archived = "archived:", case = "case:", fork = "fork:", - public = "public:" + public = "public:", + 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 - ] - } -]; - -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." - }, -]; - 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 54d48a2b..f95a926e 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; @@ -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,9 +80,11 @@ const SearchSuggestionsBox = forwardRef(({ symbolSuggestions, languageSuggestions, searchHistorySuggestions, + searchContextSuggestions, }: SearchSuggestionsBoxProps, ref: Ref) => { const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0); const { onOpenChanged } = useSyntaxGuide(); + const refineModeSuggestions = useRefineModeSuggestions(); const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => { if (!isEnabled) { @@ -198,6 +202,13 @@ const SearchSuggestionsBox = forwardRef(({ }, descriptionPlacement: "right", } + case "context": + return { + list: searchContextSuggestions, + onSuggestionClicked: createOnSuggestionClickedHandler(), + descriptionPlacement: "left", + DefaultIcon: VscFilter, + } case "none": case "revision": case "content": @@ -263,6 +274,7 @@ const SearchSuggestionsBox = forwardRef(({ symbolSuggestions, searchHistorySuggestions, languageSuggestions, + searchContextSuggestions, ]); // When the list of suggestions change, reset the highlight index @@ -287,6 +299,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/useRefineModeSuggestions.ts b/packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts new file mode 100644 index 00000000..fdc16d50 --- /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 555b4c22..6aa4ff9d 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 00000000..da03fd6b --- /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/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index a8ed1eb6..ac13d4f8 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 6fa0f4c7..1dad70bc 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]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 693496ba..8d712266 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 768244ee..a62770e1 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]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index 0b587903..134b6b02 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/[domain]/settings/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 5f66e6cc..87fccb7c 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 0f15f9bc..ce023831 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 cab223e6..7fb16123 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 cd8f238b..8a51aa09 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/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 987a0ad7..44349cb6 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/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 9219c463..8a466b7a 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/components/securityCard.tsx b/packages/web/src/app/components/securityCard.tsx index 22056e92..578060f5 100644 --- a/packages/web/src/app/components/securityCard.tsx +++ b/packages/web/src/app/components/securityCard.tsx @@ -3,6 +3,7 @@ import Link from "next/link" import { Shield, Lock, CheckCircle, ExternalLink, Mail } from "lucide-react" import useCaptureEvent from "@/hooks/useCaptureEvent" +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" export default function SecurityCard() { const captureEvent = useCaptureEvent(); @@ -62,7 +63,7 @@ export default function SecurityCard() {
Have questions? diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx index 69f503e8..44b5dabe 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/layout.tsx b/packages/web/src/app/layout.tsx index 859cd8b6..fa430930 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/app/login/verify/page.tsx b/packages/web/src/app/login/verify/page.tsx index 8b1c3bc0..a096f5f8 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 5fd46cae..98aeda11 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/app/onboard/components/onboardHeader.tsx b/packages/web/src/app/onboard/components/onboardHeader.tsx index b40d60c7..17281d76 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 00000000..b1068944 --- /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 878ef20e..a4febbe5 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 d38032a7..980c6cf2 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 80% rename from packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx rename to packages/web/src/ee/features/billing/components/enterpriseUpgradeCard.tsx index f18dd7c2..74de5cf0 100644 --- a/packages/web/src/app/[domain]/upgrade/components/enterpriseUpgradeCard.tsx +++ b/packages/web/src/ee/features/billing/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 ( - + { + 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 fd65253d..2a999571 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/ee/features/searchContexts/syncSearchContexts.ts b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts new file mode 100644 index 00000000..e662896d --- /dev/null +++ b/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts @@ -0,0 +1,111 @@ +import { env } from "@/env.mjs"; +import { getPlan, hasEntitlement } from "@/features/entitlements/server"; +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"; + +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. 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 ${SOURCEBOT_SUPPORT_EMAIL}.`); + } + return; + } + + 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 11e77174..e75cc5d6 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/README.md b/packages/web/src/features/entitlements/README.md new file mode 100644 index 00000000..5991640c --- /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 new file mode 100644 index 00000000..1701913a --- /dev/null +++ b/packages/web/src/features/entitlements/constants.ts @@ -0,0 +1,20 @@ + +const planLabels = { + oss: "OSS", + "cloud:team": "Team", + "self-hosted:enterprise": "Enterprise (Self-Hosted)", +} as const; +export type Plan = keyof typeof planLabels; + + +const entitlements = [ + "search-contexts", + "billing" +] as const; +export type Entitlement = (typeof entitlements)[number]; + +export const entitlementsByPlan: Record = { + oss: [], + "cloud:team": ["billing"], + "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 00000000..728eccc7 --- /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 00000000..9e08df4c --- /dev/null +++ b/packages/web/src/features/entitlements/server.ts @@ -0,0 +1,53 @@ +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({ + id: z.string(), + // ISO 8601 date string + expiryDate: z.string().datetime().optional(), +}); + +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 (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 with error: ${error}`); + console.info('Falling back to oss plan.'); + 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 00000000..c629c2c9 --- /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 00000000..126c060e --- /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 diff --git a/packages/web/src/initialize.ts b/packages/web/src/initialize.ts index 0d97404e..b9fedb30 100644 --- a/packages/web/src/initialize.ts +++ b/packages/web/src/initialize.ts @@ -9,6 +9,7 @@ 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 { syncSearchContexts } from '@/ee/features/searchContexts/syncSearchContexts'; 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,52 @@ 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), - }, - orgId: SINGLE_TENANT_ORG_ID, + id: connection.id, } - }); + }) + } +} - for (const connection of deletedConnections) { - console.log(`Deleting connection with name '${connection.name}'. Connection ID: ${connection.id}`); - await prisma.connection.delete({ - where: { - id: connection.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 +193,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/constants.ts b/packages/web/src/lib/constants.ts index faee5be9..88913335 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 diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 66229e7b..56702cd5 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 a8a35cd6..a0fc7975 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 c5b807ea..7648e1b0 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 45a3b029..587f9c4f 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,46 @@ } }, "additionalProperties": false + }, + "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", + "description": "Optional description of the search context that surfaces in the UI." + } + }, + "required": [ + "include" + ], + "additionalProperties": false } }, "properties": { @@ -74,6 +113,16 @@ "settings": { "$ref": "#/definitions/Settings" }, + "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" + } + }, + "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/vendor/zoekt b/vendor/zoekt index cf456394..7d189621 160000 --- a/vendor/zoekt +++ b/vendor/zoekt @@ -1 +1 @@ -Subproject commit cf456394003dd9bfc9a885fdfcc8cc80230a261d +Subproject commit 7d1896215eea6f97af66c9549c9ec70436356b51 diff --git a/yarn.lock b/yarn.lock index faa5a864..2ca4f10a 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"