mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-11 20:05:25 +00:00
Search contexts (#273)
This commit is contained in:
parent
c201a5e1a9
commit
cfe8b8ccc8
65 changed files with 1581 additions and 685 deletions
|
|
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- [Sourcebot EE] Added search contexts, user-defined groupings of repositories that help focus searches on specific areas of a codebase. [#273](https://github.com/sourcebot-dev/sourcebot/pull/273)
|
||||
|
||||
|
||||
## [3.0.4] - 2025-04-12
|
||||
|
||||
### Fixes
|
||||
|
|
|
|||
8
LICENSE
8
LICENSE
|
|
@ -1,6 +1,10 @@
|
|||
MIT License
|
||||
Copyright (c) 2025 Taqla Inc.
|
||||
|
||||
Copyright (c) Taqla, Inc.
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "ee/" and "packages/web/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
|
||||
- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
1
Makefile
1
Makefile
|
|
@ -33,6 +33,7 @@ clean:
|
|||
soft-reset:
|
||||
rm -rf .sourcebot
|
||||
redis-cli FLUSHALL
|
||||
yarn dev:prisma:migrate:reset
|
||||
|
||||
|
||||
.PHONY: bin
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
{
|
||||
"group": "More",
|
||||
"pages": [
|
||||
"docs/more/syntax-reference",
|
||||
"docs/more/roles-and-permissions"
|
||||
]
|
||||
}
|
||||
|
|
@ -52,7 +53,8 @@
|
|||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"self-hosting/overview",
|
||||
"self-hosting/configuration"
|
||||
"self-hosting/configuration",
|
||||
"self-hosting/license-key"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -61,7 +63,8 @@
|
|||
"self-hosting/more/authentication",
|
||||
"self-hosting/more/tenancy",
|
||||
"self-hosting/more/transactional-emails",
|
||||
"self-hosting/more/declarative-config"
|
||||
"self-hosting/more/declarative-config",
|
||||
"self-hosting/more/search-contexts"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
35
docs/docs/more/syntax-reference.mdx
Normal file
35
docs/docs/more/syntax-reference.mdx
Normal 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 |
|
||||
BIN
docs/images/search_contexts_example.png
Normal file
BIN
docs/images/search_contexts_example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
22
docs/self-hosting/license-key.mdx
Normal file
22
docs/self-hosting/license-key.mdx
Normal 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).
|
||||
|
|
@ -3,6 +3,10 @@ title: Configuring Sourcebot from a file (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).
|
||||
|
||||
|
||||
|
|
|
|||
153
docs/self-hosting/more/search-contexts.mdx
Normal file
153
docs/self-hosting/more/search-contexts.mdx
Normal 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
|
||||
|
||||

|
||||
|
||||
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
27
ee/LICENSE
Normal 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.
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces run build",
|
||||
"test": "yarn workspaces run test",
|
||||
"build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build",
|
||||
"test": "yarn workspaces foreach -A run test",
|
||||
"dev": "yarn dev:prisma:migrate:dev && npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web",
|
||||
"with-env": "cross-env PATH=\"$PWD/bin:$PATH\" dotenv -e .env.development -c --",
|
||||
"dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -61,9 +61,24 @@ model Repo {
|
|||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
|
||||
searchContexts SearchContext[]
|
||||
|
||||
@@unique([external_id, external_codeHostUrl, orgId])
|
||||
}
|
||||
|
||||
model SearchContext {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
name String
|
||||
description String?
|
||||
repos Repo[]
|
||||
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
|
||||
@@unique([name, orgId])
|
||||
}
|
||||
|
||||
model Connection {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
|
|
@ -138,6 +153,8 @@ model Org {
|
|||
|
||||
/// List of pending invites to this organization
|
||||
invites Invite[]
|
||||
|
||||
searchContexts SearchContext[]
|
||||
}
|
||||
|
||||
enum OrgRole {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,46 @@ const schema = {
|
|||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SearchContext": {
|
||||
"type": "object",
|
||||
"description": "Search context",
|
||||
"properties": {
|
||||
"include": {
|
||||
"type": "array",
|
||||
"description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": [
|
||||
[
|
||||
"github.com/sourcebot-dev/**",
|
||||
"gerrit.example.org/sub/path/**"
|
||||
]
|
||||
]
|
||||
},
|
||||
"exclude": {
|
||||
"type": "array",
|
||||
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": [
|
||||
[
|
||||
"github.com/sourcebot-dev/sourcebot",
|
||||
"gerrit.example.org/sub/path/**"
|
||||
]
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description of the search context that surfaces in the UI."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"include"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
|
@ -74,6 +114,16 @@ const schema = {
|
|||
"settings": {
|
||||
"$ref": "#/definitions/Settings"
|
||||
},
|
||||
"contexts": {
|
||||
"type": "object",
|
||||
"description": "[Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/self-hosting/more/search-contexts",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9_-]+$": {
|
||||
"$ref": "#/definitions/SearchContext"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"connections": {
|
||||
"type": "object",
|
||||
"description": "Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode.",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ export type ConnectionConfig =
|
|||
export interface SourcebotConfig {
|
||||
$schema?: string;
|
||||
settings?: Settings;
|
||||
/**
|
||||
* [Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/self-hosting/more/search-contexts
|
||||
*/
|
||||
contexts?: {
|
||||
[k: string]: SearchContext;
|
||||
};
|
||||
/**
|
||||
* Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode.
|
||||
*/
|
||||
|
|
@ -72,6 +78,29 @@ export interface Settings {
|
|||
*/
|
||||
repoIndexTimeoutMs?: number;
|
||||
}
|
||||
/**
|
||||
* Search context
|
||||
*
|
||||
* This interface was referenced by `undefined`'s JSON-Schema definition
|
||||
* via the `patternProperty` "^[a-zA-Z0-9_-]+$".
|
||||
*
|
||||
* This interface was referenced by `SourcebotConfig`'s JSON-Schema
|
||||
* via the `definition` "SearchContext".
|
||||
*/
|
||||
export interface SearchContext {
|
||||
/**
|
||||
* List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.
|
||||
*/
|
||||
include: string[];
|
||||
/**
|
||||
* List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.
|
||||
*/
|
||||
exclude?: string[];
|
||||
/**
|
||||
* Optional description of the search context that surfaces in the UI.
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
export interface GithubConnectionConfig {
|
||||
/**
|
||||
* GitHub Configuration
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
"http-status-codes": "^2.3.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.435.0",
|
||||
"micromatch": "^4.0.8",
|
||||
"next": "14.2.25",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-themes": "^0.3.0",
|
||||
|
|
@ -137,6 +138,7 @@
|
|||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/psl": "^1.1.3",
|
||||
|
|
|
|||
|
|
@ -1,33 +1,32 @@
|
|||
'use server';
|
||||
|
||||
import Ajv from "ajv";
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { auth } from "./auth";
|
||||
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists, stripeClientNotInitialized } from "@/lib/serviceError";
|
||||
import { prisma } from "@/prisma";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { env } from "@/env.mjs";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { prisma } from "@/prisma";
|
||||
import { render } from "@react-email/components";
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { decrypt, encrypt } from "@sourcebot/crypto";
|
||||
import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
||||
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
||||
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||
import { decrypt, encrypt } from "@sourcebot/crypto"
|
||||
import { getConnection } from "./data/connection";
|
||||
import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { cookies, headers } from "next/headers"
|
||||
import Ajv from "ajv";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { Session } from "next-auth";
|
||||
import { env } from "@/env.mjs";
|
||||
import Stripe from "stripe";
|
||||
import { render } from "@react-email/components";
|
||||
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { createTransport } from "nodemailer";
|
||||
import { auth } from "./auth";
|
||||
import { getConnection } from "./data/connection";
|
||||
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
|
||||
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants";
|
||||
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
||||
import { TenancyMode } from "./lib/types";
|
||||
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants";
|
||||
import { stripeClient } from "./lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "./lib/stripe";
|
||||
import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils";
|
||||
|
||||
const ajv = new Ajv({
|
||||
validateFormats: false,
|
||||
|
|
@ -230,7 +229,7 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo
|
|||
|
||||
// Else, validate that the org has an active subscription.
|
||||
} else {
|
||||
const subscriptionOrError = await fetchSubscription(domain);
|
||||
const subscriptionOrError = await getSubscriptionForOrg(orgId, prisma);
|
||||
if (isServiceError(subscriptionOrError)) {
|
||||
return subscriptionOrError;
|
||||
}
|
||||
|
|
@ -831,25 +830,6 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
|||
}
|
||||
|
||||
const res = await prisma.$transaction(async (tx) => {
|
||||
if (IS_BILLING_ENABLED) {
|
||||
// @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
|
||||
const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx);
|
||||
if (isServiceError(subscription)) {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
const existingSeatCount = subscription.items.data[0].quantity;
|
||||
const newSeatCount = (existingSeatCount || 1) + 1
|
||||
|
||||
await stripeClient?.subscriptionItems.update(
|
||||
subscription.items.data[0].id,
|
||||
{
|
||||
quantity: newSeatCount,
|
||||
proration_behavior: 'create_prorations',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
await tx.userToOrg.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
|
|
@ -863,6 +843,13 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
|
|||
id: invite.id,
|
||||
}
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const result = await incrementOrgSeatCount(invite.orgId, tx);
|
||||
if (isServiceError(result)) {
|
||||
throw result;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isServiceError(res)) {
|
||||
|
|
@ -977,261 +964,6 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
|
|||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||
));
|
||||
|
||||
export const createOnboardingSubscription = async (domain: string) => sew(() =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const user = await getMe();
|
||||
if (isServiceError(user)) {
|
||||
return user;
|
||||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({
|
||||
frozen_time: Math.floor(Date.now() / 1000)
|
||||
}) : null;
|
||||
|
||||
// Use the existing customer if it exists, otherwise create a new one.
|
||||
const customerId = await (async () => {
|
||||
if (org.stripeCustomerId) {
|
||||
return org.stripeCustomerId;
|
||||
}
|
||||
|
||||
const customer = await stripeClient.customers.create({
|
||||
name: org.name,
|
||||
email: user.email ?? undefined,
|
||||
test_clock: test_clock?.id,
|
||||
description: `Created by ${user.email} on ${domain} (id: ${org.id})`,
|
||||
});
|
||||
|
||||
await prisma.org.update({
|
||||
where: {
|
||||
id: org.id,
|
||||
},
|
||||
data: {
|
||||
stripeCustomerId: customer.id,
|
||||
}
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
})();
|
||||
|
||||
const existingSubscription = await fetchSubscription(domain);
|
||||
if (!isServiceError(existingSubscription)) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS,
|
||||
message: "Attemped to create a trial subscription for an organization that already has an active subscription",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
|
||||
const prices = await stripeClient.prices.list({
|
||||
product: env.STRIPE_PRODUCT_ID,
|
||||
expand: ['data.product'],
|
||||
});
|
||||
|
||||
try {
|
||||
const subscription = await stripeClient.subscriptions.create({
|
||||
customer: customerId,
|
||||
items: [{
|
||||
price: prices.data[0].id,
|
||||
}],
|
||||
trial_period_days: 14,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
missing_payment_method: 'cancel',
|
||||
},
|
||||
},
|
||||
payment_settings: {
|
||||
save_default_payment_method: 'on_subscription',
|
||||
},
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||
message: "Failed to create subscription",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptionId: subscription.id,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||
message: "Failed to create subscription",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
|
||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||
));
|
||||
|
||||
export const createStripeCheckoutSession = async (domain: string) => sew(() =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org || !org.stripeCustomerId) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!stripeClient) {
|
||||
return stripeClientNotInitialized();
|
||||
}
|
||||
|
||||
const orgMembers = await prisma.userToOrg.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
}
|
||||
});
|
||||
const numOrgMembers = orgMembers.length;
|
||||
|
||||
const origin = (await headers()).get('origin')!;
|
||||
const prices = await stripeClient.prices.list({
|
||||
product: env.STRIPE_PRODUCT_ID,
|
||||
expand: ['data.product'],
|
||||
});
|
||||
|
||||
const stripeSession = await stripeClient.checkout.sessions.create({
|
||||
customer: org.stripeCustomerId as string,
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: prices.data[0].id,
|
||||
quantity: numOrgMembers
|
||||
}
|
||||
],
|
||||
mode: 'subscription',
|
||||
payment_method_collection: 'always',
|
||||
success_url: `${origin}/${domain}/settings/billing`,
|
||||
cancel_url: `${origin}/${domain}`,
|
||||
});
|
||||
|
||||
if (!stripeSession.url) {
|
||||
return {
|
||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||
message: "Failed to create checkout session",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
return {
|
||||
url: stripeSession.url,
|
||||
}
|
||||
})
|
||||
));
|
||||
|
||||
export const getCustomerPortalSessionLink = async (domain: string): Promise<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(() =>
|
||||
withAuth(async () => {
|
||||
const org = await prisma.org.findFirst({
|
||||
|
|
@ -1269,25 +1001,8 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
|
|||
return notFound();
|
||||
}
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (isServiceError(subscription)) {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
const existingSeatCount = subscription.items.data[0].quantity;
|
||||
const newSeatCount = (existingSeatCount || 1) - 1;
|
||||
|
||||
await stripeClient?.subscriptionItems.update(
|
||||
subscription.items.data[0].id,
|
||||
{
|
||||
quantity: newSeatCount,
|
||||
proration_behavior: 'create_prorations',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.userToOrg.delete({
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userToOrg.delete({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId,
|
||||
|
|
@ -1296,6 +1011,14 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
|
|||
}
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const result = await decrementOrgSeatCount(orgId, tx);
|
||||
if (isServiceError(result)) {
|
||||
throw result;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
|
|
@ -1323,25 +1046,8 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
|
|||
return notFound();
|
||||
}
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (isServiceError(subscription)) {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
const existingSeatCount = subscription.items.data[0].quantity;
|
||||
const newSeatCount = (existingSeatCount || 1) - 1;
|
||||
|
||||
await stripeClient?.subscriptionItems.update(
|
||||
subscription.items.data[0].id,
|
||||
{
|
||||
quantity: newSeatCount,
|
||||
proration_behavior: 'create_prorations',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.userToOrg.delete({
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userToOrg.delete({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId,
|
||||
|
|
@ -1350,34 +1056,20 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
|
|||
}
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const result = await decrementOrgSeatCount(orgId, tx);
|
||||
if (isServiceError(result)) {
|
||||
throw result;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
})
|
||||
));
|
||||
|
||||
export const getSubscriptionData = async (domain: string) => sew(() =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async () => {
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (isServiceError(subscription)) {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
plan: "Team",
|
||||
seats: subscription.items.data[0].quantity!,
|
||||
perSeatPrice: subscription.items.data[0].price.unit_amount! / 100,
|
||||
nextBillingDate: subscription.current_period_end!,
|
||||
status: subscription.status,
|
||||
}
|
||||
})
|
||||
));
|
||||
|
||||
export const getOrgMembership = async (domain: string) => sew(() =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -1443,37 +1135,24 @@ export const dismissMobileUnsupportedSplashScreen = async () => sew(async () =>
|
|||
return true;
|
||||
});
|
||||
|
||||
|
||||
////// Helpers ///////
|
||||
|
||||
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | ServiceError> => {
|
||||
const org = await prisma.org.findUnique({
|
||||
export const getSearchContexts = async (domain: string) => sew(() =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const searchContexts = await prisma.searchContext.findMany({
|
||||
where: {
|
||||
id: orgId,
|
||||
orgId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
return notFound();
|
||||
return searchContexts.map((context) => ({
|
||||
name: context.name,
|
||||
description: context.description ?? undefined,
|
||||
}));
|
||||
}
|
||||
), /* allowSingleTenantUnauthedAccess = */ true));
|
||||
|
||||
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];
|
||||
}
|
||||
////// Helpers ///////
|
||||
|
||||
const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||
let parsedConfig: ConnectionConfig;
|
||||
|
|
|
|||
|
|
@ -6,14 +6,15 @@ import { SettingsDropdown } from "./settingsDropdown";
|
|||
import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OrgSelector } from "./orgSelector";
|
||||
import { getSubscriptionData } from "@/actions";
|
||||
import { ErrorNavIndicator } from "./errorNavIndicator";
|
||||
import { WarningNavIndicator } from "./warningNavIndicator";
|
||||
import { ProgressNavIndicator } from "./progressNavIndicator";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { TrialNavIndicator } from "./trialNavIndicator";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
import { env } from "@/env.mjs";
|
||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||
|
||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
||||
|
||||
|
|
@ -24,7 +25,7 @@ interface NavigationMenuProps {
|
|||
export const NavigationMenu = async ({
|
||||
domain,
|
||||
}: NavigationMenuProps) => {
|
||||
const subscription = IS_BILLING_ENABLED ? await getSubscriptionData(domain) : null;
|
||||
const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-screen h-fit bg-background">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Suggestion, SuggestionMode } from "./searchSuggestionsBox";
|
||||
import { Suggestion } from "./searchSuggestionsBox";
|
||||
|
||||
/**
|
||||
* List of search prefixes that can be used while the
|
||||
* `refine` suggestion mode is active.
|
||||
*/
|
||||
enum SearchPrefix {
|
||||
export enum SearchPrefix {
|
||||
repo = "repo:",
|
||||
r = "r:",
|
||||
lang = "lang:",
|
||||
|
|
@ -18,162 +18,10 @@ enum SearchPrefix {
|
|||
archived = "archived:",
|
||||
case = "case:",
|
||||
fork = "fork:",
|
||||
public = "public:"
|
||||
public = "public:",
|
||||
context = "context:",
|
||||
}
|
||||
|
||||
const negate = (prefix: SearchPrefix) => {
|
||||
return `-${prefix}`;
|
||||
}
|
||||
|
||||
type SuggestionModeMapping = {
|
||||
suggestionMode: SuggestionMode,
|
||||
prefixes: string[],
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps search prefixes to a suggestion mode. When a query starts
|
||||
* with a prefix, the corresponding suggestion mode is enabled.
|
||||
* @see [searchSuggestionsBox.tsx](./searchSuggestionsBox.tsx)
|
||||
*/
|
||||
export const suggestionModeMappings: SuggestionModeMapping[] = [
|
||||
{
|
||||
suggestionMode: "repo",
|
||||
prefixes: [
|
||||
SearchPrefix.repo, negate(SearchPrefix.repo),
|
||||
SearchPrefix.r, negate(SearchPrefix.r),
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "language",
|
||||
prefixes: [
|
||||
SearchPrefix.lang, negate(SearchPrefix.lang),
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "file",
|
||||
prefixes: [
|
||||
SearchPrefix.file, negate(SearchPrefix.file),
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "content",
|
||||
prefixes: [
|
||||
SearchPrefix.content, negate(SearchPrefix.content),
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "revision",
|
||||
prefixes: [
|
||||
SearchPrefix.rev, negate(SearchPrefix.rev),
|
||||
SearchPrefix.revision, negate(SearchPrefix.revision),
|
||||
SearchPrefix.branch, negate(SearchPrefix.branch),
|
||||
SearchPrefix.b, negate(SearchPrefix.b),
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "symbol",
|
||||
prefixes: [
|
||||
SearchPrefix.sym, negate(SearchPrefix.sym),
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "archived",
|
||||
prefixes: [
|
||||
SearchPrefix.archived
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "case",
|
||||
prefixes: [
|
||||
SearchPrefix.case
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "fork",
|
||||
prefixes: [
|
||||
SearchPrefix.fork
|
||||
]
|
||||
},
|
||||
{
|
||||
suggestionMode: "public",
|
||||
prefixes: [
|
||||
SearchPrefix.public
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const refineModeSuggestions: Suggestion[] = [
|
||||
{
|
||||
value: SearchPrefix.repo,
|
||||
description: "Include only results from the given repository.",
|
||||
spotlight: true,
|
||||
},
|
||||
{
|
||||
value: negate(SearchPrefix.repo),
|
||||
description: "Exclude results from the given repository."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.lang,
|
||||
description: "Include only results from the given language.",
|
||||
spotlight: true,
|
||||
},
|
||||
{
|
||||
value: negate(SearchPrefix.lang),
|
||||
description: "Exclude results from the given language."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.file,
|
||||
description: "Include only results from filepaths matching the given search pattern.",
|
||||
spotlight: true,
|
||||
},
|
||||
{
|
||||
value: negate(SearchPrefix.file),
|
||||
description: "Exclude results from file paths matching the given search pattern."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.rev,
|
||||
description: "Search a given branch or tag instead of the default branch.",
|
||||
spotlight: true,
|
||||
},
|
||||
{
|
||||
value: negate(SearchPrefix.rev),
|
||||
description: "Exclude results from the given branch or tag."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.sym,
|
||||
description: "Include only symbols matching the given search pattern.",
|
||||
spotlight: true,
|
||||
},
|
||||
{
|
||||
value: negate(SearchPrefix.sym),
|
||||
description: "Exclude results from symbols matching the given search pattern."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.content,
|
||||
description: "Include only results from files if their content matches the given search pattern."
|
||||
},
|
||||
{
|
||||
value: negate(SearchPrefix.content),
|
||||
description: "Exclude results from files if their content matches the given search pattern."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.archived,
|
||||
description: "Include results from archived repositories.",
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.case,
|
||||
description: "Control case-sensitivity of search patterns."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.fork,
|
||||
description: "Include only results from forked repositories."
|
||||
},
|
||||
{
|
||||
value: SearchPrefix.public,
|
||||
description: "Filter on repository visibility."
|
||||
},
|
||||
];
|
||||
|
||||
export const publicModeSuggestions: Suggestion[] = [
|
||||
{
|
||||
value: "yes",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
caseModeSuggestions,
|
||||
forkModeSuggestions,
|
||||
publicModeSuggestions,
|
||||
refineModeSuggestions,
|
||||
} from "./constants";
|
||||
import { IconType } from "react-icons/lib";
|
||||
import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc";
|
||||
|
|
@ -18,6 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { KeyboardShortcutHint } from "../keyboardShortcutHint";
|
||||
import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider";
|
||||
import { useRefineModeSuggestions } from "./useRefineModeSuggestions";
|
||||
|
||||
export type Suggestion = {
|
||||
value: string;
|
||||
|
|
@ -39,7 +39,8 @@ export type SuggestionMode =
|
|||
"symbol" |
|
||||
"content" |
|
||||
"repo" |
|
||||
"searchHistory";
|
||||
"searchHistory" |
|
||||
"context";
|
||||
|
||||
interface SearchSuggestionsBoxProps {
|
||||
query: string;
|
||||
|
|
@ -59,6 +60,7 @@ interface SearchSuggestionsBoxProps {
|
|||
symbolSuggestions: Suggestion[];
|
||||
languageSuggestions: Suggestion[];
|
||||
searchHistorySuggestions: Suggestion[];
|
||||
searchContextSuggestions: Suggestion[];
|
||||
}
|
||||
|
||||
const SearchSuggestionsBox = forwardRef(({
|
||||
|
|
@ -78,9 +80,11 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
symbolSuggestions,
|
||||
languageSuggestions,
|
||||
searchHistorySuggestions,
|
||||
searchContextSuggestions,
|
||||
}: SearchSuggestionsBoxProps, ref: Ref<HTMLDivElement>) => {
|
||||
const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0);
|
||||
const { onOpenChanged } = useSyntaxGuide();
|
||||
const refineModeSuggestions = useRefineModeSuggestions();
|
||||
|
||||
const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => {
|
||||
if (!isEnabled) {
|
||||
|
|
@ -198,6 +202,13 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
},
|
||||
descriptionPlacement: "right",
|
||||
}
|
||||
case "context":
|
||||
return {
|
||||
list: searchContextSuggestions,
|
||||
onSuggestionClicked: createOnSuggestionClickedHandler(),
|
||||
descriptionPlacement: "left",
|
||||
DefaultIcon: VscFilter,
|
||||
}
|
||||
case "none":
|
||||
case "revision":
|
||||
case "content":
|
||||
|
|
@ -263,6 +274,7 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
symbolSuggestions,
|
||||
searchHistorySuggestions,
|
||||
languageSuggestions,
|
||||
searchContextSuggestions,
|
||||
]);
|
||||
|
||||
// When the list of suggestions change, reset the highlight index
|
||||
|
|
@ -287,6 +299,8 @@ const SearchSuggestionsBox = forwardRef(({
|
|||
return "Languages";
|
||||
case "searchHistory":
|
||||
return "Search history"
|
||||
case "context":
|
||||
return "Search contexts"
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { splitQuery, SuggestionMode } from "./searchSuggestionsBox";
|
||||
import { suggestionModeMappings } from "./constants";
|
||||
import { useSuggestionModeMappings } from "./useSuggestionModeMappings";
|
||||
|
||||
interface Props {
|
||||
isSuggestionsEnabled: boolean;
|
||||
|
|
@ -18,6 +18,8 @@ export const useSuggestionModeAndQuery = ({
|
|||
query,
|
||||
}: Props) => {
|
||||
|
||||
const suggestionModeMappings = useSuggestionModeMappings();
|
||||
|
||||
const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => {
|
||||
// When suggestions are not enabled, fallback to using a sentinal
|
||||
// suggestion mode of "none".
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Suggestion, SuggestionMode } from "./searchSuggestionsBox";
|
||||
import { getRepos, search } from "@/app/api/(client)/client";
|
||||
import { getSearchContexts } from "@/actions";
|
||||
import { useMemo } from "react";
|
||||
import { Symbol } from "@/lib/types";
|
||||
import { languageMetadataMap } from "@/lib/languageMetadata";
|
||||
|
|
@ -18,7 +19,7 @@ import {
|
|||
VscSymbolVariable
|
||||
} from "react-icons/vsc";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
import { getDisplayTime } from "@/lib/utils";
|
||||
import { getDisplayTime, isServiceError } from "@/lib/utils";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
|
||||
|
|
@ -56,6 +57,10 @@ export const useSuggestionsData = ({
|
|||
maxMatchDisplayCount: 15,
|
||||
}, domain),
|
||||
select: (data): Suggestion[] => {
|
||||
if (isServiceError(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.Result.Files?.map((file) => ({
|
||||
value: file.FileName
|
||||
})) ?? [];
|
||||
|
|
@ -71,6 +76,10 @@ export const useSuggestionsData = ({
|
|||
maxMatchDisplayCount: 15,
|
||||
}, domain),
|
||||
select: (data): Suggestion[] => {
|
||||
if (isServiceError(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []);
|
||||
if (!symbols) {
|
||||
return [];
|
||||
|
|
@ -89,6 +98,24 @@ export const useSuggestionsData = ({
|
|||
});
|
||||
const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]);
|
||||
|
||||
const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({
|
||||
queryKey: ["searchContexts"],
|
||||
queryFn: () => getSearchContexts(domain),
|
||||
select: (data): Suggestion[] => {
|
||||
if (isServiceError(data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.map((context) => ({
|
||||
value: context.name,
|
||||
description: context.description,
|
||||
}));
|
||||
|
||||
},
|
||||
enabled: suggestionMode === "context",
|
||||
});
|
||||
const isLoadingSearchContexts = useMemo(() => suggestionMode === "context" && _isLoadingSearchContexts, [_isLoadingSearchContexts, suggestionMode]);
|
||||
|
||||
const languageSuggestions = useMemo((): Suggestion[] => {
|
||||
return Object.keys(languageMetadataMap).map((lang) => {
|
||||
const spotlight = [
|
||||
|
|
@ -116,13 +143,14 @@ export const useSuggestionsData = ({
|
|||
}, [searchHistory]);
|
||||
|
||||
const isLoadingSuggestions = useMemo(() => {
|
||||
return isLoadingSymbols || isLoadingFiles || isLoadingRepos;
|
||||
}, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]);
|
||||
return isLoadingSymbols || isLoadingFiles || isLoadingRepos || isLoadingSearchContexts;
|
||||
}, [isLoadingFiles, isLoadingRepos, isLoadingSymbols, isLoadingSearchContexts]);
|
||||
|
||||
return {
|
||||
repoSuggestions: repoSuggestions ?? [],
|
||||
fileSuggestions: fileSuggestions ?? [],
|
||||
symbolSuggestions: symbolSuggestions ?? [],
|
||||
searchContextSuggestions: searchContextSuggestions ?? [],
|
||||
languageSuggestions,
|
||||
searchHistorySuggestions,
|
||||
isLoadingSuggestions,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export const zoekt = () => {
|
|||
|
||||
// Check for prefixes first
|
||||
// If these match, we return 'keyword'
|
||||
if (stream.match(/(archived:|branch:|b:|rev:|c:|case:|content:|f:|file:|fork:|public:|r:|repo:|regex:|lang:|sym:|t:|type:)/)) {
|
||||
if (stream.match(/(archived:|branch:|b:|rev:|c:|case:|content:|f:|file:|fork:|public:|r:|repo:|regex:|lang:|sym:|t:|type:|context:)/)) {
|
||||
return t.keyword.toString();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { auth } from "@/auth";
|
|||
import { getOrgFromDomain } from "@/data/org";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { OnboardGuard } from "./components/onboardGuard";
|
||||
import { fetchSubscription } from "@/actions";
|
||||
import { UpgradeGuard } from "./components/upgradeGuard";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { getSelectorsByUserAgent } from "react-device-detect";
|
||||
|
|
@ -11,9 +10,11 @@ import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSpl
|
|||
import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants";
|
||||
import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide";
|
||||
import { SyntaxGuideProvider } from "./components/syntaxGuideProvider";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
import { env } from "@/env.mjs";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode,
|
||||
params: { domain: string }
|
||||
|
|
@ -58,7 +59,7 @@ export default async function Layout({
|
|||
}
|
||||
|
||||
if (IS_BILLING_ENABLED) {
|
||||
const subscription = await fetchSubscription(domain);
|
||||
const subscription = await getSubscriptionInfo(domain);
|
||||
if (
|
||||
subscription &&
|
||||
(
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { notFound, redirect } from "next/navigation";
|
|||
import { ConnectCodeHost } from "./components/connectCodeHost";
|
||||
import { InviteTeam } from "./components/inviteTeam";
|
||||
import { CompleteOnboarding } from "./components/completeOnboarding";
|
||||
import { Checkout } from "./components/checkout";
|
||||
import { Checkout } from "@/ee/features/billing/components/checkout";
|
||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
interface OnboardProps {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|||
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||
import { useSearchHistory } from "@/hooks/useSearchHistory";
|
||||
import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types";
|
||||
import { createPathWithQueryParams, measure } from "@/lib/utils";
|
||||
import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils";
|
||||
import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -22,6 +22,7 @@ import { CodePreviewPanel } from "./components/codePreviewPanel";
|
|||
import { FilterPanel } from "./components/filterPanel";
|
||||
import { SearchResultsPanel } from "./components/searchResultsPanel";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
|
||||
const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000;
|
||||
|
||||
|
|
@ -44,21 +45,31 @@ const SearchPageInternal = () => {
|
|||
const { setSearchHistory } = useSearchHistory();
|
||||
const captureEvent = useCaptureEvent();
|
||||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: searchResponse, isLoading } = useQuery({
|
||||
const { data: searchResponse, isLoading, error } = useQuery({
|
||||
queryKey: ["search", searchQuery, maxMatchDisplayCount],
|
||||
queryFn: () => measure(() => search({
|
||||
queryFn: () => measure(() => unwrapServiceError(search({
|
||||
query: searchQuery,
|
||||
maxMatchDisplayCount,
|
||||
}, domain), "client.search"),
|
||||
}, domain)), "client.search"),
|
||||
select: ({ data, durationMs }) => ({
|
||||
...data,
|
||||
durationMs,
|
||||
}),
|
||||
enabled: searchQuery.length > 0,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast({
|
||||
description: `❌ Search failed. Reason: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
|
||||
// Write the query to the search history
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import type { Metadata } from "next"
|
||||
import { CalendarIcon, DollarSign, Users } from "lucide-react"
|
||||
import { getCurrentUserRole } from "@/actions"
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ManageSubscriptionButton } from "./manageSubscriptionButton"
|
||||
import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
import { ChangeBillingEmailCard } from "./changeBillingEmailCard"
|
||||
import { notFound } from "next/navigation"
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe"
|
||||
import { getSubscriptionBillingEmail, getSubscriptionInfo } from "@/ee/features/billing/actions"
|
||||
import { ChangeBillingEmailCard } from "@/ee/features/billing/components/changeBillingEmailCard"
|
||||
import { ManageSubscriptionButton } from "@/ee/features/billing/components/manageSubscriptionButton"
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"
|
||||
import { ServiceErrorException } from "@/lib/serviceError"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
import { CalendarIcon, DollarSign, Users } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Billing | Settings",
|
||||
description: "Manage your subscription and billing information",
|
||||
|
|
@ -26,7 +28,7 @@ export default async function BillingPage({
|
|||
notFound();
|
||||
}
|
||||
|
||||
const subscription = await getSubscriptionData(domain)
|
||||
const subscription = await getSubscriptionInfo(domain)
|
||||
|
||||
if (isServiceError(subscription)) {
|
||||
throw new ServiceErrorException(subscription);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Metadata } from "next"
|
|||
import { SidebarNav } from "./components/sidebar-nav"
|
||||
import { NavigationMenu } from "../components/navigationMenu"
|
||||
import { Header } from "./components/header";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs";
|
|||
import { TabSwitcher } from "@/components/ui/tab-switcher";
|
||||
import { InvitesList } from "./components/invitesList";
|
||||
import { getOrgInvites, getMe } from "@/actions";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
import { ServiceErrorException } from "@/lib/serviceError";
|
||||
interface MembersSettingsPageProps {
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { Footer } from "@/app/components/footer";
|
||||
import { OrgSelector } from "../components/orgSelector";
|
||||
import { EnterpriseUpgradeCard } from "./components/enterpriseUpgradeCard";
|
||||
import { TeamUpgradeCard } from "./components/teamUpgradeCard";
|
||||
import { fetchSubscription } from "@/actions";
|
||||
import { EnterpriseUpgradeCard } from "@/ee/features/billing/components/enterpriseUpgradeCard";
|
||||
import { TeamUpgradeCard } from "@/ee/features/billing/components/teamUpgradeCard";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeftIcon } from "@radix-ui/react-icons";
|
||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||
import { env } from "@/env.mjs";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
import { getSubscriptionInfo } from "@/ee/features/billing/actions";
|
||||
|
||||
export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) {
|
||||
if (!IS_BILLING_ENABLED) {
|
||||
redirect(`/${domain}`);
|
||||
}
|
||||
|
||||
const subscription = await fetchSubscription(domain);
|
||||
const subscription = await getSubscriptionInfo(domain);
|
||||
if (!subscription) {
|
||||
redirect(`/${domain}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas";
|
||||
import { ServiceError } from "@/lib/serviceError";
|
||||
import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
||||
export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse> => {
|
||||
export const search = async (body: SearchRequest, domain: string): Promise<SearchResponse | ServiceError> => {
|
||||
const result = await fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -13,6 +15,10 @@ export const search = async (body: SearchRequest, domain: string): Promise<Searc
|
|||
body: JSON.stringify(body),
|
||||
}).then(response => response.json());
|
||||
|
||||
if (isServiceError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return searchResponseSchema.parse(result);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { NextRequest } from 'next/server';
|
|||
import Stripe from 'stripe';
|
||||
import { prisma } from '@/prisma';
|
||||
import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db';
|
||||
import { stripeClient } from '@/lib/stripe';
|
||||
import { stripeClient } from '@/ee/features/billing/stripe';
|
||||
import { env } from '@/env.mjs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import Link from "next/link"
|
||||
import { Shield, Lock, CheckCircle, ExternalLink, Mail } from "lucide-react"
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent"
|
||||
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"
|
||||
|
||||
export default function SecurityCard() {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
|
@ -62,7 +63,7 @@ export default function SecurityCard() {
|
|||
<div className="flex flex-row justify-center text-sm text-[#A1A1AA] mb-5">
|
||||
Have questions?
|
||||
<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"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 mr-1" />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { serviceErrorSchema } from '@/lib/serviceError';
|
||||
import { SourcebotLogo } from './components/sourcebotLogo';
|
||||
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) {
|
||||
useEffect(() => {
|
||||
|
|
@ -76,7 +77,7 @@ function ErrorCard({ message, errorCode, statusCode, onReloadButtonClicked }: Er
|
|||
Unexpected Error
|
||||
</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { Toaster } from "@/components/ui/toaster";
|
|||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { env } from "@/env.mjs";
|
||||
import { PlanProvider } from "@/features/entitlements/planProvider";
|
||||
import { getPlan } from "@/features/entitlements/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sourcebot",
|
||||
|
|
@ -27,6 +29,7 @@ export default function RootLayout({
|
|||
<body>
|
||||
<Toaster />
|
||||
<SessionProvider>
|
||||
<PlanProvider plan={getPlan()}>
|
||||
<PostHogProvider disabled={env.SOURCEBOT_TELEMETRY_DISABLED === "true"}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
|
@ -41,6 +44,7 @@ export default function RootLayout({
|
|||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
</PlanProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import VerificationFailed from "./verificationFailed"
|
|||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent"
|
||||
import { Footer } from "@/app/components/footer"
|
||||
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"
|
||||
|
||||
function VerifyPageContent() {
|
||||
const [value, setValue] = useState("")
|
||||
|
|
@ -89,7 +90,7 @@ function VerifyPageContent() {
|
|||
<div className="mt-8 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
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
|
||||
</a>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"
|
|||
import { AlertCircle } from "lucide-react"
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"
|
||||
|
||||
export default function VerificationFailed() {
|
||||
const router = useRouter()
|
||||
|
|
@ -34,7 +35,7 @@ export default function VerificationFailed() {
|
|||
<a href="https://www.sourcebot.dev" className="hover:text-gray-300">
|
||||
About
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||
import { OnboardingSteps } from "@/lib/constants";
|
||||
import { IS_BILLING_ENABLED } from "@/lib/stripe";
|
||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||
|
||||
interface OnboardHeaderProps {
|
||||
title: string
|
||||
|
|
|
|||
279
packages/web/src/ee/features/billing/actions.ts
Normal file
279
packages/web/src/ee/features/billing/actions.ts
Normal 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!,
|
||||
}
|
||||
})
|
||||
));
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
"use client"
|
||||
|
||||
import { changeSubscriptionBillingEmail } from "@/actions"
|
||||
import { useToast } from "@/components/hooks/use-toast"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { changeSubscriptionBillingEmail } from "@/ee/features/billing/actions"
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent"
|
||||
import { useDomain } from "@/hooks/useDomain"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { createOnboardingSubscription } from "@/actions";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,6 +12,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingSteps, TEAM_FEATURES } from "@/lib/constants";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { createOnboardingSubscription } from "../actions";
|
||||
|
||||
export const Checkout = () => {
|
||||
const domain = useDomain();
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { ENTERPRISE_FEATURES } from "@/lib/constants";
|
||||
import { ENTERPRISE_FEATURES, SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
|
||||
import { UpgradeCard } from "./upgradeCard";
|
||||
import Link from "next/link";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
|
@ -14,7 +14,7 @@ export const EnterpriseUpgradeCard = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Link href="mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry">
|
||||
<Link href={`mailto:${SOURCEBOT_SUPPORT_EMAIL}?subject=Enterprise%20Pricing%20Inquiry`}>
|
||||
<UpgradeCard
|
||||
title="Enterprise"
|
||||
description="For large organizations with custom needs."
|
||||
|
|
@ -4,11 +4,11 @@ import { useState } from "react"
|
|||
import { useRouter } from "next/navigation"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getCustomerPortalSessionLink } from "@/actions"
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { OrgRole } from "@sourcebot/db";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { ExternalLink, Loader2 } from "lucide-react";
|
||||
import { getCustomerPortalSessionLink } from "@/ee/features/billing/actions"
|
||||
|
||||
export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { UpgradeCard } from "./upgradeCard";
|
||||
import { createStripeCheckoutSession } from "@/actions";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
|
@ -9,6 +8,7 @@ import { useCallback, useState } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { TEAM_FEATURES } from "@/lib/constants";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { createStripeCheckoutSession } from "../actions";
|
||||
|
||||
interface TeamUpgradeCardProps {
|
||||
buttonText: string;
|
||||
80
packages/web/src/ee/features/billing/serverUtils.ts
Normal file
80
packages/web/src/ee/features/billing/serverUtils.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import 'server-only';
|
||||
import { env } from '@/env.mjs'
|
||||
import Stripe from "stripe";
|
||||
import { hasEntitlement } from '@/features/entitlements/server';
|
||||
|
||||
export const IS_BILLING_ENABLED = env.STRIPE_SECRET_KEY !== undefined;
|
||||
export const IS_BILLING_ENABLED = hasEntitlement('billing') && env.STRIPE_SECRET_KEY !== undefined;
|
||||
|
||||
export const stripeClient =
|
||||
IS_BILLING_ENABLED
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,9 @@ export const env = createEnv({
|
|||
|
||||
// Misc UI flags
|
||||
SECURITY_CARD_ENABLED: booleanSchema.default('false'),
|
||||
|
||||
// EE License
|
||||
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
|
||||
},
|
||||
// @NOTE: Please make sure of the following:
|
||||
// - Make sure you destructure all client variables in
|
||||
|
|
|
|||
8
packages/web/src/features/entitlements/README.md
Normal file
8
packages/web/src/features/entitlements/README.md
Normal 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`.
|
||||
20
packages/web/src/features/entitlements/constants.ts
Normal file
20
packages/web/src/features/entitlements/constants.ts
Normal 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;
|
||||
21
packages/web/src/features/entitlements/planProvider.tsx
Normal file
21
packages/web/src/features/entitlements/planProvider.tsx
Normal 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>
|
||||
)
|
||||
};
|
||||
53
packages/web/src/features/entitlements/server.ts
Normal file
53
packages/web/src/features/entitlements/server.ts
Normal 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);
|
||||
}
|
||||
10
packages/web/src/features/entitlements/useHasEntitlement.ts
Normal file
10
packages/web/src/features/entitlements/useHasEntitlement.ts
Normal 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);
|
||||
}
|
||||
7
packages/web/src/features/entitlements/usePlan.ts
Normal file
7
packages/web/src/features/entitlements/usePlan.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { useContext } from "react";
|
||||
import { PlanContext } from "./planProvider";
|
||||
|
||||
export const usePlan = () => {
|
||||
const plan = useContext(PlanContext);
|
||||
return plan;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { SourcebotConfig } from "@sourcebot/schemas/v3/index.type";
|
|||
import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||
import { indexSchema } from '@sourcebot/schemas/v3/index.schema';
|
||||
import Ajv from 'ajv';
|
||||
import { syncSearchContexts } from '@/ee/features/searchContexts/syncSearchContexts';
|
||||
|
||||
const ajv = new Ajv({
|
||||
validateFormats: false,
|
||||
|
|
@ -22,29 +23,9 @@ const isRemotePath = (path: string) => {
|
|||
return path.startsWith('https://') || path.startsWith('http://');
|
||||
}
|
||||
|
||||
const scheduleDeclarativeConfigSync = async (configPath: string) => {
|
||||
const configContent = await (async () => {
|
||||
if (isRemotePath(configPath)) {
|
||||
const response = await fetch(configPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config file ${configPath}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
} else {
|
||||
return readFile(configPath, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
const config = JSON.parse(stripJsonComments(configContent)) as SourcebotConfig;
|
||||
const isValidConfig = ajv.validate(indexSchema, config);
|
||||
if (!isValidConfig) {
|
||||
throw new Error(`Config file '${configPath}' is invalid: ${ajv.errorsText(ajv.errors)}`);
|
||||
}
|
||||
|
||||
if (config.connections) {
|
||||
for (const [key, newConnectionConfig] of Object.entries(config.connections)) {
|
||||
const syncConnections = async (connections?: { [key: string]: ConnectionConfig }) => {
|
||||
if (connections) {
|
||||
for (const [key, newConnectionConfig] of Object.entries(connections)) {
|
||||
const currentConnection = await prisma.connection.findUnique({
|
||||
where: {
|
||||
name_orgId: {
|
||||
|
|
@ -108,12 +89,14 @@ const scheduleDeclarativeConfigSync = async (configPath: string) => {
|
|||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete any connections that are no longer in the config.
|
||||
const deletedConnections = await prisma.connection.findMany({
|
||||
where: {
|
||||
isDeclarative: true,
|
||||
name: {
|
||||
notIn: Object.keys(config.connections),
|
||||
notIn: Object.keys(connections ?? {}),
|
||||
},
|
||||
orgId: SINGLE_TENANT_ORG_ID,
|
||||
}
|
||||
|
|
@ -127,7 +110,31 @@ const scheduleDeclarativeConfigSync = async (configPath: string) => {
|
|||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const syncDeclarativeConfig = async (configPath: string) => {
|
||||
const configContent = await (async () => {
|
||||
if (isRemotePath(configPath)) {
|
||||
const response = await fetch(configPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch config file ${configPath}: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
} else {
|
||||
return readFile(configPath, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
const config = JSON.parse(stripJsonComments(configContent)) as SourcebotConfig;
|
||||
const isValidConfig = ajv.validate(indexSchema, config);
|
||||
if (!isValidConfig) {
|
||||
throw new Error(`Config file '${configPath}' is invalid: ${ajv.errorsText(ajv.errors)}`);
|
||||
}
|
||||
|
||||
await syncConnections(config.connections);
|
||||
await syncSearchContexts(config.contexts);
|
||||
}
|
||||
|
||||
const initSingleTenancy = async () => {
|
||||
|
|
@ -186,13 +193,13 @@ const initSingleTenancy = async () => {
|
|||
// Load any connections defined declaratively in the config file.
|
||||
const configPath = env.CONFIG_PATH;
|
||||
if (configPath) {
|
||||
await scheduleDeclarativeConfigSync(configPath);
|
||||
await syncDeclarativeConfig(configPath);
|
||||
|
||||
// watch for changes assuming it is a local file
|
||||
if (!isRemotePath(configPath)) {
|
||||
watch(configPath, () => {
|
||||
console.log(`Config file ${configPath} changed. Re-syncing...`);
|
||||
scheduleDeclarativeConfigSync(configPath);
|
||||
syncDeclarativeConfig(configPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_DOMAIN = '~';
|
||||
export const SINGLE_TENANT_ORG_NAME = 'default';
|
||||
|
||||
export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';
|
||||
|
|
@ -22,4 +22,5 @@ export enum ErrorCode {
|
|||
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
|
||||
STRIPE_CLIENT_NOT_INITIALIZED = 'STRIPE_CLIENT_NOT_INITIALIZED',
|
||||
ACTION_DISALLOWED_IN_TENANCY_MODE = 'ACTION_DISALLOWED_IN_TENANCY_MODE',
|
||||
SEARCH_CONTEXT_NOT_FOUND = 'SEARCH_CONTEXT_NOT_FOUND',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { checkIfOrgDomainExists } from "@/actions";
|
|||
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||
import { z } from "zod";
|
||||
import { isServiceError } from "./utils";
|
||||
|
||||
export const searchRequestSchema = z.object({
|
||||
query: z.string(),
|
||||
maxMatchDisplayCount: z.number(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, Search
|
|||
import { fileNotFound, invalidZoektResponse, ServiceError, unexpectedError } from "../serviceError";
|
||||
import { isServiceError } from "../utils";
|
||||
import { zoektFetch } from "./zoektClient";
|
||||
import { prisma } from "@/prisma";
|
||||
import { ErrorCode } from "../errorCodes";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
// List of supported query prefixes in zoekt.
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
||||
|
|
@ -26,20 +29,68 @@ enum zoektPrefixes {
|
|||
sym = "sym:",
|
||||
typeShort = "t:",
|
||||
type = "type:",
|
||||
reposet = "reposet:",
|
||||
}
|
||||
|
||||
// Mapping of additional "alias" prefixes to zoekt prefixes.
|
||||
const aliasPrefixMappings: Record<string, zoektPrefixes> = {
|
||||
"rev:": zoektPrefixes.branch,
|
||||
"revision:": zoektPrefixes.branch,
|
||||
}
|
||||
const transformZoektQuery = async (query: string, orgId: number): Promise<string | ServiceError> => {
|
||||
const prevQueryParts = query.split(" ");
|
||||
const newQueryParts = [];
|
||||
|
||||
export const search = async ({ query, maxMatchDisplayCount, whole}: SearchRequest, orgId: number): Promise<SearchResponse | ServiceError> => {
|
||||
// Replace any alias prefixes with their corresponding zoekt prefixes.
|
||||
for (const [prefix, zoektPrefix] of Object.entries(aliasPrefixMappings)) {
|
||||
query = query.replaceAll(prefix, zoektPrefix);
|
||||
for (const part of prevQueryParts) {
|
||||
|
||||
// Handle mapping `rev:` and `revision:` to `branch:`
|
||||
if (part.match(/^-?(rev|revision):.+$/)) {
|
||||
const isNegated = part.startsWith("-");
|
||||
const revisionName = part.slice(part.indexOf(":") + 1);
|
||||
newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.branch}${revisionName}`);
|
||||
}
|
||||
|
||||
// Expand `context:` into `reposet:` atom.
|
||||
else if (part.match(/^-?context:.+$/)) {
|
||||
const isNegated = part.startsWith("-");
|
||||
const contextName = part.slice(part.indexOf(":") + 1);
|
||||
|
||||
const context = await prisma.searchContext.findUnique({
|
||||
where: {
|
||||
name_orgId: {
|
||||
name: contextName,
|
||||
orgId,
|
||||
}
|
||||
},
|
||||
include: {
|
||||
repos: true,
|
||||
}
|
||||
});
|
||||
|
||||
// If the context doesn't exist, return an error.
|
||||
if (!context) {
|
||||
return {
|
||||
errorCode: ErrorCode.SEARCH_CONTEXT_NOT_FOUND,
|
||||
message: `Search context "${contextName}" not found`,
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
const names = context.repos.map((repo) => repo.name);
|
||||
newQueryParts.push(`${isNegated ? "-" : ""}${zoektPrefixes.reposet}${names.join(",")}`);
|
||||
}
|
||||
|
||||
// no-op: add the original part to the new query parts.
|
||||
else {
|
||||
newQueryParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return newQueryParts.join(" ");
|
||||
}
|
||||
|
||||
export const search = async ({ query, maxMatchDisplayCount, whole }: SearchRequest, orgId: number): Promise<SearchResponse | ServiceError> => {
|
||||
const transformedQuery = await transformZoektQuery(query, orgId);
|
||||
if (isServiceError(transformedQuery)) {
|
||||
return transformedQuery;
|
||||
}
|
||||
query = transformedQuery;
|
||||
|
||||
const isBranchFilteringEnabled = (
|
||||
query.includes(zoektPrefixes.branch) ||
|
||||
query.includes(zoektPrefixes.branchShort)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
"type": "number",
|
||||
"description": "The maximum size of a file (in bytes) to be indexed. Files that exceed this maximum will not be indexed. Defaults to 2MB.",
|
||||
"minimum": 1
|
||||
|
||||
},
|
||||
"maxTrigramCount": {
|
||||
"type": "number",
|
||||
|
|
@ -65,6 +64,46 @@
|
|||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SearchContext": {
|
||||
"type": "object",
|
||||
"description": "Search context",
|
||||
"properties": {
|
||||
"include": {
|
||||
"type": "array",
|
||||
"description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": [
|
||||
[
|
||||
"github.com/sourcebot-dev/**",
|
||||
"gerrit.example.org/sub/path/**"
|
||||
]
|
||||
]
|
||||
},
|
||||
"exclude": {
|
||||
"type": "array",
|
||||
"description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": [
|
||||
[
|
||||
"github.com/sourcebot-dev/sourcebot",
|
||||
"gerrit.example.org/sub/path/**"
|
||||
]
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description of the search context that surfaces in the UI."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"include"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
|
|
@ -74,6 +113,16 @@
|
|||
"settings": {
|
||||
"$ref": "#/definitions/Settings"
|
||||
},
|
||||
"contexts": {
|
||||
"type": "object",
|
||||
"description": "[Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/self-hosting/more/search-contexts",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9_-]+$": {
|
||||
"$ref": "#/definitions/SearchContext"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"connections": {
|
||||
"type": "object",
|
||||
"description": "Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode.",
|
||||
|
|
|
|||
2
vendor/zoekt
vendored
2
vendor/zoekt
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit cf456394003dd9bfc9a885fdfcc8cc80230a261d
|
||||
Subproject commit 7d1896215eea6f97af66c9549c9ec70436356b51
|
||||
|
|
@ -5298,6 +5298,7 @@ __metadata:
|
|||
"@tanstack/react-query": "npm:^5.53.3"
|
||||
"@tanstack/react-table": "npm:^8.20.5"
|
||||
"@tanstack/react-virtual": "npm:^3.10.8"
|
||||
"@types/micromatch": "npm:^4.0.9"
|
||||
"@types/node": "npm:^20"
|
||||
"@types/nodemailer": "npm:^6.4.17"
|
||||
"@types/psl": "npm:^1.1.3"
|
||||
|
|
@ -5344,6 +5345,7 @@ __metadata:
|
|||
input-otp: "npm:^1.4.2"
|
||||
jsdom: "npm:^25.0.1"
|
||||
lucide-react: "npm:^0.435.0"
|
||||
micromatch: "npm:^4.0.8"
|
||||
next: "npm:14.2.25"
|
||||
next-auth: "npm:^5.0.0-beta.25"
|
||||
next-themes: "npm:^0.3.0"
|
||||
|
|
|
|||
Loading…
Reference in a new issue