diff --git a/.cursor/rules/style.mdc b/.cursor/rules/style.mdc new file mode 100644 index 00000000..6d3e8046 --- /dev/null +++ b/.cursor/rules/style.mdc @@ -0,0 +1,7 @@ +--- +description: +globs: +alwaysApply: true +--- +- Always use 4 spaces for indentation +- Filenames should always be camelCase. Exception: if there are filenames in the same directory with a format other than camelCase, use that format to keep things consistent. \ No newline at end of file diff --git a/.env.development b/.env.development index f4ca1600..0309b5fb 100644 --- a/.env.development +++ b/.env.development @@ -18,12 +18,13 @@ SRC_TENANT_ENFORCEMENT_MODE=strict AUTH_SECRET="00000000000000000000000000000000000000000000" AUTH_URL="http://localhost:3000" # AUTH_CREDENTIALS_LOGIN_ENABLED=true -# AUTH_GITHUB_CLIENT_ID="" -# AUTH_GITHUB_CLIENT_SECRET="" -# AUTH_GOOGLE_CLIENT_ID="" -# AUTH_GOOGLE_CLIENT_SECRET="" +# AUTH_EE_GITHUB_CLIENT_ID="" +# AUTH_EE_GITHUB_CLIENT_SECRET="" +# AUTH_EE_GOOGLE_CLIENT_ID="" +# AUTH_EE_GOOGLE_CLIENT_SECRET="" DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) +SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem # CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists) # Email diff --git a/.github/workflows/_gcp-deploy.yml b/.github/workflows/_gcp-deploy.yml index cee812a4..15fde89b 100644 --- a/.github/workflows/_gcp-deploy.yml +++ b/.github/workflows/_gcp-deploy.yml @@ -60,6 +60,8 @@ jobs: NEXT_PUBLIC_SENTRY_ENVIRONMENT=${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} NEXT_PUBLIC_SENTRY_WEBAPP_DSN=${{ vars.NEXT_PUBLIC_SENTRY_WEBAPP_DSN }} NEXT_PUBLIC_SENTRY_BACKEND_DSN=${{ vars.NEXT_PUBLIC_SENTRY_BACKEND_DSN }} + NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY=${{ vars.NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY }} + NEXT_PUBLIC_LANGFUSE_BASE_URL=${{ vars.NEXT_PUBLIC_LANGFUSE_BASE_URL }} SENTRY_SMUAT=${{ secrets.SENTRY_SMUAT }} SENTRY_ORG=${{ vars.SENTRY_ORG }} SENTRY_WEBAPP_PROJECT=${{ vars.SENTRY_WEBAPP_PROJECT }} diff --git a/.github/workflows/changelog-reminder.yml b/.github/workflows/changelog-reminder.yml new file mode 100644 index 00000000..afb68c38 --- /dev/null +++ b/.github/workflows/changelog-reminder.yml @@ -0,0 +1,17 @@ +name: Changelog Reminder + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + remind: + name: Changelog Reminder + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: mskelton/changelog-reminder-action@v3 \ No newline at end of file diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 50884bef..71bc9ef0 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -2,7 +2,7 @@ name: Deploy Demo on: push: - tags: ["v*.*.*"] + branches: ["main"] workflow_dispatch: jobs: diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml deleted file mode 100644 index e9a8e64a..00000000 --- a/.github/workflows/deploy-prod.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Deploy Prod - -on: - push: - tags: ["v*.*.*"] - workflow_dispatch: - -jobs: - deploy-prod: - uses: ./.github/workflows/_gcp-deploy.yml - secrets: inherit - permissions: - contents: 'read' - # Requird for OIDC auth with GCP. - # @see: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings - id-token: 'write' - with: - environment: prod diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml deleted file mode 100644 index 2de85ed1..00000000 --- a/.github/workflows/deploy-staging.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Deploy Staging - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - deploy-staging: - uses: ./.github/workflows/_gcp-deploy.yml - secrets: inherit - permissions: - contents: 'read' - # Requird for OIDC auth with GCP. - # @see: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings - id-token: 'write' - with: - environment: staging diff --git a/.github/workflows/docs-broken-links.yml b/.github/workflows/docs-broken-links.yml new file mode 100644 index 00000000..4e410c49 --- /dev/null +++ b/.github/workflows/docs-broken-links.yml @@ -0,0 +1,26 @@ +name: Check for broken links in docs + +on: + pull_request: + branches: ["main"] + paths: + - "docs/**" + +jobs: + check-links: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.Js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install Mintlify CLI + run: npm i -g mintlify + + - name: Check for broken links + working-directory: docs + run: mintlify broken-links diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b8b551b..df9d91c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,135 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.6.0] - 2025-07-25 + +### Added +- Introducing Ask Sourcebot - ask natural langauge about your codebase. Get back comprehensive Markdown responses with inline citations back to the code. Bring your own LLM api key. [#392](https://github.com/sourcebot-dev/sourcebot/pull/392) + +### Fixed +- Fixed onboarding infinite loop when GCP IAP Auth is enabled. [#381](https://github.com/sourcebot-dev/sourcebot/pull/381) + +## [4.5.3] - 2025-07-20 + +### Changed +- Relicense core to FSL-1.1-ALv2. [#388](https://github.com/sourcebot-dev/sourcebot/pull/388) + +### Added +- Added `GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS` env var to configure the GitLab client's query timeout. [#390](https://github.com/sourcebot-dev/sourcebot/pull/390) + +## [4.5.2] - 2025-07-19 + +### Changed +- Fixed typos in UI, docs, code [#369](https://github.com/sourcebot-dev/sourcebot/pull/369) +- Add anonymous access option to core and deprecate the `enablePublicAccess` config setting. [#385](https://github.com/sourcebot-dev/sourcebot/pull/385) + +## [4.5.1] - 2025-07-14 + +### Changed +- Revamped onboarding experience. [#376](https://github.com/sourcebot-dev/sourcebot/pull/376) + +### Fixed +- Fixed issue with external source code links being broken for paths with spaces. [#364](https://github.com/sourcebot-dev/sourcebot/pull/364) +- Makes base retry indexing configuration configurable and move from a default of `5s` to `60s`. [#377](https://github.com/sourcebot-dev/sourcebot/pull/377) +- Fixed issue where files would sometimes never load in the code browser. [#365](https://github.com/sourcebot-dev/sourcebot/pull/365) + +## [4.5.0] - 2025-06-21 + +### Added +- Added code nav and syntax highlighting for TCL. [#362](https://github.com/sourcebot-dev/sourcebot/pull/362) +- Added analytics dashboard. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358) + +### Fixed +- Fixed issue where invites appeared to be created successfully, but were not actually being created in the database. [#359](https://github.com/sourcebot-dev/sourcebot/pull/359) + +### Changed +- Audit logging is now enabled by default. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358) + +## [4.4.0] - 2025-06-18 + +### Added +- Added audit logging. [#355](https://github.com/sourcebot-dev/sourcebot/pull/355) + + +### Fixed +- Delete account join request when redeeming an invite. [#352](https://github.com/sourcebot-dev/sourcebot/pull/352) +- Fix issue where a repository would not be included in a search context if the context was created before the repository. [#354](https://github.com/sourcebot-dev/sourcebot/pull/354) + +### Changed +- Changed search api (and all apis that depend on it) to return raw source code instead of base64 encoded string. ([356](https://github.com/sourcebot-dev/sourcebot/pull/356)). + + +## [4.3.0] - 2025-06-11 + +### Added +- Changed repository link in search to file tree + move external link to code host logo. [#340](https://github.com/sourcebot-dev/sourcebot/pull/340) +- Added a basic file search dialog when browsing a repository. [#341](https://github.com/sourcebot-dev/sourcebot/pull/341) + +### Fixed +- Text highlighting clarity. [#342](https://github.com/sourcebot-dev/sourcebot/pull/342) +- Fixed repo list column header styling. [#344](https://github.com/sourcebot-dev/sourcebot/pull/344) +- Clean up successful and failed jobs in Redis queues. [#343](https://github.com/sourcebot-dev/sourcebot/pull/343) +- Fixed issue with files occasionally not loading after moving the cursor rapidly over the file browser. [#346](https://github.com/sourcebot-dev/sourcebot/pull/346) + +## [4.2.0] - 2025-06-09 + +### Added +- Added seperate page for signup. [#311](https://github.com/sourcebot-dev/sourcebot/pull/331) +- Fix repo images in authed instance case and add manifest json. [#332](https://github.com/sourcebot-dev/sourcebot/pull/332) +- Added encryption logic for license keys. [#335](https://github.com/sourcebot-dev/sourcebot/pull/335) +- Added hover tooltip for long repo names in filter panel. [#338](https://github.com/sourcebot-dev/sourcebot/pull/338) +- Added repo shard validation on startup. [#339](https://github.com/sourcebot-dev/sourcebot/pull/339) +- Added support for a file explorer when browsing files. [#336](https://github.com/sourcebot-dev/sourcebot/pull/336) + +## [4.1.1] - 2025-06-03 + +### Added +- Added copy button for filenames. [#328](https://github.com/sourcebot-dev/sourcebot/pull/328) +- Added development docker compose file. [#328](https://github.com/sourcebot-dev/sourcebot/pull/328) +- Added keyboard shortcuts for find all refs / go to def. [#329](https://github.com/sourcebot-dev/sourcebot/pull/329) +- Added GCP IAP JIT provisioning. [#330](https://github.com/sourcebot-dev/sourcebot/pull/330) + +### Fixed +- Fixed issue with the symbol hover popover clipping at the top of the page. [#326](https://github.com/sourcebot-dev/sourcebot/pull/326) +- Fixed slow rendering issue with large reference/definition lists. [#327](https://github.com/sourcebot-dev/sourcebot/pull/327) + +## [4.1.0] - 2025-06-02 + +### Added +- Added structured logging support. [#323](https://github.com/sourcebot-dev/sourcebot/pull/323) + +### Fixed +- Fixed issue where new oauth providers weren't being display in the login page. [commit](https://github.com/sourcebot-dev/sourcebot/commit/a2e06266dbe5e5ad4c2c3f730c73d64edecedcf7) +- Fixed client side "mark decorations may not be empty" error when viewing certain files. [#325](https://github.com/sourcebot-dev/sourcebot/pull/325) +- Fixed issue where the symbol hover popover would not appear for large source files. [#325](https://github.com/sourcebot-dev/sourcebot/pull/325) + + +## [4.0.1] - 2025-05-28 + +### Fixed +- Fixed issue with how entitlements are resolved for cloud. [#319](https://github.com/sourcebot-dev/sourcebot/pull/319) + +## [4.0.0] - 2025-05-28 + +Sourcebot V4 introduces authentication, performance improvements and code navigation. Checkout the [migration guide](https://docs.sourcebot.dev/docs/upgrade/v3-to-v4-guide) for information on upgrading your instance to v4. + +### Changed +- [**Breaking Change**] Authentication is now required by default. Notes: + - When setting up your instance, email / password login will be the default authentication provider. + - The first user that logs into the instance is given the `owner` role. ([docs](https://docs.sourcebot.dev/docs/configuration/auth/roles-and-permissions)). + - Subsequent users can request to join the instance. The `owner` can approve / deny requests to join the instance via `Settings` > `Members` > `Pending Requests`. + - If a user is approved to join the instance, they are given the `member` role. + - Additional login providers, including email links and SSO, can be configured with additional environment variables. ([docs](https://docs.sourcebot.dev/docs/configuration/auth/overview)). +- Clicking on a search result now takes you to the `/browse` view. Files can still be previewed by clicking the "Preview" button or holding `Cmd` / `Ctrl` when clicking on a search result. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) + +### Added +- [Sourcebot EE] Added search-based code navigation, allowing you to jump between symbol definition and references when viewing source files. [Read the documentation](https://docs.sourcebot.dev/docs/features/code-navigation). [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) +- Added collapsible filter panel. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) +- Added Sourcebot API key management for external clients. [#311](https://github.com/sourcebot-dev/sourcebot/pull/311) + +### Fixed +- Improved scroll performance for large numbers of search results. [#315](https://github.com/sourcebot-dev/sourcebot/pull/315) + ## [3.2.1] - 2025-05-15 ### Added @@ -15,7 +144,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.2.0] - 2025-05-12 ### Added -- Added AI code review agent [#298](https://github.com/sourcebot-dev/sourcebot/pull/298). Checkout the [docs](https://docs.sourcebot.dev/docs/agents/review-agent) for more information. +- Added AI code review agent [#298](https://github.com/sourcebot-dev/sourcebot/pull/298). Checkout the [docs](https://docs.sourcebot.dev/docs/features/agents/review-agent) for more information. ### Fixed - Fixed issue with repos appearing in the carousel when they fail indexing for the first time. [#305](https://github.com/sourcebot-dev/sourcebot/pull/305) @@ -84,20 +213,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.0.0] - 2025-04-01 -Sourcebot v3 is here and brings a number of structural changes to the tool's foundation, including a SQL database, parallelized indexing, authentication support, multitenancy, and more. Checkout the [migration guide](https://docs.sourcebot.dev/self-hosting/upgrade/v2-to-v3-guide) for information on upgrading your instance to v3. +Sourcebot v3 is here and brings a number of structural changes to the tool's foundation, including a SQL database, parallelized indexing, authentication support, multitenancy, and more. Checkout the [migration guide](https://docs.sourcebot.dev/docs/upgrade/v2-to-v3-guide) for information on upgrading your instance to v3. ### Changed -- [**Breaking Change**] Changed the config schema such that connection objects are specified in the `connection` map, instead of the `repos` array. [See migration guide](https://docs.sourcebot.dev/self-hosting/upgrade/v2-to-v3-guide). +- [**Breaking Change**] Changed the config schema such that connection objects are specified in the `connection` map, instead of the `repos` array. [See migration guide](https://docs.sourcebot.dev/docs/upgrade/v2-to-v3-guide). - Updated the tool's color-palette in dark mode. ### Added -- Added parallelized repo indexing and connection syncing via Redis & BullMQ. See the [architecture overview](https://docs.sourcebot.dev/self-hosting/overview#architecture). +- Added parallelized repo indexing and connection syncing via Redis & BullMQ. See the [architecture overview](https://docs.sourcebot.dev/docs/overview#architecture). - Added repo indexing progress indicators in the navbar. -- Added authentication support via OAuth or email/password. For instructions on enabling, see [this doc](https://docs.sourcebot.dev/self-hosting/more/authentication). -- Added the following UI for managing your deployment when **[auth is enabled](https://docs.sourcebot.dev/self-hosting/more/authentication)**: +- Added authentication support via OAuth or email/password. For instructions on enabling, see [this doc](https://docs.sourcebot.dev/docs/configuration/auth/overview). +- Added the following UI for managing your deployment when **[auth is enabled](https://docs.sourcebot.dev/docs/configuration/auth/overview)**: - connection management: create and manage your JSON configs via a integrated web-editor. - secrets: import personal access tokens (PAT) into Sourcebot (AES-256 encrypted). Reference secrets in your connection config by name. - - team & invite management: invite users to your instance to give them access. Configure team [roles & permissions](https://docs.sourcebot.dev/docs/more/roles-and-permissions). + - team & invite management: invite users to your instance to give them access. Configure team [roles & permissions](https://docs.sourcebot.dev/docs/configuration/auth/roles-and-permissions). - Added multi-tenancy support. See [this doc](https://docs.sourcebot.dev/self-hosting/more/tenancy). ### Removed @@ -129,7 +258,7 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou ### Added -- Added `maxTrigramCount` to the config to control the maximum allowable of trigrams per document. +- Added `maxTrigramCount` to the config to control the maximum allowable of trigrams per document. ### Fixed @@ -187,7 +316,7 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou - Added config option `settings.maxFileSize` to control the maximum file size zoekt will index. ([#118](https://github.com/sourcebot-dev/sourcebot/pull/118)) ### Fixed - + - Fixed syntax highlighting for zoekt query language. ([#115](https://github.com/sourcebot-dev/sourcebot/pull/115)) - Fixed issue with Gerrit repo fetching not paginating. ([#114](https://github.com/sourcebot-dev/sourcebot/pull/114)) - Fixed visual issues with filter panel. ([#105](https://github.com/sourcebot-dev/sourcebot/pull/105)) @@ -239,13 +368,13 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou ### Added - Added `DOMAIN_SUB_PATH` environment variable to allow overriding the default domain subpath. ([#74](https://github.com/sourcebot-dev/sourcebot/pull/74)) -- Added option `all` to the GitLab index schema, allowing for indexing all projects in a self-hosted GitLab instance. ([#84](https://github.com/sourcebot-dev/sourcebot/pull/84)) +- Added option `all` to the GitLab index schema, allowing for indexing all projects in a self-hosted GitLab instance. ([#84](https://github.com/sourcebot-dev/sourcebot/pull/84)) ## [2.4.3] - 2024-11-18 ### Changed -- Bumped NodeJS version to v20. ([#78](https://github.com/sourcebot-dev/sourcebot/pull/78)) +- Bumped NodeJS version to v20. ([#78](https://github.com/sourcebot-dev/sourcebot/pull/78)) ## [2.4.2] - 2024-11-14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3627186c..ae301793 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ >[!NOTE] > Building from source is only required if you'd like to contribute. The recommended way to use Sourcebot is to use the [pre-built docker image](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). -1. Install go, NodeJS, [redis](https://redis.io/), and [postgres](https://www.postgresql.org/). Note that a NodeJS version of at least `21.1.0` is required. +1. Install go, docker, and NodeJS. Note that a NodeJS version of at least `21.1.0` is required. 2. Install [ctags](https://github.com/universal-ctags/ctags) (required by zoekt) ```sh @@ -13,11 +13,15 @@ snap install universal-ctags ``` +3. Install `yarn`: + ```sh + npm install --global yarn + ``` + 3. Clone the repository with submodules: ```sh git clone --recurse-submodules https://github.com/sourcebot-dev/sourcebot.git ``` - 4. Run `make` to build zoekt and install dependencies: ```sh cd sourcebot @@ -26,15 +30,21 @@ The zoekt binaries and web dependencies are placed into `bin` and `node_modules` respectively. -5. Create a copy of `.env.development` and name it `.env.development.local`. Update the required environment variables. +5. Start the development Docker containers for PostgreSQL and Redis. -6. If you're using a declerative configuration file (the default behavior if you didn't enable auth), create a configuration file and update the `CONFIG_PATH` environment variable in your `.env.development.local` file. + ```sh + docker compose -f docker-compose-dev.yml up -d + ``` -7. Start Sourcebot with the command: +6. Create a copy of `.env.development` and name it `.env.development.local`. Update the required environment variables. + +7. If you're using a declarative configuration file, create a configuration file and update the `CONFIG_PATH` environment variable in your `.env.development.local` file. + +8. Start Sourcebot with the command: ```sh yarn dev ``` A `.sourcebot` directory will be created and zoekt will begin to index the repositories found in the `config.json` file. -8. Start searching at `http://localhost:3000`. +9. Start searching at `http://localhost:3000`. diff --git a/Dockerfile b/Dockerfile index e2c9c239..d04b2518 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT ARG NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT ARG NEXT_PUBLIC_SENTRY_WEBAPP_DSN ARG NEXT_PUBLIC_SENTRY_BACKEND_DSN +ARG NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY +ARG NEXT_PUBLIC_LANGFUSE_BASE_URL FROM node:20-alpine3.19 AS node-alpine FROM golang:1.23.4-alpine3.19 AS go-alpine @@ -42,11 +44,15 @@ COPY ./packages/db ./packages/db COPY ./packages/schemas ./packages/schemas COPY ./packages/crypto ./packages/crypto COPY ./packages/error ./packages/error +COPY ./packages/logger ./packages/logger +COPY ./packages/shared ./packages/shared RUN yarn workspace @sourcebot/db install RUN yarn workspace @sourcebot/schemas install RUN yarn workspace @sourcebot/crypto install RUN yarn workspace @sourcebot/error install +RUN yarn workspace @sourcebot/logger install +RUN yarn workspace @sourcebot/shared install # ------------------------------------ # ------ Build Web ------ @@ -63,6 +69,10 @@ ARG NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT ENV NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT=$NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT ARG NEXT_PUBLIC_SENTRY_WEBAPP_DSN ENV NEXT_PUBLIC_SENTRY_WEBAPP_DSN=$NEXT_PUBLIC_SENTRY_WEBAPP_DSN +ARG NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY +ENV NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY=$NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY +ARG NEXT_PUBLIC_LANGFUSE_BASE_URL +ENV NEXT_PUBLIC_LANGFUSE_BASE_URL=$NEXT_PUBLIC_LANGFUSE_BASE_URL # To upload source maps to Sentry, we need to set the following build-time args. # It's important that we don't set these for oss builds, otherwise the Sentry @@ -89,6 +99,8 @@ COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto COPY --from=shared-libs-builder /app/packages/error ./packages/error +COPY --from=shared-libs-builder /app/packages/logger ./packages/logger +COPY --from=shared-libs-builder /app/packages/shared ./packages/shared # Fixes arm64 timeouts RUN yarn workspace @sourcebot/web install @@ -128,6 +140,8 @@ COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto COPY --from=shared-libs-builder /app/packages/error ./packages/error +COPY --from=shared-libs-builder /app/packages/logger ./packages/logger +COPY --from=shared-libs-builder /app/packages/shared ./packages/shared RUN yarn workspace @sourcebot/backend install RUN yarn workspace @sourcebot/backend build @@ -156,6 +170,10 @@ ARG NEXT_PUBLIC_SENTRY_WEBAPP_DSN ENV NEXT_PUBLIC_SENTRY_WEBAPP_DSN=$NEXT_PUBLIC_SENTRY_WEBAPP_DSN ARG NEXT_PUBLIC_SENTRY_BACKEND_DSN ENV NEXT_PUBLIC_SENTRY_BACKEND_DSN=$NEXT_PUBLIC_SENTRY_BACKEND_DSN +ARG NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY +ENV NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY=$NEXT_PUBLIC_LANGFUSE_PUBLIC_KEY +ARG NEXT_PUBLIC_LANGFUSE_BASE_URL +ENV NEXT_PUBLIC_LANGFUSE_BASE_URL=$NEXT_PUBLIC_LANGFUSE_BASE_URL # ----------- RUN echo "Sourcebot Version: $NEXT_PUBLIC_SOURCEBOT_VERSION" @@ -170,6 +188,7 @@ ENV REDIS_DATA_DIR=$DATA_CACHE_DIR/redis ENV DATABASE_URL="postgresql://postgres@localhost:5432/sourcebot" ENV REDIS_URL="redis://localhost:6379" ENV SRC_TENANT_ENFORCEMENT_MODE=strict +ENV SOURCEBOT_PUBLIC_KEY_PATH=/app/public.pem # Valid values are: debug, info, warn, error ENV SOURCEBOT_LOG_LEVEL=info @@ -177,7 +196,7 @@ ENV SOURCEBOT_LOG_LEVEL=info # Sourcebot collects anonymous usage data using [PostHog](https://posthog.com/). Uncomment this line to disable. # ENV SOURCEBOT_TELEMETRY_DISABLED=1 -COPY package.json yarn.lock* .yarnrc.yml ./ +COPY package.json yarn.lock* .yarnrc.yml public.pem ./ COPY .yarn ./.yarn # Configure zoekt @@ -209,6 +228,8 @@ COPY --from=shared-libs-builder /app/packages/db ./packages/db COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto COPY --from=shared-libs-builder /app/packages/error ./packages/error +COPY --from=shared-libs-builder /app/packages/logger ./packages/logger +COPY --from=shared-libs-builder /app/packages/shared ./packages/shared # Configure dependencies RUN apk add --no-cache git ca-certificates bind-tools tini jansson wget supervisor uuidgen curl perl jq redis postgresql postgresql-contrib openssl util-linux unzip diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 04fbff3a..00000000 --- a/LICENSE +++ /dev/null @@ -1,25 +0,0 @@ -Copyright (c) 2025 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 -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of 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. \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..93c14254 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,115 @@ +Copyright (c) 2025 Taqla Inc. + +Portions of this software are licensed as follows: + +- All content that resides under the "ee/", "packages/web/src/ee/", and "packages/shared/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 "Functional Source License" as defined below. + +--- + +# Functional Source License, Version 1.1, ALv2 Future License + +## Abbreviation + +FSL-1.1-ALv2 + +## Notice + +Copyright 2025 Taqla Inc. + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile index 7d8f80b6..538a4e5d 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ yarn: zoekt: mkdir -p bin go build -C vendor/zoekt -o $(PWD)/bin ./cmd/... - export PATH=$(PWD)/bin:$(PATH) + export PATH="$(PWD)/bin:$(PATH)" export CTAGS_COMMANDS=ctags clean: @@ -34,6 +34,8 @@ clean: packages/error/dist \ packages/mcp/node_modules \ packages/mcp/dist \ + packages/shared/node_modules \ + packages/shared/dist \ .sourcebot soft-reset: diff --git a/README.md b/README.md index 2b9e9354..e5940334 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,6 @@

- - Sourcebot Cloud - ยท Self Host ยท @@ -47,16 +44,20 @@ # About -Sourcebot is the open source Sourcegraph alternative. Index all your repos and branches across multiple code hosts (GitHub, GitLab, Bitbucket, Gitea, or Gerrit) and search through them using a blazingly fast interface. +Sourcebot is a self-hosted tool that helps you understand your codebase. -https://github.com/user-attachments/assets/ced355f3-967e-4f37-ae6e-74ab8c06b9ec +- **Ask Sourcebot:** Ask questions about your codebase and have Sourcebot provide detailed answers grounded with inline citations. +- **Code search:** Search and navigate across all your repos and branches, no matter where theyโ€™re hosted. + +https://github.com/user-attachments/assets/286ad97a-a543-4eef-a2f1-4fa31bea1b32 ## Features - ๐Ÿ’ป **One-command deployment**: Get started instantly using Docker on your own machine. -- ๐Ÿ” **Multi-repo search**: Index and search through multiple public and private repositories and branches on GitHub, GitLab, Bitbucket, Gitea, or Gerrit. -- โšก**Lightning fast performance**: Built on top of the powerful [Zoekt](https://github.com/sourcegraph/zoekt) search engine. -- ๐ŸŽจ **Modern web app**: Enjoy a sleek interface with features like syntax highlighting, light/dark mode, and vim-style navigation +- ๐Ÿค– **Bring your own model**: Connect Sourcebot to any of the reasoning models you're already using. +- ๐Ÿ” **Multi-repo support**: Index and search through multiple public and private repositories and branches on GitHub, GitLab, Bitbucket, Gitea, or Gerrit. +- โšก **Lightning fast performance**: Built on top of the powerful [Zoekt](https://github.com/sourcegraph/zoekt) search engine. +- ๐ŸŽจ **Modern web app**: Enjoy a sleek interface with features like syntax highlighting, light/dark mode, and vim-style navigation. - ๐Ÿ“‚ **Full file visualization**: Instantly view the entire file when selecting any search result. You can try out our public hosted demo [here](https://demo.sourcebot.dev)! diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 00000000..fcf2868b --- /dev/null +++ b/_typos.toml @@ -0,0 +1,6 @@ +[default.extend-words] +# Don't correct the surname "Do Not Exists" +dne = "dne" + +[files] +extend-exclude = ["vendor/**/*", "CHANGELOG.md", "packages/web/src/lib/languageMetadata.ts"] diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 00000000..5e9aaa90 --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,30 @@ +# docker-compose-dev.yml +version: '3.8' + +services: + redis: + image: redis:7-alpine + container_name: sourcebot-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: sourcebot-postgres + ports: + - "5432:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + redis_data: + postgres_data: + diff --git a/docs/development.mdx b/docs/development.mdx deleted file mode 100644 index fc3fec15..00000000 --- a/docs/development.mdx +++ /dev/null @@ -1,107 +0,0 @@ ---- -title: 'Development' -description: 'Preview changes locally to update your docs' ---- - - - **Prerequisite**: Please install Node.js (version 19 or higher) before proceeding.
- Please upgrade to ```docs.json``` before proceeding and delete the legacy ```mint.json``` file. -
- -Follow these steps to install and run Mintlify on your operating system: - -**Step 1**: Install Mintlify: - - - - ```bash npm - npm i -g mintlify - ``` - -```bash yarn -yarn global add mintlify -``` - - - -**Step 2**: Navigate to the docs directory (where the `docs.json` file is located) and execute the following command: - -```bash -mintlify dev -``` - -A local preview of your documentation will be available at `http://localhost:3000`. - -### Custom Ports - -By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. To run Mintlify on port 3333, for instance, use this command: - -```bash -mintlify dev --port 3333 -``` - -If you attempt to run Mintlify on a port that's already in use, it will use the next available port: - -```md -Port 3000 is already in use. Trying 3001 instead. -``` - -## Mintlify Versions - -Please note that each CLI release is associated with a specific version of Mintlify. If your local website doesn't align with the production version, please update the CLI: - - - -```bash npm -npm i -g mintlify@latest -``` - -```bash yarn -yarn global upgrade mintlify -``` - - - -## Validating Links - -The CLI can assist with validating reference links made in your documentation. To identify any broken links, use the following command: - -```bash -mintlify broken-links -``` - -## Deployment - - - Unlimited editors available under the [Pro - Plan](https://mintlify.com/pricing) and above. - - -If the deployment is successful, you should see the following: - - - - - -## Code Formatting - -We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting. - -## Troubleshooting - - - - - This may be due to an outdated version of node. Try the following: - 1. Remove the currently-installed version of mintlify: `npm remove -g mintlify` - 2. Upgrade to Node v19 or higher. - 3. Reinstall mintlify: `npm install -g mintlify` - - - - - Solution: Go to the root of your device and delete the \~/.mintlify folder. Afterwards, run `mintlify dev` again. - - - -Curious about what changed in the CLI version? [Check out the CLI changelog.](https://www.npmjs.com/package/mintlify?activeTab=versions) diff --git a/docs/docs.json b/docs/docs.json index a3bac5cc..8c304519 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1,6 +1,6 @@ { "$schema": "https://mintlify.com/docs.json", - "theme": "mint", + "theme": "willow", "name": "Sourcebot", "colors": { "primary": "#851EE7", @@ -15,23 +15,55 @@ "anchors": [ { "anchor": "Docs", - "icon": "book-open", + "icon": "books", "groups": [ { - "group": "General", + "group": "Getting Started", "pages": [ "docs/overview", - "docs/getting-started", - "docs/getting-started-selfhost" + "docs/deployment-guide" ] }, { - "group": "Connecting your code", + "group": "Features", "pages": [ - "docs/connections/overview", { - "group": "Supported platforms", + "group": "Code Search", "pages": [ + "docs/features/search/overview", + "docs/features/search/syntax-reference", + "docs/features/search/multi-branch-indexing", + "docs/features/search/search-contexts" + ] + }, + { + "group": "Ask Sourcebot", + "pages": [ + "docs/features/ask/overview", + "docs/features/ask/add-model-providers" + ] + }, + "docs/features/code-navigation", + "docs/features/analytics", + "docs/features/mcp-server", + { + "group": "Agents", + "tag": "experimental", + "pages": [ + "docs/features/agents/overview", + "docs/features/agents/review-agent" + ] + } + ] + }, + { + "group": "Configuration", + "pages": [ + "docs/configuration/config-file", + { + "group": "Indexing your code", + "pages": [ + "docs/connections/overview", "docs/connections/github", "docs/connections/gitlab", "docs/connections/bitbucket-cloud", @@ -42,63 +74,30 @@ "docs/connections/local-repos", "docs/connections/request-new" ] - } - ] - }, - { - "group": "Search", - "pages": [ - "docs/search/syntax-reference", - "docs/search/multi-branch-indexing", - "docs/search/search-contexts" - ] - }, - { - "group": "Agents", - "pages": [ - "docs/agents/overview", - "docs/agents/review-agent" - ] - }, - { - "group": "More", - "pages": [ - "docs/more/roles-and-permissions", - "docs/more/mcp-server" - ] - } - ] - }, - { - "anchor": "Self Hosting", - "icon": "server", - "groups": [ - { - "group": "Getting Started", - "pages": [ - "self-hosting/overview", - "self-hosting/configuration", - "self-hosting/license-key" - ] - }, - { - "group": "More", - "pages": [ - "self-hosting/more/authentication", - "self-hosting/more/tenancy", - "self-hosting/more/transactional-emails", - "self-hosting/more/declarative-config" - ] - }, - { - "group": "Security", - "pages": [ + }, + "docs/configuration/language-model-providers", + { + "group": "Authentication", + "pages": [ + "docs/configuration/auth/overview", + "docs/configuration/auth/providers", + "docs/configuration/auth/access-settings", + "docs/configuration/auth/roles-and-permissions", + "docs/configuration/auth/faq" + ] + }, + "docs/configuration/environment-variables", + "docs/license-key", + "docs/configuration/transactional-emails", + "docs/configuration/structured-logging", + "docs/configuration/audit-logs" ] }, { "group": "Upgrade", "pages": [ - "self-hosting/upgrade/v2-to-v3-guide" + "docs/upgrade/v3-to-v4-guide", + "docs/upgrade/v2-to-v3-guide" ] } ] @@ -120,21 +119,18 @@ "dark": "/logo/dark.png" }, "navbar": { - "links": [ - { - "label": "GitHub", - "href": "https://github.com/sourcebot-dev/sourcebot" - } - ], "primary": { "type": "button", - "label": "Sourcebot Cloud", - "href": "https://app.sourcebot.dev" + "label": "GitHub", + "href": "https://github.com/sourcebot-dev/sourcebot" } }, "footer": { "socials": { - "github": "https://github.com/sourcebot-dev/sourcebot" + "github": "https://github.com/sourcebot-dev/sourcebot", + "twitter": "https://x.com/sourcebot_dev", + "discord": "https://discord.gg/Y6b78RqM", + "linkedin": "https://www.linkedin.com/company/sourcebot" } }, "integrations": { @@ -144,6 +140,6 @@ }, "appearance": { "default": "dark", - "strict": true + "strict": false } } \ No newline at end of file diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx new file mode 100644 index 00000000..f229caaf --- /dev/null +++ b/docs/docs/configuration/audit-logs.mdx @@ -0,0 +1,211 @@ +--- +title: Audit Logs +sidebarTitle: Audit logs +--- + +import LicenseKeyRequired from '/snippets/license-key-required.mdx' + + + +Audit logs are a collection of notable events performed by users within a Sourcebot deployment. Each audit log records information on the action taken, the user who performed the +action, and when the action took place. + +This feature gives security and compliance teams the necessary information to ensure proper governance and administration of your Sourcebot deployment. + +## Enabling/Disabling Audit Logs +Audit logs are enabled by default and can be controlled with the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables). + +## Fetching Audit Logs +Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API: + +```bash icon="terminal" Fetch audit logs +curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ + --header 'X-Org-Domain: ~' \ + --header 'X-Sourcebot-Api-Key: $SOURCEBOT_OWNER_API_KEY' +``` + +```json icon="brackets-curly" wrap expandable Fetch audit logs example response +[ + { + "id": "cmc146k7m0003xgo2tri5t4br", + "timestamp": "2025-06-17T22:48:08.914Z", + "action": "api_key.created", + "actorId": "cmc12tnje0000xgn58jj8655h", + "actorType": "user", + "targetId": "205d1da1c6c3772b81d4ad697f5851fa11195176c211055ff0c1509772645d6d", + "targetType": "api_key", + "sourcebotVersion": "unknown", + "orgId": 1 + }, + { + "id": "cmc146c8r0001xgo2xyu0p463", + "timestamp": "2025-06-17T22:47:58.587Z", + "action": "user.performed_code_search", + "actorId": "cmc12tnje0000xgn58jj8655h", + "actorType": "user", + "targetId": "1", + "targetType": "org", + "sourcebotVersion": "unknown", + "metadata": { + "message": "render branch:HEAD" + }, + "orgId": 1 + }, + { + "id": "cmc12vqgb0008xgn5nv5hl9y5", + "timestamp": "2025-06-17T22:11:44.171Z", + "action": "user.performed_code_search", + "actorId": "cmc12tnje0000xgn58jj8655h", + "actorType": "user", + "targetId": "1", + "targetType": "org", + "sourcebotVersion": "unknown", + "metadata": { + "message": "render branch:HEAD" + }, + "orgId": 1 + }, + { + "id": "cmc12txwn0006xgn51ow1odid", + "timestamp": "2025-06-17T22:10:20.519Z", + "action": "user.performed_code_search", + "actorId": "cmc12tnje0000xgn58jj8655h", + "actorType": "user", + "targetId": "1", + "targetType": "org", + "sourcebotVersion": "unknown", + "metadata": { + "message": "render branch:HEAD" + }, + "orgId": 1 + }, + { + "id": "cmc12tnjx0004xgn5qqeiv1ao", + "timestamp": "2025-06-17T22:10:07.101Z", + "action": "user.owner_created", + "actorId": "cmc12tnje0000xgn58jj8655h", + "actorType": "user", + "targetId": "1", + "targetType": "org", + "sourcebotVersion": "unknown", + "metadata": null, + "orgId": 1 + }, + { + "id": "cmc12tnjh0002xgn5h6vzu3rl", + "timestamp": "2025-06-17T22:10:07.086Z", + "action": "user.signed_in", + "actorId": "cmc12tnje0000xgn58jj8655h", + "actorType": "user", + "targetId": "cmc12tnje0000xgn58jj8655h", + "targetType": "user", + "sourcebotVersion": "unknown", + "metadata": null, + "orgId": 1 + } +] +``` + +## Audit action types + +| Action | Actor Type | Target Type | +| :------- | :------ | :------| +| `api_key.creation_failed` | `user` | `org` | +| `api_key.created` | `user` | `api_key` | +| `api_key.deletion_failed` | `user` | `org` | +| `api_key.deleted` | `user` | `api_key` | +| `user.creation_failed` | `user` | `user` | +| `user.owner_created` | `user` | `org` | +| `user.performed_code_search` | `user` | `org` | +| `user.performed_find_references` | `user` | `org` | +| `user.performed_goto_definition` | `user` | `org` | +| `user.jit_provisioning_failed` | `user` | `org` | +| `user.jit_provisioned` | `user` | `org` | +| `user.join_request_creation_failed` | `user` | `org` | +| `user.join_requested` | `user` | `org` | +| `user.join_request_approve_failed` | `user` | `account_join_request` | +| `user.join_request_approved` | `user` | `account_join_request` | +| `user.invite_failed` | `user` | `org` | +| `user.invites_created` | `user` | `org` | +| `user.invite_accept_failed` | `user` | `invite` | +| `user.invite_accepted` | `user` | `invite` | +| `user.signed_in` | `user` | `user` | +| `user.signed_out` | `user` | `user` | +| `org.ownership_transfer_failed` | `user` | `org` | +| `org.ownership_transferred` | `user` | `org` | + + +## Response schema + +```json icon="brackets-curly" expandable wrap Audit log fetch response schema +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "FetchAuditLogsResponse", + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "timestamp", + "action", + "actorId", + "actorType", + "targetId", + "targetType", + "sourcebotVersion", + "metadata", + "orgId" + ], + "properties": { + "id": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "action": { + "type": "string" + }, + "actorId": { + "type": "string" + }, + "actorType": { + "type": "string", + "enum": ["user", "api_key"] + }, + "targetId": { + "type": "string" + }, + "targetType": { + "type": "string", + "enum": ["user", "org", "file", "api_key", "account_join_request", "invite"] + }, + "sourcebotVersion": { + "type": "string" + }, + "metadata": { + "anyOf": [ + { + "type": "object", + "properties": { + "message": { "type": "string" }, + "api_key": { "type": "string" }, + "emails": { "type": "string" } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "orgId": { + "type": "integer" + } + }, + "additionalProperties": false + } +} + +``` \ No newline at end of file diff --git a/docs/docs/configuration/auth/access-settings.mdx b/docs/docs/configuration/auth/access-settings.mdx new file mode 100644 index 00000000..5bc638e7 --- /dev/null +++ b/docs/docs/configuration/auth/access-settings.mdx @@ -0,0 +1,40 @@ +--- +title: Access Settings +sidebarTitle: Access settings +--- + +There are various settings to control how users access your Sourcebot deployment. + +# Anonymous access + +Anonymous access cannot be enabled if you have an enterprise license. If you have any questions about this restriction [reach out to us](https://www.sourcebot.dev/contact). + +By default, your Sourcebot deployment is gated with a login page. If you'd like users to access the deployment anonymously, you can enable anonymous access. + +This can be enabled by navigating to **Settings -> Access** or by setting the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable. + +When accessing Sourcebot anonymously, a user's permissions are limited to that of the [Guest](/docs/configuration/auth/roles-and-permissions) role. + +# Member Approval + +By default, Sourcebot requires new members to be approved by the owner of the deployment. This section explains how approvals work and how +to configure this behavior. + +### Configuration +Member approval can be configured by the owner of the deployment by navigating to **Settings -> Members**: + +![Member Approval Toggle](/images/member_approval_toggle.png) + +### Managing Requests + +If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment +until this request is approved by the owner. + +The owner can see and manage all pending join requests by navigating to **Settings -> Members**. + +## Invite link + +If member approval is required, an owner of the deployment can enable an invite link. When enabled, users +can use this invite link to register and be automatically added to the organization without approval: + +![Invite Link Toggle](/images/invite_link_toggle.png) \ No newline at end of file diff --git a/docs/docs/configuration/auth/faq.mdx b/docs/docs/configuration/auth/faq.mdx new file mode 100644 index 00000000..5d37bc66 --- /dev/null +++ b/docs/docs/configuration/auth/faq.mdx @@ -0,0 +1,46 @@ +--- +title: FAQ +--- + +This page covers a range of frequently asked questions about Sourcebot's built-in authentication system. + + + + No, at this time it's not possible to disable the authentication system. If this is preventing you from deploying Sourcebot + within your organization please [reach out](https://www.sourcebot.dev/contact) + + + + Every user must register an account within your Sourcebot deployment. However, this dosn't mean their access + is restricted. + + Unless member approval is required, anyone can sign up for an account on your deployment and immediately be granted access. + + + + **No data related to authentication (or your code) leaves your deployment**. Authentication is handled + purely by your deployment and the authentication providers you configure. + + This data does not leave your device and is stored within in the database managed by your deployment. If you're + using credential login, passwords are encrypted at rest and in transit. + + + + Please note that IAP bridges are an enterprise feature + Sourcebot supports connecting your identity proxy directly into the built-in auth system using an IAP bridge. This allows Sourcebot to + register and authenticate automatically on a successful identity proxy log in. + + Sourcebot currently supports [GCP IAP](/docs/configuration/auth/providers#gcp-iap). If you're using a different IAP + and require support, please [reach out](https://www.sourcebot.dev/contact) + + + + Sourcebot uses [Auth.js](https://authjs.dev/) as its underlying authentication framework. Auth.js provides authentication providers + (credientials, Google, GitHub, etc) and an interface to enable user registration and log in. Internally, Auth.js uses JWT to provide + Sourcebot secure and reliable information about user authentication. + + + + +Have a question that's not answered here? Submit it on our [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions) +page and we'll get back to you as soon as we can! \ No newline at end of file diff --git a/docs/docs/configuration/auth/overview.mdx b/docs/docs/configuration/auth/overview.mdx new file mode 100644 index 00000000..732fef35 --- /dev/null +++ b/docs/docs/configuration/auth/overview.mdx @@ -0,0 +1,28 @@ +--- +title: Overview +--- + +If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable. + +Sourcebot's built-in authentication system gates your deployment, and allows administrators to manage users and their permissions. + + + + Configure additional authentication providers for your deployment. + + + Learn how to configure how members join your deployment. + + + Learn more about the different roles and permissions in Sourcebot. + + + Have a question about Sourcebot's auth system? We might have the answers here. + + + + +# Troubleshooting + +- If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers). +- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [github discussions](https://github.com/sourcebot-dev/sourcebot/discussions) \ No newline at end of file diff --git a/docs/docs/configuration/auth/providers.mdx b/docs/docs/configuration/auth/providers.mdx new file mode 100644 index 00000000..ae52ea46 --- /dev/null +++ b/docs/docs/configuration/auth/providers.mdx @@ -0,0 +1,105 @@ +--- +title: Providers +--- + +Sourcebot supports a wide range of different authentication providers through it's integration with [Auth.js](https://authjs.dev/). This page +highlights how to configure the various supported providers. + +If theres an authentication provider you'd like us to support, please [reach out](https://www.sourcebot.dev/contact). + +# Core Authentication Providers + +### Email / Password +--- +Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`. + +### Email codes +--- +Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables: + +- `AUTH_EMAIL_CODE_LOGIN_ENABLED` +- `SMTP_CONNECTION_URL` +- `EMAIL_FROM_ADDRESS` + + +See [transactional emails](/docs/configuration/transactional-emails) for more details. + +# Enterprise Authentication Providers + +The following authentication providers require an [enterprise license](/docs/license-key) to be enabled. + +### GitHub +--- + +[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github) + +**Required environment variables:** +- `AUTH_EE_GITHUB_CLIENT_ID` +- `AUTH_EE_GITHUB_CLIENT_SECRET` + +Optional environment variables: +- `AUTH_EE_GITHUB_BASE_URL` - Base URL for GitHub Enterprise (defaults to https://github.com) + +### GitLab +--- + +[Auth.js GitLab Provider Docs](https://authjs.dev/getting-started/providers/gitlab) + +**Required environment variables:** +- `AUTH_EE_GITLAB_CLIENT_ID` +- `AUTH_EE_GITLAB_CLIENT_SECRET` + +Optional environment variables: +- `AUTH_EE_GITLAB_BASE_URL` - Base URL for GitLab instance (defaults to https://gitlab.com) + +### Google +--- + +[Auth.js Google Provider Docs](https://authjs.dev/getting-started/providers/google) + +**Required environment variables:** +- `AUTH_EE_GOOGLE_CLIENT_ID` +- `AUTH_EE_GOOGLE_CLIENT_SECRET` + +### GCP IAP +--- + +If you're running Sourcebot in an environment that blocks egress, make sure you allow the [IAP IP ranges](https://www.gstatic.com/ipranges/goog.json) + +Custom provider built to enable automatic Sourcebot account registration/login when using GCP IAP. + +**Required environment variables** +- `AUTH_EE_GCP_IAP_ENABLED` +- `AUTH_EE_GCP_IAP_AUDIENCE` + - This can be found by selecting the โ‹ฎ icon next to the IAP-enabled backend service and pressing `Get JWT audience code` + +### Okta +--- + +[Auth.js Okta Provider Docs](https://authjs.dev/getting-started/providers/okta) + +**Required environment variables:** +- `AUTH_EE_OKTA_CLIENT_ID` +- `AUTH_EE_OKTA_CLIENT_SECRET` +- `AUTH_EE_OKTA_ISSUER` + +### Keycloak +--- + +[Auth.js Keycloak Provider Docs](https://authjs.dev/getting-started/providers/keycloak) + +**Required environment variables:** +- `AUTH_EE_KEYCLOAK_CLIENT_ID` +- `AUTH_EE_KEYCLOAK_CLIENT_SECRET` +- `AUTH_EE_KEYCLOAK_ISSUER` + +### Microsoft Entra ID + +[Auth.js Microsoft Entra ID Provider Docs](https://authjs.dev/getting-started/providers/microsoft-entra-id) + +**Required environment variables:** +- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID` +- `AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET` +- `AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER` + +--- \ No newline at end of file diff --git a/docs/docs/configuration/auth/roles-and-permissions.mdx b/docs/docs/configuration/auth/roles-and-permissions.mdx new file mode 100644 index 00000000..e766b4ee --- /dev/null +++ b/docs/docs/configuration/auth/roles-and-permissions.mdx @@ -0,0 +1,14 @@ +--- +title: Roles and Permissions +sidebarTitle: Roles and permissions +--- + +Looking to sync permissions with your identify provider? We're working on it - [reach out](https://www.sourcebot.dev/contact) to us to learn more + +Each member has a role which defines their permissions within an organization: + +| Role | Permission | +| :--- | :--------- | +| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. | +| `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, as well as view the organizations configuration and member list. However, they cannot modify this configuration or invite new members. | +| `Guest` | When accessing Sourcebot [anonymously](/docs/configuration/auth/access-settings#anonymous-access), a user has the `Guest` role. `Guest`'s can search across repos indexed by an organization's connections, but cannot view any information regarding the organizations configuration or members. | \ No newline at end of file diff --git a/docs/docs/configuration/config-file.mdx b/docs/docs/configuration/config-file.mdx new file mode 100644 index 00000000..c0e89c77 --- /dev/null +++ b/docs/docs/configuration/config-file.mdx @@ -0,0 +1,49 @@ +--- +title: Config File +sidebarTitle: Config file +--- + +When self-hosting Sourcebot, you **must** provide it a config file. This is done by defining a config file in a volume that's mounted to Sourcebot, and providing the path to this +file in the `CONFIG_PATH` environment variable. For example: + +```bash icon="terminal" Passing in a CONFIG_PATH to Sourcebot +docker run \ + -v $(pwd)/config.json:/data/config.json \ + -e CONFIG_PATH=/data/config.json \ + ... \ # other options + ghcr.io/sourcebot-dev/sourcebot:latest +``` + +The config file tells Sourcebot which repos to index, what language models to use, and various other settings as defined in the [schema](#config-file-schema). + +# Config File Schema + +The config file you provide Sourcebot must follow the [schema](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/index.json). This schema consists of the following properties: + +- [Connections](/docs/connections/overview) (`connections`): Defines a set of connections that tell Sourcebot which repos to index and from where +- [Language Models](/docs/configuration/language-model-providers) (`models`): Defines a set of language model providers for use with [Ask Sourcebot](/docs/features/ask) +- [Settings](#settings) (`settings`): Additional settings to tweak your Sourcebot deployment +- [Search Contexts](/docs/features/search/search-contexts) (`contexts`): Groupings of repos that you can search against + +# Config File Syncing + +Sourcebot syncs the config file on startup, and automatically whenever a change is detected. + +# Settings + +The following are settings that can be provided in your config file to modify Sourcebot's behavior + +| Setting | Type | Default | Minimum | Description / Notes | +|-------------------------------------------|---------|------------|---------|----------------------------------------------------------------------------------------| +| `maxFileSize` | number | 2โ€ฏMB | 1 | Maximum size (bytes) of a file to index. Files exceeding this are skipped. | +| `maxTrigramCount` | number | 20โ€ฏ000 | 1 | Maximum trigrams per document. Larger files are skipped. | +| `reindexIntervalMs` | number | 1โ€ฏhour | 1 | Interval at which all repositories are reโ€‘indexed. | +| `resyncConnectionIntervalMs` | number | 24โ€ฏhours | 1 | Interval for checking connections that need reโ€‘syncing. | +| `resyncConnectionPollingIntervalMs` | number | 1โ€ฏsecond | 1 | DB polling rate for connections that need reโ€‘syncing. | +| `reindexRepoPollingIntervalMs` | number | 1โ€ฏsecond | 1 | DB polling rate for repos that should be reโ€‘indexed. | +| `maxConnectionSyncJobConcurrency` | number | 8 | 1 | Concurrent connectionโ€‘sync jobs. | +| `maxRepoIndexingJobConcurrency` | number | 8 | 1 | Concurrent repoโ€‘indexing jobs. | +| `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repoโ€‘garbageโ€‘collection jobs. | +| `repoGarbageCollectionGracePeriodMs` | number | 10โ€ฏseconds | 1 | Grace period to avoid deleting shards while loading. | +| `repoIndexTimeoutMs` | number | 2โ€ฏhours | 1 | Timeout for a single repoโ€‘indexing run. | +| `enablePublicAccess` **(deprecated)** | boolean | false | โ€” | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. | diff --git a/docs/self-hosting/more/declarative-config.mdx b/docs/docs/configuration/declarative-config.mdx similarity index 88% rename from docs/self-hosting/more/declarative-config.mdx rename to docs/docs/configuration/declarative-config.mdx index 233f365c..cdfdb445 100644 --- a/docs/self-hosting/more/declarative-config.mdx +++ b/docs/docs/configuration/declarative-config.mdx @@ -5,10 +5,6 @@ sidebarTitle: Declarative config import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' - -Declaratively defining `connections` is not available when [multi-tenancy](/self-hosting/more/tenancy) is enabled. - - Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/overview). diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx new file mode 100644 index 00000000..d6aab9eb --- /dev/null +++ b/docs/docs/configuration/environment-variables.mdx @@ -0,0 +1,75 @@ +--- +title: Environment variables +sidebarTitle: Environment variables +mode: "wide" +--- + +This page provides a detailed reference of all environment variables supported by Sourcebot. If you're just looking to get up and running, we recommend starting with the [deployment guide](/docs/deployment-guide) instead. + +### Core Environment Variables +The following environment variables allow you to configure your Sourcebot deployment. + +| Variable | Default | Description | +| :------- | :------ | :---------- | +| `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` |

Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/docs/configuration/auth/overview) for more info

| +| `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` |

Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/docs/configuration/auth/overview) for more info

| +| `AUTH_SECRET` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 33` |

Used to validate login session cookies

| +| `AUTH_URL` | - |

URL of your Sourcebot deployment, e.g., `https://example.com` or `http://localhost:3000`.

| +| `CONFIG_PATH` | `-` |

The container relative path to the declerative configuration file. See [this doc](/docs/configuration/declarative-config) for more info.

| +| `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` |

The root data directory in which all data written to disk by Sourcebot will be located.

| +| `DATA_DIR` | `/data` |

The directory within the container to store all persistent data. Typically, this directory will be volume mapped such that data is persisted across container restarts (e.g., `docker run -v $(pwd):/data`)

| +| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` |

The data directory for the default Postgres database.

| +| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` |

Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.

If you'd like to use a non-default schema, you can provide it as a parameter in the database url

| +| `EMAIL_FROM_ADDRESS` | `-` |

The email address that transactional emails will be sent from. See [this doc](/docs/configuration/transactional-emails) for more info.

| +| `FORCE_ENABLE_ANONYMOUS_ACCESS` | `false` |

When enabled, [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) to the organization will always be enabled

+| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` |

The data directory for the default Redis instance.

| +| `REDIS_URL` | `redis://localhost:6379` |

Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.

| +| `REDIS_REMOVE_ON_COMPLETE` | `0` |

Controls how many completed jobs are allowed to remain in Redis queues

| +| `REDIS_REMOVE_ON_FAIL` | `100` |

Controls how many failed jobs are allowed to remain in Redis queues

| +| `REPO_SYNC_RETRY_BASE_SLEEP_SECONDS` | `60` |

The base sleep duration (in seconds) for exponential backoff when retrying repository sync operations that fail

| +| `GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS` | `600` |

The timeout duration (in seconds) for GitLab client queries

| +| `SHARD_MAX_MATCH_COUNT` | `10000` |

The maximum shard count per query

| +| `SMTP_CONNECTION_URL` | `-` |

The url to the SMTP service used for sending transactional emails. See [this doc](/docs/configuration/transactional-emails) for more info.

| +| `SOURCEBOT_ENCRYPTION_KEY` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 24` |

Used to encrypt connection secrets and generate API keys.

| +| `SOURCEBOT_PUBLIC_KEY_PATH` | `/app/public.pem` |

Sourcebot's public key that's used to verify encrypted license key signatures.

| +| `SOURCEBOT_LOG_LEVEL` | `info` |

The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.

| +| `SOURCEBOT_STRUCTURED_LOGGING_ENABLED` | `false` |

Enables/disable structured JSON logging. See [this doc](/docs/configuration/structured-logging) for more info.

| +| `SOURCEBOT_STRUCTURED_LOGGING_FILE` | - |

Optional file to log to if structured logging is enabled

| +| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/docs/overview.mdx#telemetry) for more info.

| +| `TOTAL_MAX_MATCH_COUNT` | `100000` |

The maximum number of matches per query

| +| `ZOEKT_MAX_WALL_TIME_MS` | `10000` |

The maximum real world duration (in milliseconds) per zoekt query

| + +### Enterprise Environment Variables +| Variable | Default | Description | +| :------- | :------ | :---------- | +| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` |

Enables/disables audit logging

| +| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` |

The base URL for GitHub Enterprise SSO authentication.

| +| `AUTH_EE_GITHUB_CLIENT_ID` | `-` |

The client ID for GitHub Enterprise SSO authentication.

| +| `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` |

The client secret for GitHub Enterprise SSO authentication.

| +| `AUTH_EE_GITLAB_BASE_URL` | `https://gitlab.com` |

The base URL for GitLab Enterprise SSO authentication.

| +| `AUTH_EE_GITLAB_CLIENT_ID` | `-` |

The client ID for GitLab Enterprise SSO authentication.

| +| `AUTH_EE_GITLAB_CLIENT_SECRET` | `-` |

The client secret for GitLab Enterprise SSO authentication.

| +| `AUTH_EE_GOOGLE_CLIENT_ID` | `-` |

The client ID for Google SSO authentication.

| +| `AUTH_EE_GOOGLE_CLIENT_SECRET` | `-` |

The client secret for Google SSO authentication.

| +| `AUTH_EE_KEYCLOAK_CLIENT_ID` | `-` |

The client ID for Keycloak SSO authentication.

| +| `AUTH_EE_KEYCLOAK_CLIENT_SECRET` | `-` |

The client secret for Keycloak SSO authentication.

| +| `AUTH_EE_KEYCLOAK_ISSUER` | `-` |

The issuer URL for Keycloak SSO authentication.

| +| `AUTH_EE_OKTA_CLIENT_ID` | `-` |

The client ID for Okta SSO authentication.

| +| `AUTH_EE_OKTA_CLIENT_SECRET` | `-` |

The client secret for Okta SSO authentication.

| +| `AUTH_EE_OKTA_ISSUER` | `-` |

The issuer URL for Okta SSO authentication.

| +| `AUTH_EE_GCP_IAP_ENABLED` | `false` |

When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect

| +| `AUTH_EE_GCP_IAP_AUDIENCE` | - |

The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning

| + + +### Review Agent Environment Variables +| Variable | Default | Description | +| :------- | :------ | :---------- | +| `GITHUB_APP_ID` | `-` |

The GitHub App ID used for review agent authentication.

| +| `GITHUB_APP_PRIVATE_KEY_PATH` | `-` |

The container relative path to the private key file for the GitHub App used by the review agent.

| +| `GITHUB_APP_WEBHOOK_SECRET` | `-` |

The webhook secret for the GitHub App used by the review agent.

| +| `OPENAI_API_KEY` | `-` |

The OpenAI API key used by the review agent.

| +| `REVIEW_AGENT_API_KEY` | `-` |

The Sourcebot API key used by the review agent.

| +| `REVIEW_AGENT_AUTO_REVIEW_ENABLED` | `false` |

Enables/disables automatic code reviews by the review agent.

| +| `REVIEW_AGENT_LOGGING_ENABLED` | `true` |

Enables/disables logging for the review agent. Logs are saved in `DATA_CACHE_DIR/review-agent`

| +| `REVIEW_AGENT_REVIEW_COMMAND` | `review` |

The command used to trigger a code review by the review agent.

| + diff --git a/docs/docs/configuration/language-model-providers.mdx b/docs/docs/configuration/language-model-providers.mdx new file mode 100644 index 00000000..d372c83d --- /dev/null +++ b/docs/docs/configuration/language-model-providers.mdx @@ -0,0 +1,291 @@ +--- +title: Language Model Providers +sidebarTitle: Language model providers +--- + +To use [Ask Sourcebot](/docs/features/ask) you must define at least one Language Model Provider. These providers are defined within the [config file](/docs/configuration/config-file) you +provide Sourcebot. + + +```json wrap icon="code" Example config with language model provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + // 1. Google Vertex config for Gemini 2.5 Pro + { + "provider": "google-vertex", + "model": "gemini-2.5-pro", + "displayName": "Gemini 2.5 Pro", + "project": "sourcebot", + "credentials": { + "env": "GOOGLE_APPLICATION_CREDENTIALS" + } + }, + // 2. OpenAI config for o3 + { + "provider": "openai", + "model": "o3", + "displayName": "o3", + "token": { + "env": "OPENAI_API_KEY" + } + } + ] +} +``` + +# Supported Providers + +Sourcebot uses the [Vercel AI SDK](https://ai-sdk.dev/docs/introduction), so it can integrate with any provider the SDK supports. If you don't see your provider below please submit +a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/feature-requests). + +For a detailed description of all the providers, please refer to the [schema](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/languageModel.json). + +Any parameter defined using `env` will read the value from the corresponding environment variable you provide Sourcebot + +### Amazon Bedrock + +[Vercel AI SDK Amazon Bedrock Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock) + +```json wrap icon="code" Example config with Amazon Bedrock provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "amazon-bedrock", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "accessKeyId": { + "env": "AWS_ACCESS_KEY_ID" + }, + "accessKeySecret": { + "env": "AWS_SECRET_ACCESS_KEY" + }, + "region": "YOUR_REGION_HERE", // defaults to the AWS_REGION env var if not set + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### Anthropic + +[Vercel AI SDK Anthropic Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic) + +```json wrap icon="code" Example config with Anthropic provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "anthropic", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "token": { + "env": "ANTHROPIC_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### Azure OpenAI + +[Vercel AI SDK Azure OpenAI Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure) + +```json wrap icon="code" Example config with Azure AI provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "azure", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "resourceName": "YOUR_RESOURCE_NAME", // defaults to the AZURE_RESOURCE_NAME env var if not set + "apiVersion": "OPTIONAL_API_VERSION", // defailts to 'preview' if not set + "token": { + "env": "AZURE_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### Deepseek + +[Vercel AI SDK Deepseek Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepseek) + +```json wrap icon="code" Example config with Deepseek provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "deepseek", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "token": { + "env": "DEEPSEEK_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### Google Generative AI + +[Vercel AI SDK Google Generative AI Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai) + +```json wrap icon="code" Example config with Google Generative AI provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "google-generative-ai", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "token": { + "env": "GOOGLE_GENERATIVE_AI_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### Google Vertex + +If you're using an Anthropic model on Google Vertex, you must define a [Google Vertex Anthropic](#google-vertex-anthropic) provider instead +The `credentials` paramater here expects a **path** to a [credentials](https://console.cloud.google.com/apis/credentials) file. This file **must be in a volume mounted by Sourcebot** for it to be readable. + +[Vercel AI SDK Google Vertex AI Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex) + +```json wrap icon="code" Example config with Google Vertex provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "google-vertex", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "project": "YOUR_PROJECT_ID", // defaults to the GOOGLE_VERTEX_PROJECT env var if not set + "region": "YOUR_REGION_HERE", // defaults to the GOOGLE_VERTEX_REGION env var if not set + "credentials": { + "env": "GOOGLE_APPLICATION_CREDENTIALS" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### Google Vertex Anthropic + +The `credentials` paramater here expects a **path** to a [credentials](https://console.cloud.google.com/apis/credentials) file. This file **must be in a volume mounted by Sourcebot** for it to be readable. + + +[Vercel AI SDK Google Vertex Anthropic Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#google-vertex-anthropic-provider-usage) + +```json wrap icon="code" Example config with Google Vertex Anthropic provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "google-vertex-anthropic", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "project": "YOUR_PROJECT_ID", // defaults to the GOOGLE_VERTEX_PROJECT env var if not set + "region": "YOUR_REGION_HERE", // defaults to the GOOGLE_VERTEX_REGION env var if not set + "credentials": { + "env": "GOOGLE_APPLICATION_CREDENTIALS" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### Mistral + +[Vercel AI SDK Mistral Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral) + +```json wrap icon="code" Example config with Mistral provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "mistral", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "token": { + "env": "MISTRAL_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### OpenAI + +[Vercel AI SDK OpenAI Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai) + +```json wrap icon="code" Example config with OpenAI provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "openai", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "token": { + "env": "OPENAI_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### OpenRouter + +[Vercel AI SDK OpenRouter Docs](https://v5.ai-sdk.dev/providers/community-providers/openrouter) + +```json wrap icon="code" Example config with OpenRouter provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "openai", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "token": { + "env": "OPENROUTER_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` + +### xAI + +[Vercel AI SDK xAI Docs](https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai) + +```json wrap icon="code" Example config with xAI provider +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "models": [ + { + "provider": "xai", + "model": "YOUR_MODEL_HERE", + "displayName": "OPTIONAL_DISPLAY_NAME", + "token": { + "env": "XAI_API_KEY" + }, + "baseUrl": "OPTIONAL_BASE_URL" + } + ] +} +``` \ No newline at end of file diff --git a/docs/docs/configuration/structured-logging.mdx b/docs/docs/configuration/structured-logging.mdx new file mode 100644 index 00000000..1bc0c9a0 --- /dev/null +++ b/docs/docs/configuration/structured-logging.mdx @@ -0,0 +1,40 @@ +--- +title: Structured Logging +sidebarTitle: Structured logging +--- + +By default, Sourcebot will output logs to the console in a human readable format. If you'd like Sourcebot to output structured JSON logs, set the following env vars: + +- `SOURCEBOT_STRUCTURED_LOGGING_ENABLED` (default: `false`): Controls whether logs are in a structured JSON format +- `SOURCEBOT_STRUCTURED_LOGGING_FILE`: If structured logging is enabled and this env var is set, structured logs will be written to this file (ex. `/data/sourcebot.log`) + +### Structured log schema +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "SourcebotLog", + "properties": { + "level": { + "type": "string", + "description": "The log level (error, warning, info, debug)" + }, + "service": { + "type": "string", + "description": "The Sourcebot component that generated the log" + }, + "message": { + "type": "string", + "description": "The log message" + }, + "status": { + "type": "string", + "description": "The same value as the level field added for datadog support" + }, + "timestamp": { + "type": "string", + "description": "The timestamp of the log in ISO 8061 format" + } + } +} +``` \ No newline at end of file diff --git a/docs/self-hosting/more/tenancy.mdx b/docs/docs/configuration/tenancy.mdx similarity index 95% rename from docs/self-hosting/more/tenancy.mdx rename to docs/docs/configuration/tenancy.mdx index bbf3e18e..b70412a9 100644 --- a/docs/self-hosting/more/tenancy.mdx +++ b/docs/docs/configuration/tenancy.mdx @@ -4,7 +4,7 @@ sidebarTitle: Multi tenancy --- If you're switching from single-tenant mode, delete the Sourcebot cache (the `.sourcebot` folder) before starting. -[Authentication](/self-hosting/more/authentication) must be enabled to enable multi tenancy mode +[Authentication](/docs/configuration/auth/overview) must be enabled to enable multi tenancy mode Multi tenancy allows your Sourcebot deployment to have **multiple organizations**, each with their own set of members and repos. To enable multi tenancy mode, define an environment variable named `SOURCEBOT_TENANCY_MODE` and set its value to `multi`. When multi tenancy mode is enabled: diff --git a/docs/self-hosting/more/transactional-emails.mdx b/docs/docs/configuration/transactional-emails.mdx similarity index 61% rename from docs/self-hosting/more/transactional-emails.mdx rename to docs/docs/configuration/transactional-emails.mdx index d84c17b7..53fea615 100644 --- a/docs/self-hosting/more/transactional-emails.mdx +++ b/docs/docs/configuration/transactional-emails.mdx @@ -6,9 +6,10 @@ sidebarTitle: Transactional email To enable transactional emails in your deployment, set the following environment variables. We recommend using [Resend](https://resend.com/), but you can use any provider. Setting this enables you to: - Send emails when new members are invited -- Log into the Sourcebot deployment using [email codes](self-hosting/more/authentication#email-codes) +- Send emails when organization join requests are created/accepted +- Log into the Sourcebot deployment using [email codes](/docs/configuration/auth/overview#email-codes) | Variable | Description | | :------- | :---------- | -| `SMTP_CONNECTION_URL` | SMTP server connection. | +| `SMTP_CONNECTION_URL` | SMTP server connection (`smtp://[user[:password]@]host[:port]`)| | `EMAIL_FROM_ADDRESS` | The sender's email address | \ No newline at end of file diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx index 66636b02..bde7a665 100644 --- a/docs/docs/connections/bitbucket-cloud.mdx +++ b/docs/docs/connections/bitbucket-cloud.mdx @@ -1,12 +1,19 @@ --- title: Linking code from Bitbucket Cloud sidebarTitle: Bitbucket Cloud +icon: Bitbucket --- import BitbucketToken from '/snippets/bitbucket-token.mdx'; import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' + +Looking for docs on Bitbucket Data Center? See [this doc](/docs/connections/bitbucket-data-center). + + +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. + ## Examples diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx index b80afbfd..77479e46 100644 --- a/docs/docs/connections/bitbucket-data-center.mdx +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -1,12 +1,19 @@ --- title: Linking code from Bitbucket Data Center sidebarTitle: Bitbucket Data Center +icon: Bitbucket --- import BitbucketToken from '/snippets/bitbucket-token.mdx'; import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' + +Looking for docs on Bitbucket Cloud? See [this doc](/docs/connections/bitbucket-cloud). + + +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. + ## Examples diff --git a/docs/docs/connections/generic-git-host.mdx b/docs/docs/connections/generic-git-host.mdx index cee01aaa..c960f248 100644 --- a/docs/docs/connections/generic-git-host.mdx +++ b/docs/docs/connections/generic-git-host.mdx @@ -1,11 +1,14 @@ --- title: Other Git hosts +icon: git-alt --- import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' Sourcebot can sync code from any Git host (by clone url). This is helpful when you want to search code that not in a [supported code host](/docs/connections/overview#supported-code-hosts). +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. + ## Getting Started To connect to a Git host, create a new [connection](/docs/connections/overview) with type `git` and specify the clone url in the `url` property. For example: diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx index 29bb627b..08e72a8d 100644 --- a/docs/docs/connections/gerrit.mdx +++ b/docs/docs/connections/gerrit.mdx @@ -1,6 +1,7 @@ --- title: Linking code from Gerrit sidebarTitle: Gerrit +icon: crow --- import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx' @@ -9,6 +10,8 @@ import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx' Sourcebot can sync code from self-hosted gerrit instances. +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. + ## Connecting to a Gerrit instance To connect to a gerrit instance, provide the `url` property to your config: diff --git a/docs/docs/connections/gitea.mdx b/docs/docs/connections/gitea.mdx index 7e670d2d..2318b166 100644 --- a/docs/docs/connections/gitea.mdx +++ b/docs/docs/connections/gitea.mdx @@ -1,12 +1,15 @@ --- title: Linking code from Gitea sidebarTitle: Gitea +icon: mug-tea --- import GiteaSchema from '/snippets/schemas/v3/gitea.schema.mdx' Sourcebot can sync code from Gitea Cloud, and self-hosted. +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. + ## Examples @@ -82,7 +85,7 @@ Next, provide the access token via the `token` property, either as an environmen - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -107,7 +110,7 @@ Next, provide the access token via the `token` property, either as an environmen - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/docs/configuration/auth/overview) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: diff --git a/docs/docs/connections/github.mdx b/docs/docs/connections/github.mdx index 58a1d59f..0abf13b8 100644 --- a/docs/docs/connections/github.mdx +++ b/docs/docs/connections/github.mdx @@ -1,12 +1,15 @@ --- title: Linking code from GitHub sidebarTitle: GitHub +icon: GitHub --- import GitHubSchema from '/snippets/schemas/v3/github.schema.mdx' Sourcebot can sync code from GitHub.com, GitHub Enterprise Server, and GitHub Enterprise Cloud. +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. + ## Examples @@ -103,15 +106,33 @@ Sourcebot can sync code from GitHub.com, GitHub Enterprise Server, and GitHub En ## Authenticating with GitHub -In order to index private repositories, you'll need to generate a GitHub Personal Access Token (PAT). Create a new PAT [here](https://github.com/settings/tokens/new) and make sure you select the `repo` scope: +In order to index private repositories, you'll need to generate a access token and provide it to Sourcebot. GitHub provides [two types](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#types-of-personal-access-tokens) of access tokens: -![GitHub PAT Scope](/images/github_pat_scopes.png) -Next, provide the PAT via the `token` property, either as an environment variable or a secret: + + + Create a new fine-grained PAT [here](https://github.com/settings/personal-access-tokens/new). First, select the resource owner and the repositories that you want Sourcebot to have access to. + + Next, under "Repository permissions", select permissions `Contents` and `Metadata` with access `Read-only`. The permissions should look like the following: + + ![GitHub PAT Scope](/images/github_pat_scopes_fine_grained.png) + + [GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens) + + + Create a new PAT [here](https://github.com/settings/tokens/new) and make sure you select the `repo` scope: + + ![GitHub PAT Scope](/images/github_pat_scopes.png) + + [GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#personal-access-tokens-classic) + + + +Next, provide the access token via the `token` property, either as an environment variable or a secret: - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -136,7 +157,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/docs/configuration/auth/overview) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: diff --git a/docs/docs/connections/gitlab.mdx b/docs/docs/connections/gitlab.mdx index 87f01661..87c9bd03 100644 --- a/docs/docs/connections/gitlab.mdx +++ b/docs/docs/connections/gitlab.mdx @@ -1,12 +1,14 @@ --- title: Linking code from GitLab sidebarTitle: GitLab +icon: GitLab --- import GitLabSchema from '/snippets/schemas/v3/gitlab.schema.mdx' Sourcebot can sync code from GitLab.com, Self Managed (CE & EE), and Dedicated. +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. ## Examples @@ -116,7 +118,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -141,7 +143,7 @@ Next, provide the PAT via the `token` property, either as an environment variabl - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/docs/configuration/auth/overview) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: @@ -174,6 +176,10 @@ To connect to a GitLab host other than `gitlab.com`, provide the `url` property } ``` +## Troubleshooting + +- If you're seeing errors like `GitbeakerTimeoutError: Query timeout was reached` when syncing a large number of projects, you can increase the client's timeout by setting the `GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS` environment variable. [#162](https://github.com/sourcebot-dev/sourcebot/issues/162) + ## Schema reference diff --git a/docs/docs/connections/local-repos.mdx b/docs/docs/connections/local-repos.mdx index ebed93b5..114c3c8c 100644 --- a/docs/docs/connections/local-repos.mdx +++ b/docs/docs/connections/local-repos.mdx @@ -1,14 +1,13 @@ --- title: Local Git repositories +icon: folder --- import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' - -This feature is only supported when [self-hosting](/self-hosting/overview). - +Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaning Sourcebot will **not** `git fetch` new revisions. -Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaing Sourcebot will **not** `git fetch` new revisions. +If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. ## Getting Started @@ -30,7 +29,7 @@ To get Sourcebot to index these repositories: - We need to mount a docker volume to the `repos` directory so Sourcebot can read it's contents. Sourcebot will **not** write to local repositories, so we can mount a seperate **read-only** volume: + We need to mount a docker volume to the `repos` directory so Sourcebot can read it's contents. Sourcebot will **not** write to local repositories, so we can mount a separate **read-only** volume: ``` bash docker run \ diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/overview.mdx index b259da28..15d0a4eb 100644 --- a/docs/docs/connections/overview.mdx +++ b/docs/docs/connections/overview.mdx @@ -3,35 +3,79 @@ title: Overview sidebarTitle: Overview --- -To connect your code to Sourcebot you create **connections**. A **connection** is a configuration object that describes how Sourcebot should fetch information from a supported code host. +import SupportedPlatforms from '/snippets/platform-support.mdx' +import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' -There are two ways to define connections: +A **connection** represents Sourcebot's link to a code host platform (GitHub, GitLab, etc). Connections are defined within the [config file](/docs/configuration/config-file) you provide Sourcebot. - - - This is only supported when self-hosting, and is the default mechanism to define connections. Connections are defined in a [JSON file](/self-hosting/more/declarative-config) - and the path to the file is provided through the `CONFIG_PATH` environment variable - - - This is the only way to define connections when using Sourcebot Cloud, and can be configured when self-hosting by enabling [authentication](/self-hosting/more/authentications). +Each connection defines how Sourcebot should authenticate and interact with a particular host, and which repositories to sync and index from that host. Connections are uniquely identified by their name. - In this method, connections are defined and managed within the webapp: +```json wrap icon="code" Example config with two connections +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "connections": { + // 1. A connection to GitHub.com + "github-connection": { + "type": "github", + "repos": [ + "sourcebot-dev/sourcebot" + ], + "token": { + "env": "GITHUB_TOKEN" + } + }, + // 2. A self-hosted GitLab instance + "gitlab-connection": { + "type": "gitlab", + "url": "https://gitlab.example.com", + "groups": [ + "my-group", + "my-other-group/sub-group" + ], + "token": { + "env": "GITLAB_TOKEN" + } + } + } +} +``` - ![Connections page](/images/connection_page.png) - - +Configuration files must conform to the [JSON schema](#schema-reference). -### Supported code hosts +## Connection Syncing - - - - - - - - - - +When a connection is first discovered, or the `resyncConnectionIntervalMs` [setting](/docs/configuration/config-file#settings) has exceeded, the connection will be synced. This consists of: +1. Fetching the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host. +2. Re-indexing the repository. -Missing your code host? [Submit a feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas). \ No newline at end of file +This is processed in a [job queue](/docs/overview#architecture), and is parallelized across multiple worker processes. Jobs will take longer to complete the first time a repository is synced, or when a diff is large. + +On the home page, you can view the sync status of ongoing jobs: + + + +## Platform Connection Guides + +To learn more about how to create a connection for a specific code host, check out the guides below. + + + +Missing your code host? [Submit a feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas). + + +## Schema reference +--- + + +[schemas/v3/index.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/index.json) + + + + diff --git a/docs/docs/connections/request-new.mdx b/docs/docs/connections/request-new.mdx index dc42d9fc..5308736e 100644 --- a/docs/docs/connections/request-new.mdx +++ b/docs/docs/connections/request-new.mdx @@ -2,6 +2,7 @@ sidebarTitle: Request another host url: https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas title: Request another code host +icon: plus --- Is your code host not supported? Please open a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas). \ No newline at end of file diff --git a/docs/docs/deployment-guide.mdx b/docs/docs/deployment-guide.mdx new file mode 100644 index 00000000..d74cf783 --- /dev/null +++ b/docs/docs/deployment-guide.mdx @@ -0,0 +1,89 @@ +--- +title: "Deployment guide" +--- + +import SupportedPlatforms from '/snippets/platform-support.mdx' + +The following guide will walk you through the steps to deploy Sourcebot on your own infrastructure. Sourcebot is distributed as a [single docker container](/docs/overview#architecture) that can be deployed to a k8s cluster, a VM, or any platform that supports docker. + + +Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev). + + + + - Docker -> use [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Mac or Windows. + + + Create a `config.json` file that tells Sourcebot which repositories to sync and index: + + ```bash wrap icon="terminal" Create example config + touch config.json + echo '{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "connections": { + // comments are supported + "starter-connection": { + "type": "github", + "repos": [ + "sourcebot-dev/sourcebot" + ] + } + } + }' > config.json + ``` + + This config creates a single GitHub connection named `starter-connection` that specifies [Sourcebot](https://github.com/sourcebot-dev/sourcebot) as a repo to sync. [Learn more about the config file](/docs/connections/overview). + + + + If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable. + + + In the same directory as `config.json`, run the following command to start your instance: + + ``` bash icon="terminal" Start the Sourcebot container + docker run \ + -p 3000:3000 \ + --pull=always \ + --rm \ + -v $(pwd):/data \ + -e CONFIG_PATH=/data/config.json \ + --name sourcebot \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + + **This command**: + - pulls the latest version of the `sourcebot` docker image. + - mounts the working directory to `/data` in the container to allow Sourcebot to persist data across restarts, and to access the `config.json`. In your local directory, you should see a `.sourcebot` folder created that contains all persistent data. + - runs any pending database migrations. + - starts up all services, including the webserver exposed on port 3000. + - reads `config.json` and starts syncing. + + + + + + Navigate to `http://localhost:3000` and complete the onboarding flow. + + + By default, only email / password authentication is enabled. [Learn more about authentication](/docs/configuration/auth/overview). + + + + + You're all set! If you'd like to setup [Ask Sourcebot](/docs/features/ask/overview), configure a language model [provider](/docs/configuration/language-model-providers) + + + +## Next steps +--- + + + + Learn more about how to connect your code to Sourcebot. + + + Learn more about how to setup SSO, email codes, and other authentication providers. + + \ No newline at end of file diff --git a/docs/docs/agents/overview.mdx b/docs/docs/features/agents/overview.mdx similarity index 60% rename from docs/docs/agents/overview.mdx rename to docs/docs/features/agents/overview.mdx index 86fce12d..3f5a82c6 100644 --- a/docs/docs/agents/overview.mdx +++ b/docs/docs/features/agents/overview.mdx @@ -3,15 +3,15 @@ title: "Agents Overview" sidebarTitle: "Overview" --- - -Have an idea for an agent that we haven't built? Submit a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/feature-requests) on our GitHub - + +Agents are currently a experimental feature. Have an idea for an agent that we haven't built? Submit a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/feature-requests) on our GitHub. + Agents are automations that leverage the code indexed on Sourcebot to perform a specific task. Once you've setup Sourcebot, check out the guides below to configure additional agents. - + An AI agent that reviews your PRs to identify issues \ No newline at end of file diff --git a/docs/docs/agents/review-agent.mdx b/docs/docs/features/agents/review-agent.mdx similarity index 96% rename from docs/docs/agents/review-agent.mdx rename to docs/docs/features/agents/review-agent.mdx index 27d9932d..74582c53 100644 --- a/docs/docs/agents/review-agent.mdx +++ b/docs/docs/features/agents/review-agent.mdx @@ -1,6 +1,6 @@ --- title: AI Code Review Agent -sidebarTitle: AI Code Review Agent +sidebarTitle: AI code review agent --- @@ -10,7 +10,7 @@ codebase that the agent may fetch to perform the review. This agent provides codebase-aware reviews for your PRs. For each diff, this agent fetches relevant context from Sourcebot and feeds it into an LLM for a detailed review of your changes. -The AI Code Review Agent is [open source](https://github.com/sourcebot-dev/sourcebot/tree/main/packages/web/src/features/agents/review-agent) and packaged in [Sourcebot](https://github.com/sourcebot-dev/sourcebot). To get started using this agent, [deploy Sourcebot](/self-hosting/overview) +The AI Code Review Agent is [open source](https://github.com/sourcebot-dev/sourcebot/tree/main/packages/web/src/features/agents/review-agent) and packaged in [Sourcebot](https://github.com/sourcebot-dev/sourcebot). To get started using this agent, [deploy Sourcebot](/docs/deployment-guide) and then follow the configuration instructions below. ![AI Code Review Agent Example](/images/review_agent_example.png) @@ -53,6 +53,7 @@ Before you get started, make sure you have an OpenAPI account that you can creat directory that you mount to Sourcebot ![GitHub App Private Key](/images/github_app_private_key.png) - `OPENAI_API_KEY`: Your OpenAI API key + - `REVIEW_AGENT_API_KEY`: The Sourcebot API key that the review agent uses to hit the Sourcebot API to fetch code context - `REVIEW_AGENT_AUTO_REVIEW_ENABLED` (default: `false`): If enabled, the review agent will automatically review any new or updated PR. If disabled, you must invoke it using the command defined by `REVIEW_AGENT_REVIEW_COMMAND` - `REVIEW_AGENT_REVIEW_COMMAND` (default: `review`): The command that invokes the review agent (ex. `/review`) when a user comments on the PR. Don't include the slash character in this value. @@ -76,6 +77,7 @@ Before you get started, make sure you have an OpenAPI account that you can creat GITHUB_APP_ID: "my-github-app-id" GITHUB_APP_WEBHOOK_SECRET: "my-github-app-webhook-secret" GITHUB_APP_PRIVATE_KEY_PATH: "/data/review-agent-key.pem" + REVIEW_AGENT_API_KEY: "sourcebot-my-key" OPENAI_API_KEY: "sk-proj-my-open-api-key" ``` diff --git a/docs/docs/features/analytics.mdx b/docs/docs/features/analytics.mdx new file mode 100644 index 00000000..0b22acad --- /dev/null +++ b/docs/docs/features/analytics.mdx @@ -0,0 +1,51 @@ +--- +title: Analytics +sidebarTitle: Analytics +--- + +import LicenseKeyRequired from '/snippets/license-key-required.mdx' +import { Callout } from 'nextra/components' + + + + +## Overview + +Analytics provides comprehensive insights into your organization's usage of Sourcebot, helping you understand adoption patterns and +quantify the value of time saved. + +This dashboard is backed by [audit log](/docs/configuration/audit-logs) events. Please ensure you have audit logging enabled in order to see these insights. + + + +## Data Metrics + +### Active Users +Tracks the number of unique users who performed any Sourcebot operation within each time period. This metric helps you understand team adoption +and engagement with Sourcebot. + +![DAU Chart](/images/dau_chart.png) + +### Code Searches +Counts the number of code search operations performed by your team. + +![Code Search Chart](/images/code_search_chart.png) + +### Code Navigation +Tracks "Go to Definition" and "Find All References" navigation actions. Navigation actions help developers quickly move +between code locations and understand code relationships. + +![Code Nav Chart](/images/code_nav_chart.png) + +## Cost Savings Calculator + +The analytics dashboard includes a built-in cost savings calculator that helps you quantify the ROI of using Sourcebot. + +![Cost Savings Chart](/images/cost_savings_chart.png) diff --git a/docs/docs/features/ask/add-model-providers.mdx b/docs/docs/features/ask/add-model-providers.mdx new file mode 100644 index 00000000..19f03add --- /dev/null +++ b/docs/docs/features/ask/add-model-providers.mdx @@ -0,0 +1,5 @@ +--- +sidebarTitle: Configure language models +url: /docs/configuration/language-model-providers +title: Configure Language Models +--- \ No newline at end of file diff --git a/docs/docs/features/ask/overview.mdx b/docs/docs/features/ask/overview.mdx new file mode 100644 index 00000000..cd48b8c9 --- /dev/null +++ b/docs/docs/features/ask/overview.mdx @@ -0,0 +1,56 @@ +--- +title: Overview +--- + +Ask Sourcebot gives you the ability to ask complex questions about your codebase in natural language. + +It uses Sourcebotโ€™s existing [code search](/docs/features/search/overview) and [navigation](/docs/features/code-navigation) tools to allow reasoning models to search your code, +follow code nav references, and provide an answer thatโ€™s rich with inline citations and navigable code snippets. + + + + Learn how to connect your language model to Sourcebot + + + Learn how to index your repos so you can ask questions about them + + + Learn how to self-host Sourcebot in a few simple steps. + + + Try Ask Sourcebot on our public demo instance. + + + + + +# Why do we need another AI dev tool? + +Existing AI dev tools (Cursor, Claude Code, Copilot) are great at generating code. However, we believe one of the hardest parts of being +a software engineer is **understanding code**. + +In this domain, these tools fall short: +- You can only ask questions about the code you have checked out locally +- You get a wall of text that's difficult to parse, requiring you to go back and forth through different code snippets in the response +- The richness of the explanation is limited by the fact that you're in your IDE + +We built Ask Sourcebot to address these problems. With Ask Sourcebot, you can: +- Ask questions about your teams entire codebase (even on repos you don't have locally) +- Easily parse the response with side-by-side citations and code navigation +- Share answers with your team to spread the knowledge + +Being a web app is less convenient than being in your IDE, but it allows Sourcebot to provide responses in a richer UI that isn't constrained by the IDE. + +We believe this experience of understanding your codebase is superior, and we hope you find it useful. We'd love to know what you think! Feel free to join the discussion on our +[GitHub](https://github.com/sourcebot-dev/sourcebot/discussions). + +# Troubleshooting + +- **Network timeouts**: If you are hitting generic "network error" message while the answer is streaming when Sourcebot is deployed in a production environment, it may be due to your load balancer or proxy not being configured to handle long-lived connections. The timeout should be configured to a sufficiently large value (e.g., 5 minutes). \ No newline at end of file diff --git a/docs/docs/features/code-navigation.mdx b/docs/docs/features/code-navigation.mdx new file mode 100644 index 00000000..6720556d --- /dev/null +++ b/docs/docs/features/code-navigation.mdx @@ -0,0 +1,43 @@ +--- +title: Code navigation +sidebarTitle: Code navigation +--- + +import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' +import LicenseKeyRequired from '/snippets/license-key-required.mdx' + + + +**Code navigation** allows you to jump between symbol definition and references when viewing source files in Sourcebot. This feature is enabled **automatically** when a valid license key is present and works with all popular programming languages. + + + + +## Features + +| Feature | Description | +|:--------|:------------| +| **Hover popover** | Hovering over a symbol reveals the symbol's definition signature as a inline preview. | +| **Go to definition** | Clicking the "go to definition" button in the popover or clicking the symbol name navigates to the symbol's definition. | +| **Find references** | Clicking the "find all references" button in the popover lists all references in the explore panel. | +| **Explore panel** | Lists all references and definitions for the symbol selected in the popover. | + +## How does it work? + +Code navigation is **search-based**, meaning it uses the same code search engine and [query language](/docs/features/search/syntax-reference) to estimate a symbol's references and definitions. We refer to these estimations as "search heuristics". We have two search heuristics to enable the following operations: + +### Find references +Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search: + +```bash +\\b{symbolName}\\b rev:{git_revision} lang:{language} case:yes +``` + +### Find definitions +Given a `symbolName`, along with information about the file the symbol is contained within (`git_revision`, and `language`), runs the following search: + +```bash +sym:\\b{symbolName}\\b rev:{git_revision} lang:{language} +``` + +Note that the `sym:` prefix is used to filter the search by symbol definitions. These are created at index time by [universal ctags](https://ctags.io/). diff --git a/docs/docs/more/mcp-server.mdx b/docs/docs/features/mcp-server.mdx similarity index 83% rename from docs/docs/more/mcp-server.mdx rename to docs/docs/features/mcp-server.mdx index d992f7de..adfb98ce 100644 --- a/docs/docs/more/mcp-server.mdx +++ b/docs/docs/features/mcp-server.mdx @@ -3,21 +3,25 @@ title: Sourcebot MCP server (@sourcebot/mcp) sidebarTitle: Sourcebot MCP server --- - -This feature is only available when [self-hosting](/self-hosting) with [authentication](/self-hosting/more/authentication) disabled. - - The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is an open standard for providing context to LLMs. The [@sourcebot/mcp](https://www.npmjs.com/package/@sourcebot/mcp) package is a MCP server that enables LLMs to interface with your Sourcebot instance, enabling MCP clients like Cursor, Vscode, and others to have context over your entire codebase. ## Getting Started - Follow the self-hosting [quick start guide](/self-hosting/overview#quick-start-guide) to launch Sourcebot and get your code indexed. The host url of your instance (e.g., `http://localhost:3000`) is passed to the MCP server via the `SOURCEBOT_HOST` url. + Follow the [deployment guide](/docs/deployment-guide) to launch Sourcebot and get your code indexed. The host url of your instance (e.g., `http://localhost:3000`) is passed to the MCP server via the `SOURCEBOT_HOST` url. If a host is not provided, then the server will fallback to using the demo instance hosted at https://demo.sourcebot.dev. You can see the list of repositories indexed [here](https://demo.sourcebot.dev/~/repos). Add additional repositories by [opening a PR](https://github.com/sourcebot-dev/sourcebot/blob/main/demo-site-config.json). + + Create an API key to allow the MCP server to query your Sourcebot instance. To create an API key, login to your Sourcebot instance and navigate to **Settings -> API Keys**: + + ![API Keys UI](/images/api_key.png) + + Copy the API key and set it as the `SOURCEBOT_API_KEY` environment variable. + + Ensure you have [Node.js](https://nodejs.org/en) >= v18.0.0 installed. @@ -39,7 +43,8 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) "command": "npx", "args": ["-y", "@sourcebot/mcp@latest" ], "env": { - "SOURCEBOT_HOST": "http://localhost:3000" + "SOURCEBOT_HOST": "http://localhost:3000", + "SOURCEBOT_API_KEY": "your-api-key" } } } @@ -62,7 +67,8 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) "command": "npx", "args": ["-y", "@sourcebot/mcp@latest" ], "env": { - "SOURCEBOT_HOST": "http://localhost:3000" + "SOURCEBOT_HOST": "http://localhost:3000", + "SOURCEBOT_API_KEY": "your-api-key" } } } @@ -75,19 +81,18 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) - Add the following to your [settings.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers): + Add the following to your [.vscode/mcp.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server-to-your-workspace) file: ```json { - "mcp": { - "servers": { - "sourcebot": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@sourcebot/mcp@latest"] - }, + "servers": { + "sourcebot": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest"], "env": { - "SOURCEBOT_HOST": "http://localhost:3000" + "SOURCEBOT_HOST": "http://localhost:3000", + "SOURCEBOT_API_KEY": "your-api-key" } } } @@ -102,7 +107,7 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) Run the following command: ```sh - claude mcp add sourcebot -e SOURCEBOT_HOST=http://localhost:3000 -- npx -y @sourcebot/mcp@latest + claude mcp add sourcebot -e SOURCEBOT_HOST=http://localhost:3000 -e SOURCEBOT_API_KEY=your-api-key -- npx -y @sourcebot/mcp@latest ``` Replace `http://localhost:3000` with wherever your Sourcebot instance is hosted. @@ -119,7 +124,8 @@ The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) "command": "npx", "args": ["-y", "@sourcebot/mcp@latest"], "env": { - "SOURCEBOT_HOST": "http://localhost:3000" + "SOURCEBOT_HOST": "http://localhost:3000", + "SOURCEBOT_API_KEY": "your-api-key" } } } @@ -176,6 +182,7 @@ Parameters: | Name | Default | Description | |:-------------------------|:-----------------------|:--------------------------------------------------| | `SOURCEBOT_HOST` | http://localhost:3000 | URL of your Sourcebot instance. | +| `SOURCEBOT_API_KEY` | - | Sourcebot API key. | | `DEFAULT_MINIMUM_TOKENS` | 10000 | Minimum number of tokens to return in responses. | | `DEFAULT_MATCHES` | 10000 | Number of code matches to fetch per search. | | `DEFAULT_CONTEXT_LINES` | 5 | Lines of context to include above/below matches. | diff --git a/docs/docs/search/multi-branch-indexing.mdx b/docs/docs/features/search/multi-branch-indexing.mdx similarity index 95% rename from docs/docs/search/multi-branch-indexing.mdx rename to docs/docs/features/search/multi-branch-indexing.mdx index fce1c443..dd64e279 100644 --- a/docs/docs/search/multi-branch-indexing.mdx +++ b/docs/docs/features/search/multi-branch-indexing.mdx @@ -15,7 +15,7 @@ By default, only the default branch of a repository is indexed and can be search Multi-branch indexing is currently limited to 64 branches and tags. If this limitation impacts your use-case, please [open a discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support). -Multi-branch indexing is configured on in the [connection](/docs/connections/overview) using the `revisions.branches` and `revisions.tags` arrays. Glob patterns are supported. For example: +Multi-branch indexing is configured in the [connection](/docs/connections/overview) using the `revisions.branches` and `revisions.tags` arrays. Glob patterns are supported. For example: ```json { diff --git a/docs/docs/features/search/overview.mdx b/docs/docs/features/search/overview.mdx new file mode 100644 index 00000000..5f703a3c --- /dev/null +++ b/docs/docs/features/search/overview.mdx @@ -0,0 +1,40 @@ +--- +title: Overview +--- + +Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more. + + +- **Regex support:** Use regular expressions to find code with precision. +- **Query language:** Scope searches to specific files, repos, languages, symbol definitions and more using a rich [query language](/docs/features/search/syntax-reference). +- **Branch search:** Specify a list of branches to search across ([docs](/docs/features/search/multi-branch-indexing)). +- **Fast & scalable:** Sourcebot uses [trigram indexing](https://en.wikipedia.org/wiki/Trigram_search), allowing it to scale to massive codebases. +- **Syntax highlighting:** Syntax highlighting support for over [100+ languages](https://github.com/sourcebot-dev/sourcebot/blob/57724689303f351c279d37f45b6406f1d5d5d5ab/packages/web/src/lib/codemirrorLanguage.ts#L125). +- **Multi-repository:** Search across all of your repositories in a single search. +- **Search suggestions:** Get search suggestions as you craft your query. +- **Filter panel:** Filter results by repository or by language. + + + + + Learn how to index your repos so you can ask questions about them + + + Learn how to index and search through your branches + + + Learn how to self-host Sourcebot in a few simple steps. + + + Try Sourcebot's code search on our public demo instance. + + + + \ No newline at end of file diff --git a/docs/docs/search/search-contexts.mdx b/docs/docs/features/search/search-contexts.mdx similarity index 86% rename from docs/docs/search/search-contexts.mdx rename to docs/docs/features/search/search-contexts.mdx index d701cc60..e8b7aab4 100644 --- a/docs/docs/search/search-contexts.mdx +++ b/docs/docs/features/search/search-contexts.mdx @@ -1,13 +1,12 @@ --- title: Search contexts -sidebarTitle: Search contexts (EE) +sidebarTitle: Search contexts --- import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' +import LicenseKeyRequired from '/snippets/license-key-required.mdx' - -This feature is only available when [self-hosting](/self-hosting) with an active Enterprise license. Please add your [license key](/self-hosting/license-key) to activate it. - + A **search context** is a user-defined grouping of repositories that helps focus searches on specific areas of your codebase, like frontend, backend, or infrastructure code. Some example queries using search contexts: @@ -16,7 +15,7 @@ A **search context** is a user-defined grouping of repositories that helps focus - `( 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. +Search contexts are defined in the `context` object inside of a [declarative config](/docs/configuration/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 @@ -41,7 +40,7 @@ shared/ โ”œโ”€ ... ``` -To make searching easier, we can create three search contexts in our [config.json](/self-hosting/more/declarative-config): +To make searching easier, we can create three search contexts in our [config.json](/docs/configuration/declarative-config): - `web`: For all frontend-related code - `backend`: For backend services and shared APIs - `pipelines`: For all CI/CD configurations @@ -105,7 +104,7 @@ 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/search/syntax-reference) for more details on the search query syntax. +See [this doc](/docs/features/search/syntax-reference) for more details on the search query syntax. ## Schema reference diff --git a/docs/docs/search/syntax-reference.mdx b/docs/docs/features/search/syntax-reference.mdx similarity index 93% rename from docs/docs/search/syntax-reference.mdx rename to docs/docs/features/search/syntax-reference.mdx index 30cfb109..cde52d0e 100644 --- a/docs/docs/search/syntax-reference.mdx +++ b/docs/docs/features/search/syntax-reference.mdx @@ -32,4 +32,4 @@ Expressions can be prefixed with certain keywords to modify search behavior. Som | `rev:` | Filter results from a specific branch or tag. By default **only** the default branch is searched. | `rev:beta` - Filter results to branches that match regex `/beta/` | | `lang:` | Filter results by language (as defined by [linguist](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml)). By default all languages are searched. | `lang:TypeScript` - Filter results to TypeScript files
`-lang:YAML` - Ignore results from YAML files | | `sym:` | Match symbol definitions created by [universal ctags](https://ctags.io/) at index time. | `sym:\bmain\b` - Filter results to symbols that match regex `/\bmain\b/` | -| `context:` | Filter results to a predefined [search context](/docs/search/search-contexts). | `context:web` - Filter results to the web context
`-context:pipelines` - Ignore results from the pipelines context | \ No newline at end of file +| `context:` | Filter results to a predefined [search context](/docs/features/search/search-contexts). | `context:web` - Filter results to the web context
`-context:pipelines` - Ignore results from the pipelines context | \ No newline at end of file diff --git a/docs/docs/getting-started-selfhost.mdx b/docs/docs/getting-started-selfhost.mdx deleted file mode 100644 index 17113ea6..00000000 --- a/docs/docs/getting-started-selfhost.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -sidebarTitle: Quick start guide (self-host) -url: /self-hosting/overview ---- - -{/*This page acts as a navigation link*/} - -[Quick start guide (self-host)](/self-hosting/overview) \ No newline at end of file diff --git a/docs/docs/getting-started.mdx b/docs/docs/getting-started.mdx deleted file mode 100644 index 2d78b61c..00000000 --- a/docs/docs/getting-started.mdx +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Cloud quick start guide -sidebarTitle: Quick start guide (cloud) ---- - -Looking for a self-hosted solution? Checkout our [self-hosting docs](/self-hosting/overview). - -This page will provide a quick walkthrough of how to get onboarded on Sourcebot, import your code, and start searching. - -{/*@todo: record a quick start guide - -*/} - - - - Head over to [app.sourcebot.dev](https://app.sourcebot.dev) and create an account. - - - - After logging in, you'll be asked to create an organization. You'll invite your team members to this organization later so they can also use Sourcebot. - - ![Org Creation](/images/org_create.png) - - - - After selecting a code host you want to connect to, you'll be presented with the connection creation page. This page has the following three inputs: - - Connection name (required): The name of the connection within Sourcebot - - Secret (optional): An [access token](/access-tokens/overview) that is used to fetch private repos - - Configuration: The JSON configuration schema that defines the repos/orgs to fetch. - - For a more detailed explanation of connections, check out the [Connections](/docs/connections/overview) page. - - The example below shows a connection named `sourcebot-org` that fetches all of the repos for the `sourcebot-dev` GitHub organization, but excludes the `sourcebot-dev/zoekt` repo - - This page won't let you continue with an invalid connection schema. If you're hitting errors, make sure the input you're providing is a valid JSON - ![Connection Create Example](/images/create_connection_example.png) - - - -### Search - -Once you create your organization's first connection successfully, you'll be redirected to your org's main search page. From here, you can use the search bar to search across all -of the repos you've indexed - -![Onboard Complete](/images/onboard_complete.png) - -Congrats, you've successfuly setup Sourcebot! Read on to learn more about the Sourcebot's capabilities. Checkout the [Connections](/docs/connections/overview) page to learn how to control which repos Sourcebot fetches \ No newline at end of file diff --git a/docs/docs/license-key.mdx b/docs/docs/license-key.mdx new file mode 100644 index 00000000..64b04f4e --- /dev/null +++ b/docs/docs/license-key.mdx @@ -0,0 +1,45 @@ +--- +title: License key +sidebarTitle: License key +--- + + +If you'd like a trial license, [reach out](https://www.sourcebot.dev/contact) and we'll send one over within 24 hours + + +All core Sourcebot features are available in Sourcebot OSS (MIT Licensed) without any limits. Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details. + + +## Activating a license key +--- + +After purchasing a license key, you can activate it by setting the `SOURCEBOT_EE_LICENSE_KEY` environment variable. + +```bash +docker run \ + -e SOURCEBOT_EE_LICENSE_KEY= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest +``` + +## Feature availability +--- + +| Feature | OSS | Licensed | +|:---------|:-----|:----------| +| [Search](/docs/features/search/syntax-reference) | โœ… | โœ… | +| [Full code host support](/docs/connections/overview) | โœ… | โœ… | +| [MCP Server](/docs/features/mcp-server) | โœ… | โœ… | +| [Agents](/docs/features/agents/overview) | โœ… | โœ… | +| [Login with credentials](/docs/configuration/auth/overview) | โœ… | โœ… | +| [Login with email codes](/docs/configuration/auth/overview) | โœ… | โœ… | +| [Login with SSO](/docs/configuration/auth/overview#enterprise-authentication-providers) | ๐Ÿ›‘ | โœ… | +| [Code navigation](/docs/features/code-navigation) | ๐Ÿ›‘ | โœ… | +| [Search contexts](/docs/features/search/search-contexts) | ๐Ÿ›‘ | โœ… | +| [Audit logs](/docs/configuration/audit-logs) | ๐Ÿ›‘ | โœ… | +| [Analytics](/docs/features/analytics) | ๐Ÿ›‘ | โœ… | + + +## Questions? + +If you have any questions regarding licensing, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file diff --git a/docs/docs/more/roles-and-permissions.mdx b/docs/docs/more/roles-and-permissions.mdx deleted file mode 100644 index 92ff91a7..00000000 --- a/docs/docs/more/roles-and-permissions.mdx +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Roles and Permissions ---- - -Looking to sync permissions with your identify provider? We're working on it - [reach out](https://www.sourcebot.dev/contact) to us to learn more - -If you're using Sourcebot Cloud, or are self-hosting with [authentication](/self-hosting/more/authentication) enabled, you may have multiple members in your organization. Each -member has a role which defines their permissions: - -| Role | Permission | -| :--- | :--------- | -| `Owner` | Each organization has a single `Owner`. This user has full access rights, including: connection management, organization management, and inviting new members. | -| `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, but may not manage the organization or its connections. | \ No newline at end of file diff --git a/docs/docs/overview.mdx b/docs/docs/overview.mdx index 04aa3bd9..2363323b 100644 --- a/docs/docs/overview.mdx +++ b/docs/docs/overview.mdx @@ -2,19 +2,225 @@ title: "Overview" --- -Sourcebot is an **[open-source](https://github.com/sourcebot-dev/sourcebot) code search tool** that is purpose built to search multi-million line codebases in seconds. It integrates with [GitHub](/docs/connections/github), [GitLab](/docs/connections/gitlab), and [other platforms](/docs/connections). +[Sourcebot](https://github.com/sourcebot-dev/sourcebot) is a self-hosted tool that helps you understand your codebase. -## Getting Started +- [Code search](/docs/features/search/overview): Search and navigate across all your repos and branches, no matter where theyโ€™re hosted +- [Ask Sourcebot](/docs/features/ask): Ask questions about your codebase and have Sourcebot provide detailed answers grounded with inline citations -There are two ways to get started using Sourcebot: - - - - Deploy Sourcebot on your own infrastructure. - - - Use Sourcebot on our managed infrastructure. - + + + Learn how to self-host Sourcebot in a few simple steps. + + + Check out Sourcebot's features on our public demo instance. + -We also have a [public demo](https://demo.sourcebot.dev) if you'd like to try Sourcebot out before registering. + + + - **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more. + - **Self-hosted:** Deploy it in minutes using our official [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). All of your data stays on your machine. + - **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc. + - **Scalable:** Scales to millions of lines of code. + - **Open-source:** Core features are MIT licensed. + + + +## Features +--- + + + Find an overview of all Sourcebot features below. For details, see the individual documentation pages. + + +### Ask Sourcebot + +[Ask Sourcebot](/docs/features/ask) gives you the ability to ask complex questions about your codebase, and have Sourcebot provide detailed answers with inline citations. + + +- **Bring your own model:** [Configure](/docs/configuration/language-model-providers) to any language model you'd like +- **Inline citations:** Every answer Sourcebot provides is grounded with inline citations directly into your codebase +- **Mutli-repo:** Ask questions about any repository you have indexed on Sourcebot + + + + +### Code Search + +Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more. + + +- **Regex support:** Use regular expressions to find code with precision. +- **Query language:** Scope searches to specific files, repos, languages, symbol definitions and more using a rich [query language](/docs/features/search/syntax-reference). +- **Branch search:** Specify a list of branches to search across ([docs](/docs/features/search/multi-branch-indexing)). +- **Fast & scalable:** Sourcebot uses [trigram indexing](https://en.wikipedia.org/wiki/Trigram_search), allowing it to scale to massive codebases. +- **Syntax highlighting:** Syntax highlighting support for over [100+ languages](https://github.com/sourcebot-dev/sourcebot/blob/57724689303f351c279d37f45b6406f1d5d5d5ab/packages/web/src/lib/codemirrorLanguage.ts#L125). +- **Multi-repository:** Search across all of your repositories in a single search. +- **Search suggestions:** Get search suggestions as you craft your query. +- **Filter panel:** Filter results by repository or by language. + + + + +### Code Navigation + +[Code navigation](/docs/features/code-navigation) helps you jump between symbol definitions and references quickly when browsing source code in Sourcebot. + + +- **Hover popover:** Hovering over a symbol reveals the symbol's definition signature in a inline preview. +- **Go to definition:** Navigate to a symbol's definition(s). +- **Find references:** Get all references to a symbol. +- **Cross-repository:** Sourcebot can resolve references and definitions across repositories. + + + + + +### Cross code-host support + +Connect your code from multiple code-host platforms and search across all of them from a single interface. + + +- **Auto re-syncing:** Sourcebot will periodically sync with code hosts to pull the latest changes. +- **Flexible configuration:** Sourcebot uses an expressive [JSON schema](/docs/connections/overview) config format to specify exactly what repositories to index (and what not to index). +- **Parallel indexing:** Repositories are indexed in parallel. + + + + + + + + + + + + + + + + + + + + + +### Authentication + +Sourcebot comes with built-in support for authentication via [email/password](/docs/configuration/auth/overview#email-%2F-password), [email codes](/docs/configuration/auth/overview#email-codes), and various [SSO providers](/docs/configuration/auth/overview#enterprise-authentication-providers). + + +- **Configurable auth providers:** Configure the auth providers that are available to your team. +- **SSO:** Support for various SSO providers. +- **_(coming soon)_ RBAC:** Role-based access control for managing user permissions. +- **_(coming soon)_ Code host permission syncing:** Sync permissions from GitHub, Gitlab, etc. to Sourcebot. +- **_(coming soon)_ Audit logs:** Audit logs for all actions performed on Sourcebot, such as user login, search, etc. + + + + +### Self-hosted + +Sourcebot is designed to be easily self-hosted, allowing you to deploy it onto your own infrastructure, keeping your code private and secure. + + +- **Easy deployment:** Sourcebot is shipped as a [single docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot) that can be deployed to a k8s cluster, a VM, or any other platform that supports docker. +- **Secure:** Your code **never** leaves your infrastructure. +- **No-vendor lock-in:** Avoid dependency on a third-party SaaS provider; you can modify, extend, or migrate your deployment as needed. + + +## Get started +--- + + + + + + + + +## Architecture +--- + +Sourcebot is shipped as a single docker container that runs a collection of services using [supervisord](https://supervisord.org/): + +![architecture diagram](/images/architecture_diagram.png) + +{/*TODO: outline the different services, how Sourcebot communicates with code hosts, and the different*/} + +Sourcebot consists of the following components: +- **Web Server** : main Next.js web application serving the Sourcebot UI. +- **Backend Worker** : Node.js process that incrementally syncs with code hosts (e.g., GitHub, GitLab etc.) and asynchronously indexes configured repositories. +- **Zoekt** : the [open-source](https://github.com/sourcegraph/zoekt), trigram indexing code search engine that powers Sourcebot under the hood. +- **Postgres** : transactional database for storing business-logic data. +- **Redis Job Queue** : fast in-memory store. Used with [BullMQ](https://docs.bullmq.io/) for queuing asynchronous work. +- **`.sourcebot/` cache** : file-system cache where persistent data is written. + +You can use managed Redis / Postgres services that run outside of the Sourcebot container by providing the `REDIS_URL` and `DATABASE_URL` environment variables, respectively. See the [environment variables](/docs/configuration/environment-variables) doc for more configuration options. + +## Scalability +--- + +One of our design philosophies for Sourcebot is to keep our infrastructure [radically simple](https://www.radicalsimpli.city/) while balancing scalability concerns. Depending on the number of repositories you have indexed and the instance you are running Sourcebot on, you may experience slow search times or other performance degradations. Our recommendation is to vertically scale your instance by increasing the number of CPU cores and memory. + +Sourcebot does not support horizontal scaling at this time, but it is on our roadmap. If this is something your team would be interested in, please contact us at [team@sourcebot.dev](mailto:team@sourcebot.dev). + +## License key +--- + +Sourcebot's core features are available under an [MIT license](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/LICENSE) without any limits. Some [additional features](/docs/license-key#feature-availability) such as SSO and code navigation require a [license key](/docs/license-key). + + + + + + +## Telemetry +--- + +By default, Sourcebot collects anonymized usage data through [PostHog](https://posthog.com/) to help us improve the performance and reliability of our tool. We don't collect or transmit any information related to your codebase. In addition, all events are [sanitized](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/packages/web/src/app/posthogProvider.tsx) to ensure that no sensitive details (ex. ip address, query info) leave your machine. + +The data we collect includes general usage statistics and metadata such as query performance (e.g., search duration, error rates) to monitor the application's health and functionality. This information helps us better understand how Sourcebot is used and where improvements can be made. + +If you'd like to disable all telemetry, you can do so by setting the environment variable `SOURCEBOT_TELEMETRY_DISABLED` to `true`: + +```bash +docker run \ + -e SOURCEBOT_TELEMETRY_DISABLED=true \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest +``` + +If you disabled telemetry correctly, you'll see the following log when starting Sourcebot: + +```sh +Disabling telemetry since SOURCEBOT_TELEMETRY_DISABLED was set. +``` \ No newline at end of file diff --git a/docs/self-hosting/upgrade/v2-to-v3-guide.mdx b/docs/docs/upgrade/v2-to-v3-guide.mdx similarity index 100% rename from docs/self-hosting/upgrade/v2-to-v3-guide.mdx rename to docs/docs/upgrade/v2-to-v3-guide.mdx diff --git a/docs/docs/upgrade/v3-to-v4-guide.mdx b/docs/docs/upgrade/v3-to-v4-guide.mdx new file mode 100644 index 00000000..93477157 --- /dev/null +++ b/docs/docs/upgrade/v3-to-v4-guide.mdx @@ -0,0 +1,61 @@ +--- +title: V3 to V4 Guide +sidebarTitle: V3 to V4 guide +--- + +This guide will walk you through upgrading your Sourcebot deployment from v3 to v4. + + +Please note that the following features are no longer supported in v4: +- Multi-tenancy mode +- Unauthenticated access to a Sourcebot deployment - authentication is now built in by default. Unauthenticated access to a organization can be enabled with an unlimited seat [enterprise license](/docs/license-key) + + +### If your deployment doesn't have authentication enabled + + + + + If your Sourcebot instance is deployed behind a domain (ex. `https://sourcebot.yourcompany.com`) you **must** set the `AUTH_URL` environment variable to your deployment domain. + + + When you visit your new deployment you'll be presented with a sign-in page. Sourcebot now requires authentication, and all users must register and sign-in to the deployment. + + The first account that's registered will be made the owner. By default, you can register using basic credentials which will be stored encrypted within the postgres DB connected to Sourcebot. Check out + the [auth docs](/docs/configuration/auth/overview) to setup additional auth providers. + + + + + Emails can be sent on organization join request/approval by configuring [transactional emails](/docs/configuration/transactional-emails) + + + + + After the first account is created, all new account registrations must be approved by the owner. When new users register onto the deployment they'll be presented with the following request approval page: + + ![Pending Approval Page](/images/pending_approval.png) + + The owner can view and approve join requests by navigating to **Settings -> Members**. Automatic provisioning of accounts is supported when using SSO/Oauth providers, check out the [auth docs](/docs/configuration/auth/overview#enterprise-authentication-providers) for more info + + + + Congrats, you've successfully migrated to v4! Please let us know what you think of the new features by reaching out on our [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) + + + +### If your deployment has authentication enabled + +The only change that's required if your deployment has authentication enabled is to unset the `SOURCEBOT_AUTH_ENABLED` environment variable. New user registrations will now submit a request to join the organization which can be approved by the owner by +navigating to **Settings -> Members**. Emails can be sent on organization join request/approval by configuring [transactional emails](/docs/configuration/transactional-emails) + +### If your deployment uses multi-tenancy mode + +Unfortunately, multi-tenancy mode is no longer officially supported in v4. To upgrade to v4, you'll need to unset the `SOURCEBOT_TENANCY_MODE` environment variable and wipe your Sourcebot cache. You can then follow the [instructions above](/docs/upgrade/v3-to-v4-guide#if-your-deployment-doesnt-have-authentication-enabled) +to finish upgrading to v4 in single-tenant mode. + +## Troubleshooting +- If you're hitting issues with signing into your Sourcebot instance, make sure you're setting `AUTH_URL` correctly to your deployment domain (ex. `https://sourcebot.yourcompany.com`) + + +Having troubles migrating from v3 to v4? Reach out to us on [discord](https://discord.gg/6Fhp27x7Pb) or [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) and we'll try our best to help \ No newline at end of file diff --git a/docs/images/analytics_demo.mp4 b/docs/images/analytics_demo.mp4 new file mode 100644 index 00000000..10b537df Binary files /dev/null and b/docs/images/analytics_demo.mp4 differ diff --git a/docs/images/api_key.png b/docs/images/api_key.png new file mode 100644 index 00000000..64e94e6e Binary files /dev/null and b/docs/images/api_key.png differ diff --git a/docs/images/ask_sourcebot_low_res.mp4 b/docs/images/ask_sourcebot_low_res.mp4 new file mode 100644 index 00000000..25495568 Binary files /dev/null and b/docs/images/ask_sourcebot_low_res.mp4 differ diff --git a/docs/images/bitbucket.png b/docs/images/bitbucket.png new file mode 100644 index 00000000..259e5d1e Binary files /dev/null and b/docs/images/bitbucket.png differ diff --git a/docs/images/code_nav_chart.png b/docs/images/code_nav_chart.png new file mode 100644 index 00000000..e56b117f Binary files /dev/null and b/docs/images/code_nav_chart.png differ diff --git a/docs/images/code_search_chart.png b/docs/images/code_search_chart.png new file mode 100644 index 00000000..81a903ff Binary files /dev/null and b/docs/images/code_search_chart.png differ diff --git a/docs/images/cost_savings_chart.png b/docs/images/cost_savings_chart.png new file mode 100644 index 00000000..42238a4b Binary files /dev/null and b/docs/images/cost_savings_chart.png differ diff --git a/docs/images/dau_chart.png b/docs/images/dau_chart.png new file mode 100644 index 00000000..d1bdb80b Binary files /dev/null and b/docs/images/dau_chart.png differ diff --git a/docs/images/demo.mp4 b/docs/images/demo.mp4 deleted file mode 100644 index e6162d19..00000000 Binary files a/docs/images/demo.mp4 and /dev/null differ diff --git a/docs/images/gerrit.png b/docs/images/gerrit.png new file mode 100644 index 00000000..7e27d840 Binary files /dev/null and b/docs/images/gerrit.png differ diff --git a/docs/images/git.png b/docs/images/git.png new file mode 100644 index 00000000..f4e98b22 Binary files /dev/null and b/docs/images/git.png differ diff --git a/docs/images/gitea.png b/docs/images/gitea.png new file mode 100644 index 00000000..77e228cb Binary files /dev/null and b/docs/images/gitea.png differ diff --git a/docs/images/github.png b/docs/images/github.png new file mode 100644 index 00000000..21e5d953 Binary files /dev/null and b/docs/images/github.png differ diff --git a/docs/images/github_pat_scopes_fine_grained.png b/docs/images/github_pat_scopes_fine_grained.png new file mode 100644 index 00000000..66342a72 Binary files /dev/null and b/docs/images/github_pat_scopes_fine_grained.png differ diff --git a/docs/images/gitlab.png b/docs/images/gitlab.png new file mode 100644 index 00000000..5bf7e3cb Binary files /dev/null and b/docs/images/gitlab.png differ diff --git a/docs/images/invite_link_toggle.png b/docs/images/invite_link_toggle.png new file mode 100644 index 00000000..979033e8 Binary files /dev/null and b/docs/images/invite_link_toggle.png differ diff --git a/docs/images/join_request_email.png b/docs/images/join_request_email.png new file mode 100644 index 00000000..3c076c5d Binary files /dev/null and b/docs/images/join_request_email.png differ diff --git a/docs/images/local.png b/docs/images/local.png new file mode 100644 index 00000000..2c04c81a Binary files /dev/null and b/docs/images/local.png differ diff --git a/docs/images/login.png b/docs/images/login.png index 08d2d591..93ac56d0 100644 Binary files a/docs/images/login.png and b/docs/images/login.png differ diff --git a/docs/images/login_basic.png b/docs/images/login_basic.png new file mode 100644 index 00000000..0aff946b Binary files /dev/null and b/docs/images/login_basic.png differ diff --git a/docs/images/member_approval_toggle.png b/docs/images/member_approval_toggle.png new file mode 100644 index 00000000..e6c2cfac Binary files /dev/null and b/docs/images/member_approval_toggle.png differ diff --git a/docs/images/pending_approval.png b/docs/images/pending_approval.png new file mode 100644 index 00000000..b242a570 Binary files /dev/null and b/docs/images/pending_approval.png differ diff --git a/docs/self-hosting/configuration.mdx b/docs/self-hosting/configuration.mdx deleted file mode 100644 index bf740dcc..00000000 --- a/docs/self-hosting/configuration.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Configuration -sidebarTitle: Configuration ---- - - -## Environment Variables - -Sourcebot accepts a variety of environment variables to fine tune your deployment. - -| Variable | Default | Description | -| :------- | :------ | :---------- | -| `SOURCEBOT_LOG_LEVEL` | `info` |

The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.

| -| `DATABASE_URL` | `postgresql://postgres@ localhost:5432/sourcebot` |

Connection string of your Postgres database. By default, a Postgres database is automatically provisioned at startup within the container.

If you'd like to use a non-default schema, you can provide it as a parameter in the database url

| -| `REDIS_URL` | `redis://localhost:6379` |

Connection string of your Redis instance. By default, a Redis database is automatically provisioned at startup within the container.

| -| `SOURCEBOT_ENCRYPTION_KEY` | - |

Used to encrypt connection secrets. Generated using `openssl rand -base64 24`. Automatically generated at startup if no value is provided.

| -| `AUTH_SECRET` | - |

Used to validate login session cookies. Generated using `openssl rand -base64 33`. Automatically generated at startup if no value is provided.

| -| `AUTH_URL` | - |

URL of your Sourcebot deployment, e.g., `https://example.com` or `http://localhost:3000`. Required when `SOURCEBOT_AUTH_ENABLED` is `true`.

| -| `SOURCEBOT_TENANCY_MODE` | `single` |

The tenancy configuration for Sourcebot. Valid values are `single` or `multi`. See [this doc](/self-hosting/more/tenancy) for more info.

| -| `SOURCEBOT_AUTH_ENABLED` | `false` |

Enables/disables authentication in Sourcebot. If set to `false`, `SOURCEBOT_TENANCY_MODE` must be `single`. See [this doc](/self-hosting/more/authentication) for more info.

| -| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/self-hosting/security/telemetry) for more info.

| -| `DATA_DIR` | `/data` |

The directory within the container to store all persistent data. Typically, this directory will be volume mapped such that data is persisted across container restarts (e.g., `docker run -v $(pwd):/data`)

| -| `DATA_CACHE_DIR` | `$DATA_DIR/.sourcebot` |

The root data directory in which all data written to disk by Sourcebot will be located.

| -| `DATABASE_DATA_DIR` | `$DATA_CACHE_DIR/db` |

The data directory for the default Postgres database.

| -| `REDIS_DATA_DIR` | `$DATA_CACHE_DIR/redis` |

The data directory for the default Redis instance.

| - - -## Additional Features - -There are additional features that can be enabled and configured via environment variables. - - - - - - - - -## Health Check and Version Endpoints - -Sourcebot includes a health check endpoint that indicates if the application is alive, returning `200 OK` if it is: - -```sh -curl http://localhost:3000/api/health -``` - -It also includes a version endpoint to check the current version of the application: - -```sh -curl http://localhost:3000/api/version -``` - -Sample response: - -```json -{ - "version": "v3.0.0" -} -``` \ No newline at end of file diff --git a/docs/self-hosting/license-key.mdx b/docs/self-hosting/license-key.mdx deleted file mode 100644 index ea7c99fa..00000000 --- a/docs/self-hosting/license-key.mdx +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: License key -sidebarTitle: License key ---- - -All core Sourcebot features are available in Sourcebot OSS (MIT Licensed). Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details. - - -## Activating a license key - -After purchasing a license key, you can activate it by setting the `SOURCEBOT_EE_LICENSE_KEY` environment variable. - -```bash -docker run \ - -e SOURCEBOT_EE_LICENSE_KEY= \ - /* additional args */ \ - ghcr.io/sourcebot-dev/sourcebot:latest -``` - -## Questions? - -If you have any questions regarding licensing, please [contact us](mailto:team@sourcebot.dev). \ No newline at end of file diff --git a/docs/self-hosting/more/authentication.mdx b/docs/self-hosting/more/authentication.mdx deleted file mode 100644 index 78c14657..00000000 --- a/docs/self-hosting/more/authentication.mdx +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Authentication -sidebarTitle: Authentication ---- - -SSO is currently not supported. If you'd like SSO, please reach out using our [contact form](https://www.sourcebot.dev/contact) -If you're switching from non-auth, delete the Sourcebot cache (the `.sourcebot` folder) before starting. - -Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported. To enable authentication, set the `SOURCEBOT_AUTH_ENABLED` environment variable to `true`. -When authentication is enabled: - -- [Connection managment](/docs/connections/overview) happens through the UI -- Members must be invited to an organization to gain access -- If you're in single-tenant mode, the first user to register will be made the owner of the default organization. Check out the [roles page](/docs/more/roles-and-permissions) for more info on the different roles and permissions - -![Login Page](/images/login.png) - - -# Authentication Providers - -Make sure the `AUTH_URL` environment variable is [configured correctly](/self-hosting/configuration) when using Sourcebot in a deployed environment. - -To enable an authentication provider in Sourcebot, configure the required environment variables for the provider. Under the hood, Sourcebot uses Auth.js which supports [many providers](https://authjs.dev/getting-started/authentication/oauth). Submit a [feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas) if you want us to add support for a specific provider. - - -## Email / Password ---- -Email / password authentication is enabled by default. It can be **disabled** by setting `AUTH_CREDENTIALS_LOGIN_ENABLED` to `false`. - -## Email codes ---- -Email codes are 6 digit codes sent to a provided email. Email codes are enabled when transactional emails are configured using the following environment variables: - -- `SMTP_CONNECTION_URL` -- `EMAIL_FROM_ADDRESS` - - -See [transactional emails](/self-hosting/more/transactional-emails) for more details. - -## GitHub ---- - -[Auth.js GitHub Provider Docs](https://authjs.dev/getting-started/providers/github) - -**Required environment variables:** -- `AUTH_GITHUB_CLIENT_ID` -- `AUTH_GITHUB_CLIENT_SECRET` - -## Google ---- - -[Auth.js Google Provider Docs](https://next-auth.js.org/providers/google) - -**Required environment variables:** -- `AUTH_GOOGLE_CLIENT_ID` -- `AUTH_GOOGLE_CLIENT_SECRET` - ---- - -# Troubleshooting - -- If you experience issues logging in, logging out, or accessing an organization you should have access to, try clearing your cookies & performing a full page refresh (`Cmd/Ctrl + Shift + R` on most browsers). -- Still not working? Reach out to us on our [discord](https://discord.com/invite/6Fhp27x7Pb) or [github discussions](https://github.com/sourcebot-dev/sourcebot/discussions) \ No newline at end of file diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx deleted file mode 100644 index e6939789..00000000 --- a/docs/self-hosting/overview.mdx +++ /dev/null @@ -1,136 +0,0 @@ ---- -title: Self-host Sourcebot -sidebarTitle: Overview ---- - -Want a managed solution? Checkout [Sourcebot Cloud](/docs/getting-started). - -Sourcebot is open source and can be self-hosted using our official [Docker image](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). - -## Quick Start Guide - -{/*@todo: record a self-hosting quick start guide - -*/} - - - - By default, Sourcebot requires a configuration file with a list of [code host connections](/docs/connections/overview) that specify what repositories should be **synced** (cloned and indexed). To get started, run the following command to create a starter `config.json`: - - ```bash - touch config.json - echo '{ - "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", - "connections": { - // Comments are supported - "starter-connection": { - "type": "github", - "repos": [ - "sourcebot-dev/sourcebot" - ] - } - } - }' > config.json - ``` - - This config creates a single GitHub connection named `starter-connection` that specifies [Sourcebot](https://github.com/sourcebot-dev/sourcebot) as a repo to sync. - - - - Sourcebot is packaged as a [single Docker image](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). In the same directory as `config.json`, run the following command to start your instance: - - ``` bash - docker run \ - -p 3000:3000 \ - --pull=always \ - --rm \ - -v $(pwd):/data \ - -e CONFIG_PATH=/data/config.json \ - --name sourcebot \ - ghcr.io/sourcebot-dev/sourcebot:latest - ``` - - Navigate to `localhost:3000` to start searching the Sourcebot repo. - - - **This command**: - - pulls the latest version of the `sourcebot` docker image. - - mounts the working directory to `/data` in the container to allow Sourcebot to persist data across restarts, and to access the `config.json`. In your local directory, you should see a `.sourcebot` folder created that contains all persistent data. - - runs any pending database migrations. - - starts up all services, including the webserver exposed on port 3000. - - reads `config.json` and starts syncing. - - - Hit an issue? Please let us know on [GitHub discussions](https://github.com/sourcebot-dev/sourcebot/discussions/categories/support) or by [emailing us](mailto:team@sourcebot.dev). - - - - Sourcebot supports indexing public & private code on the following code hosts: - - - - - - - - - - - - - Missing your code host? [Submit a feature request on GitHub](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas). - - - -## Architecture - -Sourcebot is shipped as a single docker container that runs a collection of services using [supervisord](https://supervisord.org/): - -![architecture diagram](/images/architecture_diagram.png) - -{/*TODO: outline the different services, how Sourcebot communicates with code hosts, and the different*/} - -Sourcebot consists of the following components: -- **Web Server** : main Next.js web application serving the Sourcebot UI. -- **Backend Worker** : Node.js process that incrementally syncs with code hosts (e.g., GitHub, GitLab etc.) and asynchronously indexes configured repositories. -- **Zoekt** : the [open-source](https://github.com/sourcegraph/zoekt), trigram indexing code search engine that powers Sourcebot under the hood. -- **Postgres** : transactional database for storing business-logic data. -- **Redis Job Queue** : fast in-memory store. Used with [BullMQ](https://docs.bullmq.io/) for queuing asynchronous work. -- **`.sourcebot/` cache** : file-system cache where persistent data is written. - -You can use managed Redis / Postgres services that run outside of the Sourcebot container by providing the `REDIS_URL` and `DATABASE_URL` environment variables, respectively. See the [configuration](/self-hosting/configuration) for more configuration options. - -## Scalability - -One of our design philosophies for Sourcebot is to keep our infrastructure [radically simple](https://www.radicalsimpli.city/) while balancing scalability concerns. Depending on the number of repositories you have indexed and the instance you are running Sourcebot on, you may experience slow search times or other performance degradations. Our recommendation is to vertically scale your instance by increasing the number of CPU cores and memory. - -Sourcebot does not support horizontal scaling at this time, but it is on our roadmap. If this is something your team would be interested in, please contact us at [team@sourcebot.dev](mailto:team@sourcebot.dev). - - -## Telemetry -By default, Sourcebot collects anonymized usage data through [PostHog](https://posthog.com/) to help us improve the performance and reliability of our tool. We don't collect or transmit any information related to your codebase. In addition, all events are [sanitized](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/packages/web/src/app/posthogProvider.tsx) to ensure that no sensitive details (ex. ip address, query info) leave your machine. - -The data we collect includes general usage statistics and metadata such as query performance (e.g., search duration, error rates) to monitor the application's health and functionality. This information helps us better understand how Sourcebot is used and where improvements can be made. - -If you'd like to disable all telemetry, you can do so by setting the environment variable `SOURCEBOT_TELEMETRY_DISABLED` to `true`: - -```bash -docker run \ - -e SOURCEBOT_TELEMETRY_DISABLED=true \ - /* additional args */ \ - ghcr.io/sourcebot-dev/sourcebot:latest -``` - -If you disabled telemetry correctly, you'll see the following log when starting Sourcebot: - -```sh -Disabling telemetry since SOURCEBOT_TELEMETRY_DISABLED was set. -``` \ No newline at end of file diff --git a/docs/snippets/bitbucket-app-password.mdx b/docs/snippets/bitbucket-app-password.mdx index ac8a1e27..1f52e79c 100644 --- a/docs/snippets/bitbucket-app-password.mdx +++ b/docs/snippets/bitbucket-app-password.mdx @@ -1,6 +1,6 @@ - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` and `user` (username associated with the app password you created) properties to your connection config: ```json @@ -27,7 +27,7 @@ - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/docs/configuration/auth/overview) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your access token: diff --git a/docs/snippets/bitbucket-token.mdx b/docs/snippets/bitbucket-token.mdx index 48f27a87..8b7e1db6 100644 --- a/docs/snippets/bitbucket-token.mdx +++ b/docs/snippets/bitbucket-token.mdx @@ -1,6 +1,6 @@ - Environment variables are only supported in a [declarative config](/self-hosting/more/declarative-config) and cannot be used in the web UI. + Environment variables are only supported in a [declarative config](/docs/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -25,7 +25,7 @@ - Secrets are only supported when [authentication](/self-hosting/more/authentication) is enabled. + Secrets are only supported when [authentication](/docs/configuration/auth/overview) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: diff --git a/docs/snippets/license-key-required.mdx b/docs/snippets/license-key-required.mdx new file mode 100644 index 00000000..603994fd --- /dev/null +++ b/docs/snippets/license-key-required.mdx @@ -0,0 +1,4 @@ + + +This feature is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it. + \ No newline at end of file diff --git a/docs/snippets/platform-support.mdx b/docs/snippets/platform-support.mdx new file mode 100644 index 00000000..38266b2d --- /dev/null +++ b/docs/snippets/platform-support.mdx @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/docs/snippets/schemas/v2/index.schema.mdx b/docs/snippets/schemas/v2/index.schema.mdx index 4fe85eca..df78084a 100644 --- a/docs/snippets/schemas/v2/index.schema.mdx +++ b/docs/snippets/schemas/v2/index.schema.mdx @@ -685,7 +685,7 @@ "type": "string", "pattern": ".+" }, - "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.", "default": [], "examples": [ [ @@ -1387,7 +1387,7 @@ "type": "string", "pattern": ".+" }, - "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.", "default": [], "examples": [ [ @@ -2171,7 +2171,7 @@ "type": "string", "pattern": ".+" }, - "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.", "default": [], "examples": [ [ diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 502b4cb6..51f483af 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -63,6 +63,12 @@ "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "deprecated": true, + "description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.", + "default": false } }, "additionalProperties": false @@ -172,13 +178,19 @@ "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "deprecated": true, + "description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.", + "default": false } }, "additionalProperties": false }, "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/docs/search/search-contexts", + "description": "[Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/docs/features/search/search-contexts", "patternProperties": { "^[a-zA-Z0-9_-]+$": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -1124,6 +1136,1500 @@ } }, "additionalProperties": false + }, + "models": { + "type": "array", + "description": "Defines a collection of language models that are available to Sourcebot.", + "items": { + "type": "object", + "title": "LanguageModel", + "definitions": { + "AmazonBedrockLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AzureLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "DeepSeekLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleGenerativeAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexAnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "MistralLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenRouterLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "XaiLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + ] + } } }, "additionalProperties": false diff --git a/docs/snippets/schemas/v3/languageModel.schema.mdx b/docs/snippets/schemas/v3/languageModel.schema.mdx new file mode 100644 index 00000000..2df94d6f --- /dev/null +++ b/docs/snippets/schemas/v3/languageModel.schema.mdx @@ -0,0 +1,1493 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "type": "object", + "title": "LanguageModel", + "definitions": { + "AmazonBedrockLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AzureLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "DeepSeekLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleGenerativeAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexAnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "MistralLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenRouterLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "XaiLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + ] +} +``` diff --git a/package.json b/package.json index e4781586..59f74fe5 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,13 @@ "dev:prisma:migrate:dev": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:dev", "dev:prisma:studio": "yarn with-env yarn workspace @sourcebot/db prisma:studio", "dev:prisma:migrate:reset": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:reset", - "build:deps": "yarn workspaces foreach -R --from '{@sourcebot/schemas,@sourcebot/error,@sourcebot/crypto,@sourcebot/db}' run build" + "dev:prisma:db:push": "yarn with-env yarn workspace @sourcebot/db prisma:db:push", + "build:deps": "yarn workspaces foreach -R --from '{@sourcebot/schemas,@sourcebot/error,@sourcebot/crypto,@sourcebot/db,@sourcebot/shared}' run build" }, "devDependencies": { "cross-env": "^7.0.3", "dotenv-cli": "^8.0.0", "npm-run-all": "^4.1.5" }, - "packageManager": "yarn@4.7.0", - "dependencies": { - "@coderabbitai/bitbucket": "^1.1.3" - } + "packageManager": "yarn@4.7.0" } diff --git a/packages/backend/package.json b/packages/backend/package.json index a7adf99a..6338b0bb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,9 +22,8 @@ "vitest": "^2.1.9" }, "dependencies": { + "@coderabbitai/bitbucket": "^1.1.3", "@gitbeaker/rest": "^40.5.1", - "@logtail/node": "^0.5.2", - "@logtail/winston": "^0.5.2", "@octokit/rest": "^21.0.2", "@sentry/cli": "^2.42.2", "@sentry/node": "^9.3.0", @@ -32,10 +31,11 @@ "@sourcebot/crypto": "workspace:*", "@sourcebot/db": "workspace:*", "@sourcebot/error": "workspace:*", + "@sourcebot/logger": "workspace:*", "@sourcebot/schemas": "workspace:*", + "@sourcebot/shared": "workspace:*", "@t3-oss/env-core": "^0.12.0", "@types/express": "^5.0.0", - "ajv": "^8.17.1", "argparse": "^2.0.1", "bullmq": "^5.34.10", "cross-fetch": "^4.0.0", @@ -50,8 +50,6 @@ "posthog-node": "^4.2.1", "prom-client": "^15.1.3", "simple-git": "^3.27.0", - "strip-json-comments": "^5.0.1", - "winston": "^3.15.0", "zod": "^3.24.3" } } diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 5ffdf7e0..e204850c 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -2,7 +2,7 @@ import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server"; import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch"; -import { createLogger } from "./logger.js"; +import { createLogger } from "@sourcebot/logger"; import { PrismaClient } from "@sourcebot/db"; import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import * as Sentry from "@sentry/node"; @@ -13,7 +13,7 @@ import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucke import { processPromiseResults } from "./connectionUtils.js"; import { throwIfAnyFailed } from "./connectionUtils.js"; -const logger = createLogger("Bitbucket"); +const logger = createLogger('bitbucket'); const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org'; const BITBUCKET_CLOUD_API = 'https://api.bitbucket.org/2.0'; const BITBUCKET_CLOUD = "cloud"; diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 132b6f66..f025bdf7 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -2,13 +2,14 @@ import { Connection, ConnectionSyncStatus, PrismaClient, Prisma } from "@sourceb import { Job, Queue, Worker } from 'bullmq'; import { Settings } from "./types.js"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; -import { createLogger } from "./logger.js"; +import { createLogger } from "@sourcebot/logger"; import { Redis } from 'ioredis'; import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig, compileBitbucketConfig, compileGenericGitHostConfig } from "./repoCompileUtils.js"; import { BackendError, BackendException } from "@sourcebot/error"; import { captureEvent } from "./posthog.js"; import { env } from "./env.js"; import * as Sentry from "@sentry/node"; +import { loadConfig, syncSearchContexts } from "@sourcebot/shared"; interface IConnectionManager { scheduleConnectionSync: (connection: Connection) => Promise; @@ -32,7 +33,7 @@ type JobResult = { export class ConnectionManager implements IConnectionManager { private worker: Worker; private queue: Queue; - private logger = createLogger('ConnectionManager'); + private logger = createLogger('connection-manager'); constructor( private db: PrismaClient, @@ -64,6 +65,9 @@ export class ConnectionManager implements IConnectionManager { connectionName: connection.name, orgId: connection.orgId, config: connectionConfig, + }, { + removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE, + removeOnFail: env.REDIS_REMOVE_ON_FAIL, }); this.logger.info(`Added job to queue for connection ${connection.name} (id: ${connection.id})`); }).catch((err: unknown) => { @@ -261,7 +265,7 @@ export class ConnectionManager implements IConnectionManager { private async onSyncJobCompleted(job: Job, result: JobResult) { this.logger.info(`Connection sync job for connection ${job.data.connectionName} (id: ${job.data.connectionId}, jobId: ${job.id}) completed`); - const { connectionId } = job.data; + const { connectionId, orgId } = job.data; let syncStatusMetadata: Record = (await this.db.connection.findUnique({ where: { id: connectionId }, @@ -286,7 +290,25 @@ export class ConnectionManager implements IConnectionManager { notFound.repos.length > 0 ? ConnectionSyncStatus.SYNCED_WITH_WARNINGS : ConnectionSyncStatus.SYNCED, syncedAt: new Date() } - }) + }); + + // After a connection has synced, we need to re-sync the org's search contexts as + // there may be new repos that match the search context's include/exclude patterns. + if (env.CONFIG_PATH) { + try { + const config = await loadConfig(env.CONFIG_PATH); + + await syncSearchContexts({ + db: this.db, + orgId, + contexts: config.contexts, + }); + } catch (err) { + this.logger.error(`Failed to sync search contexts for connection ${connectionId}: ${err}`); + Sentry.captureException(err); + } + } + captureEvent('backend_connection_sync_job_completed', { connectionId: connectionId, diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index bd6246a0..c0d77f05 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -15,4 +15,5 @@ export const DEFAULT_SETTINGS: Settings = { maxRepoGarbageCollectionJobConcurrency: 8, repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours -} \ No newline at end of file + enablePublicAccess: false // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead +} diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index 512c9dae..735361a8 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -22,7 +22,6 @@ dotenv.config({ export const env = createEnv({ server: { SOURCEBOT_ENCRYPTION_KEY: z.string(), - SOURCEBOT_LOG_LEVEL: z.enum(["info", "debug", "warn", "error"]).default("info"), SOURCEBOT_TELEMETRY_DISABLED: booleanSchema.default("false"), SOURCEBOT_INSTALL_ID: z.string().default("unknown"), NEXT_PUBLIC_SOURCEBOT_VERSION: z.string().default("unknown"), @@ -36,6 +35,8 @@ export const env = createEnv({ FALLBACK_GITEA_CLOUD_TOKEN: z.string().optional(), REDIS_URL: z.string().url().default("redis://localhost:6379"), + REDIS_REMOVE_ON_COMPLETE: numberSchema.default(0), + REDIS_REMOVE_ON_FAIL: numberSchema.default(100), NEXT_PUBLIC_SENTRY_BACKEND_DSN: z.string().optional(), NEXT_PUBLIC_SENTRY_ENVIRONMENT: z.string().optional(), @@ -47,6 +48,9 @@ export const env = createEnv({ CONFIG_PATH: z.string().optional(), CONNECTION_MANAGER_UPSERT_TIMEOUT_MS: numberSchema.default(300000), + REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60), + + GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS: numberSchema.default(60 * 10), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/packages/backend/src/gerrit.ts b/packages/backend/src/gerrit.ts index 1ecb4add..25e3cfa7 100644 --- a/packages/backend/src/gerrit.ts +++ b/packages/backend/src/gerrit.ts @@ -1,6 +1,6 @@ import fetch from 'cross-fetch'; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/index.type" -import { createLogger } from './logger.js'; +import { createLogger } from '@sourcebot/logger'; import micromatch from "micromatch"; import { measure, fetchWithRetry } from './utils.js'; import { BackendError } from '@sourcebot/error'; @@ -33,7 +33,7 @@ interface GerritWebLink { url: string; } -const logger = createLogger('Gerrit'); +const logger = createLogger('gerrit'); export const getGerritReposFromConfig = async (config: GerritConnectionConfig): Promise => { const url = config.url.endsWith('/') ? config.url : `${config.url}/`; @@ -95,7 +95,7 @@ const fetchAllProjects = async (url: string): Promise => { try { response = await fetch(endpointWithParams); if (!response.ok) { - console.log(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}`); + logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}`); const e = new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, { status: response.status, }); @@ -109,7 +109,7 @@ const fetchAllProjects = async (url: string): Promise => { } const status = (err as any).code; - console.log(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${status}`); + logger.error(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${status}`); throw new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, { status: status, }); diff --git a/packages/backend/src/gitea.ts b/packages/backend/src/gitea.ts index 3c8d134f..aefe1c24 100644 --- a/packages/backend/src/gitea.ts +++ b/packages/backend/src/gitea.ts @@ -2,14 +2,14 @@ import { Api, giteaApi, HttpResponse, Repository as GiteaRepository } from 'gite import { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type'; import { getTokenFromConfig, measure } from './utils.js'; import fetch from 'cross-fetch'; -import { createLogger } from './logger.js'; +import { createLogger } from '@sourcebot/logger'; import micromatch from 'micromatch'; import { PrismaClient } from '@sourcebot/db'; import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js'; import * as Sentry from "@sentry/node"; import { env } from './env.js'; -const logger = createLogger('Gitea'); +const logger = createLogger('gitea'); const GITEA_CLOUD_HOSTNAME = "gitea.com"; export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, orgId: number, db: PrismaClient) => { diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index d976fb8b..376ed039 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -1,6 +1,6 @@ import { Octokit } from "@octokit/rest"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; -import { createLogger } from "./logger.js"; +import { createLogger } from "@sourcebot/logger"; import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import micromatch from "micromatch"; import { PrismaClient } from "@sourcebot/db"; @@ -9,7 +9,7 @@ import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import * as Sentry from "@sentry/node"; import { env } from "./env.js"; -const logger = createLogger("GitHub"); +const logger = createLogger('github'); const GITHUB_CLOUD_HOSTNAME = "github.com"; export type OctokitRepository = { diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index 2981e50e..79ab643b 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -1,6 +1,6 @@ import { Gitlab, ProjectSchema } from "@gitbeaker/rest"; import micromatch from "micromatch"; -import { createLogger } from "./logger.js"; +import { createLogger } from "@sourcebot/logger"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type" import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; import { PrismaClient } from "@sourcebot/db"; @@ -8,7 +8,7 @@ import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; import * as Sentry from "@sentry/node"; import { env } from "./env.js"; -const logger = createLogger("GitLab"); +const logger = createLogger('gitlab'); export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => { @@ -29,6 +29,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o ...(config.url ? { host: config.url, } : {}), + queryTimeout: env.GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS * 1000, }); let allRepos: ProjectSchema[] = []; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 149d3bd4..c93622d6 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -8,31 +8,35 @@ import { AppContext } from "./types.js"; import { main } from "./main.js" import { PrismaClient } from "@sourcebot/db"; import { env } from "./env.js"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('backend-entrypoint'); + // Register handler for normal exit process.on('exit', (code) => { - console.log(`Process is exiting with code: ${code}`); + logger.info(`Process is exiting with code: ${code}`); }); // Register handlers for abnormal terminations process.on('SIGINT', () => { - console.log('Process interrupted (SIGINT)'); - process.exit(130); + logger.info('Process interrupted (SIGINT)'); + process.exit(0); }); process.on('SIGTERM', () => { - console.log('Process terminated (SIGTERM)'); - process.exit(143); + logger.info('Process terminated (SIGTERM)'); + process.exit(0); }); // Register handlers for uncaught exceptions and unhandled rejections process.on('uncaughtException', (err) => { - console.log(`Uncaught exception: ${err.message}`); + logger.error(`Uncaught exception: ${err.message}`); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { - console.log(`Unhandled rejection at: ${promise}, reason: ${reason}`); + logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`); process.exit(1); }); @@ -60,12 +64,13 @@ main(prisma, context) await prisma.$disconnect(); }) .catch(async (e) => { - console.error(e); + logger.error(e); Sentry.captureException(e); await prisma.$disconnect(); process.exit(1); }) .finally(() => { - console.log("Shutting down..."); + logger.info("Shutting down..."); }); + diff --git a/packages/backend/src/instrument.ts b/packages/backend/src/instrument.ts index 754c0641..926bf2ae 100644 --- a/packages/backend/src/instrument.ts +++ b/packages/backend/src/instrument.ts @@ -1,5 +1,8 @@ import * as Sentry from "@sentry/node"; import { env } from "./env.js"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('instrument'); if (!!env.NEXT_PUBLIC_SENTRY_BACKEND_DSN && !!env.NEXT_PUBLIC_SENTRY_ENVIRONMENT) { Sentry.init({ @@ -8,5 +11,5 @@ if (!!env.NEXT_PUBLIC_SENTRY_BACKEND_DSN && !!env.NEXT_PUBLIC_SENTRY_ENVIRONMENT environment: env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, }); } else { - console.debug("Sentry was not initialized"); + logger.debug("Sentry was not initialized"); } diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts deleted file mode 100644 index 1701d7e6..00000000 --- a/packages/backend/src/logger.ts +++ /dev/null @@ -1,47 +0,0 @@ -import winston, { format } from 'winston'; -import { Logtail } from '@logtail/node'; -import { LogtailTransport } from '@logtail/winston'; -import { env } from './env.js'; - -const { combine, colorize, timestamp, prettyPrint, errors, printf, label: labelFn } = format; - - -const createLogger = (label: string) => { - return winston.createLogger({ - level: env.SOURCEBOT_LOG_LEVEL, - format: combine( - errors({ stack: true }), - timestamp(), - prettyPrint(), - labelFn({ - label: label, - }) - ), - transports: [ - new winston.transports.Console({ - format: combine( - errors({ stack: true }), - colorize(), - printf(({ level, message, timestamp, stack, label: _label }) => { - const label = `[${_label}] `; - if (stack) { - return `${timestamp} ${level}: ${label}${message}\n${stack}`; - } - return `${timestamp} ${level}: ${label}${message}`; - }), - ), - }), - ...(env.LOGTAIL_TOKEN && env.LOGTAIL_HOST ? [ - new LogtailTransport( - new Logtail(env.LOGTAIL_TOKEN, { - endpoint: env.LOGTAIL_HOST, - }) - ) - ] : []), - ] - }); -} - -export { - createLogger -}; \ No newline at end of file diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 7a22e9e5..f3cf0050 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -1,5 +1,5 @@ import { PrismaClient } from '@sourcebot/db'; -import { createLogger } from "./logger.js"; +import { createLogger } from "@sourcebot/logger"; import { AppContext } from "./types.js"; import { DEFAULT_SETTINGS } from './constants.js'; import { Redis } from 'ioredis'; @@ -7,40 +7,16 @@ import { ConnectionManager } from './connectionManager.js'; import { RepoManager } from './repoManager.js'; import { env } from './env.js'; import { PromClient } from './promClient.js'; -import { isRemotePath } from './utils.js'; -import { readFile } from 'fs/promises'; -import stripJsonComments from 'strip-json-comments'; -import { SourcebotConfig } from '@sourcebot/schemas/v3/index.type'; -import { indexSchema } from '@sourcebot/schemas/v3/index.schema'; -import { Ajv } from "ajv"; +import { loadConfig } from '@sourcebot/shared'; -const logger = createLogger('main'); -const ajv = new Ajv({ - validateFormats: false, -}); +const logger = createLogger('backend-main'); const getSettings = async (configPath?: string) => { if (!configPath) { return DEFAULT_SETTINGS; } - 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)}`); - } + const config = await loadConfig(configPath); return { ...DEFAULT_SETTINGS, @@ -56,7 +32,7 @@ export const main = async (db: PrismaClient, context: AppContext) => { logger.info('Connected to redis'); }).catch((err: unknown) => { logger.error('Failed to connect to redis'); - console.error(err); + logger.error(err); process.exit(1); }); @@ -68,5 +44,6 @@ export const main = async (db: PrismaClient, context: AppContext) => { connectionManager.registerPollingCallback(); const repoManager = new RepoManager(db, settings, redis, promClient, context); + await repoManager.validateIndexedReposHaveShards(); await repoManager.blockingPollLoop(); } diff --git a/packages/backend/src/promClient.ts b/packages/backend/src/promClient.ts index 8e8c4372..058cfe0b 100644 --- a/packages/backend/src/promClient.ts +++ b/packages/backend/src/promClient.ts @@ -1,5 +1,8 @@ import express, { Request, Response } from 'express'; import client, { Registry, Counter, Gauge } from 'prom-client'; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('prometheus-client'); export class PromClient { private registry: Registry; @@ -96,7 +99,7 @@ export class PromClient { }); this.app.listen(this.PORT, () => { - console.log(`Prometheus metrics server is running on port ${this.PORT}`); + logger.info(`Prometheus metrics server is running on port ${this.PORT}`); }); } diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index b2814ebf..0013cd89 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -9,7 +9,7 @@ import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitb import { Prisma, PrismaClient } from '@sourcebot/db'; import { WithRequired } from "./types.js" import { marshalBool } from "./utils.js"; -import { createLogger } from './logger.js'; +import { createLogger } from '@sourcebot/logger'; import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { RepoMetadata } from './types.js'; import path from 'path'; @@ -20,7 +20,7 @@ import GitUrlParse from 'git-url-parse'; export type RepoData = WithRequired; -const logger = createLogger('RepoCompileUtils'); +const logger = createLogger('repo-compile-utils'); export const compileGithubConfig = async ( config: GithubConnectionConfig, @@ -121,7 +121,10 @@ export const compileGitlabConfig = async ( const isFork = project.forked_from_project !== undefined; const repoDisplayName = project.path_with_namespace; const repoName = path.join(repoNameRoot, repoDisplayName); - + // project.avatar_url is not directly accessible with tokens; use the avatar API endpoint if available + const avatarUrl = project.avatar_url + ? new URL(`/api/v4/projects/${project.id}/avatar`, hostUrl).toString() + : null; logger.debug(`Found gitlab repo ${repoDisplayName} with webUrl: ${projectUrl}`); const record: RepoData = { @@ -132,7 +135,7 @@ export const compileGitlabConfig = async ( webUrl: projectUrl, name: repoName, displayName: repoDisplayName, - imageUrl: project.avatar_url, + imageUrl: avatarUrl, isFork: isFork, isArchived: !!project.archived, org: { @@ -373,7 +376,7 @@ export const compileBitbucketConfig = async ( throw new Error(`No links found for ${isServer ? 'server' : 'cloud'} repo ${repoName}`); } - // In server case we get an array of lenth == 1 links in the self field, while in cloud case we get a single + // In server case we get an array of length == 1 links in the self field, while in cloud case we get a single // link object in the html field const link = isServer ? (repoLinks.self as { name: string, href: string }[])?.[0] : repoLinks.html as { href: string }; if (!link || !link.href) { diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index 6f357d15..491e9d1d 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -1,6 +1,6 @@ import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; -import { createLogger } from "./logger.js"; +import { createLogger } from "@sourcebot/logger"; import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { AppContext, Settings, repoMetadataSchema } from "./types.js"; @@ -10,8 +10,10 @@ import { existsSync, readdirSync, promises } from 'fs'; import { indexGitRepository } from "./zoekt.js"; import { PromClient } from './promClient.js'; import * as Sentry from "@sentry/node"; +import { env } from './env.js'; interface IRepoManager { + validateIndexedReposHaveShards: () => Promise; blockingPollLoop: () => void; dispose: () => void; } @@ -28,12 +30,13 @@ type RepoGarbageCollectionPayload = { repo: Repo, } +const logger = createLogger('repo-manager'); + export class RepoManager implements IRepoManager { private indexWorker: Worker; private indexQueue: Queue; private gcWorker: Worker; private gcQueue: Queue; - private logger = createLogger('RepoManager'); constructor( private db: PrismaClient, @@ -104,8 +107,10 @@ export class RepoManager implements IRepoManager { name: 'repoIndexJob', data: { repo }, opts: { - priority: priority - } + priority: priority, + removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE, + removeOnFail: env.REDIS_REMOVE_ON_FAIL, + }, }))); // Increment pending jobs counter for each repo added @@ -113,12 +118,12 @@ export class RepoManager implements IRepoManager { this.promClient.pendingRepoIndexingJobs.inc({ repo: repo.id.toString() }); }); - this.logger.info(`Added ${orgRepos.length} jobs to indexQueue for org ${orgId} with priority ${priority}`); + logger.info(`Added ${orgRepos.length} jobs to indexQueue for org ${orgId} with priority ${priority}`); } }).catch((err: unknown) => { - this.logger.error(`Failed to add jobs to indexQueue for repos ${repos.map(repo => repo.id).join(', ')}: ${err}`); + logger.error(`Failed to add jobs to indexQueue for repos ${repos.map(repo => repo.id).join(', ')}: ${err}`); }); } @@ -169,14 +174,14 @@ export class RepoManager implements IRepoManager { // We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to // fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each // may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This - // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referrencing. + // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing. private async getCloneCredentialsForRepo(repo: RepoWithConnections, db: PrismaClient): Promise<{ username?: string, password: string } | undefined> { for (const { connection } of repo.connections) { if (connection.connectionType === 'github') { const config = connection.config as unknown as GithubConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); return { password: token, } @@ -186,7 +191,7 @@ export class RepoManager implements IRepoManager { else if (connection.connectionType === 'gitlab') { const config = connection.config as unknown as GitlabConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); return { username: 'oauth2', password: token, @@ -197,7 +202,7 @@ export class RepoManager implements IRepoManager { else if (connection.connectionType === 'gitea') { const config = connection.config as unknown as GiteaConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); return { password: token, } @@ -207,7 +212,7 @@ export class RepoManager implements IRepoManager { else if (connection.connectionType === 'bitbucket') { const config = connection.config as unknown as BitbucketConnectionConfig; if (config.token) { - const token = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); + const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const username = config.user ?? 'x-token-auth'; return { username, @@ -228,23 +233,23 @@ export class RepoManager implements IRepoManager { // If the repo was already in the indexing state, this job was likely killed and picked up again. As a result, // to ensure the repo state is valid, we delete the repo if it exists so we get a fresh clone if (repoAlreadyInIndexingState && existsSync(repoPath) && !isReadOnly) { - this.logger.info(`Deleting repo directory ${repoPath} during sync because it was already in the indexing state`); + logger.info(`Deleting repo directory ${repoPath} during sync because it was already in the indexing state`); await promises.rm(repoPath, { recursive: true, force: true }); } if (existsSync(repoPath) && !isReadOnly) { - this.logger.info(`Fetching ${repo.displayName}...`); + logger.info(`Fetching ${repo.displayName}...`); const { durationMs } = await measure(() => fetchRepository(repoPath, ({ method, stage, progress }) => { - this.logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`) + logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`) })); const fetchDuration_s = durationMs / 1000; process.stdout.write('\n'); - this.logger.info(`Fetched ${repo.displayName} in ${fetchDuration_s}s`); + logger.info(`Fetched ${repo.displayName} in ${fetchDuration_s}s`); } else if (!isReadOnly) { - this.logger.info(`Cloning ${repo.displayName}...`); + logger.info(`Cloning ${repo.displayName}...`); const auth = await this.getCloneCredentialsForRepo(repo, this.db); const cloneUrl = new URL(repo.cloneUrl); @@ -263,12 +268,12 @@ export class RepoManager implements IRepoManager { } const { durationMs } = await measure(() => cloneRepository(cloneUrl.toString(), repoPath, ({ method, stage, progress }) => { - this.logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`) + logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`) })); const cloneDuration_s = durationMs / 1000; process.stdout.write('\n'); - this.logger.info(`Cloned ${repo.displayName} in ${cloneDuration_s}s`); + logger.info(`Cloned ${repo.displayName} in ${cloneDuration_s}s`); } // Regardless of clone or fetch, always upsert the git config for the repo. @@ -278,14 +283,14 @@ export class RepoManager implements IRepoManager { await upsertGitConfig(repoPath, metadata.gitConfig); } - this.logger.info(`Indexing ${repo.displayName}...`); + logger.info(`Indexing ${repo.displayName}...`); const { durationMs } = await measure(() => indexGitRepository(repo, this.settings, this.ctx)); const indexDuration_s = durationMs / 1000; - this.logger.info(`Indexed ${repo.displayName} in ${indexDuration_s}s`); + logger.info(`Indexed ${repo.displayName} in ${indexDuration_s}s`); } private async runIndexJob(job: Job) { - this.logger.info(`Running index job (id: ${job.id}) for repo ${job.data.repo.displayName}`); + logger.info(`Running index job (id: ${job.id}) for repo ${job.data.repo.displayName}`); const repo = job.data.repo as RepoWithConnections; // We have to use the existing repo object to get the repoIndexingStatus because the repo object @@ -296,7 +301,7 @@ export class RepoManager implements IRepoManager { }, }); if (!existingRepo) { - this.logger.error(`Repo ${repo.id} not found`); + logger.error(`Repo ${repo.id} not found`); const e = new Error(`Repo ${repo.id} not found`); Sentry.captureException(e); throw e; @@ -328,19 +333,19 @@ export class RepoManager implements IRepoManager { attempts++; this.promClient.repoIndexingReattemptsTotal.inc(); if (attempts === maxAttempts) { - this.logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}) after ${maxAttempts} attempts. Error: ${error}`); + logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}) after ${maxAttempts} attempts. Error: ${error}`); throw error; } - const sleepDuration = 5000 * Math.pow(2, attempts - 1); - this.logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}), attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`); + const sleepDuration = (env.REPO_SYNC_RETRY_BASE_SLEEP_SECONDS * 1000) * Math.pow(2, attempts - 1); + logger.error(`Failed to sync repository ${repo.name} (id: ${repo.id}), attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`); await new Promise(resolve => setTimeout(resolve, sleepDuration)); } } } private async onIndexJobCompleted(job: Job) { - this.logger.info(`Repo index job for repo ${job.data.repo.displayName} (id: ${job.data.repo.id}, jobId: ${job.id}) completed`); + logger.info(`Repo index job for repo ${job.data.repo.displayName} (id: ${job.data.repo.id}, jobId: ${job.id}) completed`); this.promClient.activeRepoIndexingJobs.dec(); this.promClient.repoIndexingSuccessTotal.inc(); @@ -356,7 +361,7 @@ export class RepoManager implements IRepoManager { } private async onIndexJobFailed(job: Job | undefined, err: unknown) { - this.logger.info(`Repo index job for repo ${job?.data.repo.displayName} (id: ${job?.data.repo.id}, jobId: ${job?.id}) failed with error: ${err}`); + logger.info(`Repo index job for repo ${job?.data.repo.displayName} (id: ${job?.data.repo.id}, jobId: ${job?.id}) failed with error: ${err}`); Sentry.captureException(err, { tags: { repoId: job?.data.repo.id, @@ -394,9 +399,13 @@ export class RepoManager implements IRepoManager { await this.gcQueue.addBulk(repos.map(repo => ({ name: 'repoGarbageCollectionJob', data: { repo }, + opts: { + removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE, + removeOnFail: env.REDIS_REMOVE_ON_FAIL, + } }))); - this.logger.info(`Added ${repos.length} jobs to gcQueue`); + logger.info(`Added ${repos.length} jobs to gcQueue`); }); } @@ -425,7 +434,7 @@ export class RepoManager implements IRepoManager { }, }); if (reposWithNoConnections.length > 0) { - this.logger.info(`Garbage collecting ${reposWithNoConnections.length} repos with no connections: ${reposWithNoConnections.map(repo => repo.id).join(', ')}`); + logger.info(`Garbage collecting ${reposWithNoConnections.length} repos with no connections: ${reposWithNoConnections.map(repo => repo.id).join(', ')}`); } //////////////////////////////////// @@ -448,7 +457,7 @@ export class RepoManager implements IRepoManager { }); if (inactiveOrgRepos.length > 0) { - this.logger.info(`Garbage collecting ${inactiveOrgRepos.length} inactive org repos: ${inactiveOrgRepos.map(repo => repo.id).join(', ')}`); + logger.info(`Garbage collecting ${inactiveOrgRepos.length} inactive org repos: ${inactiveOrgRepos.map(repo => repo.id).join(', ')}`); } const reposToDelete = [...reposWithNoConnections, ...inactiveOrgRepos]; @@ -458,7 +467,7 @@ export class RepoManager implements IRepoManager { } private async runGarbageCollectionJob(job: Job) { - this.logger.info(`Running garbage collection job (id: ${job.id}) for repo ${job.data.repo.displayName} (id: ${job.data.repo.id})`); + logger.info(`Running garbage collection job (id: ${job.id}) for repo ${job.data.repo.displayName} (id: ${job.data.repo.id})`); this.promClient.activeRepoGarbageCollectionJobs.inc(); const repo = job.data.repo as Repo; @@ -474,7 +483,7 @@ export class RepoManager implements IRepoManager { // delete cloned repo const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx); if (existsSync(repoPath) && !isReadOnly) { - this.logger.info(`Deleting repo directory ${repoPath}`); + logger.info(`Deleting repo directory ${repoPath}`); await promises.rm(repoPath, { recursive: true, force: true }); } @@ -483,13 +492,13 @@ export class RepoManager implements IRepoManager { const files = readdirSync(this.ctx.indexPath).filter(file => file.startsWith(shardPrefix)); for (const file of files) { const filePath = `${this.ctx.indexPath}/${file}`; - this.logger.info(`Deleting shard file ${filePath}`); + logger.info(`Deleting shard file ${filePath}`); await promises.rm(filePath, { force: true }); } } private async onGarbageCollectionJobCompleted(job: Job) { - this.logger.info(`Garbage collection job ${job.id} completed`); + logger.info(`Garbage collection job ${job.id} completed`); this.promClient.activeRepoGarbageCollectionJobs.dec(); this.promClient.repoGarbageCollectionSuccessTotal.inc(); @@ -501,7 +510,7 @@ export class RepoManager implements IRepoManager { } private async onGarbageCollectionJobFailed(job: Job | undefined, err: unknown) { - this.logger.info(`Garbage collection job failed (id: ${job?.id ?? 'unknown'}) with error: ${err}`); + logger.info(`Garbage collection job failed (id: ${job?.id ?? 'unknown'}) with error: ${err}`); Sentry.captureException(err, { tags: { repoId: job?.data.repo.id, @@ -525,6 +534,61 @@ export class RepoManager implements IRepoManager { } } + /////////////////////////// + // Repo index validation + /////////////////////////// + + public async validateIndexedReposHaveShards() { + logger.info('Validating indexed repos have shards...'); + + const indexedRepos = await this.db.repo.findMany({ + where: { + repoIndexingStatus: RepoIndexingStatus.INDEXED + } + }); + logger.info(`Found ${indexedRepos.length} repos in the DB marked as INDEXED`); + + if (indexedRepos.length === 0) { + return; + } + + const reposToReindex: number[] = []; + + for (const repo of indexedRepos) { + const shardPrefix = getShardPrefix(repo.orgId, repo.id); + + // TODO: this doesn't take into account if a repo has multiple shards and only some of them are missing. To support that, this logic + // would need to know how many total shards are expected for this repo + let hasShards = false; + try { + const files = readdirSync(this.ctx.indexPath); + hasShards = files.some(file => file.startsWith(shardPrefix)); + } catch (error) { + logger.error(`Failed to read index directory ${this.ctx.indexPath}: ${error}`); + continue; + } + + if (!hasShards) { + logger.info(`Repo ${repo.displayName} (id: ${repo.id}) is marked as INDEXED but has no shards on disk. Marking for reindexing.`); + reposToReindex.push(repo.id); + } + } + + if (reposToReindex.length > 0) { + await this.db.repo.updateMany({ + where: { + id: { in: reposToReindex } + }, + data: { + repoIndexingStatus: RepoIndexingStatus.NEW + } + }); + logger.info(`Marked ${reposToReindex.length} repos for reindexing due to missing shards`); + } + + logger.info('Done validating indexed repos have shards'); + } + private async fetchAndScheduleRepoTimeouts() { const repos = await this.db.repo.findMany({ where: { @@ -536,7 +600,7 @@ export class RepoManager implements IRepoManager { }); if (repos.length > 0) { - this.logger.info(`Scheduling ${repos.length} repo timeouts`); + logger.info(`Scheduling ${repos.length} repo timeouts`); await this.scheduleRepoTimeoutsBulk(repos); } } diff --git a/packages/backend/src/utils.test.ts b/packages/backend/src/utils.test.ts index 84d77f21..c24cf6f5 100644 --- a/packages/backend/src/utils.test.ts +++ b/packages/backend/src/utils.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; -import { arraysEqualShallow, isRemotePath } from './utils'; +import { arraysEqualShallow } from './utils'; +import { isRemotePath } from '@sourcebot/shared'; test('should return true for identical arrays', () => { expect(arraysEqualShallow([1, 2, 3], [1, 2, 3])).toBe(true); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 1d95f0fb..3245828d 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -2,8 +2,7 @@ import { Logger } from "winston"; import { AppContext } from "./types.js"; import path from 'path'; import { PrismaClient, Repo } from "@sourcebot/db"; -import { decrypt } from "@sourcebot/crypto"; -import { Token } from "@sourcebot/schemas/v3/shared.type"; +import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto"; import { BackendException, BackendError } from "@sourcebot/error"; import * as Sentry from "@sentry/node"; @@ -21,48 +20,21 @@ export const marshalBool = (value?: boolean) => { return !!value ? '1' : '0'; } -export const isRemotePath = (path: string) => { - return path.startsWith('https://') || path.startsWith('http://'); -} - -export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient, logger?: Logger) => { - if ('secret' in token) { - const secretKey = token.secret; - const secret = await db.secret.findUnique({ - where: { - orgId_key: { - key: secretKey, - orgId - } - } - }); - - if (!secret) { +export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => { + try { + return await getTokenFromConfigBase(token, orgId, db); + } catch (error: unknown) { + if (error instanceof Error) { const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, { - message: `Secret with key ${secretKey} not found for org ${orgId}`, + message: error.message, }); Sentry.captureException(e); - logger?.error(e.metadata.message); + logger?.error(error.message); throw e; } - - const decryptedToken = decrypt(secret.iv, secret.encryptedValue); - return decryptedToken; - } else { - const envToken = process.env[token.env]; - if (!envToken) { - const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, { - message: `Environment variable ${token.env} not found.`, - }); - Sentry.captureException(e); - logger?.error(e.metadata.message); - throw e; - } - - return envToken; + throw error; } -} - +}; export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => { let absolutePath = localPath; @@ -94,6 +66,8 @@ export const arraysEqualShallow = (a?: readonly T[], b?: readonly T[]) => { return true; } +// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`. +// @todo: we should move this to a shared package. export const getRepoPath = (repo: Repo, ctx: AppContext): { path: string, isReadOnly: boolean } => { // If we are dealing with a local repository, then use that as the path. // Mark as read-only since we aren't guaranteed to have write access to the local filesystem. diff --git a/packages/backend/src/zoekt.ts b/packages/backend/src/zoekt.ts index 3294fea8..86a191e4 100644 --- a/packages/backend/src/zoekt.ts +++ b/packages/backend/src/zoekt.ts @@ -5,7 +5,7 @@ import { getRepoPath } from "./utils.js"; import { getShardPrefix } from "./utils.js"; import { getBranches, getTags } from "./git.js"; import micromatch from "micromatch"; -import { createLogger } from "./logger.js"; +import { createLogger } from "@sourcebot/logger"; import { captureEvent } from "./posthog.js"; const logger = createLogger('zoekt'); diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 2a6a185e..abccd406 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -8,6 +8,8 @@ "postinstall": "yarn build" }, "dependencies": { + "@sourcebot/db": "*", + "@sourcebot/schemas": "*", "dotenv": "^16.4.5" }, "devDependencies": { diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index f0128297..8f6ca211 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -1,9 +1,12 @@ import crypto from 'crypto'; +import fs from 'fs'; import { SOURCEBOT_ENCRYPTION_KEY } from './environment'; const algorithm = 'aes-256-cbc'; const ivLength = 16; // 16 bytes for CBC +const publicKeyCache = new Map(); + const generateIV = (): Buffer => { return crypto.randomBytes(ivLength); }; @@ -24,6 +27,28 @@ export function encrypt(text: string): { iv: string; encryptedData: string } { return { iv: iv.toString('hex'), encryptedData: encrypted }; } +export function hashSecret(text: string): string { + if (!SOURCEBOT_ENCRYPTION_KEY) { + throw new Error('Encryption key is not set'); + } + + return crypto.createHmac('sha256', SOURCEBOT_ENCRYPTION_KEY).update(text).digest('hex'); +} + +export function generateApiKey(): { key: string; hash: string } { + if (!SOURCEBOT_ENCRYPTION_KEY) { + throw new Error('Encryption key is not set'); + } + + const secret = crypto.randomBytes(32).toString('hex'); + const hash = hashSecret(secret); + + return { + key: `sourcebot-${secret}`, + hash, + }; +} + export function decrypt(iv: string, encryptedText: string): string { if (!SOURCEBOT_ENCRYPTION_KEY) { throw new Error('Encryption key is not set'); @@ -41,3 +66,30 @@ export function decrypt(iv: string, encryptedText: string): string { return decrypted; } + +export function verifySignature(data: string, signature: string, publicKeyPath: string): boolean { + try { + let publicKey = publicKeyCache.get(publicKeyPath); + + if (!publicKey) { + if (!fs.existsSync(publicKeyPath)) { + throw new Error(`Public key file not found at: ${publicKeyPath}`); + } + + publicKey = fs.readFileSync(publicKeyPath, 'utf8'); + publicKeyCache.set(publicKeyPath, publicKey); + } + + // Convert base64url signature to base64 if needed + const base64Signature = signature.replace(/-/g, '+').replace(/_/g, '/'); + const paddedSignature = base64Signature + '='.repeat((4 - base64Signature.length % 4) % 4); + const signatureBuffer = Buffer.from(paddedSignature, 'base64'); + + return crypto.verify(null, Buffer.from(data, 'utf8'), publicKey, signatureBuffer); + } catch (error) { + console.error('Error verifying signature:', error); + return false; + } +} + +export { getTokenFromConfig } from './tokenUtils.js'; \ No newline at end of file diff --git a/packages/crypto/src/tokenUtils.ts b/packages/crypto/src/tokenUtils.ts new file mode 100644 index 00000000..be5a064d --- /dev/null +++ b/packages/crypto/src/tokenUtils.ts @@ -0,0 +1,33 @@ +import { PrismaClient } from "@sourcebot/db"; +import { Token } from "@sourcebot/schemas/v3/shared.type"; +import { decrypt } from "./index.js"; + +export const getTokenFromConfig = async (token: Token, orgId: number, db: PrismaClient) => { + if ('secret' in token) { + const secretKey = token.secret; + const secret = await db.secret.findUnique({ + where: { + orgId_key: { + key: secretKey, + orgId + } + } + }); + + if (!secret) { + throw new Error(`Secret with key ${secretKey} not found for org ${orgId}`); + } + + const decryptedToken = decrypt(secret.iv, secret.encryptedValue); + return decryptedToken; + } else if ('env' in token) { + const envToken = process.env[token.env]; + if (!envToken) { + throw new Error(`Environment variable ${token.env} not found.`); + } + + return envToken; + } else { + throw new Error('Invalid token configuration'); + } +}; \ No newline at end of file diff --git a/packages/crypto/tsconfig.json b/packages/crypto/tsconfig.json index 39b3533d..b364feca 100644 --- a/packages/crypto/tsconfig.json +++ b/packages/crypto/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES6", - "module": "CommonJS", - "lib": ["ES6"], + "target": "ES2022", + "module": "Node16", + "lib": ["ES2023"], "outDir": "dist", "rootDir": "src", "declaration": true, @@ -11,11 +11,12 @@ "strict": true, "noImplicitAny": true, "strictNullChecks": true, - "moduleResolution": "node", + "moduleResolution": "Node16", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "isolatedModules": true + "isolatedModules": true, + "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/packages/db/package.json b/packages/db/package.json index 7af80bb4..8dc07d69 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@prisma/client": "6.2.1", + "@sourcebot/logger": "workspace:*", "@types/readline-sync": "^1.4.8", "readline-sync": "^1.4.10" } diff --git a/packages/db/prisma/migrations/20250519235121_add_org_metadata_field/migration.sql b/packages/db/prisma/migrations/20250519235121_add_org_metadata_field/migration.sql new file mode 100644 index 00000000..d3f8ec23 --- /dev/null +++ b/packages/db/prisma/migrations/20250519235121_add_org_metadata_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "metadata" JSONB; diff --git a/packages/db/prisma/migrations/20250520021309_add_pending_approval_user/migration.sql b/packages/db/prisma/migrations/20250520021309_add_pending_approval_user/migration.sql new file mode 100644 index 00000000..317ff51e --- /dev/null +++ b/packages/db/prisma/migrations/20250520021309_add_pending_approval_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "pendingApproval" BOOLEAN NOT NULL DEFAULT true; diff --git a/packages/db/prisma/migrations/20250520022249_add_account_request/migration.sql b/packages/db/prisma/migrations/20250520022249_add_account_request/migration.sql new file mode 100644 index 00000000..e1b8408f --- /dev/null +++ b/packages/db/prisma/migrations/20250520022249_add_account_request/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "AccountRequest" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "requestedById" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "AccountRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountRequest_requestedById_key" ON "AccountRequest"("requestedById"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccountRequest_requestedById_orgId_key" ON "AccountRequest"("requestedById", "orgId"); + +-- AddForeignKey +ALTER TABLE "AccountRequest" ADD CONSTRAINT "AccountRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AccountRequest" ADD CONSTRAINT "AccountRequest_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250520182630_add_guest_role/migration.sql b/packages/db/prisma/migrations/20250520182630_add_guest_role/migration.sql new file mode 100644 index 00000000..30717976 --- /dev/null +++ b/packages/db/prisma/migrations/20250520182630_add_guest_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "OrgRole" ADD VALUE 'GUEST'; diff --git a/packages/db/prisma/migrations/20250523175553_add_api_key/migration.sql b/packages/db/prisma/migrations/20250523175553_add_api_key/migration.sql new file mode 100644 index 00000000..115e3f8a --- /dev/null +++ b/packages/db/prisma/migrations/20250523175553_add_api_key/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "name" TEXT NOT NULL, + "hash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + "orgId" INTEGER NOT NULL, + "createdById" TEXT NOT NULL, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("hash") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hash_key" ON "ApiKey"("hash"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250617031335_add_audit_table/migration.sql b/packages/db/prisma/migrations/20250617031335_add_audit_table/migration.sql new file mode 100644 index 00000000..f7854061 --- /dev/null +++ b/packages/db/prisma/migrations/20250617031335_add_audit_table/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "Audit" ( + "id" TEXT NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "action" TEXT NOT NULL, + "actorId" TEXT NOT NULL, + "actorType" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "sourcebotVersion" TEXT NOT NULL, + "metadata" JSONB, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "Audit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Audit_actorId_actorType_targetId_targetType_orgId_idx" ON "Audit"("actorId", "actorType", "targetId", "targetType", "orgId"); + +-- AddForeignKey +ALTER TABLE "Audit" ADD CONSTRAINT "Audit_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql b/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql new file mode 100644 index 00000000..b270515e --- /dev/null +++ b/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "idx_audit_core_actions_full" ON "Audit"("orgId", "timestamp", "action", "actorId"); + +-- CreateIndex +CREATE INDEX "idx_audit_actor_time_full" ON "Audit"("actorId", "timestamp"); diff --git a/packages/db/prisma/migrations/20250713041019_add_onboarding_revamp_changes/migration.sql b/packages/db/prisma/migrations/20250713041019_add_onboarding_revamp_changes/migration.sql new file mode 100644 index 00000000..4576b33a --- /dev/null +++ b/packages/db/prisma/migrations/20250713041019_add_onboarding_revamp_changes/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `pendingApproval` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "inviteLinkEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "inviteLinkId" TEXT, +ADD COLUMN "memberApprovalRequired" BOOLEAN NOT NULL DEFAULT true; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "pendingApproval"; diff --git a/packages/db/prisma/migrations/20250722201612_add_chat_table/migration.sql b/packages/db/prisma/migrations/20250722201612_add_chat_table/migration.sql new file mode 100644 index 00000000..38d64354 --- /dev/null +++ b/packages/db/prisma/migrations/20250722201612_add_chat_table/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "ChatVisibility" AS ENUM ('PRIVATE', 'PUBLIC'); + +-- CreateTable +CREATE TABLE "Chat" ( + "id" TEXT NOT NULL, + "name" TEXT, + "createdById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "orgId" INTEGER NOT NULL, + "visibility" "ChatVisibility" NOT NULL DEFAULT 'PRIVATE', + "isReadonly" BOOLEAN NOT NULL DEFAULT false, + "messages" JSONB NOT NULL, + + CONSTRAINT "Chat_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Chat" ADD CONSTRAINT "Chat_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Chat" ADD CONSTRAINT "Chat_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ed14810a..20454ddf 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -35,6 +35,11 @@ enum StripeSubscriptionStatus { INACTIVE } +enum ChatVisibility { + PRIVATE + PUBLIC +} + model Repo { id Int @id @default(autoincrement()) name String @@ -136,6 +141,20 @@ model Invite { @@unique([recipientEmail, orgId]) } +model AccountRequest { + id String @id @default(cuid()) + + createdAt DateTime @default(now()) + + requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade) + requestedById String @unique + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@unique([requestedById, orgId]) +} + model Org { id Int @id @default(autoincrement()) name String @@ -146,8 +165,12 @@ model Org { connections Connection[] repos Repo[] secrets Secret[] + apiKeys ApiKey[] isOnboarded Boolean @default(false) imageUrl String? + metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts + + memberApprovalRequired Boolean @default(true) stripeCustomerId String? stripeSubscriptionStatus StripeSubscriptionStatus? @@ -155,13 +178,24 @@ model Org { /// List of pending invites to this organization invites Invite[] + + /// The invite id for this organization + inviteLinkEnabled Boolean @default(false) + inviteLinkId String? + + audits Audit[] + + accountRequests AccountRequest[] searchContexts SearchContext[] + + chats Chat[] } enum OrgRole { OWNER MEMBER + GUEST } model UserToOrg { @@ -193,20 +227,64 @@ model Secret { @@id([orgId, key]) } +model ApiKey { + name String + hash String @id @unique + + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String + +} + +model Audit { + id String @id @default(cuid()) + timestamp DateTime @default(now()) + + action String + actorId String + actorType String + targetId String + targetType String + sourcebotVersion String + metadata Json? + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@index([actorId, actorType, targetId, targetType, orgId]) + + // Fast path for analytics queries โ€“ orgId is first because we assume most deployments are single tenant + @@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full") + + // Fast path for analytics queries for a specific user + @@index([actorId, timestamp], map: "idx_audit_actor_time_full") +} + // @see : https://authjs.dev/concepts/database-models#user model User { - id String @id @default(cuid()) - name String? - email String? @unique - hashedPassword String? - emailVerified DateTime? - image String? - accounts Account[] - orgs UserToOrg[] + id String @id @default(cuid()) + name String? + email String? @unique + hashedPassword String? + emailVerified DateTime? + image String? + accounts Account[] + orgs UserToOrg[] + accountRequest AccountRequest? /// List of pending invites that the user has created invites Invite[] + apiKeys ApiKey[] + + chats Chat[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -242,3 +320,23 @@ model VerificationToken { @@unique([identifier, token]) } + +model Chat { + id String @id @default(cuid()) + + name String? + + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdById String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + visibility ChatVisibility @default(PRIVATE) + isReadonly Boolean @default(false) + + messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils. +} \ No newline at end of file diff --git a/packages/db/tools/scriptRunner.ts b/packages/db/tools/scriptRunner.ts index 0b86058d..8e72063a 100644 --- a/packages/db/tools/scriptRunner.ts +++ b/packages/db/tools/scriptRunner.ts @@ -1,7 +1,9 @@ import { PrismaClient } from "@sourcebot/db"; import { ArgumentParser } from "argparse"; import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections"; +import { injectAuditData } from "./scripts/inject-audit-data"; import { confirmAction } from "./utils"; +import { createLogger } from "@sourcebot/logger"; export interface Script { run: (prisma: PrismaClient) => Promise; @@ -9,6 +11,7 @@ export interface Script { export const scripts: Record = { "migrate-duplicate-connections": migrateDuplicateConnections, + "inject-audit-data": injectAuditData, } const parser = new ArgumentParser(); @@ -16,17 +19,19 @@ parser.add_argument("--url", { required: true, help: "Database URL" }); parser.add_argument("--script", { required: true, help: "Script to run" }); const args = parser.parse_args(); +const logger = createLogger('db-script-runner'); + (async () => { if (!(args.script in scripts)) { - console.log("Invalid script"); + logger.error("Invalid script"); process.exit(1); } const selectedScript = scripts[args.script]; - console.log("\nTo confirm:"); - console.log(`- Database URL: ${args.url}`); - console.log(`- Script: ${args.script}`); + logger.info("\nTo confirm:"); + logger.info(`- Database URL: ${args.url}`); + logger.info(`- Script: ${args.script}`); confirmAction(); @@ -36,7 +41,7 @@ const args = parser.parse_args(); await selectedScript.run(prisma); - console.log("\nDone."); + logger.info("\nDone."); process.exit(0); })(); diff --git a/packages/db/tools/scripts/inject-audit-data.ts b/packages/db/tools/scripts/inject-audit-data.ts new file mode 100644 index 00000000..11f3e8cc --- /dev/null +++ b/packages/db/tools/scripts/inject-audit-data.ts @@ -0,0 +1,144 @@ +import { Script } from "../scriptRunner"; +import { PrismaClient } from "../../dist"; +import { confirmAction } from "../utils"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('inject-audit-data'); + +// Generate realistic audit data for analytics testing +// Simulates 50 engineers with varying activity patterns +export const injectAuditData: Script = { + run: async (prisma: PrismaClient) => { + const orgId = 1; + + // Check if org exists + const org = await prisma.org.findUnique({ + where: { id: orgId } + }); + + if (!org) { + logger.error(`Organization with id ${orgId} not found. Please create it first.`); + return; + } + + logger.info(`Injecting audit data for organization: ${org.name} (${org.domain})`); + + // Generate 50 fake user IDs + const userIds = Array.from({ length: 50 }, (_, i) => `user_${String(i + 1).padStart(3, '0')}`); + + // Actions we're tracking + const actions = [ + 'user.performed_code_search', + 'user.performed_find_references', + 'user.performed_goto_definition' + ]; + + // Generate data for the last 90 days + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 90); + + logger.info(`Generating data from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`); + + confirmAction(); + + // Generate data for each day + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const currentDate = new Date(d); + const dayOfWeek = currentDate.getDay(); // 0 = Sunday, 6 = Saturday + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + // For each user, generate activity for this day + for (const userId of userIds) { + // Determine if user is active today (higher chance on weekdays) + const isActiveToday = isWeekend + ? Math.random() < 0.15 // 15% chance on weekends + : Math.random() < 0.85; // 85% chance on weekdays + + if (!isActiveToday) continue; + + // Generate code searches (2-5 per day) + const codeSearches = isWeekend + ? Math.floor(Math.random() * 2) + 1 // 1-2 on weekends + : Math.floor(Math.random() * 4) + 2; // 2-5 on weekdays + + // Generate navigation actions (5-10 per day) + const navigationActions = isWeekend + ? Math.floor(Math.random() * 3) + 1 // 1-3 on weekends + : Math.floor(Math.random() * 6) + 5; // 5-10 on weekdays + + // Create code search records + for (let i = 0; i < codeSearches; i++) { + const timestamp = new Date(currentDate); + // Spread throughout the day (9 AM to 6 PM on weekdays, more random on weekends) + if (isWeekend) { + timestamp.setHours(9 + Math.floor(Math.random() * 12)); + timestamp.setMinutes(Math.floor(Math.random() * 60)); + } else { + timestamp.setHours(9 + Math.floor(Math.random() * 9)); + timestamp.setMinutes(Math.floor(Math.random() * 60)); + } + timestamp.setSeconds(Math.floor(Math.random() * 60)); + + await prisma.audit.create({ + data: { + timestamp, + action: 'user.performed_code_search', + actorId: userId, + actorType: 'user', + targetId: `search_${Math.floor(Math.random() * 1000)}`, + targetType: 'search', + sourcebotVersion: '1.0.0', + orgId + } + }); + } + + // Create navigation action records + for (let i = 0; i < navigationActions; i++) { + const timestamp = new Date(currentDate); + if (isWeekend) { + timestamp.setHours(9 + Math.floor(Math.random() * 12)); + timestamp.setMinutes(Math.floor(Math.random() * 60)); + } else { + timestamp.setHours(9 + Math.floor(Math.random() * 9)); + timestamp.setMinutes(Math.floor(Math.random() * 60)); + } + timestamp.setSeconds(Math.floor(Math.random() * 60)); + + // Randomly choose between find references and goto definition + const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition'; + + await prisma.audit.create({ + data: { + timestamp, + action, + actorId: userId, + actorType: 'user', + targetId: `symbol_${Math.floor(Math.random() * 1000)}`, + targetType: 'symbol', + sourcebotVersion: '1.0.0', + orgId + } + }); + } + } + } + + logger.info(`\nAudit data injection complete!`); + logger.info(`Users: ${userIds.length}`); + logger.info(`Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`); + + // Show some statistics + const stats = await prisma.audit.groupBy({ + by: ['action'], + where: { orgId }, + _count: { action: true } + }); + + logger.info('\nAction breakdown:'); + stats.forEach(stat => { + logger.info(` ${stat.action}: ${stat._count.action}`); + }); + }, +}; \ No newline at end of file diff --git a/packages/db/tools/scripts/migrate-duplicate-connections.ts b/packages/db/tools/scripts/migrate-duplicate-connections.ts index 7093e429..fe3fa949 100644 --- a/packages/db/tools/scripts/migrate-duplicate-connections.ts +++ b/packages/db/tools/scripts/migrate-duplicate-connections.ts @@ -1,6 +1,9 @@ import { Script } from "../scriptRunner"; import { PrismaClient } from "../../dist"; import { confirmAction } from "../utils"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('migrate-duplicate-connections'); // Handles duplicate connections by renaming them to be unique. // @see: 20250320215449_unique_connection_name_constraint_within_org @@ -15,7 +18,7 @@ export const migrateDuplicateConnections: Script = { }, })).filter(({ _count }) => _count._all > 1); - console.log(`Found ${duplicates.reduce((acc, { _count }) => acc + _count._all, 0)} duplicate connections.`); + logger.info(`Found ${duplicates.reduce((acc, { _count }) => acc + _count._all, 0)} duplicate connections.`); confirmAction(); @@ -37,7 +40,7 @@ export const migrateDuplicateConnections: Script = { const connection = connections[i]; const newName = `${name}-${i + 1}`; - console.log(`Migrating connection with id ${connection.id} from name=${name} to name=${newName}`); + logger.info(`Migrating connection with id ${connection.id} from name=${name} to name=${newName}`); await prisma.connection.update({ where: { id: connection.id }, @@ -47,6 +50,6 @@ export const migrateDuplicateConnections: Script = { } } - console.log(`Migrated ${migrated} connections.`); + logger.info(`Migrated ${migrated} connections.`); }, }; diff --git a/packages/db/tools/utils.ts b/packages/db/tools/utils.ts index dbd08b00..a096ac9f 100644 --- a/packages/db/tools/utils.ts +++ b/packages/db/tools/utils.ts @@ -1,9 +1,17 @@ import readline from 'readline-sync'; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('db-utils'); export const confirmAction = (message: string = "Are you sure you want to proceed? [N/y]") => { const response = readline.question(message).toLowerCase(); if (response !== 'y') { - console.log("Aborted."); + logger.info("Aborted."); process.exit(0); } } + +export const abort = () => { + logger.info("Aborted."); + process.exit(0); +}; diff --git a/packages/logger/.gitignore b/packages/logger/.gitignore new file mode 100644 index 00000000..96351007 --- /dev/null +++ b/packages/logger/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.tsbuildinfo \ No newline at end of file diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 00000000..2e2279a3 --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sourcebot/logger", + "version": "0.1.0", + "main": "dist/index.js", + "type": "module", + "private": true, + "scripts": { + "build": "tsc", + "postinstall": "yarn build" + }, + "dependencies": { + "@logtail/node": "^0.5.2", + "@logtail/winston": "^0.5.2", + "@t3-oss/env-core": "^0.12.0", + "dotenv": "^16.4.5", + "triple-beam": "^1.4.1", + "winston": "^3.15.0", + "zod": "^3.24.3" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "typescript": "^5.7.3" + } +} diff --git a/packages/logger/src/env.ts b/packages/logger/src/env.ts new file mode 100644 index 00000000..5f582e0d --- /dev/null +++ b/packages/logger/src/env.ts @@ -0,0 +1,28 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; +import dotenv from 'dotenv'; + +// Booleans are specified as 'true' or 'false' strings. +const booleanSchema = z.enum(["true", "false"]); + +dotenv.config({ + path: './.env', +}); + +dotenv.config({ + path: './.env.local', + override: true +}); + +export const env = createEnv({ + server: { + SOURCEBOT_LOG_LEVEL: z.enum(["info", "debug", "warn", "error"]).default("info"), + SOURCEBOT_STRUCTURED_LOGGING_ENABLED: booleanSchema.default("false"), + SOURCEBOT_STRUCTURED_LOGGING_FILE: z.string().optional(), + LOGTAIL_TOKEN: z.string().optional(), + LOGTAIL_HOST: z.string().url().optional(), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, + skipValidation: process.env.SKIP_ENV_VALIDATION === "1", +}); \ No newline at end of file diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 00000000..d3998d2c --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,87 @@ +import winston, { format } from 'winston'; +import { Logtail } from '@logtail/node'; +import { LogtailTransport } from '@logtail/winston'; +import { MESSAGE } from 'triple-beam'; +import { env } from './env.js'; + +/** + * Logger configuration with support for structured JSON logging. + * + * When SOURCEBOT_STRUCTURED_LOGGING_ENABLED=true: + * - Console output will be in JSON format suitable for Datadog ingestion + * - Logs will include structured fields: timestamp, level, message, label, stack (if error) + * + * When SOURCEBOT_STRUCTURED_LOGGING_ENABLED=false (default): + * - Console output will be human-readable with colors + * - Logs will be formatted as: "timestamp level: [label] message" + */ + +const { combine, colorize, timestamp, prettyPrint, errors, printf, label: labelFn, json } = format; + +const datadogFormat = format((info) => { + info.status = info.level.toLowerCase(); + info.service = info.label; + info.label = undefined; + + const msg = info[MESSAGE as unknown as string] as string | undefined; + if (msg) { + info.message = msg; + info[MESSAGE as unknown as string] = undefined; + } + + return info; +}); + +const humanReadableFormat = printf(({ level, message, timestamp, stack, label: _label }) => { + const label = `[${_label}] `; + if (stack) { + return `${timestamp} ${level}: ${label}${message}\n${stack}`; + } + return `${timestamp} ${level}: ${label}${message}`; +}); + +const createLogger = (label: string) => { + const isStructuredLoggingEnabled = env.SOURCEBOT_STRUCTURED_LOGGING_ENABLED === 'true'; + + return winston.createLogger({ + level: env.SOURCEBOT_LOG_LEVEL, + format: combine( + errors({ stack: true }), + timestamp(), + labelFn({ label: label }) + ), + transports: [ + new winston.transports.Console({ + format: isStructuredLoggingEnabled + ? combine( + datadogFormat(), + json() + ) + : combine( + colorize(), + humanReadableFormat + ), + }), + ...(env.SOURCEBOT_STRUCTURED_LOGGING_FILE && isStructuredLoggingEnabled ? [ + new winston.transports.File({ + filename: env.SOURCEBOT_STRUCTURED_LOGGING_FILE, + format: combine( + datadogFormat(), + json() + ), + }), + ] : []), + ...(env.LOGTAIL_TOKEN && env.LOGTAIL_HOST ? [ + new LogtailTransport( + new Logtail(env.LOGTAIL_TOKEN, { + endpoint: env.LOGTAIL_HOST, + }) + ) + ] : []), + ] + }); +} + +export { + createLogger +}; \ No newline at end of file diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 00000000..88ae91dd --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2023"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index d63d895f..b856337f 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -7,7 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -# [1.0.1] - 2025-05-15 +## [1.0.3] - 2025-06-18 + +### Changed +- Updated API client to match the latest Sourcebot release. [#356](https://github.com/sourcebot-dev/sourcebot/pull/356) + +## [1.0.2] - 2025-05-28 + +### Changed +- Added API key support. [#311](https://github.com/sourcebot-dev/sourcebot/pull/311) + +## [1.0.1] - 2025-05-15 ### Changed - Updated API client to match the latest Sourcebot release. [#307](https://github.com/sourcebot-dev/sourcebot/pull/307) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e74f6499..27808b13 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -2,7 +2,7 @@ [![Sourcebot](https://img.shields.io/badge/Website-sourcebot.dev-blue)](https://sourcebot.dev) [![GitHub](https://img.shields.io/badge/GitHub-sourcebot--dev%2Fsourcebot-green?logo=github)](https://github.com/sourcebot-dev/sourcebot) -[![Docs](https://img.shields.io/badge/Docs-docs.sourcebot.dev-yellow)](https://docs.sourcebot.dev/docs/more/mcp-server) +[![Docs](https://img.shields.io/badge/Docs-docs.sourcebot.dev-yellow)](https://docs.sourcebot.dev/docs/features/mcp-server) [![npm](https://img.shields.io/npm/v/@sourcebot/mcp)](https://www.npmjs.com/package/@sourcebot/mcp) The Sourcebot MCP server gives your LLM agents the ability to fetch code context across thousands of repos hosted on [GitHub](https://docs.sourcebot.dev/docs/connections/github), [GitLab](https://docs.sourcebot.dev/docs/connections/gitlab), [BitBucket](https://docs.sourcebot.dev/docs/connections/bitbucket-cloud) and [more](#supported-code-hosts). Ask your LLM a question, and the Sourcebot MCP server will fetch relevant context from its index and inject it into your chat session. Some use cases this unlocks include: @@ -19,7 +19,7 @@ The Sourcebot MCP server gives your LLM agents the ability to fetch code context - Building custom LLM horizontal agents like like compliance auditing agents, migration agents, etc. - _"Find all instances of hardcoded credentials"_ - - _"Identify repositories that depend on this depreacted api"_ + - _"Identify repositories that depend on this deprecated api"_ ## Getting Started @@ -87,20 +87,18 @@ The Sourcebot MCP server gives your LLM agents the ability to fetch code context [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) - Add the following to your [settings.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers): + Add the following to your [.vscode/mcp.json](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server-to-your-workspace) file: ```json { - "mcp": { - "servers": { - "sourcebot": { - "type": "stdio", - "command": "npx", - "args": ["-y", "@sourcebot/mcp@latest"], - // Optional - if not specified, https://demo.sourcebot.dev is used - "env": { - "SOURCEBOT_HOST": "http://localhost:3000" - } + "servers": { + "sourcebot": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest"], + // Optional - if not specified, https://demo.sourcebot.dev is used + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" } } } @@ -159,7 +157,7 @@ The Sourcebot MCP server gives your LLM agents the ability to fetch code context
-For a more detailed guide, checkout [the docs](https://docs.sourcebot.dev/docs/more/mcp-server). +For a more detailed guide, checkout [the docs](https://docs.sourcebot.dev/docs/features/mcp-server). ## Available Tools diff --git a/packages/mcp/package.json b/packages/mcp/package.json index a91cecd2..969a465d 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@sourcebot/mcp", - "version": "1.0.1", + "version": "1.0.3", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index bcb0fd8e..c7d7d230 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -4,12 +4,13 @@ import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, Search import { isServiceError } from './utils.js'; export const search = async (request: SearchRequest): Promise => { - console.error(`Executing search request: ${JSON.stringify(request, null, 2)}`); + console.debug(`Executing search request: ${JSON.stringify(request, null, 2)}`); const result = await fetch(`${env.SOURCEBOT_HOST}/api/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Org-Domain': '~' + 'X-Org-Domain': '~', + ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) }, body: JSON.stringify(request) }).then(response => response.json()); @@ -26,7 +27,8 @@ export const listRepos = async (): Promise response.json()); @@ -42,7 +44,8 @@ export const getFileSource = async (request: FileSourceRequest): Promise response.json()); diff --git a/packages/mcp/src/env.ts b/packages/mcp/src/env.ts index 35559f7d..d4cac622 100644 --- a/packages/mcp/src/env.ts +++ b/packages/mcp/src/env.ts @@ -9,6 +9,8 @@ export const env = createEnv({ server: { SOURCEBOT_HOST: z.string().url().default(SOURCEBOT_DEMO_HOST), + SOURCEBOT_API_KEY: z.string().optional(), + // The minimum number of tokens to return DEFAULT_MINIMUM_TOKENS: numberSchema.default(10000), diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index f2c69d12..dfd0ebe2 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { listRepos, search, getFileSource } from './client.js'; import { env, numberSchema } from './env.js'; import { TextContent } from './types.js'; -import { base64Decode, isServiceError } from './utils.js'; +import { isServiceError } from './utils.js'; // Create MCP server const server = new McpServer({ @@ -21,6 +21,7 @@ server.tool( "search_code", `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search. Results are returned as an array of matching files, with the file's URL, repository, and language. + If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable. If the \`includeCodeSnippets\` property is true, code snippets containing the matches will be included in the response. Only set this to true if the request requires code snippets (e.g., show me examples where library X is used). When referencing a file in your response, **ALWAYS** include the file's external URL as a link. This makes it easier for the user to view the file, even if they don't have it locally checked out. **ONLY USE** the \`filterByRepoIds\` property if the request requires searching a specific repo(s). Otherwise, leave it empty.`, @@ -74,7 +75,7 @@ server.tool( query += ` case:no`; } - console.error(`Executing search request: ${query}`); + console.debug(`Executing search request: ${query}`); const response = await search({ query, @@ -113,8 +114,7 @@ server.tool( if (includeCodeSnippets) { const snippets = file.chunks.map(chunk => { - const content = base64Decode(chunk.content); - return `\`\`\`\n${content}\n\`\`\`` + return `\`\`\`\n${chunk.content}\n\`\`\`` }).join('\n'); text += `\n\n${snippets}`; } @@ -151,7 +151,7 @@ server.tool( server.tool( "list_repos", - "Lists all repositories in the organization.", + "Lists all repositories in the organization. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.", async () => { const response = await listRepos(); if (isServiceError(response)) { @@ -178,7 +178,7 @@ server.tool( server.tool( "get_file_source", - "Fetches the source code for a given file.", + "Fetches the source code for a given file. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.", { fileName: z.string().describe("The file to fetch the source code for."), repoId: z.string().describe("The repository to fetch the source code for. This is the Sourcebot compatible repository ID."), @@ -200,7 +200,7 @@ server.tool( const content: TextContent[] = [{ type: "text", - text: `file: ${fileName}\nrepository: ${repoId}\nlanguage: ${response.language}\nsource:\n${base64Decode(response.source)}`, + text: `file: ${fileName}\nrepository: ${repoId}\nlanguage: ${response.language}\nsource:\n${response.source}`, }] return { @@ -214,7 +214,7 @@ server.tool( const runServer = async () => { const transport = new StdioServerTransport(); await server.connect(transport); - console.error('Sourcebot MCP server ready'); + console.info('Sourcebot MCP server ready'); } runServer().catch((error) => { diff --git a/packages/mcp/src/utils.ts b/packages/mcp/src/utils.ts index a99114c3..77f1c54b 100644 --- a/packages/mcp/src/utils.ts +++ b/packages/mcp/src/utils.ts @@ -1,10 +1,5 @@ import { ServiceError } from "./types.js"; -// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem -export const base64Decode = (base64: string): string => { - const binString = atob(base64); - return Buffer.from(Uint8Array.from(binString, (m) => m.codePointAt(0)!).buffer).toString(); -} export const isServiceError = (data: unknown): data is ServiceError => { return typeof data === 'object' && diff --git a/packages/schemas/src/v2/index.schema.ts b/packages/schemas/src/v2/index.schema.ts index bdedcca4..a37f2f3c 100644 --- a/packages/schemas/src/v2/index.schema.ts +++ b/packages/schemas/src/v2/index.schema.ts @@ -684,7 +684,7 @@ const schema = { "type": "string", "pattern": ".+" }, - "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.", "default": [], "examples": [ [ @@ -1386,7 +1386,7 @@ const schema = { "type": "string", "pattern": ".+" }, - "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.", "default": [], "examples": [ [ @@ -2170,7 +2170,7 @@ const schema = { "type": "string", "pattern": ".+" }, - "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded.", "default": [], "examples": [ [ diff --git a/packages/schemas/src/v2/index.type.ts b/packages/schemas/src/v2/index.type.ts index 98ed997c..378e9572 100644 --- a/packages/schemas/src/v2/index.type.ts +++ b/packages/schemas/src/v2/index.type.ts @@ -309,7 +309,7 @@ export interface LocalConfig { watch?: boolean; exclude?: { /** - * List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded. + * List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always excluded. */ paths?: string[]; }; diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 52421b6f..e5bdbf1f 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -62,6 +62,12 @@ const schema = { "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "deprecated": true, + "description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.", + "default": false } }, "additionalProperties": false @@ -171,13 +177,19 @@ const schema = { "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "deprecated": true, + "description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.", + "default": false } }, "additionalProperties": false }, "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/docs/search/search-contexts", + "description": "[Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/docs/features/search/search-contexts", "patternProperties": { "^[a-zA-Z0-9_-]+$": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -1123,6 +1135,1500 @@ const schema = { } }, "additionalProperties": false + }, + "models": { + "type": "array", + "description": "Defines a collection of language models that are available to Sourcebot.", + "items": { + "type": "object", + "title": "LanguageModel", + "definitions": { + "AmazonBedrockLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AzureLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "DeepSeekLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleGenerativeAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexAnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "MistralLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenRouterLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "XaiLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + ] + } } }, "additionalProperties": false diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 70c035b0..9f5fb870 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -11,12 +11,24 @@ export type ConnectionConfig = | GerritConnectionConfig | BitbucketConnectionConfig | GenericGitHostConnectionConfig; +export type LanguageModel = + | AmazonBedrockLanguageModel + | AnthropicLanguageModel + | AzureLanguageModel + | DeepSeekLanguageModel + | GoogleGenerativeAILanguageModel + | GoogleVertexAnthropicLanguageModel + | GoogleVertexLanguageModel + | MistralLanguageModel + | OpenAILanguageModel + | OpenRouterLanguageModel + | XaiLanguageModel; 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/docs/search/search-contexts + * [Sourcebot EE] Defines a collection of search contexts. This is only available in single-tenancy mode. See: https://docs.sourcebot.dev/docs/features/search/search-contexts */ contexts?: { [k: string]: SearchContext; @@ -27,6 +39,10 @@ export interface SourcebotConfig { connections?: { [k: string]: ConnectionConfig; }; + /** + * Defines a collection of language models that are available to Sourcebot. + */ + models?: LanguageModel[]; } /** * Defines the global settings for Sourcebot. @@ -79,6 +95,11 @@ export interface Settings { * The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours. */ repoIndexTimeoutMs?: number; + /** + * @deprecated + * This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. + */ + enablePublicAccess?: boolean; } /** * Search context @@ -412,3 +433,421 @@ export interface GenericGitHostConnectionConfig { url: string; revisions?: GitRevisions; } +export interface AmazonBedrockLanguageModel { + /** + * Amazon Bedrock Configuration + */ + provider: "amazon-bedrock"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable. + */ + accessKeyId?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable. + */ + accessKeySecret?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * The AWS region. Defaults to the `AWS_REGION` environment variable. + */ + region?: string; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface AnthropicLanguageModel { + /** + * Anthropic Configuration + */ + provider: "anthropic"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface AzureLanguageModel { + /** + * Azure Configuration + */ + provider: "azure"; + /** + * The deployment name of the Azure model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable. + */ + resourceName?: string; + /** + * Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Sets a custom api version. Defaults to `preview`. + */ + apiVersion?: string; + /** + * Use a different URL prefix for API calls. Either this or `resourceName` can be used. + */ + baseUrl?: string; +} +export interface DeepSeekLanguageModel { + /** + * DeepSeek Configuration + */ + provider: "deepseek"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface GoogleGenerativeAILanguageModel { + /** + * Google Generative AI Configuration + */ + provider: "google-generative-ai"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface GoogleVertexAnthropicLanguageModel { + /** + * Google Vertex AI Anthropic Configuration + */ + provider: "google-vertex-anthropic"; + /** + * The name of the Anthropic language model running on Google Vertex. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable. + */ + project?: string; + /** + * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable. + */ + region?: string; + /** + * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials. + */ + credentials?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface GoogleVertexLanguageModel { + /** + * Google Vertex AI Configuration + */ + provider: "google-vertex"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable. + */ + project?: string; + /** + * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable. + */ + region?: string; + /** + * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials. + */ + credentials?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface MistralLanguageModel { + /** + * Mistral AI Configuration + */ + provider: "mistral"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface OpenAILanguageModel { + /** + * OpenAI Configuration + */ + provider: "openai"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface OpenRouterLanguageModel { + /** + * OpenRouter Configuration + */ + provider: "openrouter"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface XaiLanguageModel { + /** + * xAI Configuration + */ + provider: "xai"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} diff --git a/packages/schemas/src/v3/languageModel.schema.ts b/packages/schemas/src/v3/languageModel.schema.ts new file mode 100644 index 00000000..4bf2a82e --- /dev/null +++ b/packages/schemas/src/v3/languageModel.schema.ts @@ -0,0 +1,1492 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "type": "object", + "title": "LanguageModel", + "definitions": { + "AmazonBedrockLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "AzureLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "DeepSeekLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleGenerativeAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexAnthropicLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "GoogleVertexLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "MistralLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenAILanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "OpenRouterLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + "XaiLanguageModel": { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "type": "object", + "properties": { + "provider": { + "const": "amazon-bedrock", + "description": "Amazon Bedrock Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "accessKeyId": { + "description": "Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "accessKeySecret": { + "description": "Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "region": { + "type": "string", + "description": "The AWS region. Defaults to the `AWS_REGION` environment variable.", + "examples": [ + "us-east-1", + "us-west-2", + "eu-west-1" + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "anthropic", + "description": "Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "azure", + "description": "Azure Configuration" + }, + "model": { + "type": "string", + "description": "The deployment name of the Azure model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "resourceName": { + "type": "string", + "description": "Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "apiVersion": { + "type": "string", + "description": "Sets a custom api version. Defaults to `preview`." + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Use a different URL prefix for API calls. Either this or `resourceName` can be used." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "deepseek", + "description": "DeepSeek Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-generative-ai", + "description": "Google Generative AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex-anthropic", + "description": "Google Vertex AI Anthropic Configuration" + }, + "model": { + "type": "string", + "description": "The name of the Anthropic language model running on Google Vertex.", + "examples": [ + "claude-sonnet-4" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "google-vertex", + "description": "Google Vertex AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gemini-2.0-flash-exp", + "gemini-1.5-pro", + "gemini-1.5-flash" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "project": { + "type": "string", + "description": "The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable." + }, + "region": { + "type": "string", + "description": "The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable.", + "examples": [ + "us-central1", + "us-east1", + "europe-west1" + ] + }, + "credentials": { + "description": "Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "mistral", + "description": "Mistral AI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openai", + "description": "OpenAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "gpt-4.1", + "o4-mini", + "o3", + "o3-deep-research" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "openrouter", + "description": "OpenRouter Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model." + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "provider": { + "const": "xai", + "description": "xAI Configuration" + }, + "model": { + "type": "string", + "description": "The name of the language model.", + "examples": [ + "grok-beta", + "grok-vision-beta" + ] + }, + "displayName": { + "type": "string", + "description": "Optional display name." + }, + "token": { + "description": "Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable.", + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "baseUrl": { + "type": "string", + "format": "url", + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$", + "description": "Optional base URL." + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": false + } + ] +} as const; +export { schema as languageModelSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/languageModel.type.ts b/packages/schemas/src/v3/languageModel.type.ts new file mode 100644 index 00000000..88034678 --- /dev/null +++ b/packages/schemas/src/v3/languageModel.type.ts @@ -0,0 +1,433 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export type LanguageModel = + | AmazonBedrockLanguageModel + | AnthropicLanguageModel + | AzureLanguageModel + | DeepSeekLanguageModel + | GoogleGenerativeAILanguageModel + | GoogleVertexAnthropicLanguageModel + | GoogleVertexLanguageModel + | MistralLanguageModel + | OpenAILanguageModel + | OpenRouterLanguageModel + | XaiLanguageModel; + +export interface AmazonBedrockLanguageModel { + /** + * Amazon Bedrock Configuration + */ + provider: "amazon-bedrock"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional access key ID to use with the model. Defaults to the `AWS_ACCESS_KEY_ID` environment variable. + */ + accessKeyId?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional secret access key to use with the model. Defaults to the `AWS_SECRET_ACCESS_KEY` environment variable. + */ + accessKeySecret?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * The AWS region. Defaults to the `AWS_REGION` environment variable. + */ + region?: string; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface AnthropicLanguageModel { + /** + * Anthropic Configuration + */ + provider: "anthropic"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `ANTHROPIC_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface AzureLanguageModel { + /** + * Azure Configuration + */ + provider: "azure"; + /** + * The deployment name of the Azure model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Azure resource name. Defaults to the `AZURE_RESOURCE_NAME` environment variable. + */ + resourceName?: string; + /** + * Optional API key to use with the model. Defaults to the `AZURE_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Sets a custom api version. Defaults to `preview`. + */ + apiVersion?: string; + /** + * Use a different URL prefix for API calls. Either this or `resourceName` can be used. + */ + baseUrl?: string; +} +export interface DeepSeekLanguageModel { + /** + * DeepSeek Configuration + */ + provider: "deepseek"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `DEEPSEEK_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface GoogleGenerativeAILanguageModel { + /** + * Google Generative AI Configuration + */ + provider: "google-generative-ai"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `GOOGLE_GENERATIVE_AI_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface GoogleVertexAnthropicLanguageModel { + /** + * Google Vertex AI Anthropic Configuration + */ + provider: "google-vertex-anthropic"; + /** + * The name of the Anthropic language model running on Google Vertex. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable. + */ + project?: string; + /** + * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable. + */ + region?: string; + /** + * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials. + */ + credentials?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface GoogleVertexLanguageModel { + /** + * Google Vertex AI Configuration + */ + provider: "google-vertex"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * The Google Cloud project ID. Defaults to the `GOOGLE_VERTEX_PROJECT` environment variable. + */ + project?: string; + /** + * The Google Cloud region. Defaults to the `GOOGLE_VERTEX_REGION` environment variable. + */ + region?: string; + /** + * Optional file path to service account credentials JSON. Defaults to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable or application default credentials. + */ + credentials?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface MistralLanguageModel { + /** + * Mistral AI Configuration + */ + provider: "mistral"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `MISTRAL_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface OpenAILanguageModel { + /** + * OpenAI Configuration + */ + provider: "openai"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `OPENAI_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface OpenRouterLanguageModel { + /** + * OpenRouter Configuration + */ + provider: "openrouter"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `OPENROUTER_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} +export interface XaiLanguageModel { + /** + * xAI Configuration + */ + provider: "xai"; + /** + * The name of the language model. + */ + model: string; + /** + * Optional display name. + */ + displayName?: string; + /** + * Optional API key to use with the model. Defaults to the `XAI_API_KEY` environment variable. + */ + token?: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * Optional base URL. + */ + baseUrl?: string; +} diff --git a/packages/shared/.gitignore b/packages/shared/.gitignore new file mode 100644 index 00000000..96351007 --- /dev/null +++ b/packages/shared/.gitignore @@ -0,0 +1,2 @@ +dist/ +*.tsbuildinfo \ No newline at end of file diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 00000000..5623ed43 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,9 @@ +This package contains shared code between the backend & webapp packages. + +### Why two index files? + +This package contains two index files: `index.server.ts` and `index.client.ts`. There is some code in this package that will only work in a Node.JS runtime (e.g., because it depends on the `fs` package. Entitlements are a good example of this), and other code that is runtime agnostic (e.g., `constants.ts`). To deal with this, we these two index files export server code and client code, respectively. + +For package consumers, the usage would look like the following: +- Server: `import { ... } from @sourcebot/shared` +- Client: `import { ... } from @sourcebot/shared/client` \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 00000000..902a3485 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,32 @@ +{ + "name": "@sourcebot/shared", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "build": "tsc", + "build:watch": "tsc-watch --preserveWatchOutput", + "postinstall": "yarn build" + }, + "dependencies": { + "@sourcebot/crypto": "workspace:*", + "@sourcebot/db": "workspace:*", + "@sourcebot/logger": "workspace:*", + "@sourcebot/schemas": "workspace:*", + "@t3-oss/env-core": "^0.12.0", + "ajv": "^8.17.1", + "micromatch": "^4.0.8", + "strip-json-comments": "^5.0.1", + "zod": "^3.24.3" + }, + "devDependencies": { + "@types/micromatch": "^4.0.9", + "@types/node": "^22.7.5", + "tsc-watch": "6.2.1", + "typescript": "^5.7.3" + }, + "exports": { + ".": "./dist/index.server.js", + "./client": "./dist/index.client.js" + } +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts new file mode 100644 index 00000000..e0bbd29b --- /dev/null +++ b/packages/shared/src/constants.ts @@ -0,0 +1,11 @@ + +export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev'; + +export const SOURCEBOT_CLOUD_ENVIRONMENT = [ + "dev", + "demo", + "staging", + "prod", +] as const; + +export const SOURCEBOT_UNLIMITED_SEATS = -1; \ No newline at end of file diff --git a/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts b/packages/shared/src/ee/syncSearchContexts.ts similarity index 65% rename from packages/web/src/ee/features/searchContexts/syncSearchContexts.ts rename to packages/shared/src/ee/syncSearchContexts.ts index e662896d..7ab59b35 100644 --- a/packages/web/src/ee/features/searchContexts/syncSearchContexts.ts +++ b/packages/shared/src/ee/syncSearchContexts.ts @@ -1,28 +1,34 @@ -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"; +import { createLogger } from "@sourcebot/logger"; +import { PrismaClient } from "@sourcebot/db"; +import { getPlan, hasEntitlement } from "../entitlements.js"; +import { SOURCEBOT_SUPPORT_EMAIL } from "../constants.js"; +import { SearchContext } from "@sourcebot/schemas/v3/index.type"; -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."); - } +const logger = createLogger('sync-search-contexts'); + +interface SyncSearchContextsParams { + contexts?: { [key: string]: SearchContext } | undefined; + orgId: number; + db: PrismaClient; +} + +export const syncSearchContexts = async (params: SyncSearchContextsParams) => { + const { contexts, orgId, db } = params; 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}.`); + logger.warn(`Skipping search context sync. Reason: "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; + return false; } if (contexts) { for (const [key, newContextConfig] of Object.entries(contexts)) { - const allRepos = await prisma.repo.findMany({ + const allRepos = await db.repo.findMany({ where: { - orgId: SINGLE_TENANT_ORG_ID, + orgId, }, select: { id: true, @@ -41,11 +47,11 @@ export const syncSearchContexts = async (contexts?: { [key: string]: SearchConte }); } - const currentReposInContext = (await prisma.searchContext.findUnique({ + const currentReposInContext = (await db.searchContext.findUnique({ where: { name_orgId: { name: key, - orgId: SINGLE_TENANT_ORG_ID, + orgId, } }, include: { @@ -53,11 +59,11 @@ export const syncSearchContexts = async (contexts?: { [key: string]: SearchConte } }))?.repos ?? []; - await prisma.searchContext.upsert({ + await db.searchContext.upsert({ where: { name_orgId: { name: key, - orgId: SINGLE_TENANT_ORG_ID, + orgId, } }, update: { @@ -78,7 +84,7 @@ export const syncSearchContexts = async (contexts?: { [key: string]: SearchConte description: newContextConfig.description, org: { connect: { - id: SINGLE_TENANT_ORG_ID, + id: orgId, } }, repos: { @@ -91,21 +97,23 @@ export const syncSearchContexts = async (contexts?: { [key: string]: SearchConte } } - const deletedContexts = await prisma.searchContext.findMany({ + const deletedContexts = await db.searchContext.findMany({ where: { name: { notIn: Object.keys(contexts ?? {}), }, - orgId: SINGLE_TENANT_ORG_ID, + orgId, } }); for (const context of deletedContexts) { - console.log(`Deleting search context with name '${context.name}'. ID: ${context.id}`); - await prisma.searchContext.delete({ + logger.info(`Deleting search context with name '${context.name}'. ID: ${context.id}`); + await db.searchContext.delete({ where: { id: context.id, } }) } + + return true; } \ No newline at end of file diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts new file mode 100644 index 00000000..965989c1 --- /dev/null +++ b/packages/shared/src/entitlements.ts @@ -0,0 +1,125 @@ +import { base64Decode } from "./utils.js"; +import { z } from "zod"; +import { createLogger } from "@sourcebot/logger"; +import { verifySignature } from "@sourcebot/crypto"; +import { env } from "./env.js"; +import { SOURCEBOT_SUPPORT_EMAIL, SOURCEBOT_UNLIMITED_SEATS } from "./constants.js"; + +const logger = createLogger('entitlements'); + +const eeLicenseKeyPrefix = "sourcebot_ee_"; + +const eeLicenseKeyPayloadSchema = z.object({ + id: z.string(), + seats: z.number(), + // ISO 8601 date string + expiryDate: z.string().datetime(), + sig: z.string(), +}); + +type LicenseKeyPayload = z.infer; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const planLabels = { + oss: "OSS", + "cloud:team": "Team", + "cloud:demo": "Demo", + "self-hosted:enterprise": "Enterprise (Self-Hosted)", + "self-hosted:enterprise-unlimited": "Enterprise (Self-Hosted) Unlimited", +} as const; +export type Plan = keyof typeof planLabels; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const entitlements = [ + "search-contexts", + "billing", + "anonymous-access", + "multi-tenancy", + "sso", + "code-nav", + "audit", + "analytics" +] as const; +export type Entitlement = (typeof entitlements)[number]; + +const entitlementsByPlan: Record = { + oss: ["anonymous-access"], + "cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"], + "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"], + "self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics"], + // Special entitlement for https://demo.sourcebot.dev + "cloud:demo": ["anonymous-access", "code-nav", "search-contexts"], +} as const; + + +const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { + try { + const decodedPayload = base64Decode(payload); + const payloadJson = JSON.parse(decodedPayload); + const licenseData = eeLicenseKeyPayloadSchema.parse(payloadJson); + + const dataToVerify = JSON.stringify({ + expiryDate: licenseData.expiryDate, + id: licenseData.id, + seats: licenseData.seats + }); + + const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH); + if (!isSignatureValid) { + logger.error('License key signature verification failed'); + process.exit(1); + } + + return licenseData; + } catch (error) { + logger.error(`Failed to decode license key payload: ${error}`); + process.exit(1); + } +} + +export const getLicenseKey = (): LicenseKeyPayload | null => { + const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY; + if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) { + const payload = licenseKey.substring(eeLicenseKeyPrefix.length); + return decodeLicenseKeyPayload(payload); + } + return null; +} + +export const getPlan = (): Plan => { + if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT) { + if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === "demo") { + return "cloud:demo"; + } + + return "cloud:team"; + } + + const licenseKey = getLicenseKey(); + if (licenseKey) { + const expiryDate = new Date(licenseKey.expiryDate); + if (expiryDate.getTime() < new Date().getTime()) { + logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); + process.exit(1); + } + + return licenseKey.seats === SOURCEBOT_UNLIMITED_SEATS ? "self-hosted:enterprise-unlimited" : "self-hosted:enterprise"; + } else { + return "oss"; + } +} + +export const getSeats = (): number => { +const licenseKey = getLicenseKey(); + return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; +} + +export const hasEntitlement = (entitlement: Entitlement) => { + const entitlements = getEntitlements(); + return entitlements.includes(entitlement); +} + +export const getEntitlements = (): Entitlement[] => { + const plan = getPlan(); + return entitlementsByPlan[plan]; +} diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts new file mode 100644 index 00000000..c1162923 --- /dev/null +++ b/packages/shared/src/env.ts @@ -0,0 +1,21 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; +import { SOURCEBOT_CLOUD_ENVIRONMENT } from "./constants.js"; + +export const env = createEnv({ + server: { + SOURCEBOT_EE_LICENSE_KEY: z.string().optional(), + SOURCEBOT_PUBLIC_KEY_PATH: z.string(), + }, + client: { + NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: z.enum(SOURCEBOT_CLOUD_ENVIRONMENT).optional(), + }, + clientPrefix: "NEXT_PUBLIC_", + runtimeEnvStrict: { + SOURCEBOT_EE_LICENSE_KEY: process.env.SOURCEBOT_EE_LICENSE_KEY, + SOURCEBOT_PUBLIC_KEY_PATH: process.env.SOURCEBOT_PUBLIC_KEY_PATH, + NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT: process.env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT, + }, + emptyStringAsUndefined: true, + skipValidation: process.env.SKIP_ENV_VALIDATION === "1", +}); \ No newline at end of file diff --git a/packages/shared/src/index.client.ts b/packages/shared/src/index.client.ts new file mode 100644 index 00000000..ca2bfad2 --- /dev/null +++ b/packages/shared/src/index.client.ts @@ -0,0 +1,2 @@ + +export * from "./constants.js"; \ No newline at end of file diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts new file mode 100644 index 00000000..3cc4be65 --- /dev/null +++ b/packages/shared/src/index.server.ts @@ -0,0 +1,20 @@ +export { + hasEntitlement, + getLicenseKey, + getPlan, + getSeats, + getEntitlements, +} from "./entitlements.js"; +export type { + Plan, + Entitlement, +} from "./entitlements.js"; +export { + base64Decode, + loadConfig, + isRemotePath, +} from "./utils.js"; +export { + syncSearchContexts, +} from "./ee/syncSearchContexts.js"; +export * from "./constants.js"; \ No newline at end of file diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts new file mode 100644 index 00000000..431ff741 --- /dev/null +++ b/packages/shared/src/utils.ts @@ -0,0 +1,71 @@ +import { SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; +import { indexSchema } from "@sourcebot/schemas/v3/index.schema"; +import { readFile } from 'fs/promises'; +import stripJsonComments from 'strip-json-comments'; +import { Ajv } from "ajv"; + +const ajv = new Ajv({ + validateFormats: false, +}); + +// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem +export const base64Decode = (base64: string): string => { + const binString = atob(base64); + return Buffer.from(Uint8Array.from(binString, (m) => m.codePointAt(0)!).buffer).toString(); +} + +export const isRemotePath = (path: string) => { + return path.startsWith('https://') || path.startsWith('http://'); +} + +export const loadConfig = async (configPath: string): Promise => { + 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 { + // Retry logic for handling race conditions with mounted volumes + const maxAttempts = 5; + const retryDelayMs = 2000; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await readFile(configPath, { + encoding: 'utf-8', + }); + } catch (error) { + lastError = error as Error; + + // Only retry on ENOENT errors (file not found) + if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { + throw error; // Throw immediately for non-ENOENT errors + } + + // Log warning before retry (except on the last attempt) + if (attempt < maxAttempts) { + console.warn(`Config file not found, retrying in 2s... (Attempt ${attempt}/${maxAttempts})`); + await new Promise(resolve => setTimeout(resolve, retryDelayMs)); + } + } + } + + // If we've exhausted all retries, throw the last ENOENT error + if (lastError) { + throw lastError; + } + + throw new Error('Failed to load config after all retry attempts'); + } + })(); + + 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)}`); + } + return config; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 00000000..88ae91dd --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2023"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json index 6b1e43a1..1808f80a 100644 --- a/packages/web/.eslintrc.json +++ b/packages/web/.eslintrc.json @@ -7,7 +7,8 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", - "next/core-web-vitals" + "next/core-web-vitals", + "plugin:@tanstack/query/recommended" ], "rules": { "react-hooks/exhaustive-deps": "warn", diff --git a/packages/web/package.json b/packages/web/package.json index 662071a6..331b4e54 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -12,6 +12,16 @@ "stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "3.0.0-beta.10", + "@ai-sdk/anthropic": "2.0.0-beta.9", + "@ai-sdk/azure": "2.0.0-beta.12", + "@ai-sdk/deepseek": "1.0.0-beta.9", + "@ai-sdk/google": "2.0.0-beta.15", + "@ai-sdk/google-vertex": "3.0.0-beta.17", + "@ai-sdk/mistral": "2.0.0-beta.7", + "@ai-sdk/openai": "2.0.0-beta.12", + "@ai-sdk/react": "2.0.0-beta.28", + "@ai-sdk/xai": "2.0.0-beta.11", "@auth/prisma-adapter": "^2.7.4", "@codemirror/commands": "^6.6.0", "@codemirror/lang-cpp": "^6.0.2", @@ -34,6 +44,7 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.0.0", + "@codemirror/language-data": "^6.5.1", "@codemirror/legacy-modes": "^6.4.2", "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", @@ -42,8 +53,15 @@ "@hookform/resolvers": "^3.9.0", "@iconify/react": "^5.1.0", "@iizukak/codemirror-lang-wgsl": "^0.3.0", + "@openrouter/ai-sdk-provider": "1.0.0-beta.5", + "@opentelemetry/api-logs": "^0.203.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/sdk-logs": "^0.203.0", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "^1.1.2", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.6", @@ -55,6 +73,7 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.0", @@ -68,21 +87,28 @@ "@replit/codemirror-vim": "^6.2.1", "@sentry/nextjs": "^9", "@shopify/lang-jsonc": "^1.0.0", + "@sourcebot/codemirror-lang-tcl": "^1.0.12", "@sourcebot/crypto": "workspace:*", "@sourcebot/db": "workspace:*", "@sourcebot/error": "workspace:*", + "@sourcebot/logger": "workspace:*", "@sourcebot/schemas": "workspace:*", + "@sourcebot/shared": "workspace:*", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", "@stripe/react-stripe-js": "^3.1.1", "@stripe/stripe-js": "^5.6.0", "@t3-oss/env-nextjs": "^0.12.0", + "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.53.3", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.8", + "@uidotdev/usehooks": "^2.4.1", "@uiw/codemirror-themes": "^4.23.6", "@uiw/react-codemirror": "^4.23.0", + "@vercel/otel": "^1.13.0", "@viz-js/lang-dot": "^1.0.4", "@xiechao/codemirror-lang-handlebars": "^1.0.4", + "ai": "5.0.0-beta.28", "ajv": "^8.17.1", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.0", @@ -105,17 +131,22 @@ "codemirror-lang-sparql": "^2.0.0", "codemirror-lang-spreadsheet": "^1.3.0", "codemirror-lang-zig": "^0.1.0", + "date-fns": "^4.1.0", "embla-carousel-auto-scroll": "^8.3.0", "embla-carousel-react": "^8.3.0", "escape-string-regexp": "^5.0.0", "fuse.js": "^7.0.0", + "google-auth-library": "^10.1.0", "graphql": "^16.9.0", "http-status-codes": "^2.3.0", "input-otp": "^1.4.2", - "lucide-react": "^0.435.0", + "langfuse": "^3.38.4", + "langfuse-vercel": "^3.38.4", + "lucide-react": "^0.517.0", "micromatch": "^4.0.8", "next": "14.2.26", "next-auth": "^5.0.0-beta.25", + "next-navigation-guard": "^0.2.0", "next-themes": "^0.3.0", "nodemailer": "^6.10.0", "octokit": "^4.1.3", @@ -130,18 +161,32 @@ "react-hook-form": "^7.53.0", "react-hotkeys-hook": "^4.5.1", "react-icons": "^5.3.0", + "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.1", + "recharts": "^2.15.3", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "scroll-into-view-if-needed": "^3.1.0", "server-only": "^0.0.1", "sharp": "^0.33.5", + "simple-git": "^3.27.0", + "slate": "^0.117.0", + "slate-dom": "^0.116.0", + "slate-history": "^0.113.1", + "slate-react": "^0.117.1", "strip-json-comments": "^5.0.1", "stripe": "^17.6.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "usehooks-ts": "^3.1.0", - "zod": "^3.24.3", + "vscode-icons-js": "^11.6.1", + "zod": "^3.25.74", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.74.7", + "@testing-library/react-hooks": "^8.0.1", "@types/micromatch": "^4.0.9", "@types/node": "^20", "@types/nodemailer": "^6.4.17", diff --git a/packages/web/public/anthropic.svg b/packages/web/public/anthropic.svg new file mode 100644 index 00000000..578301de --- /dev/null +++ b/packages/web/public/anthropic.svg @@ -0,0 +1,7 @@ + + + Anthropic Icon Streamline Icon: https://streamlinehq.com + + + + \ No newline at end of file diff --git a/packages/web/public/azureai.svg b/packages/web/public/azureai.svg new file mode 100644 index 00000000..c4ed5c4d --- /dev/null +++ b/packages/web/public/azureai.svg @@ -0,0 +1 @@ +AzureAI \ No newline at end of file diff --git a/packages/web/public/bedrock.svg b/packages/web/public/bedrock.svg new file mode 100644 index 00000000..a5649399 --- /dev/null +++ b/packages/web/public/bedrock.svg @@ -0,0 +1 @@ +Bedrock \ No newline at end of file diff --git a/packages/web/public/deepseek.svg b/packages/web/public/deepseek.svg new file mode 100644 index 00000000..3fc23024 --- /dev/null +++ b/packages/web/public/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/packages/web/public/gemini.svg b/packages/web/public/gemini.svg new file mode 100644 index 00000000..3d7ccb42 --- /dev/null +++ b/packages/web/public/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/public/keycloak.svg b/packages/web/public/keycloak.svg new file mode 100644 index 00000000..44798d21 --- /dev/null +++ b/packages/web/public/keycloak.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/public/logo_512.png b/packages/web/public/logo_512.png new file mode 100644 index 00000000..6028d763 Binary files /dev/null and b/packages/web/public/logo_512.png differ diff --git a/packages/web/public/manifest.json b/packages/web/public/manifest.json new file mode 100644 index 00000000..acfd2f05 --- /dev/null +++ b/packages/web/public/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Sourcebot", + "short_name": "Sourcebot", + "display": "standalone", + "start_url": "/", + "icons": [ + { + "src": "/logo_512.png", + "sizes": "512x512", + "type": "image/png" + } + ] + } + \ No newline at end of file diff --git a/packages/web/public/microsoft_entra.svg b/packages/web/public/microsoft_entra.svg new file mode 100644 index 00000000..0ed35fb7 --- /dev/null +++ b/packages/web/public/microsoft_entra.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/web/public/mistral.svg b/packages/web/public/mistral.svg new file mode 100644 index 00000000..8e03e244 --- /dev/null +++ b/packages/web/public/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/packages/web/public/okta.svg b/packages/web/public/okta.svg new file mode 100644 index 00000000..75b1a850 --- /dev/null +++ b/packages/web/public/okta.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/web/public/openai.svg b/packages/web/public/openai.svg new file mode 100644 index 00000000..3b4eff96 --- /dev/null +++ b/packages/web/public/openai.svg @@ -0,0 +1,2 @@ + +OpenAI icon \ No newline at end of file diff --git a/packages/web/public/openrouter.svg b/packages/web/public/openrouter.svg new file mode 100644 index 00000000..e6cca2a8 --- /dev/null +++ b/packages/web/public/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/packages/web/public/xai.svg b/packages/web/public/xai.svg new file mode 100644 index 00000000..536e7139 --- /dev/null +++ b/packages/web/public/xai.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/packages/web/sentry.server.config.ts b/packages/web/sentry.server.config.ts index a2049565..548160c8 100644 --- a/packages/web/sentry.server.config.ts +++ b/packages/web/sentry.server.config.ts @@ -3,6 +3,9 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('sentry-server-config'); if (!!process.env.NEXT_PUBLIC_SENTRY_WEBAPP_DSN && !!process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT) { Sentry.init({ @@ -13,5 +16,5 @@ if (!!process.env.NEXT_PUBLIC_SENTRY_WEBAPP_DSN && !!process.env.NEXT_PUBLIC_SEN debug: false, }); } else { - console.debug("[server] Sentry was not initialized"); + logger.debug("[server] Sentry was not initialized"); } diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index a2ed5119..a78c9e14 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2,38 +2,51 @@ import { env } from "@/env.mjs"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError"; +import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; import { CodeHostType, 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 { decrypt, encrypt, generateApiKey, hashSecret, getTokenFromConfig } from "@sourcebot/crypto"; +import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus, Org, ApiKey } 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 { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; +import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import Ajv from "ajv"; import { StatusCodes } from "http-status-codes"; -import { Session } from "next-auth"; 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 { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas"; -import { TenancyMode } from "./lib/types"; -import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils"; +import { TenancyMode, ApiKeyPayload } from "./lib/types"; +import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils"; import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; +import { getPlan, hasEntitlement } from "@sourcebot/shared"; +import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; +import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; +import { createLogger } from "@sourcebot/logger"; +import { getAuditService } from "@/ee/features/audit/factory"; +import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; +import { getOrgMetadata } from "@/lib/utils"; +import { getOrgFromDomain } from "./data/org"; const ajv = new Ajv({ validateFormats: false, }); +const logger = createLogger('web-actions'); +const auditService = getAuditService(); + /** * "Service Error Wrapper". * @@ -45,38 +58,71 @@ export const sew = async (fn: () => Promise): Promise => return await fn(); } catch (e) { Sentry.captureException(e); - console.error(e); + logger.error(e); + + if (e instanceof Error) { + return unexpectedError(e.message); + } + return unexpectedError(`An unexpected error occurred. Please try again later.`); } } -export const withAuth = async (fn: (session: Session) => Promise, allowSingleTenantUnauthedAccess: boolean = false) => { +export const withAuth = async (fn: (userId: string, apiKeyHash: string | undefined) => Promise, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { const session = await auth(); - if (!session) { - if ( - env.SOURCEBOT_TENANCY_MODE === 'single' && - env.SOURCEBOT_AUTH_ENABLED === 'false' && - allowSingleTenantUnauthedAccess === true - ) { - // To allow for unauthed acccess in single-tenant mode, we can - // create a fake session with the default user. This user has membership - // in the default org. - // @see: initialize.ts - return fn({ - user: { - id: SINGLE_TENANT_USER_ID, - email: SINGLE_TENANT_USER_EMAIL, - }, - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), - }); - } + if (!session) { + // First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not, + // then this is an invalid unauthed request and we return a 401. + const anonymousAccessEnabled = await getAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN); + if (apiKey) { + const apiKeyOrError = await verifyApiKey(apiKey); + if (isServiceError(apiKeyOrError)) { + logger.error(`Invalid API key: ${JSON.stringify(apiKey)}. Error: ${JSON.stringify(apiKeyOrError)}`); + return notAuthenticated(); + } + + const user = await prisma.user.findUnique({ + where: { + id: apiKeyOrError.apiKey.createdById, + }, + }); + + if (!user) { + logger.error(`No user found for API key: ${apiKey}`); + return notAuthenticated(); + } + + await prisma.apiKey.update({ + where: { + hash: apiKeyOrError.apiKey.hash, + }, + data: { + lastUsedAt: new Date(), + }, + }); + + return fn(user.id, apiKeyOrError.apiKey.hash); + } else if ( + allowAnonymousAccess && + !isServiceError(anonymousAccessEnabled) && + anonymousAccessEnabled + ) { + if (!hasEntitlement("anonymous-access")) { + const plan = getPlan(); + logger.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); + return notAuthenticated(); + } + + // To support anonymous access a guest user is created in initialize.ts, which we return here + return fn(SOURCEBOT_GUEST_USER_ID, undefined); + } return notAuthenticated(); } - return fn(session); + return fn(session.user.id, undefined); } -export const withOrgMembership = async (session: Session, domain: string, fn: (params: { orgId: number, userRole: OrgRole }) => Promise, minRequiredRole: OrgRole = OrgRole.MEMBER) => { +export const withOrgMembership = async (userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise, minRequiredRole: OrgRole = OrgRole.MEMBER) => { const org = await prisma.org.findUnique({ where: { domain, @@ -84,33 +130,35 @@ export const withOrgMembership = async (session: Session, domain: string, fn: }); if (!org) { - return notFound(); + return notFound("Organization not found"); } const membership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { - userId: session.user.id, + userId, orgId: org.id, } }, }); if (!membership) { - return notFound(); + return notFound("User not a member of this organization"); } - const getAuthorizationPrecendence = (role: OrgRole): number => { + const getAuthorizationPrecedence = (role: OrgRole): number => { switch (role) { - case OrgRole.MEMBER: + case OrgRole.GUEST: return 0; - case OrgRole.OWNER: + case OrgRole.MEMBER: return 1; + case OrgRole.OWNER: + return 2; } } - if (getAuthorizationPrecendence(membership.role) < getAuthorizationPrecendence(minRequiredRole)) { + if (getAuthorizationPrecedence(membership.role) < getAuthorizationPrecedence(minRequiredRole)) { return { statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, @@ -119,7 +167,7 @@ export const withOrgMembership = async (session: Session, domain: string, fn: } return fn({ - orgId: org.id, + org: org, userRole: membership.role, }); } @@ -139,7 +187,7 @@ export const withTenancyModeEnforcement = async(mode: TenancyMode, fn: () => export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => withTenancyModeEnforcement('multi', () => - withAuth(async (session) => { + withAuth(async (userId) => { const org = await prisma.org.create({ data: { name, @@ -149,7 +197,7 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number } role: "OWNER", user: { connect: { - id: session.user.id, + id: userId, } } } @@ -163,8 +211,8 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number } }))); export const updateOrgName = async (name: string, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const { success } = orgNameSchema.safeParse(name); if (!success) { return { @@ -175,7 +223,7 @@ export const updateOrgName = async (name: string, domain: string) => sew(() => } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { name }, }); @@ -187,8 +235,8 @@ export const updateOrgName = async (name: string, domain: string) => sew(() => export const updateOrgDomain = async (newDomain: string, existingDomain: string) => sew(() => withTenancyModeEnforcement('multi', () => - withAuth((session) => - withOrgMembership(session, existingDomain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, existingDomain, async ({ org }) => { const { success } = await orgDomainSchema.safeParseAsync(newDomain); if (!success) { return { @@ -199,7 +247,7 @@ export const updateOrgDomain = async (newDomain: string, existingDomain: string) } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { domain: newDomain }, }); @@ -210,20 +258,12 @@ export const updateOrgDomain = async (newDomain: string, existingDomain: string) ))); export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { id: orgId }, - }); - - if (!org) { - return notFound(); - } - + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { // If billing is not enabled, we can just mark the org as onboarded. if (!IS_BILLING_ENABLED) { await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { isOnboarded: true, } @@ -231,13 +271,13 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo // Else, validate that the org has an active subscription. } else { - const subscriptionOrError = await getSubscriptionForOrg(orgId, prisma); + const subscriptionOrError = await getSubscriptionForOrg(org.id, prisma); if (isServiceError(subscriptionOrError)) { return subscriptionOrError; } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { isOnboarded: true, stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE, @@ -253,11 +293,11 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo )); export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const secrets = await prisma.secret.findMany({ where: { - orgId, + orgId: org.id, }, select: { key: true, @@ -272,13 +312,13 @@ export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: stri }))); export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const encrypted = encrypt(value); const existingSecret = await prisma.secret.findUnique({ where: { orgId_key: { - orgId, + orgId: org.id, key, } } @@ -290,13 +330,13 @@ export const createSecret = async (key: string, value: string, domain: string): await prisma.secret.create({ data: { - orgId, + orgId: org.id, key, encryptedValue: encrypted.encryptedData, iv: encrypted.iv, } }); - + return { success: true, @@ -304,12 +344,12 @@ export const createSecret = async (key: string, value: string, domain: string): }))); export const checkIfSecretExists = async (key: string, domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const secret = await prisma.secret.findUnique({ where: { orgId_key: { - orgId, + orgId: org.id, key, } } @@ -319,12 +359,12 @@ export const checkIfSecretExists = async (key: string, domain: string): Promise< }))); export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { await prisma.secret.delete({ where: { orgId_key: { - orgId, + orgId: org.id, key, } } @@ -335,13 +375,208 @@ export const deleteSecret = async (key: string, domain: string): Promise<{ succe } }))); +export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiKey: ApiKey } | ServiceError> => sew(async () => { + const parts = apiKeyPayload.apiKey.split("-"); + if (parts.length !== 2 || parts[0] !== "sourcebot") { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_API_KEY, + message: "Invalid API key", + } satisfies ServiceError; + } + + const hash = hashSecret(parts[1]) + const apiKey = await prisma.apiKey.findUnique({ + where: { + hash, + }, + }); + + if (!apiKey) { + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorCode: ErrorCode.INVALID_API_KEY, + message: "Invalid API key", + } satisfies ServiceError; + } + + const apiKeyTargetOrg = await prisma.org.findUnique({ + where: { + domain: apiKeyPayload.domain, + }, + }); + + if (!apiKeyTargetOrg) { + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorCode: ErrorCode.INVALID_API_KEY, + message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not exist.`, + } satisfies ServiceError; + } + + if (apiKey.orgId !== apiKeyTargetOrg.id) { + return { + statusCode: StatusCodes.UNAUTHORIZED, + errorCode: ErrorCode.INVALID_API_KEY, + message: `Invalid API key payload. Provided domain ${apiKeyPayload.domain} does not match the API key's org.`, + } satisfies ServiceError; + } + + return { + apiKey, + } +}); + + +export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const existingApiKey = await prisma.apiKey.findFirst({ + where: { + createdById: userId, + name, + }, + }); + + if (existingApiKey) { + await auditService.createAudit({ + action: "api_key.creation_failed", + actor: { + id: userId, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + message: `API key ${name} already exists`, + api_key: name + } + }); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.API_KEY_ALREADY_EXISTS, + message: `API key ${name} already exists`, + } satisfies ServiceError; + } + + const { key, hash } = generateApiKey(); + const apiKey = await prisma.apiKey.create({ + data: { + name, + hash, + orgId: org.id, + createdById: userId, + } + }); + + await auditService.createAudit({ + action: "api_key.created", + actor: { + id: userId, + type: "user" + }, + target: { + id: apiKey.hash, + type: "api_key" + }, + orgId: org.id + }); + + return { + key, + } + }))); + +export const deleteApiKey = async (name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const apiKey = await prisma.apiKey.findFirst({ + where: { + name, + createdById: userId, + }, + }); + + if (!apiKey) { + await auditService.createAudit({ + action: "api_key.deletion_failed", + actor: { + id: userId, + type: "user" + }, + target: { + id: domain, + type: "org" + }, + orgId: org.id, + metadata: { + message: `API key ${name} not found for user ${userId}`, + api_key: name + } + }); + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.API_KEY_NOT_FOUND, + message: `API key ${name} not found for user ${userId}`, + } satisfies ServiceError; + } + + await prisma.apiKey.delete({ + where: { + hash: apiKey.hash, + }, + }); + + await auditService.createAudit({ + action: "api_key.deleted", + actor: { + id: userId, + type: "user" + }, + target: { + id: apiKey.hash, + type: "api_key" + }, + orgId: org.id, + metadata: { + api_key: name + } + }); + + return { + success: true, + } + }))); + +export const getUserApiKeys = async (domain: string): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const apiKeys = await prisma.apiKey.findMany({ + where: { + orgId: org.id, + createdById: userId, + }, + orderBy: { + createdAt: 'desc', + } + }); + + return apiKeys.map((apiKey) => ({ + name: apiKey.name, + createdAt: apiKey.createdAt, + lastUsedAt: apiKey.lastUsedAt, + })); + }))); export const getConnections = async (domain: string, filter: { status?: ConnectionSyncStatus[] } = {}) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const connections = await prisma.connection.findMany({ where: { - orgId, + orgId: org.id, ...(filter.status ? { syncStatus: { in: filter.status } } : {}), @@ -369,15 +604,16 @@ export const getConnections = async (domain: string, filter: { status?: Connecti repoIndexingStatus: repo.repoIndexingStatus, })), })); - }), /* allowSingleTenantUnauthedAccess = */ true)); + }) + )); export const getConnectionInfo = async (connectionId: number, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const connection = await prisma.connection.findUnique({ where: { id: connectionId, - orgId, + orgId: org.id, }, include: { repos: true, @@ -401,11 +637,11 @@ export const getConnectionInfo = async (connectionId: number, domain: string) => }))); export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const repos = await prisma.repo.findMany({ where: { - orgId, + orgId: org.id, ...(filter.status ? { repoIndexingStatus: { in: filter.status } } : {}), @@ -441,12 +677,12 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, })); - } - ), /* allowSingleTenantUnauthedAccess = */ true)); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true + )); export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { // @note: repo names are represented by their remote url // on the code host. E.g.,: // - github.com/sourcebot-dev/sourcebot @@ -479,13 +715,13 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew // In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot". // We will leave this as an edge case for now since it's unlikely to happen in practice. // - // @v4-todo: we could add a unique contraint on repo name + orgId to help de-duplicate + // @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate // these cases. // @see: repoCompileUtils.ts const repo = await prisma.repo.findFirst({ where: { name: repoName, - orgId, + orgId: org.id, }, }); @@ -503,11 +739,20 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, } - }), /* allowSingleTenantUnauthedAccess = */ true)); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true + )); export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + if (env.CONFIG_PATH !== undefined) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.CONNECTION_CONFIG_PATH_SET, + message: "A configuration file has been provided. New connections cannot be added through the web interface.", + } satisfies ServiceError; + } + const parsedConfig = parseConnectionConfig(connectionConfig); if (isServiceError(parsedConfig)) { return parsedConfig; @@ -516,7 +761,7 @@ export const createConnection = async (name: string, type: CodeHostType, connect const existingConnectionWithName = await prisma.connection.findUnique({ where: { name_orgId: { - orgId, + orgId: org.id, name, } } @@ -532,7 +777,7 @@ export const createConnection = async (name: string, type: CodeHostType, connect const connection = await prisma.connection.create({ data: { - orgId, + orgId: org.id, name, config: parsedConfig as unknown as Prisma.InputJsonValue, connectionType: type, @@ -546,9 +791,9 @@ export const createConnection = async (name: string, type: CodeHostType, connect )); export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); if (!connection) { return notFound(); } @@ -556,7 +801,7 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st const existingConnectionWithName = await prisma.connection.findUnique({ where: { name_orgId: { - orgId, + orgId: org.id, name, } } @@ -573,7 +818,7 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st await prisma.connection.update({ where: { id: connectionId, - orgId, + orgId: org.id, }, data: { name, @@ -587,9 +832,9 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st )); export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); if (!connection) { return notFound(); } @@ -612,7 +857,7 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number await prisma.connection.update({ where: { id: connectionId, - orgId, + orgId: org.id, }, data: { config: parsedConfig as unknown as Prisma.InputJsonValue, @@ -627,10 +872,10 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number )); export const flagConnectionForSync = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); - if (!connection || connection.orgId !== orgId) { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); + if (!connection || connection.orgId !== org.id) { return notFound(); } @@ -650,12 +895,12 @@ export const flagConnectionForSync = async (connectionId: number, domain: string )); export const flagReposForIndex = async (repoIds: number[], domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { await prisma.repo.updateMany({ where: { id: { in: repoIds }, - orgId, + orgId: org.id, }, data: { repoIndexingStatus: RepoIndexingStatus.NEW, @@ -669,9 +914,9 @@ export const flagReposForIndex = async (repoIds: number[], domain: string) => se )); export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const connection = await getConnection(connectionId, orgId); + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const connection = await getConnection(connectionId, org.id); if (!connection) { return notFound(); } @@ -679,7 +924,7 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr await prisma.connection.delete({ where: { id: connectionId, - orgId, + orgId: org.id, } }); @@ -690,26 +935,75 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr )); export const getCurrentUserRole = async (domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ userRole }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ userRole }) => { return userRole; - }) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true )); export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const failAuditCallback = async (error: string) => { + await auditService.createAudit({ + action: "user.invite_failed", + actor: { + id: userId, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + message: error, + emails: emails.join(", ") + } + }); + } + const user = await getMe(); + if (isServiceError(user)) { + throw new ServiceErrorException(user); + } + + const hasAvailability = await orgHasAvailability(domain); + if (!hasAvailability) { + await auditService.createAudit({ + action: "user.invite_failed", + actor: { + id: userId, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + message: "Organization has reached maximum number of seats", + emails: emails.join(", ") + } + }); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "The organization has reached the maximum number of seats. Unable to create a new invite", + } satisfies ServiceError; + } + // Check for existing invites const existingInvites = await prisma.invite.findMany({ where: { recipientEmail: { in: emails }, - orgId, + orgId: org.id, } }); if (existingInvites.length > 0) { + await failAuditCallback("A pending invite already exists for one or more of the provided emails"); return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_INVITE, @@ -725,11 +1019,12 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ in: emails, } }, - orgId, + orgId: org.id, }, }); if (existingMembers.length > 0) { + await failAuditCallback("One or more of the provided emails are already members of this org"); return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_INVITE, @@ -740,8 +1035,8 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ await prisma.invite.createMany({ data: emails.map((email) => ({ recipientEmail: email, - hostUserId: session.user.id, - orgId, + hostUserId: userId, + orgId: org.id, })), skipDuplicates: true, }); @@ -754,7 +1049,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ where: { recipientEmail_orgId: { recipientEmail: email, - orgId, + orgId: org.id, }, }, include: { @@ -774,11 +1069,10 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ const inviteLink = `${origin}/redeem?invite_id=${invite.id}`; const transport = createTransport(env.SMTP_CONNECTION_URL); const html = await render(InviteUserEmail({ - baseUrl: origin, host: { - name: session.user.name ?? undefined, - email: session.user.email!, - avatarUrl: session.user.image ?? undefined, + name: user.name ?? undefined, + email: user.email!, + avatarUrl: user.image ?? undefined, }, recipient: { name: recipient?.name ?? undefined, @@ -798,11 +1092,28 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ const failed = result.rejected.concat(result.pending).filter(Boolean); if (failed.length > 0) { - console.error(`Failed to send invite email to ${email}: ${failed}`); + logger.error(`Failed to send invite email to ${email}: ${failed}`); } })); + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); } + await auditService.createAudit({ + action: "user.invites_created", + actor: { + id: userId, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + emails: emails.join(", ") + } + }); return { success: true, } @@ -810,12 +1121,12 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ )); export const cancelInvite = async (inviteId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const invite = await prisma.invite.findUnique({ where: { id: inviteId, - orgId, + orgId: org.id, }, }); @@ -835,11 +1146,18 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{ }, /* minRequiredRole = */ OrgRole.OWNER) )); +export const getOrgInviteId = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + return org.inviteLinkId; + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + export const getMe = async () => sew(() => - withAuth(async (session) => { + withAuth(async (userId) => { const user = await prisma.user.findUnique({ where: { - id: session.user.id, + id: userId, }, include: { orgs: { @@ -858,6 +1176,7 @@ export const getMe = async () => sew(() => id: user.id, email: user.email, name: user.name, + image: user.image, memberships: user.orgs.map((org) => ({ id: org.orgId, role: org.role, @@ -869,6 +1188,11 @@ export const getMe = async () => sew(() => export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => withAuth(async () => { + const user = await getMe(); + if (isServiceError(user)) { + return user; + } + const invite = await prisma.invite.findUnique({ where: { id: inviteId, @@ -882,43 +1206,60 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean return notFound(); } - const user = await getMe(); - if (isServiceError(user)) { - return user; + const failAuditCallback = async (error: string) => { + await auditService.createAudit({ + action: "user.invite_accept_failed", + actor: { + id: user.id, + type: "user" + }, + target: { + id: inviteId, + type: "invite" + }, + orgId: invite.org.id, + metadata: { + message: error + } + }); + } + + + const hasAvailability = await orgHasAvailability(invite.org.domain); + if (!hasAvailability) { + await failAuditCallback("Organization is at max capacity"); + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", + } satisfies ServiceError; } // Check if the user is the recipient of the invite if (user.email !== invite.recipientEmail) { + await failAuditCallback("User is not the recipient of the invite"); return notFound(); } - const res = await prisma.$transaction(async (tx) => { - await tx.userToOrg.create({ - data: { - userId: user.id, - orgId: invite.orgId, - role: "MEMBER", - } - }); + const addUserToOrgRes = await addUserToOrganization(user.id, invite.orgId); + if (isServiceError(addUserToOrgRes)) { + await failAuditCallback(addUserToOrgRes.message); + return addUserToOrgRes; + } - await tx.invite.delete({ - where: { - id: invite.id, - } - }); - - if (IS_BILLING_ENABLED) { - const result = await incrementOrgSeatCount(invite.orgId, tx); - if (isServiceError(result)) { - throw result; - } + await auditService.createAudit({ + action: "user.invite_accepted", + actor: { + id: user.id, + type: "user" + }, + orgId: invite.org.id, + target: { + id: inviteId, + type: "invite" } }); - if (isServiceError(res)) { - return res; - } - return { success: true, } @@ -967,11 +1308,29 @@ export const getInviteInfo = async (inviteId: string) => sew(() => })); export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const currentUserId = session.user.id; + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const currentUserId = userId; + const failAuditCallback = async (error: string) => { + await auditService.createAudit({ + action: "org.ownership_transfer_failed", + actor: { + id: currentUserId, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + message: error + } + }) + } if (newOwnerId === currentUserId) { + await failAuditCallback("User is already the owner of this org"); return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, @@ -983,12 +1342,13 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: newOwnerId, - orgId, + orgId: org.id, }, }, }); if (!newOwner) { + await failAuditCallback("The user you're trying to make the owner doesn't exist"); return { statusCode: StatusCodes.BAD_REQUEST, errorCode: ErrorCode.INVALID_REQUEST_BODY, @@ -1001,7 +1361,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: newOwnerId, - orgId, + orgId: org.id, }, }, data: { @@ -1012,7 +1372,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: currentUserId, - orgId, + orgId: org.id, }, }, data: { @@ -1021,6 +1381,22 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro }) ]); + await auditService.createAudit({ + action: "org.ownership_transferred", + actor: { + id: currentUserId, + type: "user" + }, + target: { + id: org.id.toString(), + type: "org" + }, + orgId: org.id, + metadata: { + message: `Ownership transferred from ${currentUserId} to ${newOwnerId}` + } + }); + return { success: true, } @@ -1039,12 +1415,12 @@ export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const targetMember = await prisma.userToOrg.findUnique({ where: { orgId_userId: { - orgId, + orgId: org.id, userId: memberId, } } @@ -1054,28 +1430,18 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro return notFound(); } - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - await prisma.$transaction(async (tx) => { await tx.userToOrg.delete({ where: { orgId_userId: { - orgId, + orgId: org.id, userId: memberId, } } }); if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(orgId, tx); + const result = await decrementOrgSeatCount(org.id, tx); if (isServiceError(result)) { throw result; } @@ -1089,8 +1455,8 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro )); export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId, userRole }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org, userRole }) => { if (userRole === OrgRole.OWNER) { return { statusCode: StatusCodes.FORBIDDEN, @@ -1099,28 +1465,18 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S } satisfies ServiceError; } - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - await prisma.$transaction(async (tx) => { await tx.userToOrg.delete({ where: { orgId_userId: { - orgId, - userId: session.user.id, + orgId: org.id, + userId: userId, } } }); if (IS_BILLING_ENABLED) { - const result = await decrementOrgSeatCount(orgId, tx); + const result = await decrementOrgSeatCount(org.id, tx); if (isServiceError(result)) { throw result; } @@ -1133,14 +1489,15 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S }) )); + export const getOrgMembership = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const membership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { - orgId, - userId: session.user.id, + orgId: org.id, + userId: userId, } } }); @@ -1154,11 +1511,14 @@ export const getOrgMembership = async (domain: string) => sew(() => )); export const getOrgMembers = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const members = await prisma.userToOrg.findMany({ where: { - orgId, + orgId: org.id, + role: { + not: OrgRole.GUEST, + } }, include: { user: true, @@ -1177,11 +1537,11 @@ export const getOrgMembers = async (domain: string) => sew(() => )); export const getOrgInvites = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { const invites = await prisma.invite.findMany({ where: { - orgId, + orgId: org.id, }, }); @@ -1193,17 +1553,314 @@ export const getOrgInvites = async (domain: string) => sew(() => }) )); +export const getOrgAccountRequests = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const requests = await prisma.accountRequest.findMany({ + where: { + orgId: org.id, + }, + include: { + requestedBy: true, + }, + }); + + return requests.map((request) => ({ + id: request.id, + email: request.requestedBy.email!, + createdAt: request.createdAt, + name: request.requestedBy.name ?? undefined, + })); + }) + )); + +export const createAccountRequest = async (userId: string, domain: string) => sew(async () => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + return notFound("User not found"); + } + + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return notFound("Organization not found"); + } + + const existingRequest = await prisma.accountRequest.findUnique({ + where: { + requestedById_orgId: { + requestedById: userId, + orgId: org.id, + }, + }, + }); + + if (existingRequest) { + logger.warn(`User ${userId} already has an account request for org ${org.id}. Skipping account request creation.`); + return { + success: true, + existingRequest: true, + } + } + + if (!existingRequest) { + await prisma.accountRequest.create({ + data: { + requestedById: userId, + orgId: org.id, + }, + }); + + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) { + // TODO: This is needed because we can't fetch the origin from the request headers when this is called + // on user creation (the header isn't set when next-auth calls onCreateUser for some reason) + const deploymentUrl = env.AUTH_URL; + + const owner = await prisma.user.findFirst({ + where: { + orgs: { + some: { + orgId: org.id, + role: "OWNER", + }, + }, + }, + }); + + if (!owner) { + logger.error(`Failed to find owner for org ${org.id} when drafting email for account request from ${userId}`); + } else { + const html = await render(JoinRequestSubmittedEmail({ + baseUrl: deploymentUrl, + requestor: { + name: user.name ?? undefined, + email: user.email!, + avatarUrl: user.image ?? undefined, + }, + orgName: org.name, + orgDomain: org.domain, + orgImageUrl: org.imageUrl ?? undefined, + })); + + const transport = createTransport(env.SMTP_CONNECTION_URL); + const result = await transport.sendMail({ + to: owner.email!, + from: env.EMAIL_FROM_ADDRESS, + subject: `New account request for ${org.name} on Sourcebot`, + html, + text: `New account request for ${org.name} on Sourcebot by ${user.name ?? user.email}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + logger.error(`Failed to send account request email to ${owner.email}: ${failed}`); + } + } + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); + } + } + + return { + success: true, + existingRequest: false, + } +}); + +export const getMemberApprovalRequired = async (domain: string): Promise => sew(async () => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return orgNotFound(); + } + + return org.memberApprovalRequired; +}); + +export const setMemberApprovalRequired = async (domain: string, required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + await prisma.org.update({ + where: { id: org.id }, + data: { memberApprovalRequired: required }, + }); + + return { + success: true, + }; + }, /* minRequiredRole = */ OrgRole.OWNER) + ) +); + +export const getInviteLinkEnabled = async (domain: string): Promise => sew(async () => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return orgNotFound(); + } + + return org.inviteLinkEnabled; +}); + +export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + await prisma.org.update({ + where: { id: org.id }, + data: { inviteLinkEnabled: enabled }, + }); + + return { + success: true, + }; + }, /* minRequiredRole = */ OrgRole.OWNER) + ) +); + +export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const failAuditCallback = async (error: string) => { + await auditService.createAudit({ + action: "user.join_request_approve_failed", + actor: { + id: userId, + type: "user" + }, + target: { + id: requestId, + type: "account_join_request" + }, + orgId: org.id, + metadata: { + message: error, + } + }); + } + + const request = await prisma.accountRequest.findUnique({ + where: { + id: requestId, + }, + include: { + requestedBy: true, + }, + }); + + if (!request || request.orgId !== org.id) { + await failAuditCallback("Request not found"); + return notFound(); + } + + const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id); + if (isServiceError(addUserToOrgRes)) { + await failAuditCallback(addUserToOrgRes.message); + return addUserToOrgRes; + } + + // Send approval email to the user + if (env.SMTP_CONNECTION_URL && env.EMAIL_FROM_ADDRESS) { + const origin = (await headers()).get('origin')!; + + const html = await render(JoinRequestApprovedEmail({ + baseUrl: origin, + user: { + name: request.requestedBy.name ?? undefined, + email: request.requestedBy.email!, + avatarUrl: request.requestedBy.image ?? undefined, + }, + orgName: org.name, + orgDomain: org.domain + })); + + const transport = createTransport(env.SMTP_CONNECTION_URL); + const result = await transport.sendMail({ + to: request.requestedBy.email!, + from: env.EMAIL_FROM_ADDRESS, + subject: `Your request to join ${org.name} has been approved`, + html, + text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${origin}/${org.domain}`, + }); + + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); + } + } else { + logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); + } + + await auditService.createAudit({ + action: "user.join_request_approved", + actor: { + id: userId, + type: "user" + }, + orgId: org.id, + target: { + id: requestId, + type: "account_join_request" + } + }); + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + +export const rejectAccountRequest = async (requestId: string, domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const request = await prisma.accountRequest.findUnique({ + where: { + id: requestId, + }, + }); + + if (!request || request.orgId !== org.id) { + return notFound(); + } + + await prisma.accountRequest.delete({ + where: { + id: requestId, + }, + }); + + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); + export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => { await cookies().set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true'); return true; }); export const getSearchContexts = async (domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const searchContexts = await prisma.searchContext.findMany({ where: { - orgId, + orgId: org.id, }, }); @@ -1211,9 +1868,147 @@ export const getSearchContexts = async (domain: string) => sew(() => name: context.name, description: context.description ?? undefined, })); - } - ), /* allowSingleTenantUnauthedAccess = */ true)); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true + )); +export const getRepoImage = async (repoId: number, domain: string): Promise => sew(async () => { + return await withAuth(async (userId) => { + return await withOrgMembership(userId, domain, async ({ org }) => { + const repo = await prisma.repo.findUnique({ + where: { + id: repoId, + orgId: org.id, + }, + include: { + connections: { + include: { + connection: true, + } + } + } + }); + + if (!repo || !repo.imageUrl) { + return notFound(); + } + + const authHeaders: Record = {}; + for (const { connection } of repo.connections) { + try { + if (connection.connectionType === 'github') { + const config = connection.config as unknown as GithubConnectionConfig; + if (config.token) { + const token = await getTokenFromConfig(config.token, connection.orgId, prisma); + authHeaders['Authorization'] = `token ${token}`; + break; + } + } else if (connection.connectionType === 'gitlab') { + const config = connection.config as unknown as GitlabConnectionConfig; + if (config.token) { + const token = await getTokenFromConfig(config.token, connection.orgId, prisma); + authHeaders['PRIVATE-TOKEN'] = token; + break; + } + } else if (connection.connectionType === 'gitea') { + const config = connection.config as unknown as GiteaConnectionConfig; + if (config.token) { + const token = await getTokenFromConfig(config.token, connection.orgId, prisma); + authHeaders['Authorization'] = `token ${token}`; + break; + } + } + } catch (error) { + logger.warn(`Failed to get token for connection ${connection.id}:`, error); + } + } + + try { + const response = await fetch(repo.imageUrl, { + headers: authHeaders, + }); + + if (!response.ok) { + logger.warn(`Failed to fetch image from ${repo.imageUrl}: ${response.status}`); + return notFound(); + } + + const imageBuffer = await response.arrayBuffer(); + return imageBuffer; + } catch (error) { + logger.error(`Error proxying image for repo ${repoId}:`, error); + return notFound(); + } + }, /* minRequiredRole = */ OrgRole.GUEST); + }, /* allowAnonymousAccess = */ true); +}); + +export const getAnonymousAccessStatus = async (domain: string): Promise => sew(async () => { + const org = await getOrgFromDomain(domain); + if (!org) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: "Organization not found", + } satisfies ServiceError; + } + + // If no metadata is set we don't try to parse it since it'll result in a parse error + if (org.metadata === null) { + return false; + } + + const orgMetadata = getOrgMetadata(org); + if (!orgMetadata) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INVALID_ORG_METADATA, + message: "Invalid organization metadata", + } satisfies ServiceError; + } + + return !!orgMetadata.anonymousAccessEnabled; +}); + +export const setAnonymousAccessStatus = async (domain: string, enabled: boolean): Promise => sew(async () => { + return await withAuth(async (userId) => { + return await withOrgMembership(userId, domain, async ({ org }) => { + const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); + if (!hasAnonymousAccessEntitlement) { + const plan = getPlan(); + console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "Anonymous access is not supported in your current plan", + } satisfies ServiceError; + } + + const currentMetadata = getOrgMetadata(org); + const mergedMetadata = { + ...(currentMetadata ?? {}), + anonymousAccessEnabled: enabled, + }; + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + metadata: mergedMetadata, + }, + }); + + return true; + }, /* minRequiredRole = */ OrgRole.OWNER); + }); +}); + +export async function setSearchModeCookie(searchMode: "precise" | "agentic") { + const cookieStore = await cookies(); + cookieStore.set(SEARCH_MODE_COOKIE_NAME, searchMode, { + httpOnly: false, // Allow client-side access + }); +} ////// Helpers /////// @@ -1275,7 +2070,7 @@ const parseConnectionConfig = (config: string) => { const { numRepos, hasToken } = (() => { switch (connectionType) { case "gitea": - case "github": + case "github": case "bitbucket": { return { numRepos: parsedConfig.repos?.length, diff --git a/packages/web/src/app/[domain]/agents/page.tsx b/packages/web/src/app/[domain]/agents/page.tsx index 7f283ede..3bbccd0c 100644 --- a/packages/web/src/app/[domain]/agents/page.tsx +++ b/packages/web/src/app/[domain]/agents/page.tsx @@ -9,7 +9,7 @@ const agents = [ name: "Review Agent", description: "An AI code review agent that reviews your PRs. Uses the code indexed on Sourcebot to provide codebase-wide context.", requiredEnvVars: ["GITHUB_APP_ID", "GITHUB_APP_WEBHOOK_SECRET", "GITHUB_APP_PRIVATE_KEY_PATH", "OPENAI_API_KEY"], - configureUrl: "https://docs.sourcebot.dev/docs/agents/review-agent" + configureUrl: "https://docs.sourcebot.dev/docs/features/agents/review-agent" }, ]; diff --git a/packages/web/src/app/[domain]/browse/README.md b/packages/web/src/app/[domain]/browse/README.md new file mode 100644 index 00000000..8613d6da --- /dev/null +++ b/packages/web/src/app/[domain]/browse/README.md @@ -0,0 +1,12 @@ +# File browser + +This directory contains Sourcebot's file browser implementation. URL paths are used to determine what file the user wants to view. The following template is used: + +```sh +/browse/[@]/-/(blob|tree)/ +``` + +For example, to view `packages/backend/src/env.ts` in Sourcebot, we would use the following path: +```sh +/browse/github.com/sourcebot-dev/sourcebot@HEAD/-/blob/packages/backend/src/env.ts +``` diff --git a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx b/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx deleted file mode 100644 index 8f6243c7..00000000 --- a/packages/web/src/app/[domain]/browse/[...path]/codePreview.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client'; - -import { ScrollArea } from "@/components/ui/scroll-area"; -import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; -import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; -import { search } from "@codemirror/search"; -import CodeMirror, { Decoration, DecorationSet, EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, StateField, ViewUpdate } from "@uiw/react-codemirror"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { EditorContextMenu } from "../../components/editorContextMenu"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; - -interface CodePreviewProps { - path: string; - repoName: string; - revisionName: string; - source: string; - language: string; -} - -export const CodePreview = ({ - source, - language, - path, - repoName, - revisionName, -}: CodePreviewProps) => { - const editorRef = useRef(null); - const syntaxHighlighting = useSyntaxHighlightingExtension(language, editorRef.current?.view); - const [currentSelection, setCurrentSelection] = useState(); - const keymapExtension = useKeymapExtension(editorRef.current?.view); - const [isEditorCreated, setIsEditorCreated] = useState(false); - - const highlightRangeQuery = useNonEmptyQueryParam('highlightRange'); - const highlightRange = useMemo(() => { - if (!highlightRangeQuery) { - return; - } - - const rangeRegex = /^\d+:\d+,\d+:\d+$/; - if (!rangeRegex.test(highlightRangeQuery)) { - return; - } - - const [start, end] = highlightRangeQuery.split(',').map((range) => { - return range.split(':').map((val) => parseInt(val, 10)); - }); - - return { - start: { - line: start[0], - character: start[1], - }, - end: { - line: end[0], - character: end[1], - } - } - }, [highlightRangeQuery]); - - const extensions = useMemo(() => { - const highlightDecoration = Decoration.mark({ - class: "cm-searchMatch-selected", - }); - - return [ - syntaxHighlighting, - EditorView.lineWrapping, - keymapExtension, - search({ - top: true, - }), - EditorView.updateListener.of((update: ViewUpdate) => { - if (update.selectionSet) { - setCurrentSelection(update.state.selection.main); - } - }), - StateField.define({ - create(state) { - if (!highlightRange) { - return Decoration.none; - } - - const { start, end } = highlightRange; - const from = state.doc.line(start.line).from + start.character - 1; - const to = state.doc.line(end.line).from + end.character - 1; - - return Decoration.set([ - highlightDecoration.range(from, to), - ]); - }, - update(deco, tr) { - return deco.map(tr.changes); - }, - provide: (field) => EditorView.decorations.from(field), - }), - ]; - }, [keymapExtension, syntaxHighlighting, highlightRange]); - - useEffect(() => { - if (!highlightRange || !editorRef.current || !editorRef.current.state) { - return; - } - - const doc = editorRef.current.state.doc; - const { start, end } = highlightRange; - const from = doc.line(start.line).from + start.character - 1; - const to = doc.line(end.line).from + end.character - 1; - const selection = EditorSelection.range(from, to); - - editorRef.current.view?.dispatch({ - effects: [ - EditorView.scrollIntoView(selection, { y: "center" }), - ] - }); - // @note: we need to include `isEditorCreated` in the dependency array since - // a race-condition can happen if the `highlightRange` is resolved before the - // editor is created. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [highlightRange, isEditorCreated]); - - const theme = useCodeMirrorTheme(); - - return ( - - { - setIsEditorCreated(true); - }} - value={source} - extensions={extensions} - readOnly={true} - theme={theme} - > - {editorRef.current && editorRef.current.view && currentSelection && ( - - )} - - - ) -} - diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx new file mode 100644 index 00000000..c25f9a33 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -0,0 +1,75 @@ +import { getRepoInfoByName } from "@/actions"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; +import { Separator } from "@/components/ui/separator"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; +import Image from "next/image"; +import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; + +interface CodePreviewPanelProps { + path: string; + repoName: string; + revisionName?: string; + domain: string; +} + +export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }: CodePreviewPanelProps) => { + const [fileSourceResponse, repoInfoResponse] = await Promise.all([ + getFileSource({ + fileName: path, + repository: repoName, + branch: revisionName, + }, domain), + getRepoInfoByName(repoName, domain), + ]); + + if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) { + return
Error loading file source
+ } + + const codeHostInfo = getCodeHostInfoForRepo({ + codeHostType: repoInfoResponse.codeHostType, + name: repoInfoResponse.name, + displayName: repoInfoResponse.displayName, + webUrl: repoInfoResponse.webUrl, + }); + + return ( + <> +
+ + {(fileSourceResponse.webUrl && codeHostInfo) && ( + + {codeHostInfo.codeHostName} + Open in {codeHostInfo.codeHostName} + + )} +
+ + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx new file mode 100644 index 00000000..b2398e9b --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup"; +import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; +import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; +import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; +import { useKeymapExtension } from "@/hooks/useKeymapExtension"; +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { search } from "@codemirror/search"; +import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { EditorContextMenu } from "../../../components/editorContextMenu"; +import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation } from "../../hooks/useBrowseNavigation"; +import { useBrowseState } from "../../hooks/useBrowseState"; +import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { createAuditAction } from "@/ee/features/audit/actions"; +import { useDomain } from "@/hooks/useDomain"; + +interface PureCodePreviewPanelProps { + path: string; + repoName: string; + revisionName: string; + source: string; + language: string; +} + +export const PureCodePreviewPanel = ({ + source, + language, + path, + repoName, + revisionName, +}: PureCodePreviewPanelProps) => { + const [editorRef, setEditorRef] = useState(null); + const languageExtension = useCodeMirrorLanguageExtension(language, editorRef?.view); + const [currentSelection, setCurrentSelection] = useState(); + const keymapExtension = useKeymapExtension(editorRef?.view); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const { updateBrowseState } = useBrowseState(); + const { navigateToPath } = useBrowseNavigation(); + const domain = useDomain(); + const captureEvent = useCaptureEvent(); + + const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM); + const highlightRange = useMemo((): BrowseHighlightRange | undefined => { + if (!highlightRangeQuery) { + return; + } + + // Highlight ranges can be formatted in two ways: + // 1. start_line,end_line (no column specified) + // 2. start_line:start_column,end_line:end_column (column specified) + const rangeRegex = /^(\d+:\d+,\d+:\d+|\d+,\d+)$/; + if (!rangeRegex.test(highlightRangeQuery)) { + return; + } + + const [start, end] = highlightRangeQuery.split(',').map((range) => { + if (range.includes(':')) { + return range.split(':').map((val) => parseInt(val, 10)); + } + // For line-only format, use column 1 for start and last column for end + const line = parseInt(range, 10); + return [line]; + }); + + if (start.length === 1 || end.length === 1) { + return { + start: { + lineNumber: start[0], + }, + end: { + lineNumber: end[0], + } + } + } else { + return { + start: { + lineNumber: start[0], + column: start[1], + }, + end: { + lineNumber: end[0], + column: end[1], + } + } + } + + }, [highlightRangeQuery]); + + const extensions = useMemo(() => { + return [ + languageExtension, + EditorView.lineWrapping, + keymapExtension, + search({ + top: true, + }), + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.selectionSet) { + setCurrentSelection(update.state.selection.main); + } + }), + highlightRange ? rangeHighlightingExtension(highlightRange) : [], + hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], + ]; + }, [ + keymapExtension, + languageExtension, + highlightRange, + hasCodeNavEntitlement, + ]); + + // Scroll the highlighted range into view. + useEffect(() => { + if (!highlightRange || !editorRef || !editorRef.state) { + return; + } + + const doc = editorRef.state.doc; + const { start, end } = highlightRange; + const selection = EditorSelection.range( + doc.line(start.lineNumber).from, + doc.line(end.lineNumber).from, + ); + + editorRef.view?.dispatch({ + effects: [ + EditorView.scrollIntoView(selection, { y: "center" }), + ] + }); + }, [editorRef, highlightRange]); + + const onFindReferences = useCallback((symbolName: string) => { + captureEvent('wa_find_references_pressed', { + source: 'browse', + }); + createAuditAction({ + action: "user.performed_find_references", + metadata: { + message: symbolName, + }, + }, domain) + + updateBrowseState({ + selectedSymbolInfo: { + repoName, + symbolName, + revisionName, + language, + }, + isBottomPanelCollapsed: false, + activeExploreMenuTab: "references", + }) + }, [captureEvent, updateBrowseState, repoName, revisionName, language, domain]); + + + // If we resolve multiple matches, instead of navigating to the first match, we should + // instead popup the bottom sheet with the list of matches. + const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { + captureEvent('wa_goto_definition_pressed', { + source: 'browse', + }); + createAuditAction({ + action: "user.performed_goto_definition", + metadata: { + message: symbolName, + }, + }, domain) + + if (symbolDefinitions.length === 0) { + return; + } + + if (symbolDefinitions.length === 1) { + const symbolDefinition = symbolDefinitions[0]; + const { fileName, repoName } = symbolDefinition; + + navigateToPath({ + repoName, + revisionName, + path: fileName, + pathType: 'blob', + highlightRange: symbolDefinition.range, + }) + } else { + updateBrowseState({ + selectedSymbolInfo: { + symbolName, + repoName, + revisionName, + language, + }, + activeExploreMenuTab: "definitions", + isBottomPanelCollapsed: false, + }) + } + }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language, domain]); + + const theme = useCodeMirrorTheme(); + + return ( + + + {editorRef && editorRef.view && currentSelection && ( + + )} + {editorRef && hasCodeNavEntitlement && ( + + )} + + + + ) +} + diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/pureTreePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/pureTreePreviewPanel.tsx new file mode 100644 index 00000000..17860185 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/pureTreePreviewPanel.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useCallback, useRef } from "react"; +import { FileTreeItem } from "@/features/fileTree/actions"; +import { FileTreeItemComponent } from "@/features/fileTree/components/fileTreeItemComponent"; +import { useBrowseNavigation } from "../../hooks/useBrowseNavigation"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useBrowseParams } from "../../hooks/useBrowseParams"; + +interface PureTreePreviewPanelProps { + items: FileTreeItem[]; +} + +export const PureTreePreviewPanel = ({ items }: PureTreePreviewPanelProps) => { + const { repoName, revisionName } = useBrowseParams(); + const { navigateToPath } = useBrowseNavigation(); + const scrollAreaRef = useRef(null); + + const onNodeClicked = useCallback((node: FileTreeItem) => { + navigateToPath({ + repoName: repoName, + revisionName: revisionName, + path: node.path, + pathType: node.type === 'tree' ? 'tree' : 'blob', + }); + }, [navigateToPath, repoName, revisionName]); + + return ( + + {items.map((item) => ( + onNodeClicked(item)} + parentRef={scrollAreaRef} + /> + ))} + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts new file mode 100644 index 00000000..cb0fe977 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts @@ -0,0 +1,42 @@ +'use client'; + +import { StateField, Range } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; +import { BrowseHighlightRange } from "../../hooks/useBrowseNavigation"; + +const markDecoration = Decoration.mark({ + class: "searchMatch-selected", +}); + +const lineDecoration = Decoration.line({ + attributes: { class: "cm-range-border-radius lineHighlight" }, +}); + +export const rangeHighlightingExtension = (range: BrowseHighlightRange) => StateField.define({ + create(state) { + const { start, end } = range; + + if ('column' in start && 'column' in end) { + const from = state.doc.line(start.lineNumber).from + start.column - 1; + const to = state.doc.line(end.lineNumber).from + end.column - 1; + + const decorations: Range[] = []; + if (from < to) { + decorations.push(markDecoration.range(from, to)); + } + + return Decoration.set(decorations); + } else { + const decorations: Range[] = []; + for (let line = start.lineNumber; line <= end.lineNumber; line++) { + decorations.push(lineDecoration.range(state.doc.line(line).from)); + } + + return Decoration.set(decorations); + } + }, + update(deco, tr) { + return deco.map(tr.changes); + }, + provide: (field) => EditorView.decorations.from(field), +}); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx new file mode 100644 index 00000000..91fba4c1 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/treePreviewPanel.tsx @@ -0,0 +1,49 @@ + +import { Separator } from "@/components/ui/separator"; +import { getRepoInfoByName } from "@/actions"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; +import { getFolderContents } from "@/features/fileTree/actions"; +import { isServiceError } from "@/lib/utils"; +import { PureTreePreviewPanel } from "./pureTreePreviewPanel"; + +interface TreePreviewPanelProps { + path: string; + repoName: string; + revisionName?: string; + domain: string; +} + +export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }: TreePreviewPanelProps) => { + const [repoInfoResponse, folderContentsResponse] = await Promise.all([ + getRepoInfoByName(repoName, domain), + getFolderContents({ + repoName, + revisionName: revisionName ?? 'HEAD', + path, + }, domain) + ]); + + if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) { + return
Error loading tree preview
+ } + + return ( + <> +
+ +
+ + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/[...path]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 38ddedf1..12689f86 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,16 +1,8 @@ -import { FileHeader } from "@/app/[domain]/components/fileHeader"; -import { TopBar } from "@/app/[domain]/components/topBar"; -import { Separator } from '@/components/ui/separator'; -import { getFileSource } from '@/features/search/fileSourceApi'; -import { isServiceError } from "@/lib/utils"; -import { base64Decode } from "@/lib/utils"; -import { CodePreview } from "./codePreview"; -import { ErrorCode } from "@/lib/errorCodes"; -import { LuFileX2, LuBookX } from "react-icons/lu"; -import { getOrgFromDomain } from "@/data/org"; -import { notFound } from "next/navigation"; -import { ServiceErrorException } from "@/lib/serviceError"; -import { getRepoInfoByName } from "@/actions"; +import { Suspense } from "react"; +import { getBrowseParamsFromPathParam } from "../hooks/utils"; +import { CodePreviewPanel } from "./components/codePreviewPanel"; +import { Loader2 } from "lucide-react"; +import { TreePreviewPanel } from "./components/treePreviewPanel"; interface BrowsePageProps { params: { @@ -19,137 +11,35 @@ interface BrowsePageProps { }; } -export default async function BrowsePage({ - params, -}: BrowsePageProps) { - const rawPath = decodeURIComponent(params.path.join('/')); - const sentinalIndex = rawPath.search(/\/-\/(tree|blob)\//); - if (sentinalIndex === -1) { - notFound(); - } - - const repoAndRevisionName = rawPath.substring(0, sentinalIndex).split('@'); - const repoName = repoAndRevisionName[0]; - const revisionName = repoAndRevisionName.length > 1 ? repoAndRevisionName[1] : undefined; - - const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { - const path = rawPath.substring(sentinalIndex + '/-/'.length); - const pathType = path.startsWith('tree/') ? 'tree' : 'blob'; - switch (pathType) { - case 'tree': - return { - path: path.substring('tree/'.length), - pathType, - }; - case 'blob': - return { - path: path.substring('blob/'.length), - pathType, - }; - } - })(); - - const repoInfo = await getRepoInfoByName(repoName, params.domain); - if (isServiceError(repoInfo) && repoInfo.errorCode !== ErrorCode.NOT_FOUND) { - throw new ServiceErrorException(repoInfo); - } - - if (pathType === 'tree') { - // @todo : proper tree handling - return ( - <> - Tree view not supported - - ) - } +export default async function BrowsePage({ params: { path: _rawPath, domain } }: BrowsePageProps) { + const rawPath = decodeURIComponent(_rawPath.join('/')); + const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath); return ( -
-
- - - {!isServiceError(repoInfo) && ( - <> -
- -
- - - )} -
- {isServiceError(repoInfo) ? ( -
-
- - Repository not found -
+
+ + + Loading...
- ) : ( - - )} + }> + {pathType === 'blob' ? ( + + ) : ( + + )} +
) } -interface CodePreviewWrapper { - path: string, - repoName: string, - revisionName: string, - domain: string, -} - -const CodePreviewWrapper = async ({ - path, - repoName, - revisionName, - domain, -}: CodePreviewWrapper) => { - // @todo: this will depend on `pathType`. - const fileSourceResponse = await getFileSource({ - fileName: path, - repository: repoName, - branch: revisionName, - }, domain); - - if (isServiceError(fileSourceResponse)) { - if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { - return ( -
-
- - File not found -
-
- ) - } - - throw new ServiceErrorException(fileSourceResponse); - } - - return ( - - ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx new file mode 100644 index 00000000..a3dea45b --- /dev/null +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { createContext, useCallback, useEffect, useState } from "react"; + +export interface BrowseState { + selectedSymbolInfo?: { + symbolName: string; + repoName: string; + revisionName: string; + language: string; + } + isBottomPanelCollapsed: boolean; + isFileTreePanelCollapsed: boolean; + isFileSearchOpen: boolean; + activeExploreMenuTab: "references" | "definitions"; + bottomPanelSize: number; +} + +const defaultState: BrowseState = { + selectedSymbolInfo: undefined, + isBottomPanelCollapsed: true, + isFileTreePanelCollapsed: false, + isFileSearchOpen: false, + activeExploreMenuTab: "references", + bottomPanelSize: 35, +}; + +export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; + +export const BrowseStateContext = createContext<{ + state: BrowseState; + updateBrowseState: (state: Partial) => void; +}>({ + state: defaultState, + updateBrowseState: () => {}, +}); + +interface BrowseStateProviderProps { + children: React.ReactNode; +} + +export const BrowseStateProvider = ({ children }: BrowseStateProviderProps) => { + const [state, setState] = useState(defaultState); + + const hydratedBrowseState = useNonEmptyQueryParam(SET_BROWSE_STATE_QUERY_PARAM); + + const onUpdateState = useCallback((state: Partial) => { + setState((prevState) => ({ + ...prevState, + ...state, + })); + }, []); + + useEffect(() => { + if (hydratedBrowseState) { + try { + const parsedState = JSON.parse(hydratedBrowseState) as Partial; + onUpdateState(parsedState); + } catch (error) { + console.error("Error parsing hydratedBrowseState", error); + } + + // Remove the query param + const url = new URL(window.location.href); + url.searchParams.delete(SET_BROWSE_STATE_QUERY_PARAM); + window.history.replaceState({}, '', url.toString()); + } + }, [hydratedBrowseState, onUpdateState]); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx new file mode 100644 index 00000000..4a155207 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { Button } from "@/components/ui/button"; +import { ResizablePanel } from "@/components/ui/resizable"; +import { Separator } from "@/components/ui/separator"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { useEffect, useRef } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { FaChevronDown } from "react-icons/fa"; +import { VscReferences, VscSymbolMisc } from "react-icons/vsc"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { useBrowseState } from "../hooks/useBrowseState"; +import { ExploreMenu } from "@/ee/features/codeNav/components/exploreMenu"; +import Link from "next/link"; +import { useDomain } from "@/hooks/useDomain"; +import { useRouter } from "next/navigation"; + +export const BOTTOM_PANEL_MIN_SIZE = 35; +export const BOTTOM_PANEL_MAX_SIZE = 65; +const CODE_NAV_DOCS_URL = "https://docs.sourcebot.dev/docs/features/code-navigation"; + +interface BottomPanelProps { + order: number; +} + +export const BottomPanel = ({ order }: BottomPanelProps) => { + const panelRef = useRef(null); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const domain = useDomain(); + const router = useRouter(); + + const { + state: { selectedSymbolInfo, isBottomPanelCollapsed, bottomPanelSize }, + updateBrowseState, + } = useBrowseState(); + + useEffect(() => { + if (isBottomPanelCollapsed) { + panelRef.current?.collapse(); + } else { + panelRef.current?.expand(); + } + }, [isBottomPanelCollapsed]); + + useHotkeys("shift+mod+e", (event) => { + event.preventDefault(); + updateBrowseState({ isBottomPanelCollapsed: !isBottomPanelCollapsed }); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open Explore Panel", + }); + + return ( + <> +
+
+ +
+ + {!isBottomPanelCollapsed && ( + + )} +
+ + updateBrowseState({ isBottomPanelCollapsed: true })} + onExpand={() => updateBrowseState({ isBottomPanelCollapsed: false })} + onResize={(size) => { + if (!isBottomPanelCollapsed) { + updateBrowseState({ bottomPanelSize: size }); + } + }} + order={order} + id={"bottom-panel"} + > + {!hasCodeNavEntitlement ? ( +
+ +

+ Code navigation is not enabled for router.push(`/${domain}/settings/license`)}>your plan. +

+ + + Learn more + +
+ ) : !selectedSymbolInfo ? ( +
+ +

No symbol selected

+ + Learn more + +
+ ) : ( + + )} +
+ + ) +} + diff --git a/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx new file mode 100644 index 00000000..d87eab85 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/components/fileSearchCommandDialog.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { useState, useRef, useMemo, useEffect, useCallback } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useQuery } from "@tanstack/react-query"; +import { unwrapServiceError } from "@/lib/utils"; +import { FileTreeItem, getFiles } from "@/features/fileTree/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; +import { useBrowseNavigation } from "../hooks/useBrowseNavigation"; +import { useBrowseState } from "../hooks/useBrowseState"; +import { useBrowseParams } from "../hooks/useBrowseParams"; +import { FileTreeItemIcon } from "@/features/fileTree/components/fileTreeItemIcon"; +import { useLocalStorage } from "usehooks-ts"; +import { Skeleton } from "@/components/ui/skeleton"; + +const MAX_RESULTS = 100; + +type SearchResult = { + file: FileTreeItem; + match?: { + from: number; + to: number; + }; +} + + +export const FileSearchCommandDialog = () => { + const { repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); + const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState(); + + const commandListRef = useRef(null); + const inputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + const { navigateToPath } = useBrowseNavigation(); + + const [recentlyOpened, setRecentlyOpened] = useLocalStorage(`recentlyOpenedFiles-${repoName}`, []); + + useHotkeys("mod+p", (event) => { + event.preventDefault(); + updateBrowseState({ + isFileSearchOpen: !isFileSearchOpen, + }); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Open File Search", + }); + + // Whenever we open the dialog, clear the search query + useEffect(() => { + if (isFileSearchOpen) { + setSearchQuery(''); + } + }, [isFileSearchOpen]); + + const { data: files, isLoading, isError } = useQuery({ + queryKey: ['files', repoName, revisionName, domain], + queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)), + enabled: isFileSearchOpen, + }); + + const { filteredFiles, maxResultsHit } = useMemo((): { filteredFiles: SearchResult[]; maxResultsHit: boolean } => { + if (!files || isLoading) { + return { + filteredFiles: [], + maxResultsHit: false, + }; + } + + const matches = files + .map((file) => { + return { + file, + matchIndex: file.path.toLowerCase().indexOf(searchQuery.toLowerCase()), + } + }) + .filter(({ matchIndex }) => { + return matchIndex !== -1; + }); + + return { + filteredFiles: matches + .slice(0, MAX_RESULTS) + .map(({ file, matchIndex }) => { + return { + file, + match: { + from: matchIndex, + to: matchIndex + searchQuery.length - 1, + }, + } + }), + maxResultsHit: matches.length > MAX_RESULTS, + } + }, [searchQuery, files, isLoading]); + + // Scroll to the top of the list whenever the search query changes + useEffect(() => { + commandListRef.current?.scrollTo({ + top: 0, + }) + }, [searchQuery]); + + const onSelect = useCallback((file: FileTreeItem) => { + setRecentlyOpened((prev) => { + const filtered = prev.filter(f => f.path !== file.path); + return [file, ...filtered]; + }); + navigateToPath({ + repoName, + revisionName, + path: file.path, + pathType: 'blob', + }); + updateBrowseState({ + isFileSearchOpen: false, + }); + }, [navigateToPath, repoName, revisionName, setRecentlyOpened, updateBrowseState]); + + // @note: We were hitting issues when the user types into the input field while the files are still + // loading. The workaround was to set `disabled` when loading and then focus the input field when + // the files are loaded, hence the `useEffect` below. + useEffect(() => { + if (!isLoading) { + inputRef.current?.focus(); + } + }, [isLoading]); + + return ( + { + updateBrowseState({ + isFileSearchOpen: isOpen, + }); + }} + modal={true} + > + + Search for files + {`Search for files in the repository ${repoName}.`} + + + { + isLoading ? ( + + ) : isError ? ( +

Error loading files.

+ ) : ( + + {searchQuery.length === 0 ? ( + + No recently opened files. + {recentlyOpened.map((file) => { + return ( + onSelect(file)} + /> + ); + })} + + ) : ( + <> + No results found. + {filteredFiles.map(({ file, match }) => { + return ( + onSelect(file)} + /> + ); + })} + {maxResultsHit && ( +
+ Maximum results hit. Please refine your search. +
+ )} + + )} +
+ ) + } +
+
+
+ ) +} + +interface SearchResultComponentProps { + file: FileTreeItem; + match?: { + from: number; + to: number; + }; + onSelect: () => void; +} + +const SearchResultComponent = ({ + file, + match, + onSelect, +}: SearchResultComponentProps) => { + return ( + +
+ +
+ + {file.name} + + + {match ? ( + + ) : ( + file.path + )} + +
+
+
+ ); +} + +const Highlight = ({ text, range }: { text: string, range: { from: number; to: number } }) => { + return ( + + {text.slice(0, range.from)} + {text.slice(range.from, range.to + 1)} + {text.slice(range.to + 1)} + + ) +} + +const ResultsSkeleton = () => { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ +
+ + +
+
+ ))} +
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts new file mode 100644 index 00000000..0d79170e --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts @@ -0,0 +1,87 @@ +'use client'; + +import { useRouter } from "next/navigation"; +import { useDomain } from "@/hooks/useDomain"; +import { useCallback } from "react"; +import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider"; + +export type BrowseHighlightRange = { + start: { lineNumber: number; column: number; }; + end: { lineNumber: number; column: number; }; +} | { + start: { lineNumber: number; }; + end: { lineNumber: number; }; +} + +export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange'; + +export interface GetBrowsePathProps { + repoName: string; + revisionName?: string; + path: string; + pathType: 'blob' | 'tree'; + highlightRange?: BrowseHighlightRange; + setBrowseState?: Partial; + domain: string; +} + +export const getBrowsePath = ({ + repoName, + revisionName = 'HEAD', + path, + pathType, + highlightRange, + setBrowseState, + domain, +}: GetBrowsePathProps) => { + const params = new URLSearchParams(); + + if (highlightRange) { + const { start, end } = highlightRange; + + if ('column' in start && 'column' in end) { + params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`); + } else { + params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber},${end.lineNumber}`); + } + } + + if (setBrowseState) { + params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState)); + } + + const encodedPath = encodeURIComponent(path); + const browsePath = `/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`; + return browsePath; +} + + +export const useBrowseNavigation = () => { + const router = useRouter(); + const domain = useDomain(); + + const navigateToPath = useCallback(({ + repoName, + revisionName = 'HEAD', + path, + pathType, + highlightRange, + setBrowseState, + }: Omit) => { + const browsePath = getBrowsePath({ + repoName, + revisionName, + path, + pathType, + highlightRange, + setBrowseState, + domain, + }); + + router.push(browsePath); + }, [domain, router]); + + return { + navigateToPath, + }; +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts new file mode 100644 index 00000000..d7917f72 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseParams.ts @@ -0,0 +1,18 @@ +import { usePathname } from "next/navigation"; +import { useMemo } from "react"; +import { getBrowseParamsFromPathParam } from "./utils"; + +export const useBrowseParams = () => { + const pathname = usePathname(); + + return useMemo(() => { + const startIndex = pathname.indexOf('/browse/'); + if (startIndex === -1) { + throw new Error(`Invalid browse pathname: "${pathname}" - expected to contain "/browse/"`); + } + + const rawPath = pathname.substring(startIndex + '/browse/'.length); + return getBrowseParamsFromPathParam(rawPath); + }, [pathname]); +} + diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowsePath.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowsePath.ts new file mode 100644 index 00000000..fcf29be8 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowsePath.ts @@ -0,0 +1,32 @@ +'use client'; + +import { useMemo } from "react"; +import { getBrowsePath, GetBrowsePathProps } from "./useBrowseNavigation"; +import { useDomain } from "@/hooks/useDomain"; + +export const useBrowsePath = ({ + repoName, + revisionName, + path, + pathType, + highlightRange, + setBrowseState, +}: Omit) => { + const domain = useDomain(); + + const browsePath = useMemo(() => { + return getBrowsePath({ + repoName, + revisionName, + path, + pathType, + highlightRange, + setBrowseState, + domain, + }); + }, [repoName, revisionName, path, pathType, highlightRange, setBrowseState, domain]); + + return { + path: browsePath, + } +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts new file mode 100644 index 00000000..5ff4924c --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseState.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useContext } from "react"; +import { BrowseStateContext } from "../browseStateProvider"; + +export const useBrowseState = () => { + const context = useContext(BrowseStateContext); + if (!context) { + throw new Error('useBrowseState must be used within a BrowseStateProvider'); + } + return context; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/hooks/utils.test.ts b/packages/web/src/app/[domain]/browse/hooks/utils.test.ts new file mode 100644 index 00000000..bbf048b6 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/utils.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from 'vitest'; +import { getBrowseParamsFromPathParam } from './utils'; + +describe('getBrowseParamsFromPathParam', () => { + describe('tree paths', () => { + it('should parse tree path with trailing slash', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: '', + pathType: 'tree', + }); + }); + + it('should parse tree path without trailing slash', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: '', + pathType: 'tree', + }); + }); + + it('should parse tree path with nested directory', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/packages/web/src'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: 'packages/web/src', + pathType: 'tree', + }); + }); + + it('should parse tree path without revision', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt/-/tree/docs'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: undefined, + path: 'docs', + pathType: 'tree', + }); + }); + }); + + describe('blob paths', () => { + + + it('should parse blob path with file', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/README.md'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: 'README.md', + pathType: 'blob', + }); + }); + + it('should parse blob path with nested file', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/packages/web/src/app/page.tsx'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: 'packages/web/src/app/page.tsx', + pathType: 'blob', + }); + }); + + it('should parse blob path without revision', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt/-/blob/main.go'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: undefined, + path: 'main.go', + pathType: 'blob', + }); + }); + }); + + describe('URL decoding', () => { + it('should decode URL-encoded spaces in path', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/tree/folder%20with%20spaces'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: 'folder with spaces', + pathType: 'tree', + }); + }); + + it('should decode URL-encoded special characters in path', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/file%20with%20%26%20symbols.txt'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: 'file with & symbols.txt', + pathType: 'blob', + }); + }); + }); + + describe('different revision formats', () => { + it('should parse with branch name', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@main/-/tree/'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'main', + path: '', + pathType: 'tree', + }); + }); + + it('should parse with commit hash', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@a1b2c3d/-/tree/'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'a1b2c3d', + path: '', + pathType: 'tree', + }); + }); + + it('should parse with tag', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@v1.0.0/-/tree/'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'v1.0.0', + path: '', + pathType: 'tree', + }); + }); + }); + + describe('edge cases', () => { + it('should handle repo name with multiple @ symbols', () => { + const result = getBrowseParamsFromPathParam('gitlab.com/user@domain/repo@main/-/tree/'); + expect(result).toEqual({ + repoName: 'gitlab.com/user@domain/repo', + revisionName: 'main', + path: '', + pathType: 'tree', + }); + }); + + it('should handle paths with @ symbols', () => { + const result = getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/file@v1.0.0.txt'); + expect(result).toEqual({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'HEAD', + path: 'file@v1.0.0.txt', + pathType: 'blob', + }); + }); + }); + + describe('error cases', () => { + it('should throw error for blob path with trailing slash and no path', () => { + expect(() => { + getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob/'); + }).toThrow(); + }); + + it('should throw error for blob path without trailing slash and no path', () => { + expect(() => { + getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/blob'); + }).toThrow(); + }); + + it('should throw error for invalid pattern - missing /-/', () => { + expect(() => { + getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/tree/'); + }).toThrow(); + }); + + it('should throw error for invalid pattern - missing tree/blob', () => { + expect(() => { + getBrowseParamsFromPathParam('github.com/sourcebot-dev/zoekt@HEAD/-/invalid/'); + }).toThrow(); + }); + + it('should throw error for completely invalid format', () => { + expect(() => { + getBrowseParamsFromPathParam('invalid-path'); + }).toThrow(); + }); + + it('should throw error for empty string', () => { + expect(() => { + getBrowseParamsFromPathParam(''); + }).toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/hooks/utils.ts b/packages/web/src/app/[domain]/browse/hooks/utils.ts new file mode 100644 index 00000000..307dd018 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/utils.ts @@ -0,0 +1,43 @@ + +export const getBrowseParamsFromPathParam = (pathParam: string) => { + const sentinelIndex = pathParam.search(/\/-\/(tree|blob)/); + if (sentinelIndex === -1) { + throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob)/" pattern`); + } + + const repoAndRevisionPart = pathParam.substring(0, sentinelIndex); + const lastAtIndex = repoAndRevisionPart.lastIndexOf('@'); + + const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex); + const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1); + + const { path, pathType } = ((): { path: string, pathType: 'tree' | 'blob' } => { + const path = pathParam.substring(sentinelIndex + '/-/'.length); + const pathType = path.startsWith('tree') ? 'tree' : 'blob'; + + // @note: decodedURIComponent is needed here incase the path contains a space. + switch (pathType) { + case 'tree': + return { + path: decodeURIComponent(path.startsWith('tree/') ? path.substring('tree/'.length) : path.substring('tree'.length)), + pathType, + }; + case 'blob': + return { + path: decodeURIComponent(path.startsWith('blob/') ? path.substring('blob/'.length) : path.substring('blob'.length)), + pathType, + }; + } + })(); + + if (pathType === 'blob' && path === '') { + throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`); + } + + return { + repoName, + revisionName, + path, + pathType, + } +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx new file mode 100644 index 00000000..6807a38f --- /dev/null +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { BottomPanel } from "./components/bottomPanel"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { BrowseStateProvider } from "./browseStateProvider"; +import { FileTreePanel } from "@/features/fileTree/components/fileTreePanel"; +import { TopBar } from "@/app/[domain]/components/topBar"; +import { useBrowseParams } from "./hooks/useBrowseParams"; +import { FileSearchCommandDialog } from "./components/fileSearchCommandDialog"; +import { useDomain } from "@/hooks/useDomain"; +import { SearchBar } from "../components/searchBar"; + +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ + children, +}: LayoutProps) { + const { repoName, revisionName } = useBrowseParams(); + const domain = useDomain(); + + return ( + +
+ + + + + + + + + + + + {children} + + + + + + +
+ +
+ ); +} diff --git a/packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx b/packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx new file mode 100644 index 00000000..38d12781 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/[id]/components/chatThreadPanel.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { ResizablePanel } from '@/components/ui/resizable'; +import { ChatThread } from '@/features/chat/components/chatThread'; +import { LanguageModelInfo, SBChatMessage, SET_CHAT_STATE_QUERY_PARAM, SetChatStatePayload } from '@/features/chat/types'; +import { RepositoryQuery } from '@/lib/types'; +import { CreateUIMessage } from 'ai'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useChatId } from '../../useChatId'; + +interface ChatThreadPanelProps { + languageModels: LanguageModelInfo[]; + repos: RepositoryQuery[]; + order: number; + messages: SBChatMessage[]; + isChatReadonly: boolean; +} + +export const ChatThreadPanel = ({ + languageModels, + repos, + order, + messages, + isChatReadonly, +}: ChatThreadPanelProps) => { + // @note: we are guaranteed to have a chatId because this component will only be + // mounted when on a /chat/[id] route. + const chatId = useChatId()!; + const router = useRouter(); + const searchParams = useSearchParams(); + const [inputMessage, setInputMessage] = useState | undefined>(undefined); + + // Use the last user's last message to determine what repos we should select by default. + const [selectedRepos, setSelectedRepos] = useState(messages.findLast((message) => message.role === "user")?.metadata?.selectedRepos ?? []); + + useEffect(() => { + const setChatState = searchParams.get(SET_CHAT_STATE_QUERY_PARAM); + if (!setChatState) { + return; + } + + try { + const { inputMessage, selectedRepos } = JSON.parse(setChatState) as SetChatStatePayload; + setInputMessage(inputMessage); + setSelectedRepos(selectedRepos); + } catch { + console.error('Invalid message in URL'); + } + + // Remove the message from the URL + const newSearchParams = new URLSearchParams(searchParams.toString()); + newSearchParams.delete(SET_CHAT_STATE_QUERY_PARAM); + router.replace(`?${newSearchParams.toString()}`, { scroll: false }); + }, [searchParams, router]); + + return ( + +
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/chat/[id]/page.tsx b/packages/web/src/app/[domain]/chat/[id]/page.tsx new file mode 100644 index 00000000..67331eb4 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/[id]/page.tsx @@ -0,0 +1,84 @@ +import { getRepos } from '@/actions'; +import { getUserChatHistory, getConfiguredLanguageModelsInfo, getChatInfo } from '@/features/chat/actions'; +import { ServiceErrorException } from '@/lib/serviceError'; +import { isServiceError } from '@/lib/utils'; +import { ChatThreadPanel } from './components/chatThreadPanel'; +import { notFound } from 'next/navigation'; +import { StatusCodes } from 'http-status-codes'; +import { TopBar } from '../../components/topBar'; +import { ChatName } from '../components/chatName'; +import { auth } from '@/auth'; +import { AnimatedResizableHandle } from '@/components/ui/animatedResizableHandle'; +import { ChatSidePanel } from '../components/chatSidePanel'; +import { ResizablePanelGroup } from '@/components/ui/resizable'; + +interface PageProps { + params: { + domain: string; + id: string; + }; +} + +export default async function Page({ params }: PageProps) { + const languageModels = await getConfiguredLanguageModelsInfo(); + const repos = await getRepos(params.domain); + const chatInfo = await getChatInfo({ chatId: params.id }, params.domain); + const session = await auth(); + const chatHistory = session ? await getUserChatHistory(params.domain) : []; + + if (isServiceError(chatHistory)) { + throw new ServiceErrorException(chatHistory); + } + + if (isServiceError(repos)) { + throw new ServiceErrorException(repos); + } + + if (isServiceError(chatInfo)) { + if (chatInfo.statusCode === StatusCodes.NOT_FOUND) { + return notFound(); + } + + throw new ServiceErrorException(chatInfo); + } + + const { messages, name, visibility, isReadonly } = chatInfo; + + const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined); + + return ( + <> + +
+ / + +
+
+ + + + + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/chat/components/chatName.tsx b/packages/web/src/app/[domain]/chat/components/chatName.tsx new file mode 100644 index 00000000..b305d3d7 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/components/chatName.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useToast } from "@/components/hooks/use-toast"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { updateChatName } from "@/features/chat/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { isServiceError } from "@/lib/utils"; +import { GlobeIcon } from "@radix-ui/react-icons"; +import { ChatVisibility } from "@sourcebot/db"; +import { LockIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { RenameChatDialog } from "./renameChatDialog"; + +interface ChatNameProps { + name: string | null; + visibility: ChatVisibility; + id: string; + isReadonly: boolean; +} + +export const ChatName = ({ name, visibility, id, isReadonly }: ChatNameProps) => { + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const { toast } = useToast(); + const domain = useDomain(); + const router = useRouter(); + + const onRenameChat = useCallback(async (name: string) => { + + const response = await updateChatName({ + chatId: id, + name: name, + }, domain); + + if (isServiceError(response)) { + toast({ + description: `โŒ Failed to rename chat. Reason: ${response.message}` + }); + } else { + toast({ + description: `โœ… Chat renamed successfully` + }); + router.refresh(); + } + }, [id, domain, toast, router]); + + return ( + <> +
+ + +

{ + setIsRenameDialogOpen(true); + }} + > + {name ?? 'Untitled chat'} +

+
+ + Rename chat + +
+ {visibility && ( + + +
+ + {visibility === ChatVisibility.PUBLIC ? ( + + ) : ( + + )} + {visibility === ChatVisibility.PUBLIC ? (isReadonly ? 'Public (Read-only)' : 'Public') : 'Private'} + +
+
+ + {visibility === ChatVisibility.PUBLIC ? `Anyone with the link can view this chat${!isReadonly ? ' and ask follow-up questions' : ''}.` : 'Only you can view and edit this chat.'} + +
+ )} +
+ + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/chat/components/chatSidePanel.tsx b/packages/web/src/app/[domain]/chat/components/chatSidePanel.tsx new file mode 100644 index 00000000..16e955f8 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/components/chatSidePanel.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { useToast } from "@/components/hooks/use-toast"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { ResizablePanel } from "@/components/ui/resizable"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { deleteChat, updateChatName } from "@/features/chat/actions"; +import { useDomain } from "@/hooks/useDomain"; +import { cn, isServiceError } from "@/lib/utils"; +import { CirclePlusIcon, EllipsisIcon, PencilIcon, TrashIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { + GoSidebarCollapse as ExpandIcon, +} from "react-icons/go"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { useChatId } from "../useChatId"; +import { RenameChatDialog } from "./renameChatDialog"; +import { DeleteChatDialog } from "./deleteChatDialog"; +import Link from "next/link"; + +interface ChatSidePanelProps { + order: number; + chatHistory: { + id: string; + name: string | null; + createdAt: Date; + }[]; + isAuthenticated: boolean; + isCollapsedInitially: boolean; +} + +export const ChatSidePanel = ({ + order, + chatHistory, + isAuthenticated, + isCollapsedInitially, +}: ChatSidePanelProps) => { + const domain = useDomain(); + const [isCollapsed, setIsCollapsed] = useState(isCollapsedInitially); + const sidePanelRef = useRef(null); + const router = useRouter(); + const { toast } = useToast(); + const chatId = useChatId(); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [chatIdToRename, setChatIdToRename] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [chatIdToDelete, setChatIdToDelete] = useState(null); + + useHotkeys("mod+b", () => { + if (isCollapsed) { + sidePanelRef.current?.expand(); + } else { + sidePanelRef.current?.collapse(); + } + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Toggle side panel", + }); + + const onRenameChat = useCallback(async (name: string, chatId: string) => { + if (!chatId) { + return; + } + + const response = await updateChatName({ + chatId, + name: name, + }, domain); + + if (isServiceError(response)) { + toast({ + description: `โŒ Failed to rename chat. Reason: ${response.message}` + }); + } else { + toast({ + description: `โœ… Chat renamed successfully` + }); + router.refresh(); + } + }, [router, toast, domain]); + + const onDeleteChat = useCallback(async (chatIdToDelete: string) => { + if (!chatIdToDelete) { + return; + } + + const response = await deleteChat({ chatId: chatIdToDelete }, domain); + + if (isServiceError(response)) { + toast({ + description: `โŒ Failed to delete chat. Reason: ${response.message}` + }); + } else { + toast({ + description: `โœ… Chat deleted successfully` + }); + + // If we just deleted the current chat, navigate to new chat + if (chatIdToDelete === chatId) { + router.push(`/${domain}/chat`); + } + + router.refresh(); + } + }, [chatId, router, toast, domain]); + + return ( + <> + setIsCollapsed(true)} + onExpand={() => setIsCollapsed(false)} + > +
+
+ +
+ +

Recent Chats

+
+ {!isAuthenticated ? ( +
+

+ + Sign in + to access your chat history. +

+
+ ) : chatHistory.length === 0 ? ( +
+

Recent chats will appear here.

+
+ ) : chatHistory.map((chat) => ( +
{ + router.push(`/${domain}/chat/${chat.id}`); + }} + > + {chat.name ?? 'Untitled chat'} + + + + + + { + e.stopPropagation(); + setChatIdToRename(chat.id); + setIsRenameDialogOpen(true); + }} + > + + Rename + + { + e.stopPropagation(); + setChatIdToDelete(chat.id); + setIsDeleteDialogOpen(true); + }} + > + + Delete + + + +
+ ))} +
+
+
+ +
+ {isCollapsed && ( +
+ + + + + + + + Open side panel + + +
+ )} + { + if (chatIdToRename) { + onRenameChat(name, chatIdToRename); + } + }} + currentName={chatHistory?.find((chat) => chat.id === chatIdToRename)?.name ?? ""} + /> + { + if (chatIdToDelete) { + onDeleteChat(chatIdToDelete); + } + }} + /> + + ) +} diff --git a/packages/web/src/app/[domain]/chat/components/deleteChatDialog.tsx b/packages/web/src/app/[domain]/chat/components/deleteChatDialog.tsx new file mode 100644 index 00000000..095f4054 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/components/deleteChatDialog.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useCallback } from "react"; + +interface DeleteChatDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onDelete: () => void; +} + +export const DeleteChatDialog = ({ isOpen, onOpenChange, onDelete }: DeleteChatDialogProps) => { + const handleDelete = useCallback(() => { + onDelete(); + onOpenChange(false); + }, [onDelete, onOpenChange]); + + return ( + + + + Delete chat? + + The chat will be deleted and removed from your chat history. This action cannot be undone. + + + + + + + + + ); +}; + diff --git a/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx b/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx new file mode 100644 index 00000000..e8573864 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/components/newChatPanel.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { ResizablePanel } from "@/components/ui/resizable"; +import { ChatBox } from "@/features/chat/components/chatBox"; +import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; +import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; +import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; +import { LanguageModelInfo } from "@/features/chat/types"; +import { RepositoryQuery } from "@/lib/types"; +import { useCallback, useState } from "react"; +import { Descendant } from "slate"; +import { useLocalStorage } from "usehooks-ts"; + +interface NewChatPanelProps { + languageModels: LanguageModelInfo[]; + repos: RepositoryQuery[]; + order: number; +} + +export const NewChatPanel = ({ + languageModels, + repos, + order, +}: NewChatPanelProps) => { + const [selectedRepos, setSelectedRepos] = useLocalStorage("selectedRepos", [], { initializeWithValue: false }); + const { createNewChatThread, isLoading } = useCreateNewChatThread(); + const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false); + + const onSubmit = useCallback((children: Descendant[]) => { + createNewChatThread(children, selectedRepos); + }, [createNewChatThread, selectedRepos]); + + + return ( + +
+

What can I help you understand?

+
+ + +
+ +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/chat/components/renameChatDialog.tsx b/packages/web/src/app/[domain]/chat/components/renameChatDialog.tsx new file mode 100644 index 00000000..e22277e1 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/components/renameChatDialog.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +interface RenameChatDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onRename: (name: string) => void; + currentName: string; +} + +export const RenameChatDialog = ({ isOpen, onOpenChange, onRename, currentName }: RenameChatDialogProps) => { + const formSchema = z.object({ + name: z.string().min(1), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + }, + }); + + useEffect(() => { + form.reset({ + name: currentName, + }); + }, [currentName, form]); + + const onSubmit = (data: z.infer) => { + onRename(data.name); + form.reset(); + onOpenChange(false); + } + + return ( + + + + Rename Chat + + {`Rename "${currentName ?? 'untitled chat'}" to a new name.`} + + +
+ { + event.stopPropagation(); + form.handleSubmit(onSubmit)(event); + }} + > + ( + + + + + + + )} + /> + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/chat/layout.tsx b/packages/web/src/app/[domain]/chat/layout.tsx new file mode 100644 index 00000000..e82ea91f --- /dev/null +++ b/packages/web/src/app/[domain]/chat/layout.tsx @@ -0,0 +1,18 @@ +import { NavigationGuardProvider } from 'next-navigation-guard'; + +interface LayoutProps { + children: React.ReactNode; +} + +export default async function Layout({ children }: LayoutProps) { + + return ( + // @note: we use a navigation guard here since we don't support resuming streams yet. + // @see: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#resuming-ongoing-streams + +
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/chat/page.tsx b/packages/web/src/app/[domain]/chat/page.tsx new file mode 100644 index 00000000..f15a11cb --- /dev/null +++ b/packages/web/src/app/[domain]/chat/page.tsx @@ -0,0 +1,57 @@ +import { getRepos } from "@/actions"; +import { getUserChatHistory, getConfiguredLanguageModelsInfo } from "@/features/chat/actions"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { NewChatPanel } from "./components/newChatPanel"; +import { TopBar } from "../components/topBar"; +import { ResizablePanelGroup } from "@/components/ui/resizable"; +import { ChatSidePanel } from "./components/chatSidePanel"; +import { auth } from "@/auth"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; + +interface PageProps { + params: { + domain: string; + }; +} + +export default async function Page({ params }: PageProps) { + const languageModels = await getConfiguredLanguageModelsInfo(); + const repos = await getRepos(params.domain); + const session = await auth(); + const chatHistory = session ? await getUserChatHistory(params.domain) : []; + + if (isServiceError(chatHistory)) { + throw new ServiceErrorException(chatHistory); + } + + if (isServiceError(repos)) { + throw new ServiceErrorException(repos); + } + + const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined); + + return ( + <> + + + + + + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/chat/useChatId.ts b/packages/web/src/app/[domain]/chat/useChatId.ts new file mode 100644 index 00000000..b42aa2a4 --- /dev/null +++ b/packages/web/src/app/[domain]/chat/useChatId.ts @@ -0,0 +1,8 @@ +'use client'; + +import { useParams } from "next/navigation"; + +export const useChatId = (): string | undefined => { + const { id: chatId } = useParams<{ id: string }>(); + return chatId; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx index 50560f5d..6bfe4baf 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx @@ -40,7 +40,7 @@ export const SecretCombobox = ({ const captureEvent = useCaptureEvent(); const { data: secrets, isPending, isError, refetch } = useQuery({ - queryKey: ["secrets"], + queryKey: ["secrets", domain], queryFn: () => unwrapServiceError(getSecrets(domain)), }); diff --git a/packages/web/src/app/[domain]/components/copyIconButton.tsx b/packages/web/src/app/[domain]/components/copyIconButton.tsx new file mode 100644 index 00000000..4c2b0996 --- /dev/null +++ b/packages/web/src/app/[domain]/components/copyIconButton.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CheckCircle2, Copy } from "lucide-react"; +import { useCallback, useState } from "react"; + +interface CopyIconButtonProps { + onCopy: () => boolean; + className?: string; +} + +export const CopyIconButton = ({ onCopy, className }: CopyIconButtonProps) => { + const [copied, setCopied] = useState(false); + + const onClick = useCallback(() => { + const success = onCopy(); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }, [onCopy]); + + return ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/editorContextMenu.tsx b/packages/web/src/app/[domain]/components/editorContextMenu.tsx index d567198c..102f9f89 100644 --- a/packages/web/src/app/[domain]/components/editorContextMenu.tsx +++ b/packages/web/src/app/[domain]/components/editorContextMenu.tsx @@ -9,6 +9,7 @@ import { Link2Icon } from "@radix-ui/react-icons"; import { EditorView, SelectionRange } from "@uiw/react-codemirror"; import { useCallback, useEffect, useRef } from "react"; import { useDomain } from "@/hooks/useDomain"; +import { HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; interface ContextMenuProps { view: EditorView; @@ -107,7 +108,7 @@ export const EditorContextMenu = ({ const basePath = `${window.location.origin}/${domain}/browse`; const url = createPathWithQueryParams(`${basePath}/${repoName}@${revisionName}/-/blob/${path}`, - ['highlightRange', `${from?.line}:${from?.column},${to?.line}:${to?.column}`], + [HIGHLIGHT_RANGE_QUERY_PARAM, `${from?.line}:${from?.column},${to?.line}:${to?.column}`], ); navigator.clipboard.writeText(url); diff --git a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx index a63a469d..2f024a61 100644 --- a/packages/web/src/app/[domain]/components/errorNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/errorNavIndicator.tsx @@ -11,6 +11,7 @@ import { useQuery } from "@tanstack/react-query"; import { ConnectionSyncStatus, RepoIndexingStatus } from "@sourcebot/db"; import { getConnections } from "@/actions"; import { getRepos } from "@/actions"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export const ErrorNavIndicator = () => { const domain = useDomain(); @@ -62,18 +63,27 @@ export const ErrorNavIndicator = () => { The following connections have failed to sync:

- {connections - .slice(0, 10) - .map(connection => ( - captureEvent('wa_error_nav_job_pressed', {})}> -
- {connection.name} -
- - ))} + + {connections + .slice(0, 10) + .map(connection => ( + captureEvent('wa_error_nav_job_pressed', {})}> +
+ + + {connection.name} + + + {connection.name} + + +
+ + ))} +
{connections.length > 10 && (
And {connections.length - 10} more... @@ -93,23 +103,29 @@ export const ErrorNavIndicator = () => { The following repositories failed to index:

- {repos - .slice(0, 10) - .filter(item => item.linkedConnections.length > 0) // edge case: don't show repos that are orphaned and awaiting gc. - .map(repo => ( - // Link to the first connection for the repo - captureEvent('wa_error_nav_job_pressed', {})}> -
- - {repo.repoName} - -
- - ))} + + {repos + .slice(0, 10) + .filter(item => item.linkedConnections.length > 0) // edge case: don't show repos that are orphaned and awaiting gc. + .map(repo => ( + // Link to the first connection for the repo + captureEvent('wa_error_nav_job_pressed', {})}> +
+ + + {repo.repoName} + + + {repo.repoName} + + +
+ + ))} +
{repos.length > 10 && (
And {repos.length - 10} more... diff --git a/packages/web/src/app/[domain]/components/fileHeader.tsx b/packages/web/src/app/[domain]/components/fileHeader.tsx deleted file mode 100644 index 852e7c50..00000000 --- a/packages/web/src/app/[domain]/components/fileHeader.tsx +++ /dev/null @@ -1,95 +0,0 @@ - -import { getCodeHostInfoForRepo } from "@/lib/utils"; -import { LaptopIcon } from "@radix-ui/react-icons"; -import clsx from "clsx"; -import Image from "next/image"; -import Link from "next/link"; - -interface FileHeaderProps { - fileName: string; - fileNameHighlightRange?: { - from: number; - to: number; - } - repo: { - name: string; - codeHostType: string; - displayName?: string; - webUrl?: string; - }, - branchDisplayName?: string; - branchDisplayTitle?: string; -} - -export const FileHeader = ({ - repo, - fileName, - fileNameHighlightRange, - branchDisplayName, - branchDisplayTitle, -}: FileHeaderProps) => { - const info = getCodeHostInfoForRepo({ - name: repo.name, - codeHostType: repo.codeHostType, - displayName: repo.displayName, - webUrl: repo.webUrl, - }); - - return ( -
- {info?.icon ? ( - {info.codeHostName} - ): ( - - )} - - {info?.displayName} - - {branchDisplayName && ( -

- {/* hack since to make the @ symbol look more centered with the text */} - - @ - - {`${branchDisplayName}`} -

- )} - ยท -
- - {!fileNameHighlightRange ? - fileName - : ( - <> - {fileName.slice(0, fileNameHighlightRange.from)} - - {fileName.slice(fileNameHighlightRange.from, fileNameHighlightRange.to)} - - {fileName.slice(fileNameHighlightRange.to)} - - )} - -
-
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/gcpIapAuth.tsx b/packages/web/src/app/[domain]/components/gcpIapAuth.tsx new file mode 100644 index 00000000..292161d6 --- /dev/null +++ b/packages/web/src/app/[domain]/components/gcpIapAuth.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { signIn } from "next-auth/react"; +import { useEffect } from "react"; + +interface GcpIapAuthProps { + callbackUrl?: string; +} + +export const GcpIapAuth = ({ callbackUrl }: GcpIapAuthProps) => { + useEffect(() => { + signIn("gcp-iap", { + redirectTo: callbackUrl ?? "/" + }).catch((error) => { + console.error("Error signing in with GCP IAP:", error); + }); + }, [callbackUrl]); + + return ( +
+
+

Signing in with Google Cloud IAP...

+
+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx new file mode 100644 index 00000000..957b6c5f --- /dev/null +++ b/packages/web/src/app/[domain]/components/homepage/agenticSearch.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ChatBox } from "@/features/chat/components/chatBox"; +import { ChatBoxToolbar } from "@/features/chat/components/chatBox/chatBoxToolbar"; +import { LanguageModelInfo } from "@/features/chat/types"; +import { useCreateNewChatThread } from "@/features/chat/useCreateNewChatThread"; +import { resetEditor } from "@/features/chat/utils"; +import { useDomain } from "@/hooks/useDomain"; +import { RepositoryQuery } from "@/lib/types"; +import { getDisplayTime } from "@/lib/utils"; +import { BrainIcon, FileIcon, LucideIcon, SearchIcon } from "lucide-react"; +import Link from "next/link"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { ReactEditor, useSlate } from "slate-react"; +import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar"; +import { useLocalStorage } from "usehooks-ts"; + +// @todo: we should probably rename this to a different type since it sort-of clashes +// with the Suggestion system we have built into the chat box. +type SuggestionType = "understand" | "find" | "summarize"; + +const suggestionTypes: Record = { + understand: { + icon: BrainIcon, + title: "Understand", + description: "Understand the codebase", + }, + find: { + icon: SearchIcon, + title: "Find", + description: "Find the codebase", + }, + summarize: { + icon: FileIcon, + title: "Summarize", + description: "Summarize the codebase", + }, +} + + +const Highlight = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +const suggestions: Record = { + understand: [ + { + queryText: "How does authentication work in this codebase?", + openRepoSelector: true, + }, + { + queryText: "How are API endpoints structured and organized?", + openRepoSelector: true, + }, + { + queryText: "How does the build and deployment process work?", + openRepoSelector: true, + }, + { + queryText: "How is error handling implemented across the application?", + openRepoSelector: true, + }, + ], + find: [ + { + queryText: "Find examples of different logging libraries used throughout the codebase.", + }, + { + queryText: "Find examples of potential security vulnerabilities or authentication issues.", + }, + { + queryText: "Find examples of API endpoints and route handlers.", + } + ], + summarize: [ + { + queryText: "Summarize the purpose of this file @file:", + queryNode: Summarize the purpose of this file @file: + }, + { + queryText: "Summarize the project structure and architecture.", + openRepoSelector: true, + }, + { + queryText: "Provide a quick start guide for ramping up on this codebase.", + openRepoSelector: true, + } + ], +} + +const MAX_RECENT_CHAT_HISTORY_COUNT = 10; + + +interface AgenticSearchProps { + searchModeSelectorProps: SearchModeSelectorProps; + languageModels: LanguageModelInfo[]; + repos: RepositoryQuery[]; + chatHistory: { + id: string; + createdAt: Date; + name: string | null; + }[]; +} + +export const AgenticSearch = ({ + searchModeSelectorProps, + languageModels, + repos, + chatHistory, +}: AgenticSearchProps) => { + const [selectedSuggestionType, _setSelectedSuggestionType] = useState(undefined); + const { createNewChatThread, isLoading } = useCreateNewChatThread(); + const dropdownRef = useRef(null); + const editor = useSlate(); + const [selectedRepos, setSelectedRepos] = useLocalStorage("selectedRepos", [], { initializeWithValue: false }); + const domain = useDomain(); + const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false); + + const setSelectedSuggestionType = useCallback((type: SuggestionType | undefined) => { + _setSelectedSuggestionType(type); + if (type) { + ReactEditor.focus(editor); + } + }, [editor, _setSelectedSuggestionType]); + + // Close dropdown when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + !dropdownRef.current?.contains(event.target as Node) + ) { + setSelectedSuggestionType(undefined); + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [setSelectedSuggestionType]); + + return ( +
+
+ { + createNewChatThread(children, selectedRepos); + }} + className="min-h-[50px]" + isRedirecting={isLoading} + languageModels={languageModels} + selectedRepos={selectedRepos} + onRepoSelectorOpenChanged={setIsRepoSelectorOpen} + /> + +
+
+ + +
+ + {selectedSuggestionType && ( +
+

+ {suggestionTypes[selectedSuggestionType].title} +

+ {suggestions[selectedSuggestionType].map(({ queryText, queryNode, openRepoSelector }, index) => ( +
{ + resetEditor(editor); + editor.insertText(queryText); + setSelectedSuggestionType(undefined); + + if (openRepoSelector) { + setIsRepoSelectorOpen(true); + } else { + ReactEditor.focus(editor); + } + }} + > + + {queryNode ?? queryText} +
+ ))} +
+ )} +
+
+
+
+ {Object.entries(suggestionTypes).map(([type, suggestion], index) => ( + { + setSelectedSuggestionType(type as SuggestionType); + }} + /> + ))} +
+
+ {chatHistory.length > 0 && ( +
+ + Recent conversations +
+ {chatHistory + .slice(0, MAX_RECENT_CHAT_HISTORY_COUNT) + .map((chat) => ( + + + {chat.name ?? "Untitled Chat"} + + + {getDisplayTime(chat.createdAt)} + + + ))} +
+ {chatHistory.length > MAX_RECENT_CHAT_HISTORY_COUNT && ( + + View all + + )} +
+ )} +
+ ) +} + + +interface ExampleButtonProps { + Icon: LucideIcon; + title: string; + onClick: () => void; +} + +const ExampleButton = ({ + Icon, + title, + onClick, +}: ExampleButtonProps) => { + return ( + + ) +} diff --git a/packages/web/src/app/[domain]/components/homepage/index.tsx b/packages/web/src/app/[domain]/components/homepage/index.tsx new file mode 100644 index 00000000..6fa4784d --- /dev/null +++ b/packages/web/src/app/[domain]/components/homepage/index.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { LanguageModelInfo } from "@/features/chat/types"; +import { RepositoryQuery } from "@/lib/types"; +import { useHotkeys } from "react-hotkeys-hook"; +import { AgenticSearch } from "./agenticSearch"; +import { PreciseSearch } from "./preciseSearch"; +import { SearchMode } from "./toolbar"; +import { CustomSlateEditor } from "@/features/chat/customSlateEditor"; +import { setSearchModeCookie } from "@/actions"; +import { useCallback, useState } from "react"; + +interface HomepageProps { + initialRepos: RepositoryQuery[]; + languageModels: LanguageModelInfo[]; + chatHistory: { + id: string; + createdAt: Date; + name: string | null; + }[]; + initialSearchMode: SearchMode; +} + + +export const Homepage = ({ + initialRepos, + languageModels, + chatHistory, + initialSearchMode, +}: HomepageProps) => { + const [searchMode, setSearchMode] = useState(initialSearchMode); + const isAgenticSearchEnabled = languageModels.length > 0; + + const onSearchModeChanged = useCallback(async (newMode: SearchMode) => { + setSearchMode(newMode); + await setSearchModeCookie(newMode); + }, [setSearchMode]); + + useHotkeys("mod+i", (e) => { + e.preventDefault(); + onSearchModeChanged("agentic"); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Switch to agentic search", + }); + + useHotkeys("mod+p", (e) => { + e.preventDefault(); + onSearchModeChanged("precise"); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Switch to precise search", + }); + + return ( +
+
+ +
+ + {searchMode === "precise" ? ( + + ) : ( + + + + )} +
+ ) +} + diff --git a/packages/web/src/app/[domain]/components/homepage/preciseSearch.tsx b/packages/web/src/app/[domain]/components/homepage/preciseSearch.tsx new file mode 100644 index 00000000..d5608940 --- /dev/null +++ b/packages/web/src/app/[domain]/components/homepage/preciseSearch.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { Separator } from "@/components/ui/separator"; +import { SyntaxReferenceGuideHint } from "../syntaxReferenceGuideHint"; +import { RepositorySnapshot } from "./repositorySnapshot"; +import { RepositoryQuery } from "@/lib/types"; +import { useDomain } from "@/hooks/useDomain"; +import Link from "next/link"; +import { SearchBar } from "../searchBar/searchBar"; +import { SearchModeSelector, SearchModeSelectorProps } from "./toolbar"; + +interface PreciseSearchProps { + initialRepos: RepositoryQuery[]; + searchModeSelectorProps: SearchModeSelectorProps; +} + +export const PreciseSearch = ({ + initialRepos, + searchModeSelectorProps, +}: PreciseSearchProps) => { + const domain = useDomain(); + + return ( + <> +
+ + +
+ +
+
+
+ +
+
+ + How to search +
+ + + test todo (both test and todo) + + + test or todo (either test or todo) + + + {`"exit boot"`} (exact match) + + + TODO case:yes (case sensitive) + + + + + file:README setup (by filename) + + + repo:torvalds/linux test (by repo) + + + lang:typescript (by language) + + + rev:HEAD (by branch or tag) + + + + + file:{`\\.py$`} {`(files that end in ".py")`} + + + sym:main {`(symbols named "main")`} + + + todo -lang:c (negate filter) + + + content:README (search content only) + + +
+ +
+ + ) +} + +const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => { + return ( +
+ {title} + {children} +
+ ) + +} + +const Highlight = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +const QueryExample = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +const QueryExplanation = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ) +} + +const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => { + return ( + + {children} + + ) +} diff --git a/packages/web/src/app/[domain]/components/repositoryCarousel.tsx b/packages/web/src/app/[domain]/components/homepage/repositoryCarousel.tsx similarity index 100% rename from packages/web/src/app/[domain]/components/repositoryCarousel.tsx rename to packages/web/src/app/[domain]/components/homepage/repositoryCarousel.tsx diff --git a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx b/packages/web/src/app/[domain]/components/homepage/repositorySnapshot.tsx similarity index 77% rename from packages/web/src/app/[domain]/components/repositorySnapshot.tsx rename to packages/web/src/app/[domain]/components/homepage/repositorySnapshot.tsx index c205b2da..c867fdd0 100644 --- a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx +++ b/packages/web/src/app/[domain]/components/homepage/repositorySnapshot.tsx @@ -18,12 +18,10 @@ import { SymbolIcon } from "@radix-ui/react-icons"; import { RepositoryQuery } from "@/lib/types"; interface RepositorySnapshotProps { - authEnabled: boolean; repos: RepositoryQuery[]; } export function RepositorySnapshot({ - authEnabled, repos: initialRepos, }: RepositorySnapshotProps) { const domain = useDomain(); @@ -59,21 +57,21 @@ export function RepositorySnapshot({
) - // ... otherwise, show the empty state. + // ... otherwise, show the empty state. } else { return ( - + ) } } - + return (
{`Search ${indexedRepos.length} `} {indexedRepos.length > 1 ? 'repositories' : 'repository'} @@ -83,7 +81,7 @@ export function RepositorySnapshot({ ) } -function EmptyRepoState({ domain, authEnabled }: { domain: string, authEnabled: boolean }) { +function EmptyRepoState() { return (
No repositories found @@ -91,23 +89,13 @@ function EmptyRepoState({ domain, authEnabled }: { domain: string, authEnabled:
- {authEnabled ? ( - <> - Create a{" "} - - connection - {" "} - to start indexing repositories - - ) : ( - <> - Create a {" "} - - configuration file - {" "} - to start indexing repositories - - )} + <> + Create a{" "} + + connection + {" "} + to start indexing repositories +
diff --git a/packages/web/src/app/[domain]/components/homepage/toolbar.tsx b/packages/web/src/app/[domain]/components/homepage/toolbar.tsx new file mode 100644 index 00000000..cc9c65e0 --- /dev/null +++ b/packages/web/src/app/[domain]/components/homepage/toolbar.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { MessageCircleIcon, SearchIcon, TriangleAlert } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; + +export type SearchMode = "precise" | "agentic"; + +const PRECISE_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/search/overview"; +// @tood: point this to the actual docs page +const AGENTIC_SEARCH_DOCS_URL = "https://docs.sourcebot.dev/docs/features/ask/overview"; + +export interface SearchModeSelectorProps { + searchMode: SearchMode; + isAgenticSearchEnabled: boolean; + onSearchModeChange: (searchMode: SearchMode) => void; + className?: string; +} + +export const SearchModeSelector = ({ + searchMode, + isAgenticSearchEnabled, + onSearchModeChange, + className, +}: SearchModeSelectorProps) => { + const [focusedSearchMode, setFocusedSearchMode] = useState(searchMode); + + return ( +
+ +
+ ) +} + + diff --git a/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx b/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx deleted file mode 100644 index f93209f1..00000000 --- a/packages/web/src/app/[domain]/components/keyboardShortcutHint.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' - -interface KeyboardShortcutHintProps { - shortcut: string - label?: string -} - -export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) { - return ( -
- - {shortcut} - -
- ) -} diff --git a/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx new file mode 100644 index 00000000..bb5912ea --- /dev/null +++ b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx @@ -0,0 +1,295 @@ +import { Parser } from '@lezer/common' +import { LanguageDescription, StreamLanguage } from '@codemirror/language' +import { Highlighter, highlightTree } from '@lezer/highlight' +import { languages as builtinLanguages } from '@codemirror/language-data' +import { memo, useEffect, useMemo, useState } from 'react' +import { useCodeMirrorHighlighter } from '@/hooks/useCodeMirrorHighlighter' +import tailwind from '@/tailwind' +import { measure } from '@/lib/utils' +import { SourceRange } from '@/features/search/types' + +// Define a plain text language +const plainTextLanguage = StreamLanguage.define({ + token(stream) { + stream.next(); + return null; + } +}); + +interface LightweightCodeHighlighter { + language: string; + children: string; + /* 1-based highlight ranges */ + highlightRanges?: SourceRange[]; + lineNumbers?: boolean; + /* 1-based line number offset */ + lineNumbersOffset?: number; + renderWhitespace?: boolean; +} + +// The maximum number of characters per line that we will display in the preview. +const MAX_NUMBER_OF_CHARACTER_PER_LINE = 1000; + +/** + * Lightweight code highlighter that uses the Lezer parser to highlight code. + * This is helpful in scenarios where we need to highlight a ton of code snippets + * (e.g., code nav, search results, etc)., but can't use the full-blown CodeMirror + * editor because of perf issues. + * + * Inspired by: https://github.com/craftzdog/react-codemirror-runmode + */ +export const LightweightCodeHighlighter = memo((props: LightweightCodeHighlighter) => { + const { + language, + children: code, + highlightRanges, + lineNumbers = false, + lineNumbersOffset = 1, + renderWhitespace = false, + } = props; + + const unhighlightedLines = useMemo(() => { + return code.trimEnd().split('\n'); + }, [code]); + + const isFileTooLargeToDisplay = useMemo(() => { + return unhighlightedLines.some(line => line.length > MAX_NUMBER_OF_CHARACTER_PER_LINE); + }, [unhighlightedLines]); + + const [highlightedLines, setHighlightedLines] = useState(null); + + const highlightStyle = useCodeMirrorHighlighter(); + + useEffect(() => { + if (isFileTooLargeToDisplay) { + return; + } + + measure(() => Promise.all( + unhighlightedLines + .map(async (line, index) => { + const lineNumber = index + lineNumbersOffset; + + // @todo: we will need to handle the case where a range spans multiple lines. + const ranges = highlightRanges?.filter(range => { + return range.start.lineNumber === lineNumber || range.end.lineNumber === lineNumber; + }).map(range => ({ + from: range.start.column - 1, + to: range.end.column - 1, + })); + + const snippets = await highlightCode( + language, + line, + highlightStyle, + ranges, + (text: string, style: string | null, from: number) => { + return ( + + {text} + + ) + } + ); + + return {snippets} + }) + ).then(highlightedLines => { + setHighlightedLines(highlightedLines); + }), 'highlightCode', /* outputLog = */ false); + }, [ + language, + code, + highlightRanges, + highlightStyle, + unhighlightedLines, + lineNumbersOffset, + isFileTooLargeToDisplay, + ]); + + const lineCount = (highlightedLines ?? unhighlightedLines).length + lineNumbersOffset; + const lineNumberDigits = String(lineCount).length; + const lineNumberWidth = `${lineNumberDigits + 2}ch`; // +2 for padding + + if (isFileTooLargeToDisplay) { + return ( +
+ File too large to display in preview. +
+ ); + } + + return ( +
+ {(highlightedLines ?? unhighlightedLines).map((line, index) => ( +
+ {lineNumbers && ( + + {index + lineNumbersOffset} + + )} + + {line} + +
+ ))} +
+ ) +}) + +LightweightCodeHighlighter.displayName = 'LightweightCodeHighlighter'; + +async function getCodeParser( + languageName: string, +): Promise { + if (languageName) { + const parser = await (async () => { + const found = LanguageDescription.matchLanguageName( + builtinLanguages, + languageName, + true + ); + + if (!found) { + return null; + } + + if (!found.support) { + await found.load(); + } + return found.support ? found.support.language.parser : null; + })(); + + if (parser) { + return parser; + } + } + return plainTextLanguage.parser; +} + +async function highlightCode( + languageName: string, + input: string, + highlighter: Highlighter, + highlightRanges: { from: number, to: number }[] = [], + callback: ( + text: string, + style: string | null, + from: number, + to: number + ) => Output, +): Promise { + const parser = await getCodeParser(languageName); + + /** + * Converts a range to a series of highlighted subranges. + */ + const convertRangeToHighlightedSubranges = ( + from: number, + to: number, + classes: string | null, + cb: (from: number, to: number, classes: string | null) => void, + ) => { + type HighlightRange = { + from: number, + to: number, + isHighlighted: boolean, + } + + const highlightClasses = classes ? `${classes} searchMatch-selected` : 'searchMatch-selected'; + + let currentRange: HighlightRange | null = null; + for (let i = from; i < to; i++) { + const isHighlighted = isIndexHighlighted(i, highlightRanges); + + if (currentRange) { + if (currentRange.isHighlighted === isHighlighted) { + currentRange.to = i + 1; + } else { + cb( + currentRange.from, + currentRange.to, + currentRange.isHighlighted ? highlightClasses : classes, + ) + + currentRange = { from: i, to: i + 1, isHighlighted }; + } + } else { + currentRange = { from: i, to: i + 1, isHighlighted }; + } + } + + if (currentRange) { + cb( + currentRange.from, + currentRange.to, + currentRange.isHighlighted ? highlightClasses : classes, + ) + } + } + + const tree = parser.parse(input) + const output: Array = []; + + let pos = 0; + highlightTree(tree, highlighter, (from, to, classes) => { + // `highlightTree` only calls this callback when at least one style/class + // is applied to the text (i.e., `classes` is not empty). This means that + // any unstyled regions will be skipped (e.g., whitespace, `=`. `;`. etc). + // This check ensures that we process these unstyled regions as well. + // @see: https://discuss.codemirror.net/t/static-highlighting-using-cm-v6/3420/2 + if (from > pos) { + convertRangeToHighlightedSubranges(pos, from, null, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + } + + convertRangeToHighlightedSubranges(from, to, classes, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + + pos = to; + }); + + // Process any remaining unstyled regions. + if (pos != tree.length) { + convertRangeToHighlightedSubranges(pos, tree.length, null, (from, to, classes) => { + output.push(callback(input.slice(from, to), classes, from, to)); + }) + } + return output; +} + +const isIndexHighlighted = (index: number, ranges: { from: number, to: number }[]) => { + return ranges.some(range => index >= range.from && index < range.to); +} diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index ba760d6b..9aa6f5e3 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -14,6 +14,8 @@ import { TrialNavIndicator } from "./trialNavIndicator"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { env } from "@/env.mjs"; import { getSubscriptionInfo } from "@/ee/features/billing/actions"; +import { auth } from "@/auth"; +import WhatsNewIndicator from "./whatsNewIndicator"; const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb"; const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot"; @@ -26,9 +28,11 @@ export const NavigationMenu = async ({ domain, }: NavigationMenuProps) => { const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null; + const session = await auth(); + const isAuthenticated = session?.user !== undefined; return ( -
+
- - - - Agents - - - @@ -73,23 +70,32 @@ export const NavigationMenu = async ({ - {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( - - - - Connections - - - - )} - {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( - - - - Settings - - - + {isAuthenticated && ( + <> + {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined && ( + + + + Agents + + + + )} + + + + Connections + + + + + + + Settings + + + + )} @@ -100,6 +106,7 @@ export const NavigationMenu = async ({ +
{ "use server"; @@ -128,7 +135,7 @@ export const NavigationMenu = async ({ - +
diff --git a/packages/web/src/app/[domain]/components/onboardGuard.tsx b/packages/web/src/app/[domain]/components/onboardGuard.tsx index a1c5aca3..d9de6514 100644 --- a/packages/web/src/app/[domain]/components/onboardGuard.tsx +++ b/packages/web/src/app/[domain]/components/onboardGuard.tsx @@ -1,7 +1,6 @@ 'use client'; import { Redirect } from "@/app/components/redirect"; -import { useDomain } from "@/hooks/useDomain"; import { usePathname } from "next/navigation"; import { useMemo } from "react"; @@ -10,20 +9,19 @@ interface OnboardGuardProps { } export const OnboardGuard = ({ children }: OnboardGuardProps) => { - const domain = useDomain(); const pathname = usePathname(); const content = useMemo(() => { if (!pathname.endsWith('/onboard')) { return ( ) } else { return children; } - }, [domain, children, pathname]); + }, [children, pathname]); return content; } diff --git a/packages/web/src/app/[domain]/components/pathHeader.tsx b/packages/web/src/app/[domain]/components/pathHeader.tsx new file mode 100644 index 00000000..bdde8a16 --- /dev/null +++ b/packages/web/src/app/[domain]/components/pathHeader.tsx @@ -0,0 +1,305 @@ +'use client'; + +import { cn, getCodeHostInfoForRepo } from "@/lib/utils"; +import { LaptopIcon } from "@radix-ui/react-icons"; +import Image from "next/image"; +import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { useCallback, useState, useMemo, useRef, useEffect } from "react"; +import { useToast } from "@/components/hooks/use-toast"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; +import { CopyIconButton } from "./copyIconButton"; + +interface FileHeaderProps { + path: string; + pathHighlightRange?: { + from: number; + to: number; + } + pathType?: 'blob' | 'tree'; + repo: { + name: string; + codeHostType: string; + displayName?: string; + webUrl?: string; + }, + branchDisplayName?: string; + branchDisplayTitle?: string; + isCodeHostIconVisible?: boolean; + isFileIconVisible?: boolean; + repoNameClassName?: string; +} + +interface BreadcrumbSegment { + name: string; + fullPath: string; + isLastSegment: boolean; + highlightRange?: { + from: number; + to: number; + }; +} + +export const PathHeader = ({ + repo, + path, + pathHighlightRange, + branchDisplayName, + branchDisplayTitle, + pathType = 'blob', + isCodeHostIconVisible = true, + isFileIconVisible = true, + repoNameClassName, +}: FileHeaderProps) => { + const info = getCodeHostInfoForRepo({ + name: repo.name, + codeHostType: repo.codeHostType, + displayName: repo.displayName, + webUrl: repo.webUrl, + }); + + const { navigateToPath } = useBrowseNavigation(); + const { toast } = useToast(); + const containerRef = useRef(null); + const breadcrumbsRef = useRef(null); + const [visibleSegmentCount, setVisibleSegmentCount] = useState(null); + + // Create breadcrumb segments from file path + const breadcrumbSegments = useMemo(() => { + const pathParts = path.split('/').filter(Boolean); + const segments: BreadcrumbSegment[] = []; + + let currentPath = ''; + pathParts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + const isLastSegment = index === pathParts.length - 1; + + // Calculate highlight range for this segment if it exists + let segmentHighlight: { from: number; to: number } | undefined; + if (pathHighlightRange) { + const segmentStart = path.indexOf(part, currentPath.length - part.length); + const segmentEnd = segmentStart + part.length; + + // Check if highlight overlaps with this segment + if (pathHighlightRange.from < segmentEnd && pathHighlightRange.to > segmentStart) { + segmentHighlight = { + from: Math.max(0, pathHighlightRange.from - segmentStart), + to: Math.min(part.length, pathHighlightRange.to - segmentStart) + }; + } + } + + segments.push({ + name: part, + fullPath: currentPath, + isLastSegment, + highlightRange: segmentHighlight + }); + }); + + return segments; + }, [path, pathHighlightRange]); + + // Calculate which segments should be visible based on available space + useEffect(() => { + const measureSegments = () => { + if (!containerRef.current || !breadcrumbsRef.current) return; + + const containerWidth = containerRef.current.offsetWidth; + const availableWidth = containerWidth - 175; // Reserve space for copy button and padding + + // Create a temporary element to measure segment widths + const tempElement = document.createElement('div'); + tempElement.style.position = 'absolute'; + tempElement.style.visibility = 'hidden'; + tempElement.style.whiteSpace = 'nowrap'; + tempElement.className = 'font-mono text-sm'; + document.body.appendChild(tempElement); + + let totalWidth = 0; + let visibleCount = breadcrumbSegments.length; + + // Start from the end (most important segments) and work backwards + for (let i = breadcrumbSegments.length - 1; i >= 0; i--) { + const segment = breadcrumbSegments[i]; + tempElement.textContent = segment.name; + const segmentWidth = tempElement.offsetWidth; + const separatorWidth = i < breadcrumbSegments.length - 1 ? 16 : 0; // ChevronRight width + + if (totalWidth + segmentWidth + separatorWidth > availableWidth && i > 0) { + // If adding this segment would overflow and it's not the last segment + visibleCount = breadcrumbSegments.length - i; + // Add width for ellipsis dropdown (approximately 24px) + if (visibleCount < breadcrumbSegments.length) { + totalWidth += 40; // Ellipsis button + separator + } + break; + } + + totalWidth += segmentWidth + separatorWidth; + } + + document.body.removeChild(tempElement); + setVisibleSegmentCount(visibleCount); + }; + + measureSegments(); + + const resizeObserver = new ResizeObserver(measureSegments); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => resizeObserver.disconnect(); + }, [breadcrumbSegments]); + + const hiddenSegments = useMemo(() => { + if (visibleSegmentCount === null || visibleSegmentCount >= breadcrumbSegments.length) { + return []; + } + return breadcrumbSegments.slice(0, breadcrumbSegments.length - visibleSegmentCount); + }, [breadcrumbSegments, visibleSegmentCount]); + + const visibleSegments = useMemo(() => { + if (visibleSegmentCount === null) { + return breadcrumbSegments; + } + return breadcrumbSegments.slice(breadcrumbSegments.length - visibleSegmentCount); + }, [breadcrumbSegments, visibleSegmentCount]); + + const onCopyPath = useCallback(() => { + navigator.clipboard.writeText(path); + toast({ description: "โœ… Copied to clipboard" }); + return true; + }, [path, toast]); + + const onBreadcrumbClick = useCallback((segment: BreadcrumbSegment) => { + navigateToPath({ + repoName: repo.name, + path: segment.fullPath, + pathType: segment.isLastSegment ? pathType : 'tree', + revisionName: branchDisplayName, + }); + }, [repo.name, branchDisplayName, navigateToPath, pathType]); + + + const renderSegmentWithHighlight = (segment: BreadcrumbSegment) => { + if (!segment.highlightRange) { + return segment.name; + } + + const { from, to } = segment.highlightRange; + return ( + <> + {segment.name.slice(0, from)} + + {segment.name.slice(from, to)} + + {segment.name.slice(to)} + + ); + }; + + return ( +
+ {isCodeHostIconVisible && ( + <> + {info?.icon ? ( + + {info.codeHostName} + + ) : ( + + )} + + )} + +
navigateToPath({ + repoName: repo.name, + path: '', + pathType: 'tree', + revisionName: branchDisplayName, + })} + > + {info?.displayName} +
+ {branchDisplayName && ( +

+ @ + {`${branchDisplayName}`} +

+ )} + ยท +
+
+ {hiddenSegments.length > 0 && ( + <> + + + + + + {hiddenSegments.map((segment) => ( + onBreadcrumbClick(segment)} + className="font-mono text-sm cursor-pointer" + > + {renderSegmentWithHighlight(segment)} + + ))} + + + + + )} + {visibleSegments.map((segment, index) => ( +
+ {(isFileIconVisible && index === visibleSegments.length - 1) && ( + + )} + onBreadcrumbClick(segment)} + > + {renderSegmentWithHighlight(segment)} + + {index < visibleSegments.length - 1 && ( + + )} +
+ ))} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/pendingApproval.tsx b/packages/web/src/app/[domain]/components/pendingApproval.tsx new file mode 100644 index 00000000..4ffceb20 --- /dev/null +++ b/packages/web/src/app/[domain]/components/pendingApproval.tsx @@ -0,0 +1,56 @@ +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { auth } from "@/auth" + +export const PendingApprovalCard = async () => { + const session = await auth() + const userId = session?.user?.id + + if (!userId) { + return null + } + + return ( +
+ + +
+
+ + +
+
+ + + +
+ +
+

+ Approval Pending +

+

+ Your request is being reviewed. +

+
+
+ +
+
+
+ + + +
+ Awaiting review +
+
+ +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx index cb347d43..d6fec7d8 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx @@ -1,7 +1,6 @@ 'use client'; import { useClickListener } from "@/hooks/useClickListener"; -import { useTailwind } from "@/hooks/useTailwind"; import { SearchQueryParams } from "@/lib/types"; import { cn, createPathWithQueryParams } from "@/lib/utils"; import { @@ -43,7 +42,9 @@ import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Toggle } from "@/components/ui/toggle"; import { useDomain } from "@/hooks/useDomain"; -import { KeyboardShortcutHint } from "../keyboardShortcutHint"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { createAuditAction } from "@/ee/features/audit/actions"; +import tailwind from "@/tailwind"; interface SearchBarProps { className?: string; @@ -95,7 +96,6 @@ export const SearchBar = ({ }: SearchBarProps) => { const router = useRouter(); const domain = useDomain(); - const tailwind = useTailwind(); const suggestionBoxRef = useRef(null); const editorRef = useRef(null); const [cursorPosition, setCursorPosition] = useState(0); @@ -161,7 +161,7 @@ export const SearchBar = ({ }, ], }); - }, [tailwind]); + }, []); const extensions = useMemo(() => { return [ @@ -205,6 +205,13 @@ export const SearchBar = ({ setIsSuggestionsEnabled(false); setIsHistorySearchEnabled(false); + createAuditAction({ + action: "user.performed_code_search", + metadata: { + message: query, + }, + }, domain) + const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, query], ); @@ -267,7 +274,18 @@ export const SearchBar = ({ indentWithTab={false} autoFocus={autoFocus ?? false} /> - + + +
+ +
+
+ + Focus search bar + +
{ +test('splitQuery correctly locates the cursor index given the cursor position (1)', () => { const query = 'foo bar "fizz buzz"'; const { queryParts: parts1, cursorIndex: index1 } = splitQuery(query, 0); @@ -50,7 +50,7 @@ test('splitQuery correclty locates the cursor index given the cursor position (1 expect(parts3[index3]).toBe('"fizz buzz"'); }); -test('splitQuery correclty locates the cursor index given the cursor position (2)', () => { +test('splitQuery correctly locates the cursor index given the cursor position (2)', () => { const query = 'a b'; expect(splitQuery(query, 0).cursorIndex).toBe(0); expect(splitQuery(query, 1).cursorIndex).toBe(0); diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index 5b8fdac3..6b208c0e 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -15,7 +15,7 @@ import { IconType } from "react-icons/lib"; import { VscFile, VscFilter, VscRepo, VscSymbolMisc } from "react-icons/vsc"; import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; -import { KeyboardShortcutHint } from "../keyboardShortcutHint"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider"; import { useRefineModeSuggestions } from "./useRefineModeSuggestions"; @@ -438,7 +438,7 @@ export { SearchSuggestionsBox }; export const splitQuery = (query: string, cursorPos: number) => { const queryParts = []; - const seperator = " "; + const separator = " "; let cursorIndex = 0; let accumulator = ""; let isInQuoteCapture = false; @@ -452,7 +452,7 @@ export const splitQuery = (query: string, cursorPos: number) => { isInQuoteCapture = !isInQuoteCapture; } - if (!isInQuoteCapture && query[i] === seperator) { + if (!isInQuoteCapture && query[i] === separator) { queryParts.push(accumulator); accumulator = ""; continue; diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts index 42474a17..2b6c8dd9 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts @@ -21,7 +21,7 @@ export const useSuggestionModeAndQuery = ({ const suggestionModeMappings = useSuggestionModeMappings(); const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => { - // When suggestions are not enabled, fallback to using a sentinal + // When suggestions are not enabled, fallback to using a sentinel // suggestion mode of "none". if (!isSuggestionsEnabled) { return { diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index e77c07cf..04c2514d 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts @@ -37,7 +37,7 @@ export const useSuggestionsData = ({ }: Props) => { const domain = useDomain(); const { data: repoSuggestions, isLoading: _isLoadingRepos } = useQuery({ - queryKey: ["repoSuggestions"], + queryKey: ["repoSuggestions", domain], queryFn: () => getRepos(domain), select: (data): Suggestion[] => { return data.repos @@ -50,7 +50,7 @@ export const useSuggestionsData = ({ const isLoadingRepos = useMemo(() => suggestionMode === "repo" && _isLoadingRepos, [_isLoadingRepos, suggestionMode]); const { data: fileSuggestions, isLoading: _isLoadingFiles } = useQuery({ - queryKey: ["fileSuggestions", suggestionQuery], + queryKey: ["fileSuggestions", suggestionQuery, domain], queryFn: () => search({ query: `file:${suggestionQuery}`, matches: 15, @@ -70,7 +70,7 @@ export const useSuggestionsData = ({ const isLoadingFiles = useMemo(() => suggestionMode === "file" && _isLoadingFiles, [_isLoadingFiles, suggestionMode]); const { data: symbolSuggestions, isLoading: _isLoadingSymbols } = useQuery({ - queryKey: ["symbolSuggestions", suggestionQuery], + queryKey: ["symbolSuggestions", suggestionQuery, domain], queryFn: () => search({ query: `sym:${suggestionQuery.length > 0 ? suggestionQuery : ".*"}`, matches: 15, @@ -100,7 +100,7 @@ export const useSuggestionsData = ({ const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]); const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({ - queryKey: ["searchContexts"], + queryKey: ["searchContexts", domain], queryFn: () => getSearchContexts(domain), select: (data): Suggestion[] => { if (isServiceError(data)) { diff --git a/packages/web/src/app/[domain]/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx index 42925835..377d0ef7 100644 --- a/packages/web/src/app/[domain]/components/settingsDropdown.tsx +++ b/packages/web/src/app/[domain]/components/settingsDropdown.tsx @@ -3,6 +3,7 @@ import { CodeIcon, Laptop, + LogIn, LogOut, Moon, Settings, @@ -37,17 +38,15 @@ import { useDomain } from "@/hooks/useDomain"; interface SettingsDropdownProps { menuButtonClassName?: string; - displaySettingsOption: boolean; } export const SettingsDropdown = ({ menuButtonClassName, - displaySettingsOption, }: SettingsDropdownProps) => { const { theme: _theme, setTheme } = useTheme(); const [keymapType, setKeymapType] = useKeymapType(); - const { data: session, update } = useSession(); + const { data: session } = useSession(); const domain = useDomain(); const theme = useMemo(() => { @@ -68,24 +67,17 @@ export const SettingsDropdown = ({ }, [theme]); return ( - // Was hitting a bug with invite code login where the first time the user signs in, the settingsDropdown doesn't have a valid session. To fix this - // we can simply update the session everytime the settingsDropdown is opened. This isn't a super frequent operation and updating the session is low cost, - // so this is a simple solution to the problem. - { - if (isOpen) { - update(); - } - }}> + - {session?.user && ( + {session?.user ? ( -
- +
+ @@ -93,7 +85,7 @@ export const SettingsDropdown = ({ {session.user.name && session.user.name.length > 0 ? session.user.name[0] : 'U'} -

{session.user.email ?? "User"}

+

{session.user.email ?? "User"}

{ @@ -107,9 +99,18 @@ export const SettingsDropdown = ({ Log out - + ) : ( + { + window.location.href = "/login"; + }} + > + + Sign in + )} + @@ -150,7 +151,7 @@ export const SettingsDropdown = ({ - {displaySettingsOption && ( + {session?.user && ( diff --git a/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx new file mode 100644 index 00000000..291e5f50 --- /dev/null +++ b/packages/web/src/app/[domain]/components/submitAccountRequestButton.tsx @@ -0,0 +1,67 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Clock } from "lucide-react" +import { useState } from "react" +import { useToast } from "@/components/hooks/use-toast" +import { createAccountRequest } from "@/actions" +import { isServiceError } from "@/lib/utils" +import { useRouter } from "next/navigation" + +interface SubmitButtonProps { + domain: string + userId: string +} + +export function SubmitAccountRequestButton({ domain, userId }: SubmitButtonProps) { + const { toast } = useToast() + const router = useRouter() + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async () => { + setIsSubmitting(true) + const result = await createAccountRequest(userId, domain) + if (!isServiceError(result)) { + if (result.existingRequest) { + toast({ + title: "Request Already Submitted", + description: "Your request to join the organization has already been submitted. Please wait for it to be approved.", + variant: "default", + }) + } else { + toast({ + title: "Request Submitted", + description: "Your request to join the organization has been submitted.", + variant: "default", + }) + } + // Refresh the page to trigger layout re-render and show PendingApprovalCard + router.refresh() + } else { + toast({ + title: "Failed to Submit", + description: `There was an error submitting your request. Reason: ${result.message}`, + variant: "destructive", + }) + } + setIsSubmitting(false) + } + + return ( +
{ + e.preventDefault(); + handleSubmit(); + }}> + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/submitJoinRequest.tsx b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx new file mode 100644 index 00000000..7160a65c --- /dev/null +++ b/packages/web/src/app/[domain]/components/submitJoinRequest.tsx @@ -0,0 +1,55 @@ +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { auth } from "@/auth" +import { SubmitAccountRequestButton } from "./submitAccountRequestButton" + +interface SubmitJoinRequestProps { + domain: string +} + +export const SubmitJoinRequest = async ({ domain }: SubmitJoinRequestProps) => { + const session = await auth() + const userId = session?.user?.id + + if (!userId) { + return null + } + + return ( +
+ + +
+
+ + +
+
+ + + +
+ +
+

+ Request Access +

+

+ Submit a request to join this organization +

+
+
+ +
+
+ +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx b/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx index bde0a67f..52ec7aea 100644 --- a/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx +++ b/packages/web/src/app/[domain]/components/syntaxReferenceGuide.tsx @@ -10,11 +10,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import clsx from "clsx"; import Link from "next/link"; import { useCallback, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useSyntaxGuide } from "./syntaxGuideProvider"; +import { CodeSnippet } from "@/app/components/codeSnippet"; const LINGUIST_LINK = "https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml"; const CTAGS_LINK = "https://ctags.io/"; @@ -66,7 +66,7 @@ export const SyntaxReferenceGuide = () => { Syntax Reference Guide - Queries consist of space-seperated regular expressions. Wrapping expressions in {`""`} combines them. By default, a file must have at least one match for each expression to be included. + Queries consist of space-seperated regular expressions. Wrapping expressions in {`""`} combines them. By default, a file must have at least one match for each expression to be included. @@ -78,23 +78,23 @@ export const SyntaxReferenceGuide = () => { - foo - Match files with regex /foo/ + foo + Match files with regex /foo/ - foo bar - Match files with regex /foo/ and /bar/ + foo bar + Match files with regex /foo/ and /bar/ - {`"foo bar"`} - Match files with regex /foo bar/ + {`"foo bar"`} + Match files with regex /foo bar/

- {`Multiple expressions can be or'd together with `}or, negated with -, or grouped with (). + {`Multiple expressions can be or'd together with `}or, negated with -, or grouped with ().

@@ -105,23 +105,23 @@ export const SyntaxReferenceGuide = () => { - foo or bar - Match files with regex /foo/ or /bar/ + foo or bar + Match files with regex /foo/ or /bar/ - foo -bar - Match files with regex /foo/ but not /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/ + 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. + Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the - prefix.

@@ -134,87 +134,87 @@ export const SyntaxReferenceGuide = () => { - file: + file: Filter results from filepaths that match the regex. By default all files are searched.
- file:README - - + file:{`"my file"`} - - + -file:test\.ts$ - +
- repo: + repo: Filter results from repos that match the regex. By default all repos are searched.
- repo:linux - - + -repo:^web/.* - +
- rev: + rev: Filter results from a specific branch or tag. By default only the default branch is searched.
- rev:beta - +
- lang: + lang: Filter results by language (as defined by linguist). By default all languages are searched.
- lang:TypeScript - - + -lang:YAML - +
- sym: + sym: Match symbol definitions created by universal ctags at index time.
- sym:\bmain\b - +
@@ -225,17 +225,6 @@ export const SyntaxReferenceGuide = () => { ) } -const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => { - return ( - - {children} - - ) -} - const Highlight = ({ children }: { children: React.ReactNode }) => { return ( diff --git a/packages/web/src/app/[domain]/components/topBar.tsx b/packages/web/src/app/[domain]/components/topBar.tsx index 05146a91..6661ae42 100644 --- a/packages/web/src/app/[domain]/components/topBar.tsx +++ b/packages/web/src/app/[domain]/components/topBar.tsx @@ -2,46 +2,44 @@ import Link from "next/link"; import Image from "next/image"; import logoLight from "@/public/sb_logo_light.png"; import logoDark from "@/public/sb_logo_dark.png"; -import { SearchBar } from "./searchBar"; import { SettingsDropdown } from "./settingsDropdown"; +import { Separator } from "@/components/ui/separator"; interface TopBarProps { - defaultSearchQuery?: string; domain: string; + children?: React.ReactNode; } export const TopBar = ({ - defaultSearchQuery, domain, + children, }: TopBarProps) => { return ( -
-
- - - - - +
+
+ + + + + {children} +
+
- +
) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/warningNavIndicator.tsx b/packages/web/src/app/[domain]/components/warningNavIndicator.tsx index 496c8f0a..dc176b1d 100644 --- a/packages/web/src/app/[domain]/components/warningNavIndicator.tsx +++ b/packages/web/src/app/[domain]/components/warningNavIndicator.tsx @@ -10,6 +10,7 @@ import useCaptureEvent from "@/hooks/useCaptureEvent"; import { env } from "@/env.mjs"; import { useQuery } from "@tanstack/react-query"; import { ConnectionSyncStatus } from "@prisma/client"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export const WarningNavIndicator = () => { const domain = useDomain(); @@ -45,16 +46,25 @@ export const WarningNavIndicator = () => { The following connections have references that could not be found:

- {connections.slice(0, 10).map(connection => ( - captureEvent('wa_warning_nav_connection_pressed', {})}> -
- {connection.name} -
- - ))} + + {connections.slice(0, 10).map(connection => ( + captureEvent('wa_warning_nav_connection_pressed', {})}> +
+ + + {connection.name} + + + {connection.name} + + +
+ + ))} +
{connections.length > 10 && (
And {connections.length - 10} more... diff --git a/packages/web/src/app/[domain]/components/whatsNewIndicator.tsx b/packages/web/src/app/[domain]/components/whatsNewIndicator.tsx new file mode 100644 index 00000000..8259c03d --- /dev/null +++ b/packages/web/src/app/[domain]/components/whatsNewIndicator.tsx @@ -0,0 +1,179 @@ +"use client" + +import type React from "react" + +import { useState, useEffect } from "react" +import { HelpCircle, Mail, MailOpen } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { NewsItem } from "@/lib/types" +import { newsData } from "@/lib/newsData" + +interface WhatsNewProps { + newsItems?: NewsItem[] + autoMarkAsRead?: boolean +} + +const COOKIE_NAME = "whats-new-read-items" + +const getReadItems = (): string[] => { + if (typeof document === "undefined") return [] + + const cookies = document.cookie.split(';').map(cookie => cookie.trim()) + const targetCookie = cookies.find(cookie => cookie.startsWith(`${COOKIE_NAME}=`)) + + if (!targetCookie) return [] + + try { + const cookieValue = targetCookie.substring(`${COOKIE_NAME}=`.length) + return JSON.parse(decodeURIComponent(cookieValue)) + } catch (error) { + console.warn('Failed to parse whats-new cookie:', error) + return [] + } +} + +const setReadItems = (readItems: string[]) => { + if (typeof document === "undefined") return + + try { + const expires = new Date() + expires.setFullYear(expires.getFullYear() + 1) + const cookieValue = encodeURIComponent(JSON.stringify(readItems)) + + document.cookie = `${COOKIE_NAME}=${cookieValue}; expires=${expires.toUTCString()}; path=/; SameSite=Lax` + } catch (error) { + console.warn('Failed to set whats-new cookie:', error) + } +} + +export default function WhatsNewIndicator({ newsItems = newsData, autoMarkAsRead = true }: WhatsNewProps) { + const [isOpen, setIsOpen] = useState(false) + const [readItems, setReadItemsState] = useState([]) + const [isInitialized, setIsInitialized] = useState(false) + + useEffect(() => { + const items = getReadItems() + setReadItemsState(items) + setIsInitialized(true) + }, []) + + useEffect(() => { + if (isInitialized) { + setReadItems(readItems) + } + }, [readItems, isInitialized]) + + const newsItemsWithReadState = newsItems.map((item) => ({ + ...item, + read: readItems.includes(item.unique_id), + })) + + const unreadCount = newsItemsWithReadState.filter((item) => !item.read).length + + const markAsRead = (itemId: string) => { + setReadItemsState((prev) => { + if (!prev.includes(itemId)) { + return [...prev, itemId] + } + return prev + }) + } + + const markAllAsRead = () => { + const allIds = newsItems.map((item) => item.unique_id) + setReadItemsState(allIds) + } + + const handleNewsItemClick = (item: NewsItem) => { + window.open(item.url, "_blank", "noopener,noreferrer") + + if (autoMarkAsRead && !item.read) { + markAsRead(item.unique_id) + } + } + + return ( + + + + + +
+
+
+

{"What's New"}

+

+ {unreadCount > 0 ? `${unreadCount} unread update${unreadCount === 1 ? "" : "s"}` : "All caught up!"} +

+
+ {unreadCount > 0 && ( + + )} +
+
+
+ {newsItemsWithReadState.length === 0 ? ( +
No recent updates
+ ) : ( +
+ {newsItemsWithReadState.map((item, index) => ( +
+ {!item.read &&
} + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx index 4f43ba3e..a1e49637 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoList.tsx @@ -180,7 +180,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => { )}
- + {isReposPending ? (
{Array.from({ length: 3 }).map((_, i) => ( diff --git a/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx b/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx index 6fdc1c51..e2f8c55c 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/repoListItem.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getDisplayTime } from "@/lib/utils"; +import { getDisplayTime, getRepoImageSrc } from "@/lib/utils"; import Image from "next/image"; import { StatusIcon } from "../../components/statusIcon"; import { RepoIndexingStatus } from "@sourcebot/db"; @@ -46,14 +46,16 @@ export const RepoListItem = ({ } }, [status]); + const imageSrc = getRepoImageSrc(imageUrl, repoId, domain); + return (
- {imageUrl ? ( + {imageSrc ? ( - + - + ) diff --git a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx index 043e7045..f6a97fa0 100644 --- a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx +++ b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx @@ -11,26 +11,28 @@ import { OrgRole } from "@sourcebot/db" interface NewConnectionCardProps { className?: string role: OrgRole + configPathProvided: boolean } -export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) => { +export const NewConnectionCard = ({ className, role, configPathProvided }: NewConnectionCardProps) => { const isOwner = role === OrgRole.OWNER + const isDisabled = !isOwner || configPathProvided return (
- {!isOwner && ( + {isDisabled && (
)} - -

+ +

Connect to a Code Host

@@ -41,42 +43,44 @@ export const NewConnectionCard = ({ className, role }: NewConnectionCardProps) = type="github" title="GitHub" subtitle="Cloud or Enterprise supported." - disabled={!isOwner} + disabled={isDisabled} />

- {!isOwner && ( + {isDisabled && (

- Only organization owners can manage connections. + {configPathProvided + ? "Connections are managed through the configuration file." + : "Only organization owners can manage connections."}

)}
diff --git a/packages/web/src/app/[domain]/connections/page.tsx b/packages/web/src/app/[domain]/connections/page.tsx index db9920d9..a76c38bd 100644 --- a/packages/web/src/app/[domain]/connections/page.tsx +++ b/packages/web/src/app/[domain]/connections/page.tsx @@ -5,6 +5,7 @@ import { getConnections, getOrgMembership } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { notFound, ServiceErrorException } from "@/lib/serviceError"; import { OrgRole } from "@sourcebot/db"; +import { env } from "@/env.mjs"; export default async function ConnectionsPage({ params: { domain } }: { params: { domain: string } }) { const connections = await getConnections(domain); @@ -30,6 +31,7 @@ export default async function ConnectionsPage({ params: { domain } }: { params:
diff --git a/packages/web/src/app/[domain]/connections/quickActions.tsx b/packages/web/src/app/[domain]/connections/quickActions.tsx index af0b4f05..5f8008b0 100644 --- a/packages/web/src/app/[domain]/connections/quickActions.tsx +++ b/packages/web/src/app/[domain]/connections/quickActions.tsx @@ -4,18 +4,7 @@ import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type" import { QuickAction } from "../components/configEditor"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; -import { cn } from "@/lib/utils"; - -const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => { - return ( - - {children} - - ) -} +import { CodeSnippet } from "@/app/components/codeSnippet"; export const githubQuickActions: QuickAction[] = [ { @@ -30,7 +19,7 @@ export const githubQuickActions: QuickAction[] = [ selectionText: "/", description: (
- Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any). + Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any). Examples:
{[ @@ -38,7 +27,7 @@ export const githubQuickActions: QuickAction[] = [ "vercel/next.js", "torvalds/linux" ].map((repo) => ( - {repo} + {repo} ))}
@@ -56,7 +45,7 @@ export const githubQuickActions: QuickAction[] = [ selectionText: "", description: (
- Add an organization to sync with. All repositories in the organization visible to the provided token (if any) will be synced. + Add an organization to sync with. All repositories in the organization visible to the provided token (if any) will be synced. Examples:
{[ @@ -64,7 +53,7 @@ export const githubQuickActions: QuickAction[] = [ "sourcebot", "vercel" ].map((org) => ( - {org} + {org} ))}
@@ -82,7 +71,7 @@ export const githubQuickActions: QuickAction[] = [ selectionText: "", description: (
- Add a user to sync with. All repositories that the user owns visible to the provided token (if any) will be synced. + Add a user to sync with. All repositories that the user owns visible to the provided token (if any) will be synced. Examples:
{[ @@ -90,7 +79,7 @@ export const githubQuickActions: QuickAction[] = [ "torvalds", "octocat" ].map((org) => ( - {org} + {org} ))}
@@ -103,7 +92,7 @@ export const githubQuickActions: QuickAction[] = [ }), name: "Set url to GitHub instance", selectionText: "https://github.example.com", - description: Set a custom GitHub host. Defaults to https://github.com. + description: Set a custom GitHub host. Defaults to https://github.com. }, { fn: (previous: GithubConnectionConfig) => ({ @@ -127,7 +116,7 @@ export const githubQuickActions: QuickAction[] = [ "my-org/docs*", "my-org/test*" ].map((repo) => ( - {repo} + {repo} ))}
@@ -155,7 +144,7 @@ export const githubQuickActions: QuickAction[] = [ "docs", "ci" ].map((repo) => ( - {repo} + {repo} ))} @@ -180,7 +169,7 @@ export const githubQuickActions: QuickAction[] = [ "docs", "ci" ].map((repo) => ( - {repo} + {repo} ))} @@ -223,14 +212,14 @@ export const gitlabQuickActions: QuickAction[] = [ selectionText: "", description: (
- Add a individual project to sync with. Ensure the project is visible to the provided token (if any). + Add a individual project to sync with. Ensure the project is visible to the provided token (if any). Examples:
{[ "gitlab-org/gitlab", "corp/team-project", ].map((repo) => ( - {repo} + {repo} ))}
@@ -248,14 +237,14 @@ export const gitlabQuickActions: QuickAction[] = [ selectionText: "", description: (
- Add a user to sync with. All projects that the user owns visible to the provided token (if any) will be synced. + Add a user to sync with. All projects that the user owns visible to the provided token (if any) will be synced. Examples:
{[ "jane-doe", "torvalds" ].map((org) => ( - {org} + {org} ))}
@@ -273,14 +262,14 @@ export const gitlabQuickActions: QuickAction[] = [ selectionText: "", description: (
- Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided token (if any) will be synced. + Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided token (if any) will be synced. Examples:
{[ "my-group", "path/to/subgroup" ].map((org) => ( - {org} + {org} ))}
@@ -293,7 +282,7 @@ export const gitlabQuickActions: QuickAction[] = [ }), name: "Set url to GitLab instance", selectionText: "https://gitlab.example.com", - description: Set a custom GitLab host. Defaults to https://gitlab.com. + description: Set a custom GitLab host. Defaults to https://gitlab.com. }, { fn: (previous: GitlabConnectionConfig) => ({ @@ -301,7 +290,7 @@ export const gitlabQuickActions: QuickAction[] = [ all: true, }), name: "Sync all projects", - description: Sync all projects visible to the provided token (if any). Only available when using a self-hosted GitLab instance. + description: Sync all projects visible to the provided token (if any). Only available when using a self-hosted GitLab instance. }, { fn: (previous: GitlabConnectionConfig) => ({ @@ -325,7 +314,7 @@ export const gitlabQuickActions: QuickAction[] = [ "docs/**", "**/tests/**", ].map((repo) => ( - {repo} + {repo} ))} @@ -403,7 +392,7 @@ export const bitbucketCloudQuickActions: QuickAction[ selectionText: "username", description: (
- Username to use for authentication. This is only required if you're using an App Password (stored in token) for authentication. + Username to use for authentication. This is only required if you're using an App Password (stored in token) for authentication.
) }, @@ -419,7 +408,7 @@ export const bitbucketCloudQuickActions: QuickAction[ selectionText: "myWorkspace", description: (
- Add a workspace to sync with. Ensure the workspace is visible to the provided token (if any). + Add a workspace to sync with. Ensure the workspace is visible to the provided token (if any).
) }, @@ -435,7 +424,7 @@ export const bitbucketCloudQuickActions: QuickAction[ selectionText: "myWorkspace/myRepo", description: (
- Add an individual repository to sync with. Ensure the repository is visible to the provided token (if any). + Add an individual repository to sync with. Ensure the repository is visible to the provided token (if any).
) }, @@ -451,7 +440,7 @@ export const bitbucketCloudQuickActions: QuickAction[ selectionText: "myProject", description: (
- Add a project to sync with. Ensure the project is visible to the provided token (if any). + Add a project to sync with. Ensure the project is visible to the provided token (if any).
) }, @@ -506,14 +495,14 @@ export const bitbucketDataCenterQuickActions: QuickAction - Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any). + Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any). Examples:
{[ "PROJ/repo-name", "MYPROJ/api" ].map((repo) => ( - {repo} + {repo} ))}
@@ -531,7 +520,7 @@ export const bitbucketDataCenterQuickActions: QuickAction - Add a project to sync with. Ensure the project is visible to the provided token (if any). + Add a project to sync with. Ensure the project is visible to the provided token (if any). ) }, @@ -554,7 +543,7 @@ export const bitbucketDataCenterQuickActions: QuickAction ( - {repo} + {repo} ))} diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 8d712266..bca73f77 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -11,9 +11,16 @@ import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/co import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; 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"; +import { PendingApprovalCard } from "./components/pendingApproval"; +import { SubmitJoinRequest } from "./components/submitJoinRequest"; +import { hasEntitlement } from "@sourcebot/shared"; +import { env } from "@/env.mjs"; +import { GcpIapAuth } from "./components/gcpIapAuth"; +import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions"; +import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; interface LayoutProps { children: React.ReactNode, @@ -30,27 +37,65 @@ export default async function Layout({ return notFound(); } - if (env.SOURCEBOT_AUTH_ENABLED === 'true') { - const session = await auth(); - if (!session) { - redirect('/login'); - } - + const session = await auth(); + const anonymousAccessEnabled = hasEntitlement("anonymous-access") && await getAnonymousAccessStatus(domain); + + // If the user is authenticated, we must check if they're a member of the org + if (session) { const membership = await prisma.userToOrg.findUnique({ where: { orgId_userId: { orgId: org.id, userId: session.user.id } + }, + include: { + user: true } }); - + + // There's two reasons why a user might not be a member of an org: + // 1. The org doesn't require member approval, but the org was at max capacity when the user registered. In this case, we show them + // the join organization card to allow them to join the org if seat capacity is freed up. This card handles checking if the org has available seats. + // 2. The org requires member approval, and they haven't been approved yet. In this case, we allow them to submit a request to join the org. if (!membership) { - return notFound(); + const memberApprovalRequired = await getMemberApprovalRequired(domain); + if (!memberApprovalRequired) { + return ( +
+ + +
+ ) + } else { + const hasPendingApproval = await prisma.accountRequest.findFirst({ + where: { + orgId: org.id, + requestedById: session.user.id + } + }); + + if (hasPendingApproval) { + return + } else { + return + } + } + } + } else { + // If the user isn't authenticated and anonymous access isn't enabled, we need to redirect them to the login page. + if (!anonymousAccessEnabled) { + const ssoEntitlement = await hasEntitlement("sso"); + if (ssoEntitlement && env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE) { + return ; + } else { + redirect('/login'); + } } } - if (!org.isOnboarded) { + // If the org is not onboarded, and GCP IAP is not enabled, show the onboarding page + if (!org.isOnboarded && !(env.AUTH_EE_GCP_IAP_ENABLED && env.AUTH_EE_GCP_IAP_AUDIENCE)) { return ( {children} diff --git a/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx b/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx deleted file mode 100644 index a0df45d2..00000000 --- a/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { completeOnboarding } from "@/actions"; -import { OnboardingSteps } from "@/lib/constants"; -import { isServiceError } from "@/lib/utils"; -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { useDomain } from "@/hooks/useDomain"; - -export const CompleteOnboarding = () => { - const router = useRouter(); - const domain = useDomain(); - - useEffect(() => { - const complete = async () => { - const response = await completeOnboarding(domain); - if (isServiceError(response)) { - router.push(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`); - return; - } - - router.push(`/${domain}`); - router.refresh(); - }; - - complete(); - }, [domain, router]); - - return null; -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx deleted file mode 100644 index 00fecdb0..00000000 --- a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx +++ /dev/null @@ -1,164 +0,0 @@ -'use client'; - -import { useState } from "react"; -import { CodeHostType } from "@/lib/utils"; -import { getCodeHostIcon } from "@/lib/utils"; -import { - GitHubConnectionCreationForm, - GitLabConnectionCreationForm, - GiteaConnectionCreationForm, - GerritConnectionCreationForm, - BitbucketCloudConnectionCreationForm, - BitbucketDataCenterConnectionCreationForm -} from "@/app/[domain]/components/connectionCreationForms"; -import { useRouter } from "next/navigation"; -import { useCallback } from "react"; -import { OnboardingSteps } from "@/lib/constants"; -import { BackButton } from "./onboardBackButton"; -import { CodeHostIconButton } from "../../components/codeHostIconButton"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -import SecurityCard from "@/app/components/securityCard"; - -interface ConnectCodeHostProps { - nextStep: OnboardingSteps; - securityCardEnabled: boolean; -} - -export const ConnectCodeHost = ({ nextStep, securityCardEnabled }: ConnectCodeHostProps) => { - const [selectedCodeHost, setSelectedCodeHost] = useState(null); - const router = useRouter(); - - const onCreated = useCallback(() => { - router.push(`?step=${nextStep}`); - }, [nextStep, router]); - - const onBack = useCallback(() => { - setSelectedCodeHost(null); - }, []); - - if (!selectedCodeHost) { - return ( - <> - - {securityCardEnabled && } - - ) - } - - if (selectedCodeHost === "github") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "gitlab") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "gitea") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "gerrit") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "bitbucket-cloud") { - return ( - <> - - - - ) - } - - if (selectedCodeHost === "bitbucket-server") { - return ( - <> - - - - ) - } - - return null; -} - -interface CodeHostSelectionProps { - onSelect: (codeHost: CodeHostType) => void; -} - -const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => { - const captureEvent = useCaptureEvent(); - - return ( -
- { - onSelect("github"); - captureEvent("wa_onboard_github_selected", {}); - }} - /> - { - onSelect("gitlab"); - captureEvent("wa_onboard_gitlab_selected", {}); - }} - /> - { - onSelect("bitbucket-cloud"); - captureEvent("wa_onboard_bitbucket_cloud_selected", {}); - }} - /> - { - onSelect("bitbucket-server"); - captureEvent("wa_onboard_bitbucket_server_selected", {}); - }} - /> - { - onSelect("gitea"); - captureEvent("wa_onboard_gitea_selected", {}); - }} - /> - { - onSelect("gerrit"); - captureEvent("wa_onboard_gerrit_selected", {}); - }} - /> -
- ) -} diff --git a/packages/web/src/app/[domain]/onboard/components/demoCard.tsx b/packages/web/src/app/[domain]/onboard/components/demoCard.tsx deleted file mode 100644 index b9bfe93a..00000000 --- a/packages/web/src/app/[domain]/onboard/components/demoCard.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client" - -import { ExternalLink } from "lucide-react" -import Link from "next/link" - -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import useCaptureEvent from "@/hooks/useCaptureEvent" - -export default function DemoCard() { - const captureEvent = useCaptureEvent(); - - return ( - - -
-
-
-

New to Sourcebot?

-

Try our public demo before creating an account

-
- - -
-
-
-
- ) -} diff --git a/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx b/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx deleted file mode 100644 index 2aa9bb9c..00000000 --- a/packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx +++ /dev/null @@ -1,135 +0,0 @@ -'use client'; - -import { createInvites } from "@/actions"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { isServiceError } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Loader2, PlusCircleIcon } from "lucide-react"; -import { useCallback } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { inviteMemberFormSchema } from "../../settings/members/components/inviteMemberCard"; -import { useDomain } from "@/hooks/useDomain"; -import { useToast } from "@/components/hooks/use-toast"; -import { OnboardingSteps } from "@/lib/constants"; -import { useRouter } from "next/navigation"; -import useCaptureEvent from "@/hooks/useCaptureEvent"; -interface InviteTeamProps { - nextStep: OnboardingSteps; -} - -export const InviteTeam = ({ nextStep }: InviteTeamProps) => { - const domain = useDomain(); - const { toast } = useToast(); - const router = useRouter(); - const captureEvent = useCaptureEvent(); - - const form = useForm>({ - resolver: zodResolver(inviteMemberFormSchema), - defaultValues: { - emails: [{ email: "" }] - }, - }); - - const addEmailField = useCallback(() => { - const emails = form.getValues().emails; - form.setValue('emails', [...emails, { email: "" }]); - }, [form]); - - const onComplete = useCallback(() => { - router.push(`?step=${nextStep}`); - }, [nextStep, router]); - - const onSubmit = useCallback(async (data: z.infer) => { - const response = await createInvites(data.emails.map(e => e.email), domain); - if (isServiceError(response)) { - toast({ - description: `โŒ Failed to invite members. Reason: ${response.message}` - }); - captureEvent('wa_onboard_invite_team_invite_fail', { - error: response.errorCode, - num_emails: data.emails.length, - }); - } else { - toast({ - description: `โœ… Successfully invited ${data.emails.length} members` - }); - captureEvent('wa_onboard_invite_team_invite_success', { - num_emails: data.emails.length, - }); - onComplete(); - } - }, [domain, toast, onComplete, captureEvent]); - - const onSkip = useCallback(() => { - captureEvent('wa_onboard_invite_team_skip', { - num_emails: form.getValues().emails.length, - }); - onComplete(); - }, [onComplete, form, captureEvent]); - - return ( - -
- - - Email Address - {`Invite members to access your organization's Sourcebot instance.`} - {form.watch('emails').map((_, index) => ( - ( - - - - - - - )} - /> - ))} - {form.formState.errors.emails?.root?.message && ( - {form.formState.errors.emails.root.message} - )} - - - - - - - - -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/onboardBackButton.tsx b/packages/web/src/app/[domain]/onboard/components/onboardBackButton.tsx deleted file mode 100644 index 25d93615..00000000 --- a/packages/web/src/app/[domain]/onboard/components/onboardBackButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" - -interface BackButtonProps { - onClick: () => void -} - -export function BackButton({ onClick }: BackButtonProps) { - return ( -
- -
- ) -} - diff --git a/packages/web/src/app/[domain]/onboard/page.tsx b/packages/web/src/app/[domain]/onboard/page.tsx deleted file mode 100644 index a62770e1..00000000 --- a/packages/web/src/app/[domain]/onboard/page.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { OnboardHeader } from "@/app/onboard/components/onboardHeader"; -import { getOrgFromDomain } from "@/data/org"; -import { OnboardingSteps } from "@/lib/constants"; -import { notFound, redirect } from "next/navigation"; -import { ConnectCodeHost } from "./components/connectCodeHost"; -import { InviteTeam } from "./components/inviteTeam"; -import { CompleteOnboarding } from "./components/completeOnboarding"; -import { Checkout } from "@/ee/features/billing/components/checkout"; -import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; -import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; -import { env } from "@/env.mjs"; - -interface OnboardProps { - params: { - domain: string - }, - searchParams: { - step?: string - stripe_session_id?: string - } -} - -export default async function Onboard({ params, searchParams }: OnboardProps) { - const org = await getOrgFromDomain(params.domain); - - if (!org) { - notFound(); - } - - if (org.isOnboarded) { - redirect(`/${params.domain}`); - } - - const step = searchParams.step ?? OnboardingSteps.ConnectCodeHost; - if ( - !Object.values(OnboardingSteps) - .filter(s => s !== OnboardingSteps.CreateOrg) - .filter(s => !IS_BILLING_ENABLED ? s !== OnboardingSteps.Checkout : true) - .map(s => s.toString()) - .includes(step) - ) { - redirect(`/${params.domain}/onboard?step=${OnboardingSteps.ConnectCodeHost}`); - } - - const lastRequiredStep = IS_BILLING_ENABLED ? OnboardingSteps.Checkout : OnboardingSteps.Complete; - - return ( -
- {step !== OnboardingSteps.Complete && ( - - )} - {step === OnboardingSteps.ConnectCodeHost && ( - <> - - - - )} - {step === OnboardingSteps.InviteTeam && ( - <> - - - - )} - {step === OnboardingSteps.Checkout && ( - <> - - - )} - {step === OnboardingSteps.Complete && ( - - )} -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx index 99ef681c..607bc143 100644 --- a/packages/web/src/app/[domain]/page.tsx +++ b/packages/web/src/app/[domain]/page.tsx @@ -1,17 +1,16 @@ -import { NavigationMenu } from "./components/navigationMenu"; -import { SearchBar } from "./components/searchBar"; -import { Separator } from "@/components/ui/separator"; -import { UpgradeToast } from "./components/upgradeToast"; -import Link from "next/link"; -import { getOrgFromDomain } from "@/data/org"; -import { PageNotFound } from "./components/pageNotFound"; -import { Footer } from "@/app/components/footer"; -import { SourcebotLogo } from "../components/sourcebotLogo"; -import { RepositorySnapshot } from "./components/repositorySnapshot"; -import { SyntaxReferenceGuideHint } from "./components/syntaxReferenceGuideHint"; -import { env } from '@/env.mjs'; import { getRepos } from "@/actions"; +import { Footer } from "@/app/components/footer"; +import { getOrgFromDomain } from "@/data/org"; +import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions"; import { isServiceError } from "@/lib/utils"; +import { Homepage } from "./components/homepage"; +import { NavigationMenu } from "./components/navigationMenu"; +import { PageNotFound } from "./components/pageNotFound"; +import { UpgradeToast } from "./components/upgradeToast"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { auth } from "@/auth"; +import { cookies } from "next/headers"; +import { SEARCH_MODE_COOKIE_NAME } from "@/lib/constants"; export default async function Home({ params: { domain } }: { params: { domain: string } }) { const org = await getOrgFromDomain(domain); @@ -19,7 +18,30 @@ export default async function Home({ params: { domain } }: { params: { domain: s return } + const session = await auth(); + + const models = await getConfiguredLanguageModelsInfo(); const repos = await getRepos(domain); + const chatHistory = session ? await getUserChatHistory(domain) : []; + + if (isServiceError(repos)) { + throw new ServiceErrorException(repos); + } + + if (isServiceError(chatHistory)) { + throw new ServiceErrorException(chatHistory); + } + + const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined); + + // Read search mode from cookie, defaulting to agentic if not set + // (assuming a language model is configured). + const cookieStore = await cookies(); + const searchModeCookie = cookieStore.get(SEARCH_MODE_COOKIE_NAME); + const initialSearchMode = ( + searchModeCookie?.value === "agentic" || + searchModeCookie?.value === "precise" + ) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise"; return (
@@ -27,124 +49,14 @@ export default async function Home({ params: { domain } }: { params: { domain: s domain={domain} /> -
-
- -
- -
- -
-
- - How to search -
- - - test todo (both test and todo) - - - test or todo (either test or todo) - - - {`"exit boot"`} (exact match) - - - TODO case:yes (case sensitive) - - - - - file:README setup (by filename) - - - repo:torvalds/linux test (by repo) - - - lang:typescript (by language) - - - rev:HEAD (by branch or tag) - - - - - file:{`\\.py$`} {`(files that end in ".py")`} - - - sym:main {`(symbols named "main")`} - - - todo -lang:c (negate filter) - - - content:README (search content only) - - -
- -
-
+ +
) } - -const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => { - return ( -
- {title} - {children} -
- ) - -} - -const Highlight = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -} - -const QueryExample = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -} - -const QueryExplanation = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -} - -const Query = ({ query, domain, children }: { query: string, domain: string, children: React.ReactNode }) => { - return ( - - {children} - - ) -} diff --git a/packages/web/src/app/[domain]/repos/addRepoButton.tsx b/packages/web/src/app/[domain]/repos/addRepoButton.tsx index 0a72e085..739f4703 100644 --- a/packages/web/src/app/[domain]/repos/addRepoButton.tsx +++ b/packages/web/src/app/[domain]/repos/addRepoButton.tsx @@ -3,24 +3,28 @@ import { Button } from "@/components/ui/button" import { PlusCircle } from "lucide-react" import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogClose, - DialogFooter, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogClose, + DialogFooter, } from "@/components/ui/dialog" import { useState } from "react" import { ConnectionList } from "../connections/components/connectionList" import { useDomain } from "@/hooks/useDomain" import Link from "next/link"; +import { useSession } from "next-auth/react" -export function AddRepoButton({ isAddNewRepoButtonVisible }: { isAddNewRepoButtonVisible: boolean }) { - const [isOpen, setIsOpen] = useState(false) - const domain = useDomain() +export function AddRepoButton() { + const [isOpen, setIsOpen] = useState(false) + const domain = useDomain() + const { data: session } = useSession(); - return ( + return ( + <> + {session?.user && ( <> - + @@ -40,7 +44,7 @@ export function AddRepoButton({ isAddNewRepoButtonVisible }: { isAddNewRepoButto
- +
) + } + + ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/repos/columns.tsx b/packages/web/src/app/[domain]/repos/columns.tsx index 934c7c54..4d985cba 100644 --- a/packages/web/src/app/[domain]/repos/columns.tsx +++ b/packages/web/src/app/[domain]/repos/columns.tsx @@ -6,12 +6,13 @@ import { ArrowUpDown, ExternalLink, Clock, Loader2, CheckCircle2, XCircle, Trash import Image from "next/image" import { Badge } from "@/components/ui/badge" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { cn } from "@/lib/utils" +import { cn, getRepoImageSrc } from "@/lib/utils" import { RepoIndexingStatus } from "@sourcebot/db"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { AddRepoButton } from "./addRepoButton" export type RepositoryColumnInfo = { + repoId: number name: string imageUrl?: string connections: { @@ -93,13 +94,13 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => { ) } -export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): ColumnDef[] => [ +export const columns = (domain: string): ColumnDef[] => [ { accessorKey: "name", header: () => (
Repository - {isAddNewRepoButtonVisible && } +
), cell: ({ row }) => { @@ -112,7 +113,7 @@ export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): Col
{repo.imageUrl ? (
), diff --git a/packages/web/src/app/[domain]/repos/page.tsx b/packages/web/src/app/[domain]/repos/page.tsx index 9ba31340..f0ffa1e8 100644 --- a/packages/web/src/app/[domain]/repos/page.tsx +++ b/packages/web/src/app/[domain]/repos/page.tsx @@ -2,7 +2,6 @@ import { RepositoryTable } from "./repositoryTable"; import { getOrgFromDomain } from "@/data/org"; import { PageNotFound } from "../components/pageNotFound"; import { Header } from "../components/header"; -import { env } from "@/env.mjs"; export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) { const org = await getOrgFromDomain(domain); @@ -17,9 +16,7 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
- +
diff --git a/packages/web/src/app/[domain]/repos/repositoryTable.tsx b/packages/web/src/app/[domain]/repos/repositoryTable.tsx index 13cbfe7b..056c0843 100644 --- a/packages/web/src/app/[domain]/repos/repositoryTable.tsx +++ b/packages/web/src/app/[domain]/repos/repositoryTable.tsx @@ -11,11 +11,7 @@ import { useMemo } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { env } from "@/env.mjs"; -interface RepositoryTableProps { - isAddNewRepoButtonVisible: boolean; -} - -export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTableProps) => { +export const RepositoryTable = () => { const domain = useDomain(); const { data: repos, isLoading: reposLoading, error: reposError } = useQuery({ @@ -29,6 +25,7 @@ export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTablePr const tableRepos = useMemo(() => { if (reposLoading) return Array(4).fill(null).map(() => ({ + repoId: 0, name: "", connections: [], repoIndexingStatus: RepoIndexingStatus.NEW, @@ -39,6 +36,7 @@ export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTablePr if (!repos) return []; return repos.map((repo): RepositoryColumnInfo => ({ + repoId: repo.repoId, name: repo.repoDisplayName ?? repo.repoName, imageUrl: repo.imageUrl, connections: repo.linkedConnections, @@ -52,7 +50,7 @@ export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTablePr const tableColumns = useMemo(() => { if (reposLoading) { - return columns(domain, isAddNewRepoButtonVisible).map((column) => { + return columns(domain).map((column) => { if ('accessorKey' in column && column.accessorKey === "name") { return { ...column, @@ -76,8 +74,8 @@ export const RepositoryTable = ({ isAddNewRepoButtonVisible }: RepositoryTablePr }) } - return columns(domain, isAddNewRepoButtonVisible); - }, [reposLoading, domain, isAddNewRepoButtonVisible]); + return columns(domain); + }, [reposLoading, domain]); if (reposError) { diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx index 6658d9c7..2d2eadbc 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx @@ -6,7 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { SearchResultChunk } from "@/features/search/types"; import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; import { useKeymapExtension } from "@/hooks/useKeymapExtension"; -import { useSyntaxHighlightingExtension } from "@/hooks/useSyntaxHighlightingExtension"; +import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; import { gutterWidthExtension } from "@/lib/extensions/gutterWidthExtension"; import { highlightRanges, searchResultHighlightExtension } from "@/lib/extensions/searchResultHighlightExtension"; import { search } from "@codemirror/search"; @@ -14,9 +14,17 @@ import { EditorView } from "@codemirror/view"; import { Cross1Icon, FileIcon } from "@radix-ui/react-icons"; import { Scrollbar } from "@radix-ui/react-scroll-area"; import CodeMirror, { ReactCodeMirrorRef, SelectionRange } from '@uiw/react-codemirror'; -import clsx from "clsx"; import { ArrowDown, ArrowUp } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; +import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPopup"; +import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo"; +import { createAuditAction } from "@/ee/features/audit/actions"; +import { useDomain } from "@/hooks/useDomain"; + +import useCaptureEvent from "@/hooks/useCaptureEvent"; export interface CodePreviewFile { content: string; @@ -28,10 +36,10 @@ export interface CodePreviewFile { } interface CodePreviewProps { - file?: CodePreviewFile; - repoName?: string; + file: CodePreviewFile; + repoName: string; selectedMatchIndex: number; - onSelectedMatchIndexChange: (index: number) => void; + onSelectedMatchIndexChange: Dispatch>; onClose: () => void; } @@ -43,19 +51,24 @@ export const CodePreview = ({ onClose, }: CodePreviewProps) => { const [editorRef, setEditorRef] = useState(null); + const { navigateToPath } = useBrowseNavigation(); + const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const domain = useDomain(); const [gutterWidth, setGutterWidth] = useState(0); const theme = useCodeMirrorTheme(); const keymapExtension = useKeymapExtension(editorRef?.view); - const syntaxHighlighting = useSyntaxHighlightingExtension(file?.language ?? '', editorRef?.view); + const languageExtension = useCodeMirrorLanguageExtension(file?.language ?? '', editorRef?.view); const [currentSelection, setCurrentSelection] = useState(); + const captureEvent = useCaptureEvent(); + const extensions = useMemo(() => { return [ keymapExtension, gutterWidthExtension, - syntaxHighlighting, + languageExtension, EditorView.lineWrapping, searchResultHighlightExtension(), search({ @@ -74,12 +87,13 @@ export const CodePreview = ({ if (update.selectionSet || update.docChanged) { setCurrentSelection(update.state.selection.main); } - }) + }), + hasCodeNavEntitlement ? symbolHoverTargetsExtension : [], ]; - }, [keymapExtension, syntaxHighlighting]); + }, [hasCodeNavEntitlement, keymapExtension, languageExtension]); const ranges = useMemo(() => { - if (!file || !file.matches.length) { + if (!file.matches.length) { return []; } @@ -89,7 +103,7 @@ export const CodePreview = ({ }, [file]); useEffect(() => { - if (!file || !editorRef?.view) { + if (!editorRef?.view) { return; } @@ -97,12 +111,87 @@ export const CodePreview = ({ }, [ranges, selectedMatchIndex, file, editorRef]); const onUpClicked = useCallback(() => { - onSelectedMatchIndexChange(selectedMatchIndex - 1); - }, [onSelectedMatchIndexChange, selectedMatchIndex]); + onSelectedMatchIndexChange((prev) => prev - 1); + }, [onSelectedMatchIndexChange]); const onDownClicked = useCallback(() => { - onSelectedMatchIndexChange(selectedMatchIndex + 1); - }, [onSelectedMatchIndexChange, selectedMatchIndex]); + onSelectedMatchIndexChange((prev) => prev + 1); + }, [onSelectedMatchIndexChange]); + + const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { + captureEvent('wa_goto_definition_pressed', { + source: 'preview', + }); + createAuditAction({ + action: "user.performed_goto_definition", + metadata: { + message: symbolName, + }, + }, domain) + + if (symbolDefinitions.length === 0) { + return; + } + + if (symbolDefinitions.length === 1) { + const symbolDefinition = symbolDefinitions[0]; + const { fileName, repoName } = symbolDefinition; + + navigateToPath({ + repoName, + revisionName: file.revision, + path: fileName, + pathType: 'blob', + highlightRange: symbolDefinition.range, + }) + } else { + navigateToPath({ + repoName, + revisionName: file.revision, + path: file.filepath, + pathType: 'blob', + setBrowseState: { + selectedSymbolInfo: { + symbolName, + repoName, + revisionName: file.revision, + language: file.language, + }, + activeExploreMenuTab: "definitions", + isBottomPanelCollapsed: false, + } + }); + } + }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]); + + const onFindReferences = useCallback((symbolName: string) => { + captureEvent('wa_find_references_pressed', { + source: 'preview', + }); + createAuditAction({ + action: "user.performed_find_references", + metadata: { + message: symbolName, + }, + }, domain) + + navigateToPath({ + repoName, + revisionName: file.revision, + path: file.filepath, + pathType: 'blob', + setBrowseState: { + selectedSymbolInfo: { + repoName, + symbolName, + revisionName: file.revision, + language: file.language, + }, + activeExploreMenuTab: "references", + isBottomPanelCollapsed: false, + } + }) + }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]); return (
@@ -121,23 +210,24 @@ export const CodePreview = ({ {/* File path */}
{ - if (file?.link) { - window.open(file.link, "_blank"); - } + navigateToPath({ + repoName, + path: file.filepath, + pathType: 'blob', + revisionName: file.revision, + }); }} - title={file?.filepath} + title={file.filepath} > - {file?.filepath} + {file.filepath}
{/* Match selector */} - {file && file.matches.length > 0 && ( + {file.matches.length > 0 && ( <>

{`${selectedMatchIndex + 1} of ${ranges.length}`}

@@ -196,6 +286,16 @@ export const CodePreview = ({ /> ) } + + {editorRef && hasCodeNavEntitlement && ( + + )} diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx index b3ea530b..e6c32c21 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx @@ -1,74 +1,76 @@ 'use client'; -import { fetchFileSource } from "@/app/api/(client)/client"; -import { base64Decode } from "@/lib/utils"; import { useQuery } from "@tanstack/react-query"; -import { CodePreview, CodePreviewFile } from "./codePreview"; +import { CodePreview } from "./codePreview"; import { SearchResultFile } from "@/features/search/types"; import { useDomain } from "@/hooks/useDomain"; import { SymbolIcon } from "@radix-ui/react-icons"; +import { SetStateAction, Dispatch, useMemo } from "react"; +import { getFileSource } from "@/features/search/fileSourceApi"; +import { unwrapServiceError } from "@/lib/utils"; interface CodePreviewPanelProps { - fileMatch?: SearchResultFile; - onClose: () => void; + previewedFile: SearchResultFile; selectedMatchIndex: number; - onSelectedMatchIndexChange: (index: number) => void; + onClose: () => void; + onSelectedMatchIndexChange: Dispatch>; } export const CodePreviewPanel = ({ - fileMatch, - onClose, + previewedFile, selectedMatchIndex, + onClose, onSelectedMatchIndexChange, }: CodePreviewPanelProps) => { const domain = useDomain(); - const { data: file, isLoading } = useQuery({ - queryKey: ["source", fileMatch?.fileName, fileMatch?.repository, fileMatch?.branches], - queryFn: async (): Promise => { - if (!fileMatch) { - return undefined; - } + // If there are multiple branches pointing to the same revision of this file, it doesn't + // matter which branch we use here, so use the first one. + const branch = useMemo(() => { + return previewedFile.branches && previewedFile.branches.length > 0 ? previewedFile.branches[0] : undefined; + }, [previewedFile]); - // If there are multiple branches pointing to the same revision of this file, it doesn't - // matter which branch we use here, so use the first one. - const branch = fileMatch.branches && fileMatch.branches.length > 0 ? fileMatch.branches[0] : undefined; - - return fetchFileSource({ - fileName: fileMatch.fileName.text, - repository: fileMatch.repository, + const { data: file, isLoading, isPending, isError } = useQuery({ + queryKey: ["source", previewedFile, branch, domain], + queryFn: () => unwrapServiceError( + getFileSource({ + fileName: previewedFile.fileName.text, + repository: previewedFile.repository, branch, }, domain) - .then(({ source }) => { - const decodedSource = base64Decode(source); - - return { - content: decodedSource, - filepath: fileMatch.fileName.text, - matches: fileMatch.chunks, - link: fileMatch.webUrl, - language: fileMatch.language, - revision: branch ?? "HEAD", - }; - }); - }, - enabled: fileMatch !== undefined, + ), + select: (data) => { + return { + content: data.source, + filepath: previewedFile.fileName.text, + matches: previewedFile.chunks, + link: previewedFile.webUrl, + language: previewedFile.language, + revision: branch ?? "HEAD", + }; + } }); - if (isLoading) { + if (isLoading || isPending) { return

Loading...

} + if (isError) { + return ( +

Failed to load file source

+ ) + } + return ( ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx index 5020e516..590ae19e 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; @@ -8,6 +9,8 @@ export type Entry = { displayName: string; count: number; isSelected: boolean; + isHidden: boolean; + isDisabled: boolean; Icon?: React.ReactNode; } @@ -22,6 +25,7 @@ export const Entry = ({ displayName, count, Icon, + isDisabled, }, onClicked, }: EntryProps) => { @@ -36,17 +40,27 @@ export const Entry = ({ { "hover:bg-gray-200 dark:hover:bg-gray-700": !isSelected, "bg-blue-200 dark:bg-blue-400": isSelected, + "opacity-50": isDisabled, } )} onClick={() => onClicked()} > -
+
{Icon ? Icon : ( )} -

{displayName}

+
+ + +

{displayName}

+
+ +

{displayName}

+
+
+
-
+
{countText}
diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx index bb799587..231cda18 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx @@ -6,39 +6,49 @@ import { cn, getCodeHostInfoForRepo } from "@/lib/utils"; import { LaptopIcon } from "@radix-ui/react-icons"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { Entry } from "./entry"; import { Filter } from "./filter"; +import { LANGUAGES_QUERY_PARAM, REPOS_QUERY_PARAM, useFilteredMatches } from "./useFilterMatches"; +import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery"; interface FilePanelProps { matches: SearchResultFile[]; - onFilterChanged: (filteredMatches: SearchResultFile[]) => void, repoInfo: Record; } -const LANGUAGES_QUERY_PARAM = "langs"; -const REPOS_QUERY_PARAM = "repos"; - +/** + * FilterPanel Component + * + * A bidirectional filtering component that allows users to filter search results by repository and language. + * The filtering is bidirectional, meaning: + * 1. When repositories are selected, the language filter will only show languages that exist in those repositories + * 2. When languages are selected, the repository filter will only show repositories that contain those languages + * + * This prevents users from selecting filter combinations that would yield no results. For example: + * - If Repository A only contains Python and JavaScript files, selecting it will only enable these languages + * - If Language Python is selected, only repositories containing Python files will be enabled + * + * @param matches - Array of search result files to filter + * @param repoInfo - Information about repositories including their display names and icons + */ export const FilterPanel = ({ matches, - onFilterChanged, repoInfo, }: FilePanelProps) => { const router = useRouter(); const searchParams = useSearchParams(); - // Helper to parse query params into sets - const getSelectedFromQuery = useCallback((param: string) => { - const value = searchParams.get(param); - return value ? new Set(value.split(',')) : new Set(); - }, [searchParams]); + const { getSelectedFromQuery } = useGetSelectedFromQuery(); + const matchesFilteredByRepository = useFilteredMatches(matches, 'repository'); + const matchesFilteredByLanguage = useFilteredMatches(matches, 'language'); const repos = useMemo(() => { const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); return aggregateMatches( "repository", matches, - ({ key, match }) => { + /* createEntry = */ ({ key: repository, match }) => { const repo: RepositoryInfo | undefined = repoInfo[match.repositoryId]; const info = repo ? getCodeHostInfoForRepo({ @@ -58,63 +68,72 @@ export const FilterPanel = ({ ); + const isSelected = selectedRepos.has(repository); + + // If the matches filtered by language don't contain this repository, then this entry is disabled + const isDisabled = !matchesFilteredByLanguage.some((match) => match.repository === repository); + const isHidden = isDisabled && !isSelected; + return { - key, - displayName: info?.displayName ?? key, + key: repository, + displayName: info?.displayName ?? repository, count: 0, - isSelected: selectedRepos.has(key), + isSelected, + isDisabled, + isHidden, Icon, }; + }, + /* shouldCount = */ ({ match }) => { + return matchesFilteredByLanguage.some((value) => value.language === match.language) } ) - }, [getSelectedFromQuery, matches, repoInfo]); + }, [getSelectedFromQuery, matches, repoInfo, matchesFilteredByLanguage]); const languages = useMemo(() => { const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); return aggregateMatches( "language", matches, - ({ key }) => { + /* createEntry = */ ({ key: language }) => { const Icon = ( - + ) + const isSelected = selectedLanguages.has(language); + + // If the matches filtered by repository don't contain this language, then this entry is disabled + const isDisabled = !matchesFilteredByRepository.some((match) => match.language === language); + const isHidden = isDisabled && !isSelected; + return { - key, - displayName: key, + key: language, + displayName: language, count: 0, - isSelected: selectedLanguages.has(key), + isSelected, + isDisabled, + isHidden, Icon: Icon, } satisfies Entry; + }, + /* shouldCount = */ ({ match }) => { + return matchesFilteredByRepository.some((value) => value.repository === match.repository) } ); - }, [getSelectedFromQuery, matches]); + }, [getSelectedFromQuery, matches, matchesFilteredByRepository]); - // Calls `onFilterChanged` with the filtered list of matches - // whenever the filter state changes. - useEffect(() => { - const selectedRepos = new Set(Object.keys(repos).filter((key) => repos[key].isSelected)); - const selectedLanguages = new Set(Object.keys(languages).filter((key) => languages[key].isSelected)); + const visibleRepos = useMemo(() => Object.values(repos).filter((entry) => !entry.isHidden), [repos]); + const visibleLanguages = useMemo(() => Object.values(languages).filter((entry) => !entry.isHidden), [languages]); - const filteredMatches = matches.filter((match) => - ( - (selectedRepos.size === 0 ? true : selectedRepos.has(match.repository)) && - (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.language)) - ) - ); - onFilterChanged(filteredMatches); - - }, [matches, repos, languages, onFilterChanged, searchParams, router]); - - const numRepos = useMemo(() => Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length, [repos]); - const numLanguages = useMemo(() => Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length, [languages]); + const numRepos = useMemo(() => visibleRepos.length > 100 ? '100+' : visibleRepos.length, [visibleRepos]); + const numLanguages = useMemo(() => visibleLanguages.length > 100 ? '100+' : visibleLanguages.length, [visibleLanguages]); return (
{ const newRepos = { ...repos }; newRepos[key].isSelected = !newRepos[key].isSelected; @@ -136,7 +155,7 @@ export const FilterPanel = ({ { const newLanguages = { ...languages }; newLanguages[key].isSelected = !newLanguages[key].isSelected; @@ -175,7 +194,8 @@ export const FilterPanel = ({ const aggregateMatches = ( propName: 'repository' | 'language', matches: SearchResultFile[], - createEntry: (props: { key: string, match: SearchResultFile }) => Entry + createEntry: (props: { key: string, match: SearchResultFile }) => Entry, + shouldCount: (props: { key: string, match: SearchResultFile }) => boolean, ) => { return matches .map((match) => ({ key: match[propName], match })) @@ -184,7 +204,11 @@ const aggregateMatches = ( if (!aggregation[key]) { aggregation[key] = createEntry({ key, match }); } - aggregation[key].count += 1; + + if (!aggregation[key].isDisabled && shouldCount({ key, match })) { + aggregation[key].count += 1; + } + return aggregation; }, {} as Record) } diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts b/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts new file mode 100644 index 00000000..5951d8ea --- /dev/null +++ b/packages/web/src/app/[domain]/search/components/filterPanel/useFilterMatches.ts @@ -0,0 +1,36 @@ +'use client'; + +import { SearchResultFile } from "@/features/search/types"; +import { useMemo } from "react"; +import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery"; + +export const LANGUAGES_QUERY_PARAM = "langs"; +export const REPOS_QUERY_PARAM = "repos"; + + +export const useFilteredMatches = ( + matches: SearchResultFile[], + filterBy: 'repository' | 'language' | 'all' = 'all' +) => { + const { getSelectedFromQuery } = useGetSelectedFromQuery(); + + const filteredMatches = useMemo(() => { + const selectedRepos = getSelectedFromQuery(REPOS_QUERY_PARAM); + const selectedLanguages = getSelectedFromQuery(LANGUAGES_QUERY_PARAM); + + const isInRepoSet = (repo: string) => selectedRepos.size === 0 || selectedRepos.has(repo); + const isInLanguageSet = (language: string) => selectedLanguages.size === 0 || selectedLanguages.has(language); + + switch (filterBy) { + case 'repository': + return matches.filter((match) => isInRepoSet(match.repository)); + case 'language': + return matches.filter((match) => isInLanguageSet(match.language)); + case 'all': + return matches.filter((match) => isInRepoSet(match.repository) && isInLanguageSet(match.language)); + } + + }, [filterBy, getSelectedFromQuery, matches]); + + return filteredMatches; +} diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts b/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts new file mode 100644 index 00000000..5fefcb82 --- /dev/null +++ b/packages/web/src/app/[domain]/search/components/filterPanel/useGetSelectedFromQuery.ts @@ -0,0 +1,17 @@ +'use client'; + +import { useSearchParams } from "next/navigation"; +import { useCallback } from "react"; + +// Helper to parse query params into sets +export const useGetSelectedFromQuery = () => { + const searchParams = useSearchParams(); + const getSelectedFromQuery = useCallback((param: string): Set => { + const value = searchParams.get(param); + return value ? new Set(value.split(',')) : new Set(); + }, [searchParams]); + + return { + getSelectedFromQuery, + } +} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx deleted file mode 100644 index 47cb2678..00000000 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/codePreview.tsx +++ /dev/null @@ -1,90 +0,0 @@ -'use client'; - -import { getCodemirrorLanguage } from "@/lib/codemirrorLanguage"; -import { lineOffsetExtension } from "@/lib/extensions/lineOffsetExtension"; -import { SearchResultRange } from "@/features/search/types"; -import { EditorState, StateField, Transaction } from "@codemirror/state"; -import { Decoration, DecorationSet, EditorView, lineNumbers } from "@codemirror/view"; -import { useMemo, useRef } from "react"; -import { LightweightCodeMirror, CodeMirrorRef } from "./lightweightCodeMirror"; -import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; - -const markDecoration = Decoration.mark({ - class: "cm-searchMatch-selected" -}); - -interface CodePreviewProps { - content: string, - language: string, - ranges: SearchResultRange[], - lineOffset: number, -} - -export const CodePreview = ({ - content, - language, - ranges, - lineOffset, -}: CodePreviewProps) => { - const editorRef = useRef(null); - const theme = useCodeMirrorTheme(); - - const extensions = useMemo(() => { - const codemirrorExtension = getCodemirrorLanguage(language); - return [ - EditorView.editable.of(false), - theme, - lineNumbers(), - lineOffsetExtension(lineOffset), - codemirrorExtension ? codemirrorExtension : [], - StateField.define({ - create(editorState: EditorState) { - const document = editorState.doc; - - const decorations = ranges - .sort((a, b) => { - return a.start.byteOffset - b.start.byteOffset; - }) - .filter(({ start, end }) => { - const startLine = start.lineNumber - lineOffset; - const endLine = end.lineNumber - lineOffset; - - if ( - startLine < 1 || - endLine < 1 || - startLine > document.lines || - endLine > document.lines - ) { - return false; - } - return true; - }) - .map(({ start, end }) => { - const startLine = start.lineNumber - lineOffset; - const endLine = end.lineNumber - lineOffset; - - const from = document.line(startLine).from + start.column - 1; - const to = document.line(endLine).from + end.column - 1; - return markDecoration.range(from, to); - }) - .sort((a, b) => a.from - b.from); - - return Decoration.set(decorations); - }, - update(highlights: DecorationSet, _transaction: Transaction) { - return highlights; - }, - provide: (field) => EditorView.decorations.from(field), - }), - ] - }, [language, lineOffset, ranges, theme]); - - return ( - - ) - -} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx index aaefe1a6..a24b8e45 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx @@ -1,25 +1,27 @@ 'use client'; -import { useMemo } from "react"; -import { CodePreview } from "./codePreview"; +import { useCallback } from "react"; import { SearchResultFile, SearchResultChunk } from "@/features/search/types"; -import { base64Decode } from "@/lib/utils"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; interface FileMatchProps { match: SearchResultChunk; file: SearchResultFile; - onOpen: () => void; + onOpen: (startLineNumber: number, endLineNumber: number, isCtrlKeyPressed: boolean) => void; } export const FileMatch = ({ match, file, - onOpen, + onOpen: _onOpen, }: FileMatchProps) => { - const content = useMemo(() => { - return base64Decode(match.content); - }, [match.content]); + const onOpen = useCallback((isCtrlKeyPressed: boolean) => { + const startLineNumber = match.contentStart.lineNumber; + const endLineNumber = match.content.trimEnd().split('\n').length + startLineNumber - 1; + + _onOpen(startLineNumber, endLineNumber, isCtrlKeyPressed); + }, [match.content, match.contentStart.lineNumber, _onOpen]); // If it's just the title, don't show a code preview if (match.matchRanges.length === 0) { @@ -29,21 +31,28 @@ export const FileMatch = ({ return (
{ if (e.key !== "Enter") { return; } - onOpen(); + + onOpen(e.metaKey || e.ctrlKey); }} - onClick={onOpen} + onClick={(e) => { + onOpen(e.metaKey || e.ctrlKey); + }} + title="open file: click, open file preview: cmd/ctrl + click" > - + highlightRanges={match.matchRanges} + lineNumbers={true} + lineNumbersOffset={match.contentStart.lineNumber} + renderWhitespace={true} + > + {match.content} +
); } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx index 813fe10a..820521b9 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -1,18 +1,19 @@ 'use client'; -import { FileHeader } from "@/app/[domain]/components/fileHeader"; +import { PathHeader } from "@/app/[domain]/components/pathHeader"; import { Separator } from "@/components/ui/separator"; import { DoubleArrowDownIcon, DoubleArrowUpIcon } from "@radix-ui/react-icons"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { FileMatch } from "./fileMatch"; import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +import { Button } from "@/components/ui/button"; +import { useBrowseNavigation } from "@/app/[domain]/browse/hooks/useBrowseNavigation"; export const MAX_MATCHES_TO_PREVIEW = 3; interface FileMatchContainerProps { file: SearchResultFile; - onOpenFile: () => void; - onMatchIndexChanged: (matchIndex: number) => void; + onOpenFilePreview: (matchIndex?: number) => void; showAllMatches: boolean; onShowAllMatchesButtonClicked: () => void; isBranchFilteringEnabled: boolean; @@ -22,18 +23,17 @@ interface FileMatchContainerProps { export const FileMatchContainer = ({ file, - onOpenFile, - onMatchIndexChanged, + onOpenFilePreview, showAllMatches, onShowAllMatchesButtonClicked, isBranchFilteringEnabled, repoInfo, yOffset, }: FileMatchContainerProps) => { - const matchCount = useMemo(() => { return file.chunks.length; }, [file]); + const { navigateToPath } = useBrowseNavigation(); const matches = useMemo(() => { const sortedMatches = file.chunks.sort((a, b) => { @@ -63,14 +63,6 @@ export const FileMatchContainer = ({ return matchCount > MAX_MATCHES_TO_PREVIEW; }, [matchCount]); - const onOpenMatch = useCallback((index: number) => { - const matchIndex = matches.slice(0, index).reduce((acc, match) => { - return acc + match.matchRanges.length; - }, 0); - onOpenFile(); - onMatchIndexChanged(matchIndex); - }, [matches, onMatchIndexChanged, onOpenFile]); - const branches = useMemo(() => { if (!file.branches) { return []; @@ -91,31 +83,36 @@ export const FileMatchContainer = ({ return repoInfo[file.repositoryId]; }, [repoInfo, file.repositoryId]); - return (
{/* Title */}
{ - onOpenFile(); - }} > - +
{/* Matches */} @@ -126,8 +123,28 @@ export const FileMatchContainer = ({ { - onOpenMatch(index); + onOpen={(startLineNumber, endLineNumber, isCtrlKeyPressed) => { + if (isCtrlKeyPressed) { + const matchIndex = matches.slice(0, index).reduce((acc, match) => { + return acc + match.matchRanges.length; + }, 0); + onOpenFilePreview(matchIndex); + } else { + navigateToPath({ + repoName: file.repository, + revisionName: file.branches?.[0] ?? 'HEAD', + path: file.fileName.text, + pathType: 'blob', + highlightRange: { + start: { + lineNumber: startLineNumber, + }, + end: { + lineNumber: endLineNumber, + } + } + }); + } }} /> {(index !== matches.length - 1 || isMoreContentButtonVisible) && ( @@ -140,7 +157,7 @@ export const FileMatchContainer = ({ {isMoreContentButtonVisible && (
{ if (e.key !== "Enter") { return; @@ -150,7 +167,7 @@ export const FileMatchContainer = ({ onClick={onShowAllMatchesButtonClicked} >

{showAllMatches ? : } {showAllMatches ? `Show fewer matches` : `Show ${matchCount - MAX_MATCHES_TO_PREVIEW} more matches`} diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx index 88757c56..61e41332 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx @@ -2,13 +2,13 @@ import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; import { FileMatchContainer, MAX_MATCHES_TO_PREVIEW } from "./fileMatchContainer"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDebounce, usePrevious } from "@uidotdev/usehooks"; interface SearchResultsPanelProps { fileMatches: SearchResultFile[]; - onOpenFileMatch: (fileMatch: SearchResultFile) => void; - onMatchIndexChanged: (matchIndex: number) => void; + onOpenFilePreview: (fileMatch: SearchResultFile, matchIndex?: number) => void; isLoadMoreButtonVisible: boolean; onLoadMoreButtonClicked: () => void; isBranchFilteringEnabled: boolean; @@ -19,18 +19,33 @@ const ESTIMATED_LINE_HEIGHT_PX = 20; const ESTIMATED_NUMBER_OF_LINES_PER_CODE_CELL = 10; const ESTIMATED_MATCH_CONTAINER_HEIGHT_PX = 30; +type ScrollHistoryState = { + scrollOffset?: number; + measurementsCache?: VirtualItem[]; + showAllMatchesStates?: boolean[]; +} + export const SearchResultsPanel = ({ fileMatches, - onOpenFileMatch, - onMatchIndexChanged, + onOpenFilePreview, isLoadMoreButtonVisible, onLoadMoreButtonClicked, isBranchFilteringEnabled, repoInfo, }: SearchResultsPanelProps) => { const parentRef = useRef(null); - const [showAllMatchesStates, setShowAllMatchesStates] = useState(Array(fileMatches.length).fill(false)); - const [lastShowAllMatchesButtonClickIndex, setLastShowAllMatchesButtonClickIndex] = useState(-1); + + // Restore the scroll offset, measurements cache, and other state from the history + // state. This enables us to restore the scroll offset when the user navigates back + // to the page. + // @see: https://github.com/TanStack/virtual/issues/378#issuecomment-2173670081 + const { + scrollOffset: restoreOffset, + measurementsCache: restoreMeasurementsCache, + showAllMatchesStates: restoreShowAllMatchesStates, + } = history.state as ScrollHistoryState; + + const [showAllMatchesStates, setShowAllMatchesStates] = useState(restoreShowAllMatchesStates || Array(fileMatches.length).fill(false)); const virtualizer = useVirtualizer({ count: fileMatches.length, @@ -51,60 +66,55 @@ export const SearchResultsPanel = ({ return estimatedSize; }, - measureElement: (element, _entry, instance) => { - // @note : Stutters were appearing when scrolling upwards. The workaround is - // to use the cached height of the element when scrolling up. - // @see : https://github.com/TanStack/virtual/issues/659 - const isCacheDirty = element.hasAttribute("data-cache-dirty"); - element.removeAttribute("data-cache-dirty"); - const direction = instance.scrollDirection; - if (direction === "forward" || direction === null || isCacheDirty) { - return element.scrollHeight; - } else { - const indexKey = Number(element.getAttribute("data-index")); - // Unfortunately, the cache is a private property, so we need to - // hush the TS compiler. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const cacheMeasurement = instance.itemSizeCache.get(indexKey); - return cacheMeasurement; - } - }, + initialOffset: restoreOffset, + initialMeasurementsCache: restoreMeasurementsCache, enabled: true, overscan: 10, debug: false, }); - const onShowAllMatchesButtonClicked = useCallback((index: number) => { - const states = [...showAllMatchesStates]; - states[index] = !states[index]; - setShowAllMatchesStates(states); - setLastShowAllMatchesButtonClickIndex(index); - }, [showAllMatchesStates]); - - // After the "show N more/less matches" button is clicked, the FileMatchContainer's - // size can change considerably. In cases where N > 3 or 4 cells when collapsing, - // a visual artifact can appear where there is a large gap between the now collapsed - // container and the next container. This is because the container's height was not - // re-calculated. To get arround this, we force a re-measure of the element AFTER - // it was re-rendered (hence the useLayoutEffect). - useLayoutEffect(() => { - if (lastShowAllMatchesButtonClickIndex < 0) { + // When the number of file matches changes, we need to reset our scroll state. + const prevFileMatches = usePrevious(fileMatches); + useEffect(() => { + if (!prevFileMatches) { return; } - const element = virtualizer.elementsCache.get(lastShowAllMatchesButtonClickIndex); - element?.setAttribute('data-cache-dirty', 'true'); - virtualizer.measureElement(element); + if (prevFileMatches.length !== fileMatches.length) { + setShowAllMatchesStates(Array(fileMatches.length).fill(false)); + virtualizer.scrollToIndex(0); + } + }, [fileMatches.length, prevFileMatches, virtualizer]); - setLastShowAllMatchesButtonClickIndex(-1); - }, [lastShowAllMatchesButtonClickIndex, virtualizer]); - - // Reset some state when the file matches change. + // Save the scroll state to the history stack. + const debouncedScrollOffset = useDebounce(virtualizer.scrollOffset, 100); useEffect(() => { - setShowAllMatchesStates(Array(fileMatches.length).fill(false)); - virtualizer.scrollToIndex(0); - }, [fileMatches, virtualizer]); + history.replaceState( + { + scrollOffset: debouncedScrollOffset ?? undefined, + measurementsCache: virtualizer.measurementsCache, + showAllMatchesStates, + } satisfies ScrollHistoryState, + '', + window.location.href + ); + }, [debouncedScrollOffset, virtualizer.measurementsCache, showAllMatchesStates]); + + const onShowAllMatchesButtonClicked = useCallback((index: number) => { + const states = [...showAllMatchesStates]; + const wasShown = states[index]; + states[index] = !wasShown; + setShowAllMatchesStates(states); + + // When collapsing, scroll to the top of the file match container. This ensures + // that the focused "show fewer matches" button is visible. + if (wasShown) { + virtualizer.scrollToIndex(index, { + align: 'start' + }); + } + }, [showAllMatchesStates, virtualizer]); + return (

{ - onOpenFileMatch(file); - }} - onMatchIndexChanged={(matchIndex) => { - onMatchIndexChanged(matchIndex); + onOpenFilePreview={(matchIndex) => { + onOpenFilePreview(file, matchIndex); }} showAllMatches={showAllMatchesStates[virtualRow.index]} onShowAllMatchesButtonClicked={() => { diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx deleted file mode 100644 index f6d3227e..00000000 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/lightweightCodeMirror.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { EditorState, Extension, StateEffect } from "@codemirror/state"; -import { EditorView } from "@codemirror/view"; -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; - -interface CodeMirrorProps { - value?: string; - extensions?: Extension[]; - className?: string; -} - -export interface CodeMirrorRef { - editor: HTMLDivElement | null; - state?: EditorState; - view?: EditorView; -} - -/** - * This component provides a lightweight CodeMirror component that has been optimized to - * render quickly in the search results panel. Why not use react-codemirror? For whatever reason, - * react-codemirror issues many StateEffects when first rendering, causing a stuttery scroll - * experience as new cells load. This component is a workaround for that issue and provides - * a minimal react wrapper around CodeMirror that avoids this issue. - */ -const LightweightCodeMirror = forwardRef(({ - value, - extensions, - className, -}, ref) => { - const editor = useRef(null); - const viewRef = useRef(); - const stateRef = useRef(); - - useImperativeHandle(ref, () => ({ - editor: editor.current, - state: stateRef.current, - view: viewRef.current, - }), []); - - useEffect(() => { - if (!editor.current) { - return; - } - - const state = EditorState.create({ - extensions: [], /* extensions are explicitly left out here */ - doc: value, - }); - stateRef.current = state; - - const view = new EditorView({ - state, - parent: editor.current, - }); - viewRef.current = view; - - return () => { - view.destroy(); - viewRef.current = undefined; - stateRef.current = undefined; - } - }, [value]); - - useEffect(() => { - if (viewRef.current) { - viewRef.current.dispatch({ effects: StateEffect.reconfigure.of(extensions ?? []) }); - } - }, [extensions]); - - return ( -
- ) -}); - -LightweightCodeMirror.displayName = "LightweightCodeMirror"; - -export { LightweightCodeMirror }; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index ac793ff8..a92956d9 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { - ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; @@ -15,7 +14,6 @@ import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ImperativePanelHandle } from "react-resizable-panels"; import { search } from "../../api/(client)/client"; import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; @@ -24,8 +22,18 @@ import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { useFilteredMatches } from "./components/filterPanel/useFilterMatches"; +import { Button } from "@/components/ui/button"; +import { ImperativePanelHandle } from "react-resizable-panels"; +import { FilterIcon } from "lucide-react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { SearchBar } from "../components/searchBar"; -const DEFAULT_MATCH_COUNT = 10000; +const DEFAULT_MAX_MATCH_COUNT = 10000; export default function SearchPage() { // We need a suspense boundary here since we are accessing query params @@ -41,18 +49,20 @@ export default function SearchPage() { const SearchPageInternal = () => { const router = useRouter(); const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; - const _matches = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MATCH_COUNT}`); - const matches = isNaN(_matches) ? DEFAULT_MATCH_COUNT : _matches; const { setSearchHistory } = useSearchHistory(); const captureEvent = useCaptureEvent(); const domain = useDomain(); const { toast } = useToast(); + // Encodes the number of matches to return in the search response. + const _maxMatchCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.matches) ?? `${DEFAULT_MAX_MATCH_COUNT}`); + const maxMatchCount = isNaN(_maxMatchCount) ? DEFAULT_MAX_MATCH_COUNT : _maxMatchCount; + const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({ - queryKey: ["search", searchQuery, matches], + queryKey: ["search", searchQuery, maxMatchCount], queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, - matches, + matches: maxMatchCount, contextLines: 3, whole: false, }, domain)), "client.search"), @@ -63,6 +73,7 @@ const SearchPageInternal = () => { enabled: searchQuery.length > 0, refetchOnWindowFocus: false, retry: false, + staleTime: Infinity, }); useEffect(() => { @@ -122,7 +133,7 @@ const SearchPageInternal = () => { }); }, [captureEvent, searchQuery, searchResponse]); - const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo } = useMemo(() => { + const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo, matchCount } = useMemo(() => { if (!searchResponse) { return { fileMatches: [], @@ -130,6 +141,7 @@ const SearchPageInternal = () => { totalMatchCount: 0, isBranchFilteringEnabled: false, repositoryInfo: {}, + matchCount: 0, }; } @@ -142,43 +154,34 @@ const SearchPageInternal = () => { acc[repo.id] = repo; return acc; }, {} as Record), + matchCount: searchResponse.stats.matchCount, } }, [searchResponse]); const isMoreResultsButtonVisible = useMemo(() => { - return totalMatchCount > matches; - }, [totalMatchCount, matches]); - - const numMatches = useMemo(() => { - // Accumualtes the number of matches across all files - return fileMatches.reduce( - (acc, file) => - acc + file.chunks.reduce( - (acc, chunk) => acc + chunk.matchRanges.length, - 0, - ), - 0, - ); - }, [fileMatches]); + return totalMatchCount > maxMatchCount; + }, [totalMatchCount, maxMatchCount]); const onLoadMoreResults = useCallback(() => { const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, searchQuery], - [SearchQueryParams.matches, `${matches * 2}`], + [SearchQueryParams.matches, `${maxMatchCount * 2}`], ) router.push(url); - }, [matches, router, searchQuery, domain]); + }, [maxMatchCount, router, searchQuery, domain]); return (
{/* TopBar */} -
- + - -
+ {(isSearchLoading) ? (
@@ -193,7 +196,7 @@ const SearchPageInternal = () => { isBranchFilteringEnabled={isBranchFilteringEnabled} repoInfo={repositoryInfo} searchDurationMs={searchDurationMs} - numMatches={numMatches} + numMatches={matchCount} /> )}
@@ -219,22 +222,24 @@ const PanelGroup = ({ searchDurationMs, numMatches, }: PanelGroupProps) => { + const [previewedFile, setPreviewedFile] = useState(undefined); + const filteredFileMatches = useFilteredMatches(fileMatches); + const filterPanelRef = useRef(null); const [selectedMatchIndex, setSelectedMatchIndex] = useState(0); - const [selectedFile, setSelectedFile] = useState(undefined); - const [filteredFileMatches, setFilteredFileMatches] = useState(fileMatches); - const codePreviewPanelRef = useRef(null); - useEffect(() => { - if (selectedFile) { - codePreviewPanelRef.current?.expand(); + const [isFilterPanelCollapsed, setIsFilterPanelCollapsed] = useLocalStorage('isFilterPanelCollapsed', false); + + useHotkeys("mod+b", () => { + if (isFilterPanelCollapsed) { + filterPanelRef.current?.expand(); } else { - codePreviewPanelRef.current?.collapse(); + filterPanelRef.current?.collapse(); } - }, [selectedFile]); - - const onFilterChanged = useCallback((matches: SearchResultFile[]) => { - setFilteredFileMatches(matches); - }, []); + }, { + enableOnFormTags: true, + enableOnContentEditable: true, + description: "Toggle filter panel", + }); return ( {/* ~~ Filter panel ~~ */} setIsFilterPanelCollapsed(true)} + onExpand={() => setIsFilterPanelCollapsed(false)} > - + {isFilterPanelCollapsed && ( +
+ + + + + + + + Open filter panel + + +
+ )} + {/* ~~ Search results ~~ */} 0 ? ( { - setSelectedFile(fileMatch); - }} - onMatchIndexChanged={(matchIndex) => { - setSelectedMatchIndex(matchIndex); + onOpenFilePreview={(fileMatch, matchIndex) => { + setSelectedMatchIndex(matchIndex ?? 0); + setPreviewedFile(fileMatch); }} isLoadMoreButtonVisible={!!isMoreResultsButtonVisible} onLoadMoreButtonClicked={onLoadMoreResults} @@ -304,25 +332,27 @@ const PanelGroup = ({
)} - - {/* ~~ Code preview ~~ */} - - setSelectedFile(undefined)} - selectedMatchIndex={selectedMatchIndex} - onSelectedMatchIndexChange={setSelectedMatchIndex} - /> - + {previewedFile && ( + <> + + {/* ~~ Code preview ~~ */} + setPreviewedFile(undefined)} + > + setPreviewedFile(undefined)} + selectedMatchIndex={selectedMatchIndex} + onSelectedMatchIndexChange={setSelectedMatchIndex} + /> + + + )} ) } diff --git a/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx b/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx index a18e7610..d6c99fc7 100644 --- a/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx @@ -65,7 +65,7 @@ export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCar Organization Name - {`Your organization's visible name within Sourceobot. For example, the name of your company or department.`} + {`Your organization's visible name within Sourcebot. For example, the name of your company or department.`}
diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx index d028ab0e..91a2fcfb 100644 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ b/packages/web/src/app/[domain]/settings/(general)/page.tsx @@ -6,6 +6,7 @@ import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard"; import { ServiceErrorException } from "@/lib/serviceError"; import { ErrorCode } from "@/lib/errorCodes"; import { headers } from "next/headers"; + interface GeneralSettingsPageProps { params: { domain: string; diff --git a/packages/web/src/app/[domain]/settings/access/page.tsx b/packages/web/src/app/[domain]/settings/access/page.tsx new file mode 100644 index 00000000..402868b3 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/access/page.tsx @@ -0,0 +1,54 @@ +import { getOrgFromDomain } from "@/data/org"; +import { OrganizationAccessSettings } from "@/app/components/organizationAccessSettings"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { getMe } from "@/actions"; +import { OrgRole } from "@sourcebot/db"; +import { redirect } from "next/navigation"; + +interface AccessPageProps { + params: { + domain: string; + } +} + +export default async function AccessPage({ params: { domain } }: AccessPageProps) { + const org = await getOrgFromDomain(domain); + if (!org) { + throw new Error("Organization not found"); + } + + const me = await getMe(); + if (isServiceError(me)) { + throw new ServiceErrorException(me); + } + + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; + if (!userRoleInOrg) { + throw new Error("User role not found"); + } + + if (userRoleInOrg !== OrgRole.OWNER) { + redirect(`/${domain}/settings`); + } + + return ( +
+
+

Access Control

+

Configure how users can access your Sourcebot deployment.{" "} + + Learn more + +

+
+ + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/analytics/page.tsx b/packages/web/src/app/[domain]/settings/analytics/page.tsx new file mode 100644 index 00000000..a542432b --- /dev/null +++ b/packages/web/src/app/[domain]/settings/analytics/page.tsx @@ -0,0 +1,19 @@ +"use client" + +import { AnalyticsContent } from "@/ee/features/analytics/analyticsContent"; +import { AnalyticsEntitlementMessage } from "@/ee/features/analytics/analyticsEntitlementMessage"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; + +export default function AnalyticsPage() { + return ; +} + +function AnalyticsPageContent() { + const hasAnalyticsEntitlement = useHasEntitlement("analytics"); + + if (!hasAnalyticsEntitlement) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx b/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx new file mode 100644 index 00000000..a32bfabb --- /dev/null +++ b/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx @@ -0,0 +1,160 @@ +"use client" + +import { ColumnDef } from "@tanstack/react-table" +import { ArrowUpDown, Key, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { deleteApiKey } from "@/actions" +import { useParams } from "next/navigation" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { useState } from "react" +import { useToast } from "@/components/hooks/use-toast" + +export type ApiKeyColumnInfo = { + name: string + createdAt: string + lastUsedAt: string | null +} + +// Component for the actions cell to properly use React hooks +function ApiKeyActions({ apiKey }: { apiKey: ApiKeyColumnInfo }) { + const params = useParams<{ domain: string }>() + const [isPending, setIsPending] = useState(false) + const { toast } = useToast() + + const handleDelete = async () => { + setIsPending(true) + try { + await deleteApiKey(apiKey.name, params.domain) + window.location.reload() + } catch (error) { + console.error("Failed to delete API key", error) + toast({ + title: "Failed to Delete API Key", + description: `There was an error deleting the API key: ${error}`, + variant: "destructive", + }) + } finally { + setIsPending(false) + } + } + + return ( +
+ + + + + + + Delete API Key + + Are you sure you want to delete the API key {apiKey.name}? This action cannot be undone. + + + + Cancel + + {isPending ? "Deleting..." : "Delete"} + + + + +
+ ) +} + +export const columns = (): ColumnDef[] => [ + { + accessorKey: "name", + header: () =>
Name
, + cell: ({ row }) => { + const name = row.original.name + return ( +
+ + {name} +
+ ) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => { + if (!row.original.createdAt) { + return
โ€”
+ } + const date = new Date(row.original.createdAt) + return ( +
+ {date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+ ) + }, + }, + { + accessorKey: "lastUsedAt", + header: ({ column }) => ( +
+ +
+ ), + cell: ({ row }) => { + if (!row.original.lastUsedAt) { + return
Never
+ } + const date = new Date(row.original.lastUsedAt) + return ( +
+ {date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +
+ ) + }, + }, + { + id: "actions", + cell: ({ row }) => + } +] \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/apiKeys/page.tsx b/packages/web/src/app/[domain]/settings/apiKeys/page.tsx new file mode 100644 index 00000000..9940beb9 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/apiKeys/page.tsx @@ -0,0 +1,269 @@ +'use client'; + +import { createApiKey, getUserApiKeys } from "@/actions"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { isServiceError } from "@/lib/utils"; +import { Copy, Check, AlertTriangle, Loader2, Plus } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDomain } from "@/hooks/useDomain"; +import { useToast } from "@/components/hooks/use-toast"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { DataTable } from "@/components/ui/data-table"; +import { columns, ApiKeyColumnInfo } from "./columns"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function ApiKeysPage() { + const domain = useDomain(); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + + const [apiKeys, setApiKeys] = useState<{ name: string; createdAt: Date; lastUsedAt: Date | null }[]>([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [isCreatingKey, setIsCreatingKey] = useState(false); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copySuccess, setCopySuccess] = useState(false); + const [error, setError] = useState(null); + + const loadApiKeys = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const keys = await getUserApiKeys(domain); + if (isServiceError(keys)) { + setError("Failed to load API keys"); + toast({ + title: "Error", + description: "Failed to load API keys", + variant: "destructive", + }); + return; + } + setApiKeys(keys); + } catch (error) { + console.error(error); + setError("Failed to load API keys"); + toast({ + title: "Error", + description: "Failed to load API keys", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [domain, toast]); + + useEffect(() => { + loadApiKeys(); + }, [loadApiKeys]); + + const handleCreateApiKey = async () => { + if (!newKeyName.trim()) { + toast({ + title: "Error", + description: "API key name cannot be empty", + variant: "destructive", + }); + return; + } + + setIsCreatingKey(true); + try { + const result = await createApiKey(newKeyName.trim(), domain); + if (isServiceError(result)) { + toast({ + title: "Error", + description: `Failed to create API key: ${result.message}`, + variant: "destructive", + }); + captureEvent('wa_api_key_creation_fail', {}); + + return; + } + + setNewlyCreatedKey(result.key); + await loadApiKeys(); + captureEvent('wa_api_key_created', {}); + } catch (error) { + console.error(error); + toast({ + title: "Error", + description: `Failed to create API key: ${error}`, + variant: "destructive", + }); + captureEvent('wa_api_key_creation_fail', {}); + } finally { + setIsCreatingKey(false); + } + }; + + const handleCopyApiKey = () => { + if (!newlyCreatedKey) return; + + navigator.clipboard.writeText(newlyCreatedKey) + .then(() => { + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + }) + .catch(() => { + toast({ + title: "Error", + description: "Failed to copy API key to clipboard", + variant: "destructive", + }); + }); + }; + + const handleCloseDialog = () => { + setIsCreateDialogOpen(false); + setNewKeyName(""); + setNewlyCreatedKey(null); + setCopySuccess(false); + }; + + const tableData = useMemo(() => { + if (isLoading) return Array(4).fill(null).map(() => ({ + name: "", + createdAt: "", + lastUsedAt: null, + })); + + if (!apiKeys) return []; + + return apiKeys.map((key): ApiKeyColumnInfo => ({ + name: key.name, + createdAt: key.createdAt.toISOString(), + lastUsedAt: key.lastUsedAt?.toISOString() ?? null, + })).sort((a, b) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + }, [apiKeys, isLoading]); + + const tableColumns = useMemo(() => { + if (isLoading) { + return columns().map((column) => { + if ('accessorKey' in column && column.accessorKey === "name") { + return { + ...column, + cell: () => ( +
+ {/* Icon skeleton */} + {/* Name skeleton */} +
+ ), + } + } + + return { + ...column, + cell: () => , + } + }) + } + + return columns(); + }, [isLoading]); + + if (error) { + return
Error loading API keys
; + } + + return ( +
+
+
+

API Keys

+

+ Create and manage API keys for programmatic access to Sourcebot. All API keys are scoped to the user who created them. +

+
+ + + + + + + + {newlyCreatedKey ? 'Your New API Key' : 'Create API Key'} + + + {newlyCreatedKey ? ( +
+
+ +

+ This is the only time you'll see this API key. Make sure to copy it now. +

+
+ +
+
+ {newlyCreatedKey} +
+ +
+
+ ) : ( +
+ setNewKeyName(e.target.value)} + placeholder="Enter a name for your API key" + className="mb-2" + /> +
+ )} + + + {newlyCreatedKey ? ( + + ) : ( + <> + + + + )} + +
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx index 72858962..ddf37adb 100644 --- a/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx +++ b/packages/web/src/app/[domain]/settings/components/sidebar-nav.tsx @@ -1,5 +1,6 @@ "use client" +import React from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" @@ -8,7 +9,7 @@ import { buttonVariants } from "@/components/ui/button" interface SidebarNavProps extends React.HTMLAttributes { items: { href: string - title: string + title: React.ReactNode }[] } diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index ce023831..cc8128a7 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,3 +1,4 @@ +import React from "react" import { Metadata } from "next" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" @@ -5,6 +6,12 @@ import { Header } from "./components/header"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; +import { isServiceError } from "@/lib/utils"; +import { getMe, getOrgAccountRequests } from "@/actions"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { getOrgFromDomain } from "@/data/org"; +import { OrgRole } from "@prisma/client"; +import { env } from "@/env.mjs"; export const metadata: Metadata = { title: "Settings", @@ -22,6 +29,30 @@ export default async function SettingsLayout({ return redirect(`/${domain}`); } + const org = await getOrgFromDomain(domain); + if (!org) { + throw new Error("Organization not found"); + } + + const me = await getMe(); + if (isServiceError(me)) { + throw new ServiceErrorException(me); + } + + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; + if (!userRoleInOrg) { + throw new Error("User role not found"); + } + + let numJoinRequests: number | undefined; + if (userRoleInOrg === OrgRole.OWNER) { + const requests = await getOrgAccountRequests(domain); + if (isServiceError(requests)) { + throw new ServiceErrorException(requests); + } + numJoinRequests = requests.length; + } + const sidebarNavItems = [ { title: "General", @@ -33,14 +64,43 @@ export default async function SettingsLayout({ href: `/${domain}/settings/billing`, } ] : []), - { - title: "Members", + ...(userRoleInOrg === OrgRole.OWNER ? [ + { + title: "Access", + href: `/${domain}/settings/access`, + } + ] : []), + ...(userRoleInOrg === OrgRole.OWNER ? [{ + title: ( +
+ Members + {numJoinRequests !== undefined && numJoinRequests > 0 && ( + + {numJoinRequests} + + )} +
+ ), href: `/${domain}/settings/members`, - }, + }] : []), { title: "Secrets", href: `/${domain}/settings/secrets`, - } + }, + { + title: "API Keys", + href: `/${domain}/settings/apiKeys`, + }, + { + title: "Analytics", + href: `/${domain}/settings/analytics`, + }, + ...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [ + { + title: "License", + href: `/${domain}/settings/license`, + } + ] : []), ] return ( diff --git a/packages/web/src/app/[domain]/settings/license/page.tsx b/packages/web/src/app/[domain]/settings/license/page.tsx new file mode 100644 index 00000000..af3e103f --- /dev/null +++ b/packages/web/src/app/[domain]/settings/license/page.tsx @@ -0,0 +1,127 @@ +import { getLicenseKey, getEntitlements, getPlan, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; +import { Button } from "@/components/ui/button"; +import { Info, Mail } from "lucide-react"; +import { getOrgMembers } from "@/actions"; +import { isServiceError } from "@/lib/utils"; +import { notFound, ServiceErrorException } from "@/lib/serviceError"; +import { env } from "@/env.mjs"; + +interface LicensePageProps { + params: { + domain: string; + } +} + +export default async function LicensePage({ params: { domain } }: LicensePageProps) { + if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined) { + notFound(); + } + + const licenseKey = getLicenseKey(); + const entitlements = getEntitlements(); + const plan = getPlan(); + + if (!licenseKey) { + return ( +
+
+

License

+

View your license details.

+
+ +
+ +

No License Found

+

+ Check out the docs for more information. +

+
+

+ Want to try out Sourcebot's enterprise features? Reach out to us and we'll get back to you within + a couple hours with a trial license. +

+
+ +
+
+ ) + } + + const members = await getOrgMembers(domain); + if (isServiceError(members)) { + throw new ServiceErrorException(members); + } + + const numMembers = members.length; + const expiryDate = new Date(licenseKey.expiryDate); + const isExpired = expiryDate < new Date(); + const seats = licenseKey.seats; + const isUnlimited = seats === SOURCEBOT_UNLIMITED_SEATS; + + return ( +
+
+
+

License

+

View your license details.

+
+ + +
+ +
+
+

License Details

+ +
+
+
License ID
+
{licenseKey.id}
+
+ +
+
Plan
+
{plan}
+
+ +
+
Entitlements
+
{entitlements?.join(", ") || "None"}
+
+ +
+
Seats
+
+ {isUnlimited ? 'Unlimited' : `${numMembers} / ${seats}`} +
+
+ +
+
Expiry Date
+
+ {expiryDate.toLocaleString("en-US", { + hour: "2-digit", + minute: "2-digit", + month: "long", + day: "numeric", + year: "numeric", + timeZoneName: "short" + })} {isExpired && '(Expired)'} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx index 1c454df6..2e0aa15e 100644 --- a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx @@ -8,7 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { useCallback, useState } from "react"; import { z } from "zod"; -import { PlusCircleIcon, Loader2 } from "lucide-react"; +import { PlusCircleIcon, Loader2, AlertCircle } from "lucide-react"; import { OrgRole } from "@prisma/client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { createInvites } from "@/actions"; @@ -30,9 +30,10 @@ export const inviteMemberFormSchema = z.object({ interface InviteMemberCardProps { currentUserRole: OrgRole; isBillingEnabled: boolean; + seatsAvailable?: boolean; } -export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMemberCardProps) => { +export const InviteMemberCard = ({ currentUserRole, isBillingEnabled, seatsAvailable = true }: InviteMemberCardProps) => { const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const domain = useDomain(); @@ -81,13 +82,30 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe }); }, [domain, form, toast, router, captureEvent]); + const isDisabled = !seatsAvailable || currentUserRole !== OrgRole.OWNER || isLoading; + return ( <> - + Invite Member Invite new members to your organization. + {!seatsAvailable && ( +
+
+ +
+

+ Maximum seats reached +

+

+ You've reached the maximum number of seats for your license. Upgrade your plan to invite additional members. +

+
+
+
+ )} setIsInviteDialogOpen(true))}> @@ -104,6 +122,7 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe {...field} className="max-w-md" placeholder="melissa@example.com" + disabled={isDisabled} /> @@ -119,6 +138,7 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe variant="outline" size="sm" onClick={addEmailField} + disabled={isDisabled} > Add more @@ -128,7 +148,7 @@ export const InviteMemberCard = ({ currentUserRole, isBillingEnabled }: InviteMe + + + )} +
+
+ )) + )} +
+
+ + {/* Approve Request Dialog */} + + + + Approve Request + + Are you sure you want to approve the request from {requestToAction?.email}? They will be added as a member to your organization. + + + + + Back + + { + onApproveRequest(requestToAction?.id ?? ""); + }} + > + Approve + + + + + + {/* Reject Request Dialog */} + + + + Reject Request + + Are you sure you want to reject the request from {requestToAction?.email}? + + + + + Back + + { + onRejectRequest(requestToAction?.id ?? ""); + }} + > + Reject + + + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index 7fb16123..a7696aa8 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -6,9 +6,14 @@ import { InviteMemberCard } from "./components/inviteMemberCard"; 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 { getOrgInvites, getMe, getOrgAccountRequests } from "@/actions"; import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { ServiceErrorException } from "@/lib/serviceError"; +import { getSeats, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; +import { RequestsList } from "./components/requestsList"; +import { OrgRole } from "@prisma/client"; +import { redirect } from "next/navigation"; + interface MembersSettingsPageProps { params: { domain: string @@ -34,6 +39,10 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa throw new Error("User role not found"); } + if (userRoleInOrg !== OrgRole.OWNER) { + redirect(`/${domain}/settings`); + } + const members = await getOrgMembers(domain); if (isServiceError(members)) { throw new ServiceErrorException(members); @@ -44,18 +53,40 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa throw new ServiceErrorException(invites); } + const requests = await getOrgAccountRequests(domain); + if (isServiceError(requests)) { + throw new ServiceErrorException(requests); + } + const currentTab = tab || "members"; + const seats = getSeats(); + const usedSeats = members.length + const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats; + return (
-
-

Members

-

Invite and manage members of your organization.

+
+
+

Members

+

Invite and manage members of your organization.

+
+ {seats && seats !== SOURCEBOT_UNLIMITED_SEATS && ( +
+
+ {usedSeats} + of + {seats} + seats used +
+
+ )}
@@ -64,26 +95,64 @@ export default async function MembersSettingsPage({ params: { domain }, searchPa className="h-auto p-0 bg-transparent" tabs={[ { label: "Team Members", value: "members" }, - { label: "Pending Invites", value: "invites" }, + ...(userRoleInOrg === OrgRole.OWNER ? [ + { + label: ( +
+ Pending Requests + {requests.length > 0 && ( + + {requests.length} + + )} +
+ ), + value: "requests" + }, + { + label: ( +
+ Pending Invites + {invites.length > 0 && ( + + {invites.length} + + )} +
+ ), + value: "invites" + }, + ] : []), ]} currentTab={currentTab} />
- - - - + {userRoleInOrg === OrgRole.OWNER && ( + <> + + + + + + + + + )}
) diff --git a/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx index e8a3d603..e28efe75 100644 --- a/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx +++ b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input"; import { LucideKeyRound, MoreVertical, Search, LucideTrash } from "lucide-react"; import { useState, useMemo, useCallback } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { cn, getDisplayTime, isServiceError } from "@/lib/utils"; +import { getDisplayTime, isServiceError } from "@/lib/utils"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; @@ -12,6 +12,7 @@ import { deleteSecret } from "@/actions"; import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; +import { CodeSnippet } from "@/app/components/codeSnippet"; interface Secret { key: string; @@ -138,7 +139,7 @@ export const SecretsList = ({ secrets }: SecretsListProps) => { Delete Secret - Are you sure you want to delete the secret {secretToDelete?.key}? Any connections that use this secret will fail to sync. + Are you sure you want to delete the secret {secretToDelete?.key}? Any connections that use this secret will fail to sync. @@ -155,14 +156,3 @@ export const SecretsList = ({ secrets }: SecretsListProps) => {
) } - -const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => { - return ( - - {children} - - ) -} \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/chat/route.ts b/packages/web/src/app/api/(server)/chat/route.ts new file mode 100644 index 00000000..05a6857d --- /dev/null +++ b/packages/web/src/app/api/(server)/chat/route.ts @@ -0,0 +1,466 @@ +import { sew, withAuth, withOrgMembership } from "@/actions"; +import { env } from "@/env.mjs"; +import { _getConfiguredLanguageModelsFull, updateChatMessages, updateChatName } from "@/features/chat/actions"; +import { createAgentStream } from "@/features/chat/agent"; +import { additionalChatRequestParamsSchema, SBChatMessage } from "@/features/chat/types"; +import { getAnswerPartFromAssistantMessage } from "@/features/chat/utils"; +import { ErrorCode } from "@/lib/errorCodes"; +import { notFound, schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { prisma } from "@/prisma"; +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic'; +import { createAzure } from '@ai-sdk/azure'; +import { createDeepSeek } from '@ai-sdk/deepseek'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createVertex } from '@ai-sdk/google-vertex'; +import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic'; +import { createMistral } from '@ai-sdk/mistral'; +import { createOpenAI, OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; +import { LanguageModelV2 as AISDKLanguageModelV2 } from "@ai-sdk/provider"; +import { createXai } from '@ai-sdk/xai'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import * as Sentry from "@sentry/nextjs"; +import { getTokenFromConfig } from "@sourcebot/crypto"; +import { OrgRole } from "@sourcebot/db"; +import { createLogger } from "@sourcebot/logger"; +import { LanguageModel } from "@sourcebot/schemas/v3/index.type"; +import { + createUIMessageStream, + createUIMessageStreamResponse, + generateText, + JSONValue, + ModelMessage, + StreamTextResult, + UIMessageStreamOptions, + UIMessageStreamWriter, +} from "ai"; +import { randomUUID } from "crypto"; +import { StatusCodes } from "http-status-codes"; +import { z } from "zod"; + +const logger = createLogger('chat-api'); + +const chatRequestSchema = z.object({ + // These paramt + messages: z.array(z.any()), + id: z.string(), + ...additionalChatRequestParamsSchema.shape, +}) + +export async function POST(req: Request) { + const domain = req.headers.get("X-Org-Domain"); + if (!domain) { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER, + message: "Missing X-Org-Domain header", + }); + } + + const requestBody = await req.json(); + const parsed = await chatRequestSchema.safeParseAsync(requestBody); + if (!parsed.success) { + return serviceErrorResponse(schemaValidationError(parsed.error)); + } + + const { messages, id, selectedRepos, languageModelId } = parsed.data; + const response = await chatHandler({ + messages, + id, + selectedRepos, + languageModelId, + }, domain); + + if (isServiceError(response)) { + return serviceErrorResponse(response); + } + + return response; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mergeStreamAsync = async (stream: StreamTextResult, writer: UIMessageStreamWriter, options: UIMessageStreamOptions = {}) => { + await new Promise((resolve) => writer.merge(stream.toUIMessageStream({ + ...options, + onFinish: async () => { + resolve(); + } + }))); +} + +interface ChatHandlerProps { + messages: SBChatMessage[]; + id: string; + selectedRepos: string[]; + languageModelId: string; +} + +const chatHandler = ({ messages, id, selectedRepos, languageModelId }: ChatHandlerProps, domain: string) => sew(async () => + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const chat = await prisma.chat.findUnique({ + where: { + orgId: org.id, + id, + }, + }); + + if (!chat) { + return notFound(); + } + + if (chat.isReadonly) { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: "Chat is readonly and cannot be edited.", + }); + } + + const latestMessage = messages[messages.length - 1]; + const sources = latestMessage.parts + .filter((part) => part.type === 'data-source') + .map((part) => part.data); + + // From the language model ID, attempt to find the + // corresponding config in `config.json`. + const languageModelConfig = + (await _getConfiguredLanguageModelsFull()) + .find((model) => model.model === languageModelId); + + if (!languageModelConfig) { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: `Language model ${languageModelId} is not configured.`, + }); + } + + const { model, providerOptions, headers } = await getAISDKLanguageModelAndOptions(languageModelConfig, org.id); + + if ( + messages.length === 1 && + messages[0].role === "user" && + messages[0].parts.length >= 1 && + messages[0].parts[0].type === 'text' + ) { + const content = messages[0].parts[0].text; + + const title = await generateChatTitle(content, model); + await updateChatName({ + chatId: id, + name: title, + }, domain); + } + + const traceId = randomUUID(); + + // Extract user messages and assistant answers. + // We will use this as the context we carry between messages. + const messageHistory = + messages.map((message): ModelMessage | undefined => { + if (message.role === 'user') { + return { + role: 'user', + content: message.parts[0].type === 'text' ? message.parts[0].text : '', + }; + } + + if (message.role === 'assistant') { + const answerPart = getAnswerPartFromAssistantMessage(message, false); + if (answerPart) { + return { + role: 'assistant', + content: [answerPart] + } + } + } + }).filter(message => message !== undefined); + + const stream = createUIMessageStream({ + execute: async ({ writer }) => { + writer.write({ + type: 'start', + }); + + const startTime = new Date(); + + const researchStream = await createAgentStream({ + model, + providerOptions, + headers, + inputMessages: messageHistory, + inputSources: sources, + selectedRepos, + onWriteSource: (source) => { + writer.write({ + type: 'data-source', + data: source, + }); + }, + traceId, + }); + + await mergeStreamAsync(researchStream, writer, { + sendReasoning: true, + sendStart: false, + sendFinish: false, + }); + + const totalUsage = await researchStream.totalUsage; + + writer.write({ + type: 'message-metadata', + messageMetadata: { + totalTokens: totalUsage.totalTokens, + totalInputTokens: totalUsage.inputTokens, + totalOutputTokens: totalUsage.outputTokens, + totalResponseTimeMs: new Date().getTime() - startTime.getTime(), + modelName: languageModelConfig.displayName ?? languageModelConfig.model, + traceId, + } + }) + + + writer.write({ + type: 'finish', + }); + }, + onError: errorHandler, + originalMessages: messages, + onFinish: async ({ messages }) => { + await updateChatMessages({ + chatId: id, + messages + }, domain); + }, + }); + + return createUIMessageStreamResponse({ + stream, + }); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + )); + +const generateChatTitle = async (message: string, model: AISDKLanguageModelV2) => { + const prompt = `Convert this question into a short topic title (max 50 characters). + +Rules: +- Do NOT include question words (what, where, how, why, when, which) +- Do NOT end with a question mark +- Capitalize the first letter of the title +- Focus on the subject/topic being discussed +- Make it sound like a file name or category + +Examples: +"Where is the authentication code?" โ†’ "Authentication Code" +"How to setup the database?" โ†’ "Database Setup" +"What are the API endpoints?" โ†’ "API Endpoints" + +User question: ${message}`; + + const result = await generateText({ + model, + prompt, + }); + + return result.text; +} + +const getAISDKLanguageModelAndOptions = async (config: LanguageModel, orgId: number): Promise<{ + model: AISDKLanguageModelV2, + providerOptions?: Record>, + headers?: Record, +}> => { + + const { provider, model: modelId } = config; + + switch (provider) { + case 'amazon-bedrock': { + const aws = createAmazonBedrock({ + baseURL: config.baseUrl, + region: config.region ?? env.AWS_REGION, + accessKeyId: config.accessKeyId + ? await getTokenFromConfig(config.accessKeyId, orgId, prisma) + : env.AWS_ACCESS_KEY_ID, + secretAccessKey: config.accessKeySecret + ? await getTokenFromConfig(config.accessKeySecret, orgId, prisma) + : env.AWS_SECRET_ACCESS_KEY, + }); + + return { + model: aws(modelId), + }; + } + case 'anthropic': { + const anthropic = createAnthropic({ + baseURL: config.baseUrl, + apiKey: config.token + ? await getTokenFromConfig(config.token, orgId, prisma) + : env.ANTHROPIC_API_KEY, + }); + + return { + model: anthropic(modelId), + providerOptions: { + anthropic: { + thinking: { + type: "enabled", + budgetTokens: env.ANTHROPIC_THINKING_BUDGET_TOKENS, + } + } satisfies AnthropicProviderOptions, + }, + headers: { + // @see: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + }, + }; + } + case 'azure': { + const azure = createAzure({ + baseURL: config.baseUrl, + apiKey: config.token ? (await getTokenFromConfig(config.token, orgId, prisma)) : env.AZURE_API_KEY, + apiVersion: config.apiVersion, + resourceName: config.resourceName ?? env.AZURE_RESOURCE_NAME, + }); + + return { + model: azure(modelId), + }; + } + case 'deepseek': { + const deepseek = createDeepSeek({ + baseURL: config.baseUrl, + apiKey: config.token ? (await getTokenFromConfig(config.token, orgId, prisma)) : env.DEEPSEEK_API_KEY, + }); + + return { + model: deepseek(modelId), + }; + } + case 'google-generative-ai': { + const google = createGoogleGenerativeAI({ + baseURL: config.baseUrl, + apiKey: config.token + ? await getTokenFromConfig(config.token, orgId, prisma) + : env.GOOGLE_GENERATIVE_AI_API_KEY, + }); + + return { + model: google(modelId), + }; + } + case 'google-vertex': { + const vertex = createVertex({ + project: config.project ?? env.GOOGLE_VERTEX_PROJECT, + location: config.region ?? env.GOOGLE_VERTEX_REGION, + ...(config.credentials ? { + googleAuthOptions: { + keyFilename: await getTokenFromConfig(config.credentials, orgId, prisma), + } + } : {}), + }); + + return { + model: vertex(modelId), + providerOptions: { + google: { + thinkingConfig: { + thinkingBudget: env.GOOGLE_VERTEX_THINKING_BUDGET_TOKENS, + includeThoughts: env.GOOGLE_VERTEX_INCLUDE_THOUGHTS === 'true', + } + } + }, + }; + } + case 'google-vertex-anthropic': { + const vertexAnthropic = createVertexAnthropic({ + project: config.project ?? env.GOOGLE_VERTEX_PROJECT, + location: config.region ?? env.GOOGLE_VERTEX_REGION, + ...(config.credentials ? { + googleAuthOptions: { + keyFilename: await getTokenFromConfig(config.credentials, orgId, prisma), + } + } : {}), + }); + + return { + model: vertexAnthropic(modelId), + }; + } + case 'mistral': { + const mistral = createMistral({ + baseURL: config.baseUrl, + apiKey: config.token + ? await getTokenFromConfig(config.token, orgId, prisma) + : env.MISTRAL_API_KEY, + }); + + return { + model: mistral(modelId), + }; + } + case 'openai': { + const openai = createOpenAI({ + baseURL: config.baseUrl, + apiKey: config.token + ? await getTokenFromConfig(config.token, orgId, prisma) + : env.OPENAI_API_KEY, + }); + + return { + model: openai(modelId), + providerOptions: { + openai: { + reasoningEffort: 'high' + } satisfies OpenAIResponsesProviderOptions, + }, + }; + } + case 'openrouter': { + const openrouter = createOpenRouter({ + baseURL: config.baseUrl, + apiKey: config.token + ? await getTokenFromConfig(config.token, orgId, prisma) + : env.OPENROUTER_API_KEY, + }); + + return { + model: openrouter(modelId), + }; + } + case 'xai': { + const xai = createXai({ + baseURL: config.baseUrl, + apiKey: config.token + ? await getTokenFromConfig(config.token, orgId, prisma) + : env.XAI_API_KEY, + }); + + return { + model: xai(modelId), + }; + } + } +} + +const errorHandler = (error: unknown) => { + logger.error(error); + Sentry.captureException(error); + + if (error == null) { + return 'unknown error'; + } + + if (typeof error === 'string') { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + return JSON.stringify(error); +} + diff --git a/packages/web/src/app/api/(server)/ee/audit/route.ts b/packages/web/src/app/api/(server)/ee/audit/route.ts new file mode 100644 index 00000000..80a05e2e --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/audit/route.ts @@ -0,0 +1,46 @@ +'use server'; + +import { NextRequest } from "next/server"; +import { fetchAuditRecords } from "@/ee/features/audit/actions"; +import { isServiceError } from "@/lib/utils"; +import { serviceErrorResponse } from "@/lib/serviceError"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; +import { env } from "@/env.mjs"; +import { getEntitlements } from "@sourcebot/shared"; + +export const GET = async (request: NextRequest) => { + const domain = request.headers.get("X-Org-Domain"); + const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined; + + if (!domain) { + return serviceErrorResponse({ + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER, + message: "Missing X-Org-Domain header", + }); + } + + if (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'false') { + return serviceErrorResponse({ + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: "Audit logging is not enabled", + }); + } + + const entitlements = getEntitlements(); + if (!entitlements.includes('audit')) { + return serviceErrorResponse({ + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.NOT_FOUND, + message: "Audit logging is not enabled for your license", + }); + } + + const result = await fetchAuditRecords(domain, apiKey); + if (isServiceError(result)) { + return serviceErrorResponse(result); + } + return Response.json(result); +}; \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/health/route.ts b/packages/web/src/app/api/(server)/health/route.ts index 9dd2a1a0..ac1a2ba1 100644 --- a/packages/web/src/app/api/(server)/health/route.ts +++ b/packages/web/src/app/api/(server)/health/route.ts @@ -1,7 +1,11 @@ 'use server'; -export const GET = async () => { - console.log('health check'); +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('health-check'); + +export async function GET() { + logger.info('health check'); return Response.json({ status: 'ok' }); } diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index 20048ffa..6673f0eb 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -9,6 +9,7 @@ import { ErrorCode } from "@/lib/errorCodes"; export const GET = async (request: NextRequest) => { const domain = request.headers.get("X-Org-Domain"); + const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined; if (!domain) { return serviceErrorResponse({ statusCode: StatusCodes.BAD_REQUEST, @@ -17,7 +18,7 @@ export const GET = async (request: NextRequest) => { }); } - const response = await listRepositories(domain); + const response = await listRepositories(domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index 2b80d3ec..145d3fa9 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -10,6 +10,7 @@ import { StatusCodes } from "http-status-codes"; export const POST = async (request: NextRequest) => { const domain = request.headers.get("X-Org-Domain"); + const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined; if (!domain) { return serviceErrorResponse({ statusCode: StatusCodes.BAD_REQUEST, @@ -26,7 +27,7 @@ export const POST = async (request: NextRequest) => { ); } - const response = await search(parsed.data, domain); + const response = await search(parsed.data, domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index c997ba72..a6364b36 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -10,6 +10,7 @@ import { StatusCodes } from "http-status-codes"; export const POST = async (request: NextRequest) => { const domain = request.headers.get("X-Org-Domain"); + const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined; if (!domain) { return serviceErrorResponse({ statusCode: StatusCodes.BAD_REQUEST, @@ -25,10 +26,8 @@ export const POST = async (request: NextRequest) => { schemaValidationError(parsed.error) ); } - - - const response = await getFileSource(parsed.data, domain); + const response = await getFileSource(parsed.data, domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 8a466b7a..b755fcfd 100644 --- a/packages/web/src/app/api/(server)/stripe/route.ts +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -5,6 +5,9 @@ import { prisma } from '@/prisma'; import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; import { stripeClient } from '@/ee/features/billing/stripe'; import { env } from '@/env.mjs'; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('stripe-webhook'); export async function POST(req: NextRequest) { const body = await req.text(); @@ -52,7 +55,7 @@ export async function POST(req: NextRequest) { stripeLastUpdatedAt: new Date() } }); - console.log(`Org ${org.id} subscription status updated to INACTIVE`); + logger.info(`Org ${org.id} subscription status updated to INACTIVE`); return new Response(JSON.stringify({ received: true }), { status: 200 @@ -80,7 +83,7 @@ export async function POST(req: NextRequest) { stripeLastUpdatedAt: new Date() } }); - console.log(`Org ${org.id} subscription status updated to ACTIVE`); + logger.info(`Org ${org.id} subscription status updated to ACTIVE`); // mark all of this org's connections for sync, since their repos may have been previously garbage collected await prisma.connection.updateMany({ @@ -96,14 +99,14 @@ export async function POST(req: NextRequest) { status: 200 }); } else { - console.log(`Received unknown event type: ${event.type}`); + logger.info(`Received unknown event type: ${event.type}`); return new Response(JSON.stringify({ received: true }), { status: 202 }); } } catch (err) { - console.error('Error processing webhook:', err); + logger.error('Error processing webhook:', err); return new Response( 'Webhook error: ' + (err as Error).message, { status: 400 } diff --git a/packages/web/src/app/api/(server)/webhook/route.ts b/packages/web/src/app/api/(server)/webhook/route.ts index 4f980d07..ee9d4dcc 100644 --- a/packages/web/src/app/api/(server)/webhook/route.ts +++ b/packages/web/src/app/api/(server)/webhook/route.ts @@ -9,6 +9,9 @@ import { processGitHubPullRequest } from "@/features/agents/review-agent/app"; import { throttling } from "@octokit/plugin-throttling"; import fs from "fs"; import { GitHubPullRequest } from "@/features/agents/review-agent/types"; +import { createLogger } from "@sourcebot/logger"; + +const logger = createLogger('github-webhook'); let githubApp: App | undefined; if (env.GITHUB_APP_ID && env.GITHUB_APP_WEBHOOK_SECRET && env.GITHUB_APP_PRIVATE_KEY_PATH) { @@ -26,7 +29,7 @@ if (env.GITHUB_APP_ID && env.GITHUB_APP_WEBHOOK_SECRET && env.GITHUB_APP_PRIVATE throttle: { onRateLimit: (retryAfter: number, options: Required, octokit: Octokit, retryCount: number) => { if (retryCount > 3) { - console.log(`Rate limit exceeded: ${retryAfter} seconds`); + logger.warn(`Rate limit exceeded: ${retryAfter} seconds`); return false; } @@ -35,7 +38,7 @@ if (env.GITHUB_APP_ID && env.GITHUB_APP_WEBHOOK_SECRET && env.GITHUB_APP_PRIVATE } }); } catch (error) { - console.error(`Error initializing GitHub app: ${error}`); + logger.error(`Error initializing GitHub app: ${error}`); } } @@ -53,21 +56,21 @@ export const POST = async (request: NextRequest) => { const githubEvent = headers['x-github-event'] || headers['X-GitHub-Event']; if (githubEvent) { - console.log('GitHub event received:', githubEvent); + logger.info('GitHub event received:', githubEvent); if (!githubApp) { - console.warn('Received GitHub webhook event but GitHub app env vars are not set'); + logger.warn('Received GitHub webhook event but GitHub app env vars are not set'); return Response.json({ status: 'ok' }); } if (isPullRequestEvent(githubEvent, body)) { if (env.REVIEW_AGENT_AUTO_REVIEW_ENABLED === "false") { - console.log('Review agent auto review (REVIEW_AGENT_AUTO_REVIEW_ENABLED) is disabled, skipping'); + logger.info('Review agent auto review (REVIEW_AGENT_AUTO_REVIEW_ENABLED) is disabled, skipping'); return Response.json({ status: 'ok' }); } if (!body.installation) { - console.error('Received github pull request event but installation is not present'); + logger.error('Received github pull request event but installation is not present'); return Response.json({ status: 'ok' }); } @@ -81,15 +84,15 @@ export const POST = async (request: NextRequest) => { if (isIssueCommentEvent(githubEvent, body)) { const comment = body.comment.body; if (!comment) { - console.warn('Received issue comment event but comment body is empty'); + logger.warn('Received issue comment event but comment body is empty'); return Response.json({ status: 'ok' }); } if (comment === `/${env.REVIEW_AGENT_REVIEW_COMMAND}`) { - console.log('Review agent review command received, processing'); + logger.info('Review agent review command received, processing'); if (!body.installation) { - console.error('Received github issue comment event but installation is not present'); + logger.error('Received github issue comment event but installation is not present'); return Response.json({ status: 'ok' }); } diff --git a/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts b/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts new file mode 100644 index 00000000..6d967adf --- /dev/null +++ b/packages/web/src/app/api/[domain]/repos/[repoId]/image/route.ts @@ -0,0 +1,27 @@ +import { getRepoImage } from "@/actions"; +import { isServiceError } from "@/lib/utils"; +import { NextRequest } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: { domain: string; repoId: string } } +) { + const { domain, repoId } = params; + const repoIdNum = parseInt(repoId); + + if (isNaN(repoIdNum)) { + return new Response("Invalid repo ID", { status: 400 }); + } + + const result = await getRepoImage(repoIdNum, domain); + if (isServiceError(result)) { + return new Response(result.message, { status: result.statusCode }); + } + + return new Response(result, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=3600', + }, + }); +} \ No newline at end of file diff --git a/packages/web/src/app/codemirror-styles.css b/packages/web/src/app/codemirror-styles.css new file mode 100644 index 00000000..f73b50e3 --- /dev/null +++ b/packages/web/src/app/codemirror-styles.css @@ -0,0 +1,52 @@ +/* Generic range border radius - applies 2px border radius along the perimeter */ +.cm-range-border-radius { + border-radius: 2px; +} + +/* First line in a range: rounded top corners only */ +.cm-range-border-radius:has(+ .cm-range-border-radius):not(.cm-range-border-radius + .cm-range-border-radius) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +/* Middle lines: no rounded corners */ +.cm-range-border-radius+.cm-range-border-radius:has(+ .cm-range-border-radius) { + border-radius: 0; +} + +/* Last line in a range: rounded bottom corners only */ +.cm-range-border-radius+.cm-range-border-radius:not(:has(+ .cm-range-border-radius)) { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +/* Generic range border shadow - applies 1px box shadow along the perimeter */ +.cm-range-border-shadow { + /* Default to transparent if no color is set */ + --cm-range-border-shadow-color: transparent; +} + +/* Single line (not adjacent to other range border shadow elements) */ +.cm-range-border-shadow:not(.cm-range-border-shadow + .cm-range-border-shadow):not(:has(+ .cm-range-border-shadow)) { + box-shadow: inset 0 0 0 1px var(--cm-range-border-shadow-color); +} + +/* First line in a range: top and sides only */ +.cm-range-border-shadow:has(+ .cm-range-border-shadow):not(.cm-range-border-shadow + .cm-range-border-shadow) { + box-shadow: inset 1px 0 0 0 var(--cm-range-border-shadow-color), + inset -1px 0 0 0 var(--cm-range-border-shadow-color), + inset 0 1px 0 0 var(--cm-range-border-shadow-color); +} + +/* Middle lines: sides only */ +.cm-range-border-shadow+.cm-range-border-shadow:has(+ .cm-range-border-shadow) { + box-shadow: inset 1px 0 0 0 var(--cm-range-border-shadow-color), + inset -1px 0 0 0 var(--cm-range-border-shadow-color); +} + +/* Last line in a range: bottom and sides only */ +.cm-range-border-shadow+.cm-range-border-shadow:not(:has(+ .cm-range-border-shadow)) { + box-shadow: inset 1px 0 0 0 var(--cm-range-border-shadow-color), + inset -1px 0 0 0 var(--cm-range-border-shadow-color), + inset 0 -1px 0 0 var(--cm-range-border-shadow-color); +} \ No newline at end of file diff --git a/packages/web/src/app/components/anonymousAccessToggle.tsx b/packages/web/src/app/components/anonymousAccessToggle.tsx new file mode 100644 index 00000000..4d079288 --- /dev/null +++ b/packages/web/src/app/components/anonymousAccessToggle.tsx @@ -0,0 +1,129 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { setAnonymousAccessStatus } from "@/actions" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" + +interface AnonymousAccessToggleProps { + hasAnonymousAccessEntitlement: boolean; + anonymousAccessEnabled: boolean + forceEnableAnonymousAccess: boolean + onToggleChange?: (checked: boolean) => void +} + +export function AnonymousAccessToggle({ hasAnonymousAccessEntitlement, anonymousAccessEnabled, forceEnableAnonymousAccess, onToggleChange }: AnonymousAccessToggleProps) { + const [enabled, setEnabled] = useState(anonymousAccessEnabled) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN, checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message || "Failed to update anonymous access setting", + variant: "destructive", + }) + return + } + + setEnabled(checked) + onToggleChange?.(checked) + } catch (error) { + console.error("Error updating anonymous access setting:", error) + toast({ + title: "Error", + description: "Failed to update anonymous access setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + const isDisabled = isLoading || !hasAnonymousAccessEntitlement || forceEnableAnonymousAccess; + const showPlanMessage = !hasAnonymousAccessEntitlement; + const showForceEnableMessage = !showPlanMessage && forceEnableAnonymousAccess; + + return ( +
+
+
+

+ Enable anonymous access +

+
+

+ When enabled, users can access your deployment without logging in. +

+ {showPlanMessage && ( +
+

+ + + + + Your current plan doesn't allow for anonymous access. Please{" "} + + reach out + + {" "}for assistance. + +

+
+ )} + {showForceEnableMessage && ( +
+

+ + + + + The forceEnableAnonymousAccess is set, so this cannot be changed from the UI. + +

+
+ )} +
+
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/authMethodSelector.tsx b/packages/web/src/app/components/authMethodSelector.tsx new file mode 100644 index 00000000..84d1228e --- /dev/null +++ b/packages/web/src/app/components/authMethodSelector.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { signIn } from "next-auth/react"; +import { useCallback } from "react"; +import { getAuthProviderInfo } from "@/lib/utils"; +import { MagicLinkForm } from "@/app/login/components/magicLinkForm"; +import { CredentialsForm } from "@/app/login/components/credentialsForm"; +import { DividerSet } from "@/app/components/dividerSet"; +import { ProviderButton } from "@/app/components/providerButton"; +import { AuthSecurityNotice } from "@/app/components/authSecurityNotice"; +import type { AuthProvider } from "@/lib/authProviders"; + +interface AuthMethodSelectorProps { + providers: AuthProvider[]; + callbackUrl?: string; + context: "login" | "signup"; + onProviderClick?: (providerId: string) => void; + securityNoticeClosable?: boolean; +} + +export const AuthMethodSelector = ({ + providers, + callbackUrl, + context, + onProviderClick, + securityNoticeClosable = false +}: AuthMethodSelectorProps) => { + const onSignInWithOauth = useCallback((provider: string) => { + // Call the optional analytics callback first + onProviderClick?.(provider); + + signIn(provider, { + redirectTo: callbackUrl ?? "/" + }); + }, [callbackUrl, onProviderClick]); + + // Separate OAuth providers from special auth methods + const oauthProviders = providers.filter(p => + !["credentials", "nodemailer"].includes(p.id) + ); + const hasCredentials = providers.some(p => p.id === "credentials"); + const hasMagicLink = providers.some(p => p.id === "nodemailer"); + + return ( + <> + + 0 ? [ +
+ {oauthProviders.map((provider) => { + const providerInfo = getAuthProviderInfo(provider.id); + return ( + { + onSignInWithOauth(provider.id); + }} + context={context} + /> + ); + })} +
+ ] : []), + ...(hasMagicLink ? [ + + ] : []), + ...(hasCredentials ? [ + + ] : []) + ]} + /> + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/components/authSecurityNotice.tsx b/packages/web/src/app/components/authSecurityNotice.tsx new file mode 100644 index 00000000..e903383f --- /dev/null +++ b/packages/web/src/app/components/authSecurityNotice.tsx @@ -0,0 +1,98 @@ +'use client'; + +import React, { useState, useEffect } from "react"; +import { env } from "@/env.mjs"; + +interface AuthSecurityNoticeProps { + closable?: boolean; +} + +const AUTH_SECURITY_NOTICE_COOKIE = "auth-security-notice-dismissed"; + +const getSecurityNoticeDismissed = (): boolean => { + if (typeof document === "undefined") return false; + const cookies = document.cookie.split(';').map(cookie => cookie.trim()); + const targetCookie = cookies.find(cookie => cookie.startsWith(`${AUTH_SECURITY_NOTICE_COOKIE}=`)); + + if (!targetCookie) return false; + + try { + const cookieValue = targetCookie.substring(`${AUTH_SECURITY_NOTICE_COOKIE}=`.length); + return JSON.parse(decodeURIComponent(cookieValue)); + } catch (error) { + console.warn('Failed to parse security notice cookie:', error); + return false; + } +}; + +const setSecurityNoticeDismissed = (dismissed: boolean) => { + if (typeof document === "undefined") return; + try { + const expires = new Date(); + expires.setFullYear(expires.getFullYear() + 1); + const cookieValue = encodeURIComponent(JSON.stringify(dismissed)); + document.cookie = `${AUTH_SECURITY_NOTICE_COOKIE}=${cookieValue}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`; + } catch (error) { + console.warn('Failed to set security notice cookie:', error); + } +}; + +export const AuthSecurityNotice = ({ closable = false }: AuthSecurityNoticeProps) => { + const [isDismissed, setIsDismissed] = useState(false); + const [hasMounted, setHasMounted] = useState(false); + + // Only check cookie after component mounts to avoid hydration error + useEffect(() => { + setHasMounted(true); + if (closable) { + setIsDismissed(getSecurityNoticeDismissed()); + } + }, [closable]); + + const handleDismiss = () => { + setIsDismissed(true); + setSecurityNoticeDismissed(true); + }; + + // Don't render if dismissed when closable, or if closable but not yet mounted + if (closable && (!hasMounted || isDismissed)) { + return null; + } + + // Only render for self-hosted deployments + if (env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined) { + return null; + } + + return ( +
+ {closable && ( + + )} +

+ + + + + Security Notice: Authentication data is managed by your deployment and is encrypted at rest. Zero data leaves your deployment.{' '} + + Learn more + + +

+
+ ); +}; \ No newline at end of file diff --git a/packages/web/src/app/components/codeSnippet.tsx b/packages/web/src/app/components/codeSnippet.tsx new file mode 100644 index 00000000..93ca4de3 --- /dev/null +++ b/packages/web/src/app/components/codeSnippet.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils" + +export const CodeSnippet = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/dividerSet.tsx b/packages/web/src/app/components/dividerSet.tsx new file mode 100644 index 00000000..1312cdde --- /dev/null +++ b/packages/web/src/app/components/dividerSet.tsx @@ -0,0 +1,13 @@ +import { Fragment } from "react"; +import { TextSeparator } from "./textSeparator"; + +export const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => { + return elements.map((child, index) => { + return ( + + {child} + {index < elements.length - 1 && } + + ); + }); +}; \ No newline at end of file diff --git a/packages/web/src/app/components/inviteLinkToggle.tsx b/packages/web/src/app/components/inviteLinkToggle.tsx new file mode 100644 index 00000000..feaef814 --- /dev/null +++ b/packages/web/src/app/components/inviteLinkToggle.tsx @@ -0,0 +1,130 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Switch } from "@/components/ui/switch" +import { Copy, Check } from "lucide-react" +import { useToast } from "@/components/hooks/use-toast" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { setInviteLinkEnabled } from "@/actions" +import { isServiceError } from "@/lib/utils" + +interface InviteLinkToggleProps { + inviteLinkEnabled: boolean + inviteLink: string | null +} + +export function InviteLinkToggle({ inviteLinkEnabled, inviteLink }: InviteLinkToggleProps) { + const [enabled, setEnabled] = useState(inviteLinkEnabled) + const [isLoading, setIsLoading] = useState(false) + const [copied, setCopied] = useState(false) + const { toast } = useToast() + + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setInviteLinkEnabled(SINGLE_TENANT_ORG_DOMAIN, checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: "Failed to update invite link setting", + variant: "destructive", + }) + return + } + + setEnabled(checked) + + } catch (error) { + console.error("Error updating invite link setting:", error) + toast({ + title: "Error", + description: "Failed to update invite link setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + const handleCopy = async () => { + if (!inviteLink) return + + try { + await navigator.clipboard.writeText(inviteLink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error("Failed to copy text: ", err) + toast({ + title: "Error", + description: "Failed to copy invite link to clipboard", + variant: "destructive", + }) + } + } + + return ( +
+
+
+

+ Enable invite link +

+
+

+ When enabled, team members can use the invite link to join your organization without requiring approval. +

+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ +

+ You can find this link again in the Settings โ†’ Members page. +

+
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/joinOrganizationButton.tsx b/packages/web/src/app/components/joinOrganizationButton.tsx new file mode 100644 index 00000000..eba3b8e8 --- /dev/null +++ b/packages/web/src/app/components/joinOrganizationButton.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/components/hooks/use-toast"; +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { joinOrganization } from "../invite/actions"; +import { isServiceError } from "@/lib/utils"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; + +export function JoinOrganizationButton({ inviteLinkId }: { inviteLinkId?: string }) { + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const handleJoinOrganization = async () => { + setIsLoading(true); + + try { + const result = await joinOrganization(SINGLE_TENANT_ORG_ID, inviteLinkId); + + if (isServiceError(result)) { + toast({ + title: "Failed to join organization", + description: result.message, + variant: "destructive", + }); + return; + } + + router.refresh(); + } catch (error) { + console.error("Error joining organization:", error); + toast({ + title: "Error", + description: "An unexpected error occurred. Please try again.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/packages/web/src/app/components/joinOrganizationCard.tsx b/packages/web/src/app/components/joinOrganizationCard.tsx new file mode 100644 index 00000000..bb7b1d39 --- /dev/null +++ b/packages/web/src/app/components/joinOrganizationCard.tsx @@ -0,0 +1,23 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { JoinOrganizationButton } from "./joinOrganizationButton"; + +export function JoinOrganizationCard({ inviteLinkId }: { inviteLinkId?: string }) { + return ( +
+ + + + + +
+

+ Welcome to Sourcebot! Click the button below to join this organization. +

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/components/keyboardShortcutHint.tsx b/packages/web/src/app/components/keyboardShortcutHint.tsx index f93209f1..0bbff3c0 100644 --- a/packages/web/src/app/components/keyboardShortcutHint.tsx +++ b/packages/web/src/app/components/keyboardShortcutHint.tsx @@ -8,7 +8,13 @@ interface KeyboardShortcutHintProps { export function KeyboardShortcutHint({ shortcut, label }: KeyboardShortcutHintProps) { return (
- + {shortcut}
diff --git a/packages/web/src/app/components/memberApprovalRequiredToggle.tsx b/packages/web/src/app/components/memberApprovalRequiredToggle.tsx new file mode 100644 index 00000000..9d3cf1bc --- /dev/null +++ b/packages/web/src/app/components/memberApprovalRequiredToggle.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { setMemberApprovalRequired } from "@/actions" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" + +interface MemberApprovalRequiredToggleProps { + memberApprovalRequired: boolean + onToggleChange?: (checked: boolean) => void +} + +export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleChange }: MemberApprovalRequiredToggleProps) { + const [enabled, setEnabled] = useState(memberApprovalRequired) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setMemberApprovalRequired(SINGLE_TENANT_ORG_DOMAIN, checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: "Failed to update member approval setting", + variant: "destructive", + }) + return + } + + setEnabled(checked) + onToggleChange?.(checked) + } catch (error) { + console.error("Error updating member approval setting:", error) + toast({ + title: "Error", + description: "Failed to update member approval setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+

+ Require approval for new members +

+
+

+ When enabled, new users will need approval from an organization owner before they can access your deployment. +

+
+
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/organizationAccessSettings.tsx b/packages/web/src/app/components/organizationAccessSettings.tsx new file mode 100644 index 00000000..ec618e99 --- /dev/null +++ b/packages/web/src/app/components/organizationAccessSettings.tsx @@ -0,0 +1,43 @@ +import { createInviteLink, getBaseUrl } from "@/lib/utils" +import { AnonymousAccessToggle } from "./anonymousAccessToggle" +import { OrganizationAccessSettingsWrapper } from "./organizationAccessSettingsWrapper" +import { getOrgFromDomain } from "@/data/org" +import { getOrgMetadata } from "@/lib/utils" +import { headers } from "next/headers" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { hasEntitlement } from "@sourcebot/shared" +import { env } from "@/env.mjs" + +export async function OrganizationAccessSettings() { + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (!org) { + return
Error loading organization
+ } + + const metadata = getOrgMetadata(org); + const anonymousAccessEnabled = metadata?.anonymousAccessEnabled ?? false; + + const headersList = headers(); + const baseUrl = getBaseUrl(headersList); + const inviteLink = createInviteLink(baseUrl, org.inviteLinkId) + + const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); + + const forceEnableAnonymousAccess = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true'; + + return ( +
+ + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/organizationAccessSettingsWrapper.tsx b/packages/web/src/app/components/organizationAccessSettingsWrapper.tsx new file mode 100644 index 00000000..19fa0a09 --- /dev/null +++ b/packages/web/src/app/components/organizationAccessSettingsWrapper.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useState } from "react" +import { MemberApprovalRequiredToggle } from "./memberApprovalRequiredToggle" +import { InviteLinkToggle } from "./inviteLinkToggle" + +interface OrganizationAccessSettingsWrapperProps { + memberApprovalRequired: boolean + inviteLinkEnabled: boolean + inviteLink: string | null +} + +export function OrganizationAccessSettingsWrapper({ + memberApprovalRequired, + inviteLinkEnabled, + inviteLink +}: OrganizationAccessSettingsWrapperProps) { + const [showInviteLink, setShowInviteLink] = useState(memberApprovalRequired) + + const handleMemberApprovalToggle = (checked: boolean) => { + setShowInviteLink(checked) + } + + return ( + <> +
+ +
+ +
+ +
+ + ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/providerButton.tsx b/packages/web/src/app/components/providerButton.tsx new file mode 100644 index 00000000..e67789d4 --- /dev/null +++ b/packages/web/src/app/components/providerButton.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { LoadingButton } from "@/components/ui/loading-button"; + +interface ProviderButtonProps { + name: string; + logo: { src: string, className?: string } | null; + onClick: () => void | Promise; + className?: string; + context: "login" | "signup"; +} + +export const ProviderButton = ({ + name, + logo, + onClick, + className, + context, +}: ProviderButtonProps) => { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = async () => { + setIsLoading(true); + try { + await onClick(); + } finally { + setIsLoading(false); + } + }; + + return ( + + {logo && } + {context === "login" ? `Sign in with ${name}` : `Sign up with ${name}`} + + ); +}; \ No newline at end of file diff --git a/packages/web/src/app/components/syntaxReferenceGuide.tsx b/packages/web/src/app/components/syntaxReferenceGuide.tsx deleted file mode 100644 index 1664e701..00000000 --- a/packages/web/src/app/components/syntaxReferenceGuide.tsx +++ /dev/null @@ -1,245 +0,0 @@ -'use client'; - -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Separator } from "@/components/ui/separator"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import clsx from "clsx"; -import Link from "next/link"; -import { useCallback, useRef } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { useSyntaxGuide } from "../[domain]/components/syntaxGuideProvider"; - -const LINGUIST_LINK = "https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml"; -const CTAGS_LINK = "https://ctags.io/"; - -export const SyntaxReferenceGuide = () => { - const { isOpen, onOpenChanged } = useSyntaxGuide(); - const previousFocusedElement = useRef(null); - - const openDialog = useCallback(() => { - previousFocusedElement.current = document.activeElement as HTMLElement; - onOpenChanged(true); - }, [onOpenChanged]); - - const closeDialog = useCallback(() => { - onOpenChanged(false); - - // @note: Without requestAnimationFrame, focus was not being returned - // to codemirror elements for some reason. - requestAnimationFrame(() => { - previousFocusedElement.current?.focus(); - }); - }, [onOpenChanged]); - - const handleOpenChange = useCallback((isOpen: boolean) => { - if (isOpen) { - openDialog(); - } else { - closeDialog(); - } - }, [closeDialog, openDialog]); - - useHotkeys("mod+/", (event) => { - event.preventDefault(); - handleOpenChange(!isOpen); - }, { - enableOnFormTags: true, - enableOnContentEditable: true, - description: "Open Syntax Reference Guide", - }); - - return ( - - - - Syntax Reference Guide - - Queries consist of space-seperated 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 - - - file:{`"my file"`} - - - -file:test\.ts$ - -
-
-
- - repo: - Filter results from repos that match the regex. By default all repos are searched. - -
- - repo:linux - - - -repo:^web/.* - -
-
-
- - rev: - Filter results from a specific branch or tag. By default only the default branch is searched. - -
- - rev:beta - -
-
-
- - lang: - Filter results by language (as defined by linguist). By default all languages are searched. - -
- - lang:TypeScript - - - -lang:YAML - -
-
-
- - sym: - Match symbol definitions created by universal ctags at index time. - -
- - sym:\bmain\b - -
-
-
-
-
- - - ) -} - -const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => { - return ( - - {children} - - ) -} - -const Highlight = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ) -} diff --git a/packages/web/src/app/components/vscodeFileIcon.tsx b/packages/web/src/app/components/vscodeFileIcon.tsx new file mode 100644 index 00000000..9f9e4d01 --- /dev/null +++ b/packages/web/src/app/components/vscodeFileIcon.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { cn } from "@/lib/utils"; +import { useMemo } from "react"; +import { getIconForFile } from "vscode-icons-js"; +import { Icon } from "@iconify/react"; + +interface VscodeFileIconProps { + fileName: string; + className?: string; +} + +export const VscodeFileIcon = ({ fileName, className }: VscodeFileIconProps) => { + const iconName = useMemo(() => { + const icon = getIconForFile(fileName); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + + return "vscode-icons:file-type-unknown"; + }, [fileName]); + + return ; +} diff --git a/packages/web/src/app/components/vscodeFolderIcon.tsx b/packages/web/src/app/components/vscodeFolderIcon.tsx new file mode 100644 index 00000000..98b1f447 --- /dev/null +++ b/packages/web/src/app/components/vscodeFolderIcon.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { cn } from "@/lib/utils"; +import { useMemo } from "react"; +import { getIconForFolder } from "vscode-icons-js"; +import { Icon } from "@iconify/react"; + +interface VscodeFolderIconProps { + folderName: string; + className?: string; +} + +export const VscodeFolderIcon = ({ folderName, className }: VscodeFolderIconProps) => { + const iconName = useMemo(() => { + const icon = getIconForFolder(folderName); + if (icon) { + const iconName = `vscode-icons:${icon.substring(0, icon.indexOf('.')).replaceAll('_', '-')}`; + return iconName; + } + + return "vscode-icons:folder"; + }, [folderName]); + + return ; +} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index feabe357..4c056c51 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -2,80 +2,185 @@ @tailwind components; @tailwind utilities; +@import "./codemirror-styles.css"; + @layer base { :root { - --background: 0 0% 100%; - --background-secondary: 0, 0%, 98%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; + --background: hsl(0 0% 100%); + --background-secondary: hsl(0, 0%, 98%); + --foreground: hsl(37, 84%, 5%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(222.2 84% 4.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(222.2 84% 4.9%); + --primary: hsl(222.2 47.4% 11.2%); + --primary-foreground: hsl(210 40% 98%); + --secondary: hsl(210 40% 96.1%); + --secondary-foreground: hsl(222.2 47.4% 11.2%); + --muted: hsl(210 40% 96.1%); + --muted-foreground: hsl(215.4 16.3% 46.9%); + --muted-accent: hsl(210, 12%, 87%); + --accent: hsl(210 40% 96.1%); + --accent-foreground: hsl(222.2 47.4% 11.2%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(214.3 31.8% 91.4%); + --input: hsl(214.3 31.8% 91.4%); + --ring: hsl(222.2 84% 4.9%); --radius: 0.5rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --highlight: 224, 76%, 48%; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + --highlight: hsl(224, 76%, 48%); + --sidebar-background: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + --link: hsl(217, 91%, 60%); + + --editor-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + --editor-font-size: 13px; + + --editor-background: var(--background); + --editor-foreground: var(--foreground); + --editor-caret: #3b4252; + --editor-selection: #eceff4; + --editor-selection-match: #e5e9f0; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #2e3440; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: #02255f11; + --editor-match-highlight: hsl(180, 70%, 40%); + + --editor-tag-keyword: #708; + --editor-tag-name: #256; + --editor-tag-function: #00f; + --editor-tag-label: #219; + --editor-tag-constant: #219; + --editor-tag-definition: #00c; + --editor-tag-brace: #219; + --editor-tag-type: #085; + --editor-tag-operator: #708; + --editor-tag-tag: #167; + --editor-tag-bracket-square: #219; + --editor-tag-bracket-angle: #219; + --editor-tag-attribute: #00c; + --editor-tag-string: #a11; + --editor-tag-link: inherit; + --editor-tag-meta: #404740; + --editor-tag-comment: #940; + --editor-tag-emphasis: inherit; + --editor-tag-heading: inherit; + --editor-tag-atom: #219; + --editor-tag-processing: #164; + --editor-tag-separator: #219; + --editor-tag-invalid: #f00; + --editor-tag-quote: #a11; + --editor-tag-annotation-special: #f00; + --editor-tag-number: #219; + --editor-tag-regexp: #e40; + --editor-tag-variable-local: #30a; + + --chat-reference: #02255f11; + --chat-reference-hover: #02225f22; + --chat-reference-selected: #3b83f640; + --chat-reference-selected-border: #e052b8; + + --warning: #ca8a04; } .dark { - --background: 222.2 84% 4.9%; - --background-secondary: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 212.7 26.8% 83.9%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --highlight: 217, 91%, 60%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --background: hsl(222.2 84% 4.9%); + --background-secondary: hsl(222.2 84% 4.9%); + --foreground: hsl(210 40% 98%); + --card: hsl(222.2 84% 4.9%); + --card-foreground: hsl(210 40% 98%); + --popover: hsl(222.2 84% 4.9%); + --popover-foreground: hsl(210 40% 98%); + --primary: hsl(210 40% 98%); + --primary-foreground: hsl(222.2 47.4% 11.2%); + --secondary: hsl(217.2 32.6% 17.5%); + --secondary-foreground: hsl(210 40% 98%); + --muted: hsl(217.2 32.6% 17.5%); + --muted-foreground: hsl(215 20.2% 65.1%); + --muted-accent: hsl(218, 13%, 29%); + --accent: hsl(217.2 32.6% 17.5%); + --accent-foreground: hsl(210 40% 98%); + --destructive: hsl(0, 78%, 57%); + --destructive-foreground: hsl(210 40% 98%); + --border: hsl(217.2 32.6% 17.5%); + --input: hsl(217.2 32.6% 17.5%); + --ring: hsl(212.7 26.8% 83.9%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --highlight: hsl(217 91% 60%); + --sidebar-background: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); + --link: hsl(217, 91%, 60%); + + --editor-background: var(--background); + --editor-foreground: #abb2bf; + --editor-caret: #528bff; + --editor-selection: #3E4451; + --editor-selection-match: #aafe661a; + --editor-gutter-background: var(--background); + --editor-gutter-foreground: #7d8799; + --editor-gutter-border: none; + --editor-gutter-active-foreground: #abb2bf; + --editor-line-highlight: hsl(219, 14%, 20%); + --editor-match-highlight: hsl(180, 70%, 30%); + + --editor-tag-keyword: #c678dd; + --editor-tag-name: #e06c75; + --editor-tag-function: #61afef; + --editor-tag-label: #61afef; + --editor-tag-constant: #d19a66; + --editor-tag-definition: #abb2bf; + --editor-tag-brace: #56b6c2; + --editor-tag-type: #e5c07b; + --editor-tag-operator: #56b6c2; + --editor-tag-tag: #e06c75; + --editor-tag-bracket-square: #56b6c2; + --editor-tag-bracket-angle: #56b6c2; + --editor-tag-attribute: #e5c07b; + --editor-tag-string: #98c379; + --editor-tag-link: #7d8799; + --editor-tag-meta: #7d8799; + --editor-tag-comment: #7d8799; + --editor-tag-emphasis: #e06c75; + --editor-tag-heading: #e06c75; + --editor-tag-atom: #d19a66; + --editor-tag-processing: #98c379; + --editor-tag-separator: #abb2bf; + --editor-tag-invalid: #ffffff; + --editor-tag-quote: #7d8799; + --editor-tag-annotation-special: #e5c07b; + --editor-tag-number: #e5c07b; + --editor-tag-regexp: #56b6c2; + --editor-tag-variable-local: #61afef; + + --chat-reference: #2c313aad; + --chat-reference-hover: #374151; + --chat-reference-selected: #1e3b8a87; + --chat-reference-selected-border: #60a5fa; + + --warning: #fde047; } } @@ -83,6 +188,7 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } @@ -98,13 +204,51 @@ text-align: left; } -.cm-editor .cm-searchMatch { - border: dotted; - background: transparent; +.searchMatch { + background: color-mix(in srgb, var(--editor-match-highlight) 25%, transparent); + border: 1px dashed var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03); } -.cm-editor .cm-searchMatch-selected { - border: solid; +.searchMatch-selected { + background: color-mix(in srgb, var(--editor-match-highlight) 60%, transparent); + border: 1.5px solid var(--editor-match-highlight); + border-radius: 2px; + box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.06); +} + +.lineHighlight { + background: var(--editor-line-highlight); + border-radius: 2px; +} + +/* Chat-specific styling classes */ +.chat-lineHighlight { + background: var(--chat-reference); + cursor: pointer; +} + +.chat-lineHighlight-hover { + background: var(--chat-reference-hover); + cursor: pointer; +} + +.chat-lineHighlight-selected { + background: var(--chat-reference-selected); + cursor: pointer; + --cm-range-border-shadow-color: var(--chat-reference-selected-border); +} + +/* Reference states for markdown reference spans */ +.chat-reference--selected { + background-color: var(--chat-reference-selected) !important; + border-color: var(--chat-reference-selected-border) !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.chat-reference--hover { + background-color: var(--chat-reference-hover) !important; } .cm-editor.cm-focused { @@ -119,12 +263,11 @@ text-overflow: ellipsis; } - - @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; } + body { @apply bg-background text-foreground; } @@ -136,6 +279,38 @@ } .no-scrollbar { - -ms-overflow-style: none; /* IE dan Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; + /* IE dan Edge */ + scrollbar-width: none; + /* Firefox */ +} + +.cm-underline-hover { + text-decoration: none; + transition: text-decoration 0.1s; +} + +.cm-underline-hover:hover { + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + /* Optionally, customize color or thickness: */ + /* text-decoration-color: #0070f3; */ + /* text-decoration-thickness: 2px; */ +} + +.cm-editor .cm-selectionBackground, +.cm-editor .cm-line::selection, +.cm-editor .cm-selectionLayer .cm-selectionBackground, +.cm-content ::selection { + background: var(--editor-selection-highlight, #ffe066) !important; + color: var(--editor-selection-highlight-foreground, #222) !important; +} + +.dark .cm-editor .cm-selectionBackground, +.dark .cm-editor .cm-line::selection, +.dark .cm-editor .cm-selectionLayer .cm-selectionBackground, +.dark .cm-content ::selection { + background: var(--editor-selection-highlight, #2563eb) !important; + color: var(--editor-selection-highlight-foreground, #fff) !important; } \ No newline at end of file diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts new file mode 100644 index 00000000..99bb0256 --- /dev/null +++ b/packages/web/src/app/invite/actions.ts @@ -0,0 +1,52 @@ +"use server"; + +import { withAuth } from "@/actions"; +import { isServiceError } from "@/lib/utils"; +import { orgNotFound, ServiceError } from "@/lib/serviceError"; +import { sew } from "@/actions"; +import { addUserToOrganization } from "@/lib/authUtils"; +import { prisma } from "@/prisma"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; + +export const joinOrganization = (orgId: number, inviteLinkId?: string) => sew(async () => + withAuth(async (userId) => { + const org = await prisma.org.findUnique({ + where: { + id: orgId, + }, + }); + + if (!org) { + return orgNotFound(); + } + + // If member approval is required we must be using a valid invite link + if (org.memberApprovalRequired) { + if (!org.inviteLinkEnabled) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVITE_LINK_NOT_ENABLED, + message: "Invite link is not enabled.", + } satisfies ServiceError; + } + + if (org.inviteLinkId !== inviteLinkId) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_INVITE_LINK, + message: "Invalid invite link.", + } satisfies ServiceError; + } + } + + const addUserToOrgRes = await addUserToOrganization(userId, org.id); + if (isServiceError(addUserToOrgRes)) { + return addUserToOrgRes; + } + + return { + success: true, + } + }) +) \ No newline at end of file diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx new file mode 100644 index 00000000..92fa01cf --- /dev/null +++ b/packages/web/src/app/invite/page.tsx @@ -0,0 +1,86 @@ +import { auth } from "@/auth"; +import { prisma } from "@/prisma"; +import { getOrgFromDomain } from "@/data/org"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { notFound, redirect } from "next/navigation"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { SourcebotLogo } from "@/app/components/sourcebotLogo"; +import { AuthMethodSelector } from "@/app/components/authMethodSelector"; +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; +import { getAuthProviders } from "@/lib/authProviders"; +import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; + +interface InvitePageProps { + searchParams: { + id?: string; + }; +} + +export default async function InvitePage({ searchParams }: InvitePageProps) { + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (!org || !org.isOnboarded) { + return redirect("/onboard"); + } + + const inviteLinkId = searchParams.id; + if (!org.inviteLinkEnabled || !inviteLinkId || org.inviteLinkId !== inviteLinkId) { + return notFound(); + } + + const session = await auth(); + if (!session) { + const providers = getAuthProviders(); + return ; + } + + const membership = await prisma.userToOrg.findUnique({ + where: { + orgId_userId: { + orgId: org.id, + userId: session.user.id + } + } + }); + + // If already a member, redirect to the organization + if (membership) { + redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`); + } + + // User is logged in but not a member, show join invitation + return ( +
+ + +
+ ); +} + +function WelcomeCard({ inviteLinkId, providers }: { inviteLinkId: string; providers: import("@/lib/authProviders").AuthProvider[] }) { + return ( +
+ + + + + Welcome to Sourcebot + + + +
+

+ You've been invited to join this Sourcebot deployment. Sign up to get started. +

+
+ + +
+
+
+ ); +} diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index fa430930..dc0d4b8c 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -8,11 +8,12 @@ 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"; +import { getEntitlements } from "@sourcebot/shared"; export const metadata: Metadata = { title: "Sourcebot", - description: "Sourcebot", + description: "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.", + manifest: "/manifest.json", }; export default function RootLayout({ @@ -29,7 +30,7 @@ export default function RootLayout({ - + { +export const CredentialsForm = ({ callbackUrl, context }: CredentialsFormProps) => { const captureEvent = useCaptureEvent(); const [isLoading, setIsLoading] = useState(false); const form = useForm>({ @@ -76,11 +77,10 @@ export const CredentialsForm = ({ callbackUrl }: CredentialsFormProps) => { diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx index 3d5d953c..05daabac 100644 --- a/packages/web/src/app/login/components/loginForm.tsx +++ b/packages/web/src/app/login/components/loginForm.tsx @@ -1,20 +1,13 @@ 'use client'; -import { Button } from "@/components/ui/button"; -import googleLogo from "@/public/google.svg"; -import Image from "next/image"; -import { signIn } from "next-auth/react"; -import { Fragment, useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { Card } from "@/components/ui/card"; -import { cn, getCodeHostIcon } from "@/lib/utils"; -import { MagicLinkForm } from "./magicLinkForm"; -import { CredentialsForm } from "./credentialsForm"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; -import { TextSeparator } from "@/app/components/textSeparator"; +import { AuthMethodSelector } from "@/app/components/authMethodSelector"; import useCaptureEvent from "@/hooks/useCaptureEvent"; -import DemoCard from "@/app/[domain]/onboard/components/demoCard"; import Link from "next/link"; import { env } from "@/env.mjs"; +import type { AuthProvider } from "@/lib/authProviders"; const TERMS_OF_SERVICE_URL = "https://sourcebot.dev/terms"; const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy"; @@ -22,19 +15,12 @@ const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy"; interface LoginFormProps { callbackUrl?: string; error?: string; - enabledMethods: { - github: boolean; - google: boolean; - magicLink: boolean; - credentials: boolean; - } + providers: AuthProvider[]; + context: "login" | "signup"; } -export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps) => { +export const LoginForm = ({ callbackUrl, error, providers, context }: LoginFormProps) => { const captureEvent = useCaptureEvent(); - const onSignInWithOauth = useCallback((provider: string) => { - signIn(provider, { redirectTo: callbackUrl ?? "/" }); - }, [callbackUrl]); const errorMessage = useMemo(() => { if (!error) { @@ -50,16 +36,40 @@ export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps } }, [error]); + // Helper function to get the correct analytics event name + const getLoginEventName = (providerId: string) => { + switch (providerId) { + case "github": + return "wa_login_with_github" as const; + case "google": + return "wa_login_with_google" as const; + case "gitlab": + return "wa_login_with_gitlab" as const; + case "okta": + return "wa_login_with_okta" as const; + case "keycloak": + return "wa_login_with_keycloak" as const; + case "microsoft-entra-id": + return "wa_login_with_microsoft_entra_id" as const; + default: + return "wa_login_with_github" as const; // fallback + } + }; + + // Analytics callback for provider clicks + const handleProviderClick = (providerId: string) => { + captureEvent(getLoginEventName(providerId), {}); + }; + return (
-

Sign in to your account

-
-
- +

+ {context === "login" ? "Sign in to your account" : "Create a new account"} +

{error && ( @@ -67,42 +77,24 @@ export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps {errorMessage}
)} - - {enabledMethods.github && ( - { - captureEvent("wa_login_with_github", {}); - onSignInWithOauth("github") - }} - /> - )} - {enabledMethods.google && ( - { - captureEvent("wa_login_with_google", {}); - onSignInWithOauth("google") - }} - /> - )} - - ] : []), - ...(enabledMethods.magicLink ? [ - - ] : []), - ...(enabledMethods.credentials ? [ - - ] : []) - ]} + +

+ {context === "login" ? + <> + Don't have an account? Sign up + + : + <> + Already have an account? Sign in + + } +

{env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined && (

By signing in, you agree to the Terms of Service and Privacy Policy.

@@ -110,37 +102,3 @@ export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps
) } - -const ProviderButton = ({ - name, - logo, - onClick, - className, -}: { - name: string; - logo: { src: string, className?: string }; - onClick: () => void; - className?: string; -}) => { - return ( - - ) -} - -const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => { - return elements.map((child, index) => { - return ( - - {child} - {index < elements.length - 1 && } - - ) - }) -} diff --git a/packages/web/src/app/login/components/magicLinkForm.tsx b/packages/web/src/app/login/components/magicLinkForm.tsx index 32775203..d731436a 100644 --- a/packages/web/src/app/login/components/magicLinkForm.tsx +++ b/packages/web/src/app/login/components/magicLinkForm.tsx @@ -18,9 +18,10 @@ const magicLinkSchema = z.object({ interface MagicLinkFormProps { callbackUrl?: string; + context: "login" | "signup"; } -export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => { +export const MagicLinkForm = ({ callbackUrl, context }: MagicLinkFormProps) => { const captureEvent = useCaptureEvent(); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -76,7 +77,7 @@ export const MagicLinkForm = ({ callbackUrl }: MagicLinkFormProps) => { disabled={isLoading} > {isLoading ? : ""} - Sign in with login code + {context === "login" ? "Sign in with login code" : "Sign up with login code"} diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx index 50fd3ef8..48e5031a 100644 --- a/packages/web/src/app/login/page.tsx +++ b/packages/web/src/app/login/page.tsx @@ -1,8 +1,13 @@ import { auth } from "@/auth"; import { LoginForm } from "./components/loginForm"; import { redirect } from "next/navigation"; -import { getProviders } from "@/auth"; import { Footer } from "@/app/components/footer"; +import { createLogger } from "@sourcebot/logger"; +import { getAuthProviders } from "@/lib/authProviders"; +import { getOrgFromDomain } from "@/data/org"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; + +const logger = createLogger('login-page'); interface LoginProps { searchParams: { @@ -12,34 +17,27 @@ interface LoginProps { } export default async function Login({ searchParams }: LoginProps) { + logger.info("Login page loaded"); const session = await auth(); if (session) { + logger.info("Session found in login page, redirecting to home"); return redirect("/"); } - const providers = getProviders(); - const providerMap = providers - .map((provider) => { - if (typeof provider === "function") { - const providerData = provider() - return { id: providerData.id, name: providerData.name } - } else { - return { id: provider.id, name: provider.name } - } - }); + const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN); + if (!org || !org.isOnboarded) { + return redirect("/onboard"); + } + const providers = getAuthProviders(); return (
provider.id === "github"), - google: providerMap.some(provider => provider.id === "google"), - magicLink: providerMap.some(provider => provider.id === "nodemailer"), - credentials: providerMap.some(provider => provider.id === "credentials"), - }} + providers={providers} + context="login" />