Search contexts (#273)

This commit is contained in:
Brendan Kellam 2025-04-24 22:28:13 -07:00 committed by GitHub
parent c201a5e1a9
commit cfe8b8ccc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 1581 additions and 685 deletions

View file

@ -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/), 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). 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 ## [3.0.4] - 2025-04-12
### Fixes ### Fixes

View file

@ -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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -33,6 +33,7 @@ clean:
soft-reset: soft-reset:
rm -rf .sourcebot rm -rf .sourcebot
redis-cli FLUSHALL redis-cli FLUSHALL
yarn dev:prisma:migrate:reset
.PHONY: bin .PHONY: bin

View file

@ -39,6 +39,7 @@
{ {
"group": "More", "group": "More",
"pages": [ "pages": [
"docs/more/syntax-reference",
"docs/more/roles-and-permissions" "docs/more/roles-and-permissions"
] ]
} }
@ -52,7 +53,8 @@
"group": "Getting Started", "group": "Getting Started",
"pages": [ "pages": [
"self-hosting/overview", "self-hosting/overview",
"self-hosting/configuration" "self-hosting/configuration",
"self-hosting/license-key"
] ]
}, },
{ {
@ -61,7 +63,8 @@
"self-hosting/more/authentication", "self-hosting/more/authentication",
"self-hosting/more/tenancy", "self-hosting/more/tenancy",
"self-hosting/more/transactional-emails", "self-hosting/more/transactional-emails",
"self-hosting/more/declarative-config" "self-hosting/more/declarative-config",
"self-hosting/more/search-contexts"
] ]
}, },
{ {

View file

@ -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/`<br/>`file:"my file"` - Filter results to filepaths that match regex `/my file/`<br/>`-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/`<br/>`-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<br/>`-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<br/>`-context:pipelines` - Ignore results from the pipelines context |

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View file

@ -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=<your-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).

View file

@ -3,6 +3,10 @@ title: Configuring Sourcebot from a file (declarative config)
sidebarTitle: Declarative config sidebarTitle: Declarative config
--- ---
<Warning>
Declaratively defining `connections` is not available when [multi-tenancy](/self-hosting/more/tenancy) is enabled.
</Warning>
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). 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).

View file

@ -0,0 +1,153 @@
---
title: Search contexts
sidebarTitle: Search contexts (EE)
---
<Note>
This is only available in the Enterprise Edition. Please add your [license key](/self-hosting/license-key) to activate it.
</Note>
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... */
}
}
```
<Accordion title="Repository URL details">
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))
</Accordion>
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
<Accordion title="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
}
```
</Accordion>

27
ee/LICENSE Normal file
View file

@ -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.

View file

@ -4,8 +4,8 @@
"packages/*" "packages/*"
], ],
"scripts": { "scripts": {
"build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces run build", "build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build",
"test": "yarn workspaces run test", "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", "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 --", "with-env": "cross-env PATH=\"$PWD/bin:$PATH\" dotenv -e .env.development -c --",
"dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc", "dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc",

View file

@ -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;

View file

@ -61,9 +61,24 @@ model Repo {
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int orgId Int
searchContexts SearchContext[]
@@unique([external_id, external_codeHostUrl, orgId]) @@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 { model Connection {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
@ -138,6 +153,8 @@ model Org {
/// List of pending invites to this organization /// List of pending invites to this organization
invites Invite[] invites Invite[]
searchContexts SearchContext[]
} }
enum OrgRole { enum OrgRole {

View file

@ -65,6 +65,46 @@ const schema = {
} }
}, },
"additionalProperties": false "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": { "properties": {
@ -74,6 +114,16 @@ const schema = {
"settings": { "settings": {
"$ref": "#/definitions/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": { "connections": {
"type": "object", "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.", "description": "Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode.",

View file

@ -13,6 +13,12 @@ export type ConnectionConfig =
export interface SourcebotConfig { export interface SourcebotConfig {
$schema?: string; $schema?: string;
settings?: Settings; 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. * 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; 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 { export interface GithubConnectionConfig {
/** /**
* GitHub Configuration * GitHub Configuration

View file

@ -113,6 +113,7 @@
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.435.0", "lucide-react": "^0.435.0",
"micromatch": "^4.0.8",
"next": "14.2.25", "next": "14.2.25",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@ -137,6 +138,7 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/micromatch": "^4.0.9",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/psl": "^1.1.3", "@types/psl": "^1.1.3",

View file

@ -1,33 +1,32 @@
'use server'; 'use server';
import Ajv from "ajv"; import { env } from "@/env.mjs";
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 { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils"; 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 { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; import Ajv from "ajv";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; import { StatusCodes } from "http-status-codes";
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 { Session } from "next-auth"; import { Session } from "next-auth";
import { env } from "@/env.mjs"; import { cookies, headers } from "next/headers";
import Stripe from "stripe";
import { render } from "@react-email/components";
import InviteUserEmail from "./emails/inviteUserEmail";
import { createTransport } from "nodemailer"; 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 { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { TenancyMode } from "./lib/types"; 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 { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils";
import { stripeClient } from "./lib/stripe";
import { IS_BILLING_ENABLED } from "./lib/stripe";
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, 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, validate that the org has an active subscription.
} else { } else {
const subscriptionOrError = await fetchSubscription(domain); const subscriptionOrError = await getSubscriptionForOrg(orgId, prisma);
if (isServiceError(subscriptionOrError)) { if (isServiceError(subscriptionOrError)) {
return subscriptionOrError; return subscriptionOrError;
} }
@ -831,25 +830,6 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
} }
const res = await prisma.$transaction(async (tx) => { 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({ await tx.userToOrg.create({
data: { data: {
userId: user.id, userId: user.id,
@ -863,6 +843,13 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
id: invite.id, id: invite.id,
} }
}); });
if (IS_BILLING_ENABLED) {
const result = await incrementOrgSeatCount(invite.orgId, tx);
if (isServiceError(result)) {
throw result;
}
}
}); });
if (isServiceError(res)) { if (isServiceError(res)) {
@ -977,261 +964,6 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* 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<string | 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();
}
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<Stripe.Subscription | ServiceError> => sew(() =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
return _fetchSubscriptionForOrg(orgId, prisma);
})
));
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> => 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<boolean | ServiceError> => sew(() => export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => { withAuth(async () => {
const org = await prisma.org.findFirst({ const org = await prisma.org.findFirst({
@ -1269,29 +1001,20 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
return notFound(); return notFound();
} }
if (IS_BILLING_ENABLED) { await prisma.$transaction(async (tx) => {
const subscription = await fetchSubscription(domain); await tx.userToOrg.delete({
if (isServiceError(subscription)) { where: {
return subscription; orgId_userId: {
} orgId,
userId: memberId,
const existingSeatCount = subscription.items.data[0].quantity; }
const newSeatCount = (existingSeatCount || 1) - 1;
await stripeClient?.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
} }
) });
}
await prisma.userToOrg.delete({ if (IS_BILLING_ENABLED) {
where: { const result = await decrementOrgSeatCount(orgId, tx);
orgId_userId: { if (isServiceError(result)) {
orgId, throw result;
userId: memberId,
} }
} }
}); });
@ -1323,29 +1046,20 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
return notFound(); return notFound();
} }
if (IS_BILLING_ENABLED) { await prisma.$transaction(async (tx) => {
const subscription = await fetchSubscription(domain); await tx.userToOrg.delete({
if (isServiceError(subscription)) { where: {
return subscription; orgId_userId: {
} orgId,
userId: session.user.id,
const existingSeatCount = subscription.items.data[0].quantity; }
const newSeatCount = (existingSeatCount || 1) - 1;
await stripeClient?.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
} }
) });
}
await prisma.userToOrg.delete({ if (IS_BILLING_ENABLED) {
where: { const result = await decrementOrgSeatCount(orgId, tx);
orgId_userId: { if (isServiceError(result)) {
orgId, throw result;
userId: session.user.id,
} }
} }
}); });
@ -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(() => export const getOrgMembership = async (domain: string) => sew(() =>
withAuth(async (session) => withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
@ -1443,38 +1135,25 @@ export const dismissMobileUnsupportedSplashScreen = async () => sew(async () =>
return true; return true;
}); });
export const getSearchContexts = async (domain: string) => sew(() =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const searchContexts = await prisma.searchContext.findMany({
where: {
orgId,
},
});
return searchContexts.map((context) => ({
name: context.name,
description: context.description ?? undefined,
}));
}
), /* allowSingleTenantUnauthedAccess = */ true));
////// Helpers /////// ////// Helpers ///////
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | ServiceError> => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
return notFound();
}
if (!org.stripeCustomerId) {
return notFound();
}
if (!stripeClient) {
return stripeClientNotInitialized();
}
const subscriptions = await stripeClient.subscriptions.list({
customer: org.stripeCustomerId
});
if (subscriptions.data.length === 0) {
return orgInvalidSubscription();
}
return subscriptions.data[0];
}
const parseConnectionConfig = (connectionType: string, config: string) => { const parseConnectionConfig = (connectionType: string, config: string) => {
let parsedConfig: ConnectionConfig; let parsedConfig: ConnectionConfig;
try { try {

View file

@ -6,14 +6,15 @@ import { SettingsDropdown } from "./settingsDropdown";
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OrgSelector } from "./orgSelector"; import { OrgSelector } from "./orgSelector";
import { getSubscriptionData } from "@/actions";
import { ErrorNavIndicator } from "./errorNavIndicator"; import { ErrorNavIndicator } from "./errorNavIndicator";
import { WarningNavIndicator } from "./warningNavIndicator"; import { WarningNavIndicator } from "./warningNavIndicator";
import { ProgressNavIndicator } from "./progressNavIndicator"; import { ProgressNavIndicator } from "./progressNavIndicator";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { TrialNavIndicator } from "./trialNavIndicator"; 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 { env } from "@/env.mjs";
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@ -24,7 +25,7 @@ interface NavigationMenuProps {
export const NavigationMenu = async ({ export const NavigationMenu = async ({
domain, domain,
}: NavigationMenuProps) => { }: NavigationMenuProps) => {
const subscription = IS_BILLING_ENABLED ? await getSubscriptionData(domain) : null; const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null;
return ( return (
<div className="flex flex-col w-screen h-fit bg-background"> <div className="flex flex-col w-screen h-fit bg-background">

View file

@ -1,10 +1,10 @@
import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; import { Suggestion } from "./searchSuggestionsBox";
/** /**
* List of search prefixes that can be used while the * List of search prefixes that can be used while the
* `refine` suggestion mode is active. * `refine` suggestion mode is active.
*/ */
enum SearchPrefix { export enum SearchPrefix {
repo = "repo:", repo = "repo:",
r = "r:", r = "r:",
lang = "lang:", lang = "lang:",
@ -18,162 +18,10 @@ enum SearchPrefix {
archived = "archived:", archived = "archived:",
case = "case:", case = "case:",
fork = "fork:", 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[] = [ export const publicModeSuggestions: Suggestion[] = [
{ {
value: "yes", value: "yes",

View file

@ -10,7 +10,6 @@ import {
caseModeSuggestions, caseModeSuggestions,
forkModeSuggestions, forkModeSuggestions,
publicModeSuggestions, publicModeSuggestions,
refineModeSuggestions,
} from "./constants"; } from "./constants";
import { IconType } from "react-icons/lib"; import { IconType } from "react-icons/lib";
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc"; 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 { Separator } from "@/components/ui/separator";
import { KeyboardShortcutHint } from "../keyboardShortcutHint"; import { KeyboardShortcutHint } from "../keyboardShortcutHint";
import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider"; import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider";
import { useRefineModeSuggestions } from "./useRefineModeSuggestions";
export type Suggestion = { export type Suggestion = {
value: string; value: string;
@ -39,7 +39,8 @@ export type SuggestionMode =
"symbol" | "symbol" |
"content" | "content" |
"repo" | "repo" |
"searchHistory"; "searchHistory" |
"context";
interface SearchSuggestionsBoxProps { interface SearchSuggestionsBoxProps {
query: string; query: string;
@ -59,6 +60,7 @@ interface SearchSuggestionsBoxProps {
symbolSuggestions: Suggestion[]; symbolSuggestions: Suggestion[];
languageSuggestions: Suggestion[]; languageSuggestions: Suggestion[];
searchHistorySuggestions: Suggestion[]; searchHistorySuggestions: Suggestion[];
searchContextSuggestions: Suggestion[];
} }
const SearchSuggestionsBox = forwardRef(({ const SearchSuggestionsBox = forwardRef(({
@ -78,9 +80,11 @@ const SearchSuggestionsBox = forwardRef(({
symbolSuggestions, symbolSuggestions,
languageSuggestions, languageSuggestions,
searchHistorySuggestions, searchHistorySuggestions,
searchContextSuggestions,
}: SearchSuggestionsBoxProps, ref: Ref<HTMLDivElement>) => { }: SearchSuggestionsBoxProps, ref: Ref<HTMLDivElement>) => {
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0); const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0);
const { onOpenChanged } = useSyntaxGuide(); const { onOpenChanged } = useSyntaxGuide();
const refineModeSuggestions = useRefineModeSuggestions();
const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => { const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => {
if (!isEnabled) { if (!isEnabled) {
@ -198,6 +202,13 @@ const SearchSuggestionsBox = forwardRef(({
}, },
descriptionPlacement: "right", descriptionPlacement: "right",
} }
case "context":
return {
list: searchContextSuggestions,
onSuggestionClicked: createOnSuggestionClickedHandler(),
descriptionPlacement: "left",
DefaultIcon: VscFilter,
}
case "none": case "none":
case "revision": case "revision":
case "content": case "content":
@ -263,6 +274,7 @@ const SearchSuggestionsBox = forwardRef(({
symbolSuggestions, symbolSuggestions,
searchHistorySuggestions, searchHistorySuggestions,
languageSuggestions, languageSuggestions,
searchContextSuggestions,
]); ]);
// When the list of suggestions change, reset the highlight index // When the list of suggestions change, reset the highlight index
@ -287,6 +299,8 @@ const SearchSuggestionsBox = forwardRef(({
return "Languages"; return "Languages";
case "searchHistory": case "searchHistory":
return "Search history" return "Search history"
case "context":
return "Search contexts"
default: default:
return ""; return "";
} }

View file

@ -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;
}

View file

@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { splitQuery, SuggestionMode } from "./searchSuggestionsBox"; import { splitQuery, SuggestionMode } from "./searchSuggestionsBox";
import { suggestionModeMappings } from "./constants"; import { useSuggestionModeMappings } from "./useSuggestionModeMappings";
interface Props { interface Props {
isSuggestionsEnabled: boolean; isSuggestionsEnabled: boolean;
@ -18,6 +18,8 @@ export const useSuggestionModeAndQuery = ({
query, query,
}: Props) => { }: Props) => {
const suggestionModeMappings = useSuggestionModeMappings();
const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => { const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => {
// When suggestions are not enabled, fallback to using a sentinal // When suggestions are not enabled, fallback to using a sentinal
// suggestion mode of "none". // suggestion mode of "none".

View file

@ -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;
}

View file

@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; import { Suggestion, SuggestionMode } from "./searchSuggestionsBox";
import { getRepos, search } from "@/app/api/(client)/client"; import { getRepos, search } from "@/app/api/(client)/client";
import { getSearchContexts } from "@/actions";
import { useMemo } from "react"; import { useMemo } from "react";
import { Symbol } from "@/lib/types"; import { Symbol } from "@/lib/types";
import { languageMetadataMap } from "@/lib/languageMetadata"; import { languageMetadataMap } from "@/lib/languageMetadata";
@ -18,7 +19,7 @@ import {
VscSymbolVariable VscSymbolVariable
} from "react-icons/vsc"; } from "react-icons/vsc";
import { useSearchHistory } from "@/hooks/useSearchHistory"; import { useSearchHistory } from "@/hooks/useSearchHistory";
import { getDisplayTime } from "@/lib/utils"; import { getDisplayTime, isServiceError } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
@ -56,6 +57,10 @@ export const useSuggestionsData = ({
maxMatchDisplayCount: 15, maxMatchDisplayCount: 15,
}, domain), }, domain),
select: (data): Suggestion[] => { select: (data): Suggestion[] => {
if (isServiceError(data)) {
return [];
}
return data.Result.Files?.map((file) => ({ return data.Result.Files?.map((file) => ({
value: file.FileName value: file.FileName
})) ?? []; })) ?? [];
@ -71,6 +76,10 @@ export const useSuggestionsData = ({
maxMatchDisplayCount: 15, maxMatchDisplayCount: 15,
}, domain), }, domain),
select: (data): Suggestion[] => { select: (data): Suggestion[] => {
if (isServiceError(data)) {
return [];
}
const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []); const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []);
if (!symbols) { if (!symbols) {
return []; return [];
@ -89,6 +98,24 @@ export const useSuggestionsData = ({
}); });
const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]); 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[] => { const languageSuggestions = useMemo((): Suggestion[] => {
return Object.keys(languageMetadataMap).map((lang) => { return Object.keys(languageMetadataMap).map((lang) => {
const spotlight = [ const spotlight = [
@ -116,13 +143,14 @@ export const useSuggestionsData = ({
}, [searchHistory]); }, [searchHistory]);
const isLoadingSuggestions = useMemo(() => { const isLoadingSuggestions = useMemo(() => {
return isLoadingSymbols || isLoadingFiles || isLoadingRepos; return isLoadingSymbols || isLoadingFiles || isLoadingRepos || isLoadingSearchContexts;
}, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]); }, [isLoadingFiles, isLoadingRepos, isLoadingSymbols, isLoadingSearchContexts]);
return { return {
repoSuggestions: repoSuggestions ?? [], repoSuggestions: repoSuggestions ?? [],
fileSuggestions: fileSuggestions ?? [], fileSuggestions: fileSuggestions ?? [],
symbolSuggestions: symbolSuggestions ?? [], symbolSuggestions: symbolSuggestions ?? [],
searchContextSuggestions: searchContextSuggestions ?? [],
languageSuggestions, languageSuggestions,
searchHistorySuggestions, searchHistorySuggestions,
isLoadingSuggestions, isLoadingSuggestions,

View file

@ -47,7 +47,7 @@ export const zoekt = () => {
// Check for prefixes first // Check for prefixes first
// If these match, we return 'keyword' // 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(); return t.keyword.toString();
} }

View file

@ -3,7 +3,6 @@ import { auth } from "@/auth";
import { getOrgFromDomain } from "@/data/org"; import { getOrgFromDomain } from "@/data/org";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { OnboardGuard } from "./components/onboardGuard"; import { OnboardGuard } from "./components/onboardGuard";
import { fetchSubscription } from "@/actions";
import { UpgradeGuard } from "./components/upgradeGuard"; import { UpgradeGuard } from "./components/upgradeGuard";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
import { getSelectorsByUserAgent } from "react-device-detect"; 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 { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants";
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; 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 { env } from "@/env.mjs";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode, children: React.ReactNode,
params: { domain: string } params: { domain: string }
@ -58,7 +59,7 @@ export default async function Layout({
} }
if (IS_BILLING_ENABLED) { if (IS_BILLING_ENABLED) {
const subscription = await fetchSubscription(domain); const subscription = await getSubscriptionInfo(domain);
if ( if (
subscription && subscription &&
( (

View file

@ -5,9 +5,9 @@ import { notFound, redirect } from "next/navigation";
import { ConnectCodeHost } from "./components/connectCodeHost"; import { ConnectCodeHost } from "./components/connectCodeHost";
import { InviteTeam } from "./components/inviteTeam"; import { InviteTeam } from "./components/inviteTeam";
import { CompleteOnboarding } from "./components/completeOnboarding"; 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 { 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"; import { env } from "@/env.mjs";
interface OnboardProps { interface OnboardProps {

View file

@ -10,7 +10,7 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { useSearchHistory } from "@/hooks/useSearchHistory"; import { useSearchHistory } from "@/hooks/useSearchHistory";
import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types"; 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 { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -22,6 +22,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel";
import { FilterPanel } from "./components/filterPanel"; import { FilterPanel } from "./components/filterPanel";
import { SearchResultsPanel } from "./components/searchResultsPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { useToast } from "@/components/hooks/use-toast";
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
@ -44,21 +45,31 @@ const SearchPageInternal = () => {
const { setSearchHistory } = useSearchHistory(); const { setSearchHistory } = useSearchHistory();
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const domain = useDomain(); const domain = useDomain();
const { toast } = useToast();
const { data: searchResponse, isLoading } = useQuery({ const { data: searchResponse, isLoading, error } = useQuery({
queryKey: ["search", searchQuery, maxMatchDisplayCount], queryKey: ["search", searchQuery, maxMatchDisplayCount],
queryFn: () => measure(() => search({ queryFn: () => measure(() => unwrapServiceError(search({
query: searchQuery, query: searchQuery,
maxMatchDisplayCount, maxMatchDisplayCount,
}, domain), "client.search"), }, domain)), "client.search"),
select: ({ data, durationMs }) => ({ select: ({ data, durationMs }) => ({
...data, ...data,
durationMs, durationMs,
}), }),
enabled: searchQuery.length > 0, enabled: searchQuery.length > 0,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: false,
}); });
useEffect(() => {
if (error) {
toast({
description: `❌ Search failed. Reason: ${error.message}`,
});
}
}, [error, toast]);
// Write the query to the search history // Write the query to the search history
useEffect(() => { useEffect(() => {

View file

@ -1,13 +1,15 @@
import type { Metadata } from "next" import { getCurrentUserRole } from "@/actions"
import { CalendarIcon, DollarSign, Users } from "lucide-react"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { ManageSubscriptionButton } from "./manageSubscriptionButton" import { getSubscriptionBillingEmail, getSubscriptionInfo } from "@/ee/features/billing/actions"
import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" import { ChangeBillingEmailCard } from "@/ee/features/billing/components/changeBillingEmailCard"
import { isServiceError } from "@/lib/utils" import { ManageSubscriptionButton } from "@/ee/features/billing/components/manageSubscriptionButton"
import { ChangeBillingEmailCard } from "./changeBillingEmailCard" import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"
import { notFound } from "next/navigation"
import { IS_BILLING_ENABLED } from "@/lib/stripe"
import { ServiceErrorException } from "@/lib/serviceError" import { ServiceErrorException } from "@/lib/serviceError"
import { isServiceError } from "@/lib/utils"
import { CalendarIcon, DollarSign, Users } from "lucide-react"
import type { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Billing | Settings", title: "Billing | Settings",
description: "Manage your subscription and billing information", description: "Manage your subscription and billing information",
@ -26,7 +28,7 @@ export default async function BillingPage({
notFound(); notFound();
} }
const subscription = await getSubscriptionData(domain) const subscription = await getSubscriptionInfo(domain)
if (isServiceError(subscription)) { if (isServiceError(subscription)) {
throw new ServiceErrorException(subscription); throw new ServiceErrorException(subscription);

View file

@ -2,7 +2,7 @@ import { Metadata } from "next"
import { SidebarNav } from "./components/sidebar-nav" import { SidebarNav } from "./components/sidebar-nav"
import { NavigationMenu } from "../components/navigationMenu" import { NavigationMenu } from "../components/navigationMenu"
import { Header } from "./components/header"; 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 { redirect } from "next/navigation";
import { auth } from "@/auth"; import { auth } from "@/auth";

View file

@ -7,7 +7,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabSwitcher } from "@/components/ui/tab-switcher"; import { TabSwitcher } from "@/components/ui/tab-switcher";
import { InvitesList } from "./components/invitesList"; import { InvitesList } from "./components/invitesList";
import { getOrgInvites, getMe } from "@/actions"; 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"; import { ServiceErrorException } from "@/lib/serviceError";
interface MembersSettingsPageProps { interface MembersSettingsPageProps {
params: { params: {

View file

@ -1,23 +1,23 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { Footer } from "@/app/components/footer"; import { Footer } from "@/app/components/footer";
import { OrgSelector } from "../components/orgSelector"; import { OrgSelector } from "../components/orgSelector";
import { EnterpriseUpgradeCard } from "./components/enterpriseUpgradeCard"; import { EnterpriseUpgradeCard } from "@/ee/features/billing/components/enterpriseUpgradeCard";
import { TeamUpgradeCard } from "./components/teamUpgradeCard"; import { TeamUpgradeCard } from "@/ee/features/billing/components/teamUpgradeCard";
import { fetchSubscription } from "@/actions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { ArrowLeftIcon } from "@radix-ui/react-icons";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { env } from "@/env.mjs"; 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 } }) { export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) {
if (!IS_BILLING_ENABLED) { if (!IS_BILLING_ENABLED) {
redirect(`/${domain}`); redirect(`/${domain}`);
} }
const subscription = await fetchSubscription(domain); const subscription = await getSubscriptionInfo(domain);
if (!subscription) { if (!subscription) {
redirect(`/${domain}`); redirect(`/${domain}`);
} }

View file

@ -1,9 +1,11 @@
'use client'; 'use client';
import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas"; import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas";
import { ServiceError } from "@/lib/serviceError";
import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types"; import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
import { isServiceError } from "@/lib/utils";
export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse> => { export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse | ServiceError> => {
const result = await fetch("/api/search", { const result = await fetch("/api/search", {
method: "POST", method: "POST",
headers: { headers: {
@ -13,6 +15,10 @@ export const search = async (body: SearchRequest, domain: string): Promise<Searc
body: JSON.stringify(body), body: JSON.stringify(body),
}).then(response => response.json()); }).then(response => response.json());
if (isServiceError(result)) {
return result;
}
return searchResponseSchema.parse(result); return searchResponseSchema.parse(result);
} }

View file

@ -3,7 +3,7 @@ import { NextRequest } from 'next/server';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db';
import { stripeClient } from '@/lib/stripe'; import { stripeClient } from '@/ee/features/billing/stripe';
import { env } from '@/env.mjs'; import { env } from '@/env.mjs';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {

View file

@ -3,6 +3,7 @@
import Link from "next/link" import Link from "next/link"
import { Shield, Lock, CheckCircle, ExternalLink, Mail } from "lucide-react" import { Shield, Lock, CheckCircle, ExternalLink, Mail } from "lucide-react"
import useCaptureEvent from "@/hooks/useCaptureEvent" import useCaptureEvent from "@/hooks/useCaptureEvent"
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"
export default function SecurityCard() { export default function SecurityCard() {
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
@ -62,7 +63,7 @@ export default function SecurityCard() {
<div className="flex flex-row justify-center text-sm text-[#A1A1AA] mb-5"> <div className="flex flex-row justify-center text-sm text-[#A1A1AA] mb-5">
Have questions? Have questions?
<Link <Link
href="mailto:team@sourcebot.dev" href={`mailto:${SOURCEBOT_SUPPORT_EMAIL}`}
className="inline-flex items-center ml-2 text-[#9D5CFF] hover:text-[#B47EFF] transition-colors" className="inline-flex items-center ml-2 text-[#9D5CFF] hover:text-[#B47EFF] transition-colors"
> >
<Mail className="h-3.5 w-3.5 mr-1" /> <Mail className="h-3.5 w-3.5 mr-1" />

View file

@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { serviceErrorSchema } from '@/lib/serviceError'; import { serviceErrorSchema } from '@/lib/serviceError';
import { SourcebotLogo } from './components/sourcebotLogo'; import { SourcebotLogo } from './components/sourcebotLogo';
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) { export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) {
useEffect(() => { useEffect(() => {
@ -76,7 +77,7 @@ function ErrorCard({ message, errorCode, statusCode, onReloadButtonClicked }: Er
Unexpected Error Unexpected Error
</CardTitle> </CardTitle>
<CardDescription className="text-sm"> <CardDescription className="text-sm">
An unexpected error occurred. Please reload the page and try again. If the issue persists, <Link href={`mailto:team@sourcebot.dev?subject=Sourcebot%20Error%20Report${errorCode ? `%20|%20Code:%20${errorCode}` : ''}`} className='underline'>please contact us</Link>. An unexpected error occurred. Please reload the page and try again. If the issue persists, <Link href={`mailto:${SOURCEBOT_SUPPORT_EMAIL}?subject=Sourcebot%20Error%20Report${errorCode ? `%20|%20Code:%20${errorCode}` : ''}`} className='underline'>please contact us</Link>.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View file

@ -7,6 +7,8 @@ import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { PlanProvider } from "@/features/entitlements/planProvider";
import { getPlan } from "@/features/entitlements/server";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Sourcebot", title: "Sourcebot",
@ -27,20 +29,22 @@ export default function RootLayout({
<body> <body>
<Toaster /> <Toaster />
<SessionProvider> <SessionProvider>
<PostHogProvider disabled={env.SOURCEBOT_TELEMETRY_DISABLED === "true"}> <PlanProvider plan={getPlan()}>
<ThemeProvider <PostHogProvider disabled={env.SOURCEBOT_TELEMETRY_DISABLED === "true"}>
attribute="class" <ThemeProvider
defaultTheme="system" attribute="class"
enableSystem defaultTheme="system"
disableTransitionOnChange enableSystem
> disableTransitionOnChange
<QueryClientProvider> >
<TooltipProvider> <QueryClientProvider>
{children} <TooltipProvider>
</TooltipProvider> {children}
</QueryClientProvider> </TooltipProvider>
</ThemeProvider> </QueryClientProvider>
</PostHogProvider> </ThemeProvider>
</PostHogProvider>
</PlanProvider>
</SessionProvider> </SessionProvider>
</body> </body>
</html> </html>

View file

@ -13,6 +13,7 @@ import VerificationFailed from "./verificationFailed"
import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import useCaptureEvent from "@/hooks/useCaptureEvent" import useCaptureEvent from "@/hooks/useCaptureEvent"
import { Footer } from "@/app/components/footer" import { Footer } from "@/app/components/footer"
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"
function VerifyPageContent() { function VerifyPageContent() {
const [value, setValue] = useState("") const [value, setValue] = useState("")
@ -89,7 +90,7 @@ function VerifyPageContent() {
<div className="mt-8 text-center text-sm text-muted-foreground"> <div className="mt-8 text-center text-sm text-muted-foreground">
<p> <p>
Having trouble?{" "} Having trouble?{" "}
<a href="mailto:team@sourcebot.dev" className="text-primary hover:underline"> <a href={`mailto:${SOURCEBOT_SUPPORT_EMAIL}`} className="text-primary hover:underline">
Contact support Contact support
</a> </a>
</p> </p>

View file

@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"
import { AlertCircle } from "lucide-react" import { AlertCircle } from "lucide-react"
import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"
export default function VerificationFailed() { export default function VerificationFailed() {
const router = useRouter() const router = useRouter()
@ -34,7 +35,7 @@ export default function VerificationFailed() {
<a href="https://www.sourcebot.dev" className="hover:text-gray-300"> <a href="https://www.sourcebot.dev" className="hover:text-gray-300">
About About
</a> </a>
<a href="mailto:team@sourcebot.dev" className="hover:text-gray-300"> <a href={`mailto:${SOURCEBOT_SUPPORT_EMAIL}`} className="hover:text-gray-300">
Contact Us Contact Us
</a> </a>
</div> </div>

View file

@ -1,6 +1,6 @@
import { SourcebotLogo } from "@/app/components/sourcebotLogo" import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { OnboardingSteps } from "@/lib/constants"; import { OnboardingSteps } from "@/lib/constants";
import { IS_BILLING_ENABLED } from "@/lib/stripe"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
interface OnboardHeaderProps { interface OnboardHeaderProps {
title: string title: string

View file

@ -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<string | 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();
}
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<string | ServiceError> => 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!,
}
})
));

View file

@ -1,11 +1,11 @@
"use client" "use client"
import { changeSubscriptionBillingEmail } from "@/actions"
import { useToast } from "@/components/hooks/use-toast" import { useToast } from "@/components/hooks/use-toast"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form" import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { changeSubscriptionBillingEmail } from "@/ee/features/billing/actions"
import useCaptureEvent from "@/hooks/useCaptureEvent" import useCaptureEvent from "@/hooks/useCaptureEvent"
import { useDomain } from "@/hooks/useDomain" import { useDomain } from "@/hooks/useDomain"
import { isServiceError } from "@/lib/utils" import { isServiceError } from "@/lib/utils"

View file

@ -1,6 +1,5 @@
'use client'; 'use client';
import { createOnboardingSubscription } from "@/actions";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -13,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { OnboardingSteps, TEAM_FEATURES } from "@/lib/constants"; import { OnboardingSteps, TEAM_FEATURES } from "@/lib/constants";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { createOnboardingSubscription } from "../actions";
export const Checkout = () => { export const Checkout = () => {
const domain = useDomain(); const domain = useDomain();

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { ENTERPRISE_FEATURES } from "@/lib/constants"; import { ENTERPRISE_FEATURES, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
import { UpgradeCard } from "./upgradeCard"; import { UpgradeCard } from "./upgradeCard";
import Link from "next/link"; import Link from "next/link";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
@ -14,7 +14,7 @@ export const EnterpriseUpgradeCard = () => {
} }
return ( return (
<Link href="mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry"> <Link href={`mailto:${SOURCEBOT_SUPPORT_EMAIL}?subject=Enterprise%20Pricing%20Inquiry`}>
<UpgradeCard <UpgradeCard
title="Enterprise" title="Enterprise"
description="For large organizations with custom needs." description="For large organizations with custom needs."

View file

@ -4,11 +4,11 @@ import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { isServiceError } from "@/lib/utils" import { isServiceError } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getCustomerPortalSessionLink } from "@/actions"
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { ExternalLink, Loader2 } from "lucide-react"; import { ExternalLink, Loader2 } from "lucide-react";
import { getCustomerPortalSessionLink } from "@/ee/features/billing/actions"
export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) { export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { UpgradeCard } from "./upgradeCard"; import { UpgradeCard } from "./upgradeCard";
import { createStripeCheckoutSession } from "@/actions";
import { useToast } from "@/components/hooks/use-toast"; import { useToast } from "@/components/hooks/use-toast";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
@ -9,6 +8,7 @@ import { useCallback, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { TEAM_FEATURES } from "@/lib/constants"; import { TEAM_FEATURES } from "@/lib/constants";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { createStripeCheckoutSession } from "../actions";
interface TeamUpgradeCardProps { interface TeamUpgradeCardProps {
buttonText: string; buttonText: string;

View file

@ -0,0 +1,80 @@
import 'server-only';
import { notFound, orgInvalidSubscription, ServiceError, stripeClientNotInitialized } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { Prisma } from "@sourcebot/db";
import Stripe from "stripe";
import { stripeClient } from "./stripe";
export const incrementOrgSeatCount = async (orgId: number, prisma: Prisma.TransactionClient) => {
if (!stripeClient) {
return stripeClientNotInitialized();
}
const subscription = await getSubscriptionForOrg(orgId, prisma);
if (isServiceError(subscription)) {
return subscription;
}
const existingSeatCount = subscription.items.data[0].quantity;
const newSeatCount = (existingSeatCount || 1) + 1;
await stripeClient.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
}
);
}
export const decrementOrgSeatCount = async (orgId: number, prisma: Prisma.TransactionClient) => {
if (!stripeClient) {
return stripeClientNotInitialized();
}
const subscription = await getSubscriptionForOrg(orgId, prisma);
if (isServiceError(subscription)) {
return subscription;
}
const existingSeatCount = subscription.items.data[0].quantity;
const newSeatCount = (existingSeatCount || 1) - 1;
await stripeClient.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
}
);
}
export const getSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | ServiceError> => {
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];
}

View file

@ -1,8 +1,9 @@
import 'server-only'; import 'server-only';
import { env } from '@/env.mjs' import { env } from '@/env.mjs'
import Stripe from "stripe"; 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 = export const stripeClient =
IS_BILLING_ENABLED IS_BILLING_ENABLED

View file

@ -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,
}
})
}
}

View file

@ -49,6 +49,9 @@ export const env = createEnv({
// Misc UI flags // Misc UI flags
SECURITY_CARD_ENABLED: booleanSchema.default('false'), SECURITY_CARD_ENABLED: booleanSchema.default('false'),
// EE License
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
}, },
// @NOTE: Please make sure of the following: // @NOTE: Please make sure of the following:
// - Make sure you destructure all client variables in // - Make sure you destructure all client variables in

View file

@ -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`.

View file

@ -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<Plan, Entitlement[]> = {
oss: [],
"cloud:team": ["billing"],
"self-hosted:enterprise": ["search-contexts"],
} as const;

View file

@ -0,0 +1,21 @@
'use client';
import { createContext } from "react";
import { Plan } from "./constants";
export const PlanContext = createContext<Plan>('oss');
interface PlanProviderProps {
children: React.ReactNode;
plan: Plan;
}
export const PlanProvider = ({ children, plan }: PlanProviderProps) => {
return (
<PlanContext.Provider
value={plan}
>
{children}
</PlanContext.Provider>
)
};

View file

@ -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);
}

View file

@ -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);
}

View file

@ -0,0 +1,7 @@
import { useContext } from "react";
import { PlanContext } from "./planProvider";
export const usePlan = () => {
const plan = useContext(PlanContext);
return plan;
}

View file

@ -9,6 +9,7 @@ import { SourcebotConfig } from "@sourcebot/schemas/v3/index.type";
import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { indexSchema } from '@sourcebot/schemas/v3/index.schema'; import { indexSchema } from '@sourcebot/schemas/v3/index.schema';
import Ajv from 'ajv'; import Ajv from 'ajv';
import { syncSearchContexts } from '@/ee/features/searchContexts/syncSearchContexts';
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
@ -22,29 +23,9 @@ const isRemotePath = (path: string) => {
return path.startsWith('https://') || path.startsWith('http://'); return path.startsWith('https://') || path.startsWith('http://');
} }
const scheduleDeclarativeConfigSync = async (configPath: string) => { const syncConnections = async (connections?: { [key: string]: ConnectionConfig }) => {
const configContent = await (async () => { if (connections) {
if (isRemotePath(configPath)) { for (const [key, newConnectionConfig] of Object.entries(connections)) {
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 currentConnection = await prisma.connection.findUnique({ const currentConnection = await prisma.connection.findUnique({
where: { where: {
name_orgId: { name_orgId: {
@ -108,26 +89,52 @@ const scheduleDeclarativeConfigSync = async (configPath: string) => {
}) })
} }
} }
const deletedConnections = await prisma.connection.findMany({
where: {
isDeclarative: true,
name: {
notIn: Object.keys(config.connections),
},
orgId: SINGLE_TENANT_ORG_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,
}
})
}
} }
// 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,
}
});
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 () => { const initSingleTenancy = async () => {
@ -186,13 +193,13 @@ const initSingleTenancy = async () => {
// Load any connections defined declaratively in the config file. // Load any connections defined declaratively in the config file.
const configPath = env.CONFIG_PATH; const configPath = env.CONFIG_PATH;
if (configPath) { if (configPath) {
await scheduleDeclarativeConfigSync(configPath); await syncDeclarativeConfig(configPath);
// watch for changes assuming it is a local file // watch for changes assuming it is a local file
if (!isRemotePath(configPath)) { if (!isRemotePath(configPath)) {
watch(configPath, () => { watch(configPath, () => {
console.log(`Config file ${configPath} changed. Re-syncing...`); console.log(`Config file ${configPath} changed. Re-syncing...`);
scheduleDeclarativeConfigSync(configPath); syncDeclarativeConfig(configPath);
}); });
} }
} }

View file

@ -29,3 +29,5 @@ export const SINGLE_TENANT_USER_EMAIL = 'default@sourcebot.dev';
export const SINGLE_TENANT_ORG_ID = 1; export const SINGLE_TENANT_ORG_ID = 1;
export const SINGLE_TENANT_ORG_DOMAIN = '~'; export const SINGLE_TENANT_ORG_DOMAIN = '~';
export const SINGLE_TENANT_ORG_NAME = 'default'; export const SINGLE_TENANT_ORG_NAME = 'default';
export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';

View file

@ -22,4 +22,5 @@ export enum ErrorCode {
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
STRIPE_CLIENT_NOT_INITIALIZED = 'STRIPE_CLIENT_NOT_INITIALIZED', STRIPE_CLIENT_NOT_INITIALIZED = 'STRIPE_CLIENT_NOT_INITIALIZED',
ACTION_DISALLOWED_IN_TENANCY_MODE = 'ACTION_DISALLOWED_IN_TENANCY_MODE', ACTION_DISALLOWED_IN_TENANCY_MODE = 'ACTION_DISALLOWED_IN_TENANCY_MODE',
SEARCH_CONTEXT_NOT_FOUND = 'SEARCH_CONTEXT_NOT_FOUND',
} }

View file

@ -2,6 +2,7 @@ import { checkIfOrgDomainExists } from "@/actions";
import { RepoIndexingStatus } from "@sourcebot/db"; import { RepoIndexingStatus } from "@sourcebot/db";
import { z } from "zod"; import { z } from "zod";
import { isServiceError } from "./utils"; import { isServiceError } from "./utils";
export const searchRequestSchema = z.object({ export const searchRequestSchema = z.object({
query: z.string(), query: z.string(),
maxMatchDisplayCount: z.number(), maxMatchDisplayCount: z.number(),

View file

@ -5,41 +5,92 @@ import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, Search
import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError"; import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError";
import { isServiceError } from "../utils"; import { isServiceError } from "../utils";
import { zoektFetch } from "./zoektClient"; 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. // List of supported query prefixes in zoekt.
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417 // @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
enum zoektPrefixes { enum zoektPrefixes {
archived = "archived:", archived = "archived:",
branchShort = "b:", branchShort = "b:",
branch = "branch:", branch = "branch:",
caseShort = "c:", caseShort = "c:",
case = "case:", case = "case:",
content = "content:", content = "content:",
fileShort = "f:", fileShort = "f:",
file = "file:", file = "file:",
fork = "fork:", fork = "fork:",
public = "public:", public = "public:",
repoShort = "r:", repoShort = "r:",
repo = "repo:", repo = "repo:",
regex = "regex:", regex = "regex:",
lang = "lang:", lang = "lang:",
sym = "sym:", sym = "sym:",
typeShort = "t:", typeShort = "t:",
type = "type:", type = "type:",
reposet = "reposet:",
} }
// Mapping of additional "alias" prefixes to zoekt prefixes. const transformZoektQuery = async (query: string, orgId: number): Promise<string | ServiceError> => {
const aliasPrefixMappings: Record<string, zoektPrefixes> = { const prevQueryParts = query.split(" ");
"rev:": zoektPrefixes.branch, const newQueryParts = [];
"revision:": zoektPrefixes.branch,
}
export const search = async ({ query, maxMatchDisplayCount, whole}: SearchRequest, orgId: number): Promise<SearchResponse | ServiceError> => { for (const part of prevQueryParts) {
// Replace any alias prefixes with their corresponding zoekt prefixes.
for (const [prefix, zoektPrefix] of Object.entries(aliasPrefixMappings)) { // Handle mapping `rev:` and `revision:` to `branch:`
query = query.replaceAll(prefix, zoektPrefix); 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<SearchResponse | ServiceError> => {
const transformedQuery = await transformZoektQuery(query, orgId);
if (isServiceError(transformedQuery)) {
return transformedQuery;
}
query = transformedQuery;
const isBranchFilteringEnabled = ( const isBranchFilteringEnabled = (
query.includes(zoektPrefixes.branch) || query.includes(zoektPrefixes.branch) ||
query.includes(zoektPrefixes.branchShort) query.includes(zoektPrefixes.branchShort)

View file

@ -11,7 +11,6 @@
"type": "number", "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.", "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 "minimum": 1
}, },
"maxTrigramCount": { "maxTrigramCount": {
"type": "number", "type": "number",
@ -65,6 +64,46 @@
} }
}, },
"additionalProperties": false "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": { "properties": {
@ -74,6 +113,16 @@
"settings": { "settings": {
"$ref": "#/definitions/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": { "connections": {
"type": "object", "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.", "description": "Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode.",

2
vendor/zoekt vendored

@ -1 +1 @@
Subproject commit cf456394003dd9bfc9a885fdfcc8cc80230a261d Subproject commit 7d1896215eea6f97af66c9549c9ec70436356b51

View file

@ -5298,6 +5298,7 @@ __metadata:
"@tanstack/react-query": "npm:^5.53.3" "@tanstack/react-query": "npm:^5.53.3"
"@tanstack/react-table": "npm:^8.20.5" "@tanstack/react-table": "npm:^8.20.5"
"@tanstack/react-virtual": "npm:^3.10.8" "@tanstack/react-virtual": "npm:^3.10.8"
"@types/micromatch": "npm:^4.0.9"
"@types/node": "npm:^20" "@types/node": "npm:^20"
"@types/nodemailer": "npm:^6.4.17" "@types/nodemailer": "npm:^6.4.17"
"@types/psl": "npm:^1.1.3" "@types/psl": "npm:^1.1.3"
@ -5344,6 +5345,7 @@ __metadata:
input-otp: "npm:^1.4.2" input-otp: "npm:^1.4.2"
jsdom: "npm:^25.0.1" jsdom: "npm:^25.0.1"
lucide-react: "npm:^0.435.0" lucide-react: "npm:^0.435.0"
micromatch: "npm:^4.0.8"
next: "npm:14.2.25" next: "npm:14.2.25"
next-auth: "npm:^5.0.0-beta.25" next-auth: "npm:^5.0.0-beta.25"
next-themes: "npm:^0.3.0" next-themes: "npm:^0.3.0"