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 ae6fdbb3..1c90cd01 100644 --- a/.env.development +++ b/.env.development @@ -18,10 +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) +# CONFIG_PATH=${PWD}/config.json # Path to the sourcebot config file (if one exists) # Email # EMAIL_FROM_ADDRESS="" # The from address for transactional emails. @@ -51,6 +54,16 @@ REDIS_URL="redis://localhost:6379" # STRIPE_WEBHOOK_SECRET: z.string().optional(), # STRIPE_ENABLE_TEST_CLOCKS=false +# Agents + +# GITHUB_APP_ID= +# GITHUB_APP_PRIVATE_KEY_PATH= +# GITHUB_APP_WEBHOOK_SECRET= +# OPENAI_API_KEY= +REVIEW_AGENT_LOGGING_ENABLED=true +REVIEW_AGENT_AUTO_REVIEW_ENABLED=false +REVIEW_AGENT_REVIEW_COMMAND=review + # Misc # Generated using: @@ -78,4 +91,4 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection # NODE_ENV= # SOURCEBOT_TENANCY_MODE=single -# NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT= \ No newline at end of file +# NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT= diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 41fe389f..2de85ed1 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -3,7 +3,6 @@ name: Deploy Staging on: push: branches: [main] - tags: ["v*.*.*"] workflow_dispatch: jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 563cd010..94e94262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,85 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- Fixed issue where new oauth providers weren't being display in the login page + +## [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/self-hosting/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/more/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/self-hosting/configuration/authentication)). +- 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/search/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 +- Added support for indexing generic git hosts given a remote clone url or local path. [#307](https://github.com/sourcebot-dev/sourcebot/pull/307) + +## [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. + +### 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) +- Align gitea clone_url with gitea host url [#303](https://github.com/sourcebot-dev/sourcebot/pull/303) + +## [3.1.4] - 2025-05-10 + +### Fixed +- Added better error handling to git operations + +## [3.1.3] - 2025-05-07 + +### Fixed +- Fixes bug with repos not being visible in the homepage carousel when re-indexing. [#294](https://github.com/sourcebot-dev/sourcebot/pull/294) + +### Added +- Added special `*` value for `rev:` to allow searching across all branches. [#281](https://github.com/sourcebot-dev/sourcebot/pull/281) +- Added the Sourcebot Model Context Protocol (MCP) server in [packages/mcp](./packages/mcp/README.md) to allow LLMs to interface with Sourcebot. Checkout the npm package [here](https://www.npmjs.com/package/@sourcebot/mcp). [#292](https://github.com/sourcebot-dev/sourcebot/pull/292) + +## [3.1.2] - 2025-04-30 + +### Added +- Added `exclude.readOnly` and `exclude.hidden` options to Gerrit connection config. [#280](https://github.com/sourcebot-dev/sourcebot/pull/280) + +### Fixes +- Fixes regression introduced in v3.1.0 that causes auth errors with GitHub. [#288](https://github.com/sourcebot-dev/sourcebot/pull/288) + +## [3.1.1] - 2025-04-28 + +### Changed +- Changed the filter panel to embed the filter selection state in the query params. [#276](https://github.com/sourcebot-dev/sourcebot/pull/276) + +## [3.1.0] - 2025-04-25 + +### Added +- [Sourcebot EE] Added search contexts, user-defined groupings of repositories that help focus searches on specific areas of a codebase. [#273](https://github.com/sourcebot-dev/sourcebot/pull/273) +- Added support for Bitbucket Cloud and Bitbucket Data Center connections. [#275](https://github.com/sourcebot-dev/sourcebot/pull/275) + + ## [3.0.4] - 2025-04-12 ### Fixes @@ -43,8 +122,8 @@ Sourcebot v3 is here and brings a number of structural changes to the tool's fou ### Added - Added parallelized repo indexing and connection syncing via Redis & BullMQ. See the [architecture overview](https://docs.sourcebot.dev/self-hosting/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/self-hosting/configuration/authentication). +- Added the following UI for managing your deployment when **[auth is enabled](https://docs.sourcebot.dev/self-hosting/configuration/authentication)**: - 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). diff --git a/LICENSE b/LICENSE index 83bbbcd6..04fbff3a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,10 @@ -MIT License +Copyright (c) 2025 Taqla Inc. -Copyright (c) Taqla, Inc. +Portions of this software are licensed as follows: + +- All content that resides under the "ee/" and "packages/web/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE". +- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component. +- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index abae628d..7d8f80b6 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ ALL: $(CMDS) yarn: yarn install + yarn build:deps zoekt: mkdir -p bin @@ -13,6 +14,9 @@ zoekt: export CTAGS_COMMANDS=ctags clean: + redis-cli FLUSHALL + yarn dev:prisma:migrate:reset + rm -rf \ bin \ node_modules \ @@ -28,11 +32,14 @@ clean: packages/crypto/dist \ packages/error/node_modules \ packages/error/dist \ + packages/mcp/node_modules \ + packages/mcp/dist \ .sourcebot soft-reset: rm -rf .sourcebot redis-cli FLUSHALL + yarn dev:prisma:migrate:reset .PHONY: bin diff --git a/README.md b/README.md index 9bb3cf0a..2b9e9354 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@

-

@@ -48,14 +47,14 @@ # About -Sourcebot is the open source Sourcegraph alternative. Index all your repos and branches across multiple code hosts (GitHub, GitLab, Gitea, or Gerrit) and search through them using a blazingly fast interface. +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. https://github.com/user-attachments/assets/ced355f3-967e-4f37-ae6e-74ab8c06b9ec ## 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, Gitea, or Gerrit. +- 🔍 **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 - 📂 **Full file visualization**: Instantly view the entire file when selecting any search result. @@ -113,7 +112,7 @@ To learn how to configure Sourcebot to index your own repos, please refer to our > [!NOTE] > Sourcebot collects anonymous usage data by default to help us improve the product. No sensitive data is collected, but if you'd like to disable this you can do so by setting the `SOURCEBOT_TELEMETRY_DISABLED` environment -> variable to `false`. Please refer to our [telemetry docs](https://docs.sourcebot.dev/self-hosting/overview#telemetry) for more information. +> variable to `true`. Please refer to our [telemetry docs](https://docs.sourcebot.dev/self-hosting/overview#telemetry) for more information. # Build from source >[!NOTE] diff --git a/demo-site-config.json b/demo-site-config.json index 169e4c93..42b8b346 100644 --- a/demo-site-config.json +++ b/demo-site-config.json @@ -1,75 +1,49 @@ +// This is the config file for https://demo.sourcebot.dev. +// To add a new repository, edit this file and open a PR. +// After the PR is merged, the deploy demo workflow will +// run (see: https://github.com/sourcebot-dev/sourcebot/actions/workflows/deploy-demo.yml), +// after which the changes will be reflected on the demo site. { - "$schema": "./schemas/v2/index.json", - "settings": { - "reindexInterval": 86400000, // 24 hours - "resyncInterval": 86400000 // 24 hours - }, - "repos": [ - { + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "connections": { + // Defines the GitHub repositories. + // See: https://docs.sourcebot.dev/docs/connections/github + "github-repos": { "type": "github", "token": { "env": "GITHUB_TOKEN" }, - "exclude": { - "size": { - "max": 1000000000 // Limit to 1GB - } - }, "repos": [ "torvalds/linux", - "pytorch/pytorch", + "pytorch/pytorch", "commaai/openpilot", "ggerganov/whisper.cpp", "ggerganov/llama.cpp", "codemirror/dev", "tailwindlabs/tailwindcss", "sourcebot-dev/sourcebot", - "freeCodeCamp/freeCodeCamp", - "EbookFoundation/free-programming-books", "sindresorhus/awesome", - "public-apis/public-apis", - "codecrafters-io/build-your-own-x", - "jwasham/coding-interview-university", - "kamranahmedse/developer-roadmap", - "donnemartin/system-design-primer", - "996icu/996.ICU", "facebook/react", "vinta/awesome-python", "vuejs/vue", - "practical-tutorials/project-based-learning", - "awesome-selfhosted/awesome-selfhosted", "TheAlgorithms/Python", - "trekhleb/javascript-algorithms", "tensorflow/tensorflow", - "getify/You-Dont-Know-JS", - "CyC2018/CS-Notes", - "ohmyzsh/ohmyzsh", - "ossu/computer-science", "twbs/bootstrap", - "Significant-Gravitas/AutoGPT", "flutter/flutter", "microsoft/vscode", "github/gitignore", - "jackfrued/Python-100-Days", - "jlevy/the-art-of-command-line", - "trimstray/the-book-of-secret-knowledge", - "Snailclimb/JavaGuide", "airbnb/javascript", "AUTOMATIC1111/stable-diffusion-webui", "huggingface/transformers", "avelino/awesome-go", "ytdl-org/youtube-dl", "vercel/next.js", - "labuladong/fucking-algorithm", "golang/go", - "Chalarangelo/30-seconds-of-code", - "yangshun/tech-interview-handbook", "facebook/react-native", "electron/electron", "Genymobile/scrcpy", "f/awesome-chatgpt-prompts", "microsoft/PowerToys", - "justjavac/free-programming-books-zh_CN", "kubernetes/kubernetes", "d3/d3", "nodejs/node", @@ -90,23 +64,13 @@ "mui/material-ui", "ant-design/ant-design", "yt-dlp/yt-dlp", - "ryanmcdermott/clean-code-javascript", - "godotengine/godot", - "ripienaar/free-for-dev", - "iluwatar/java-design-patterns", "puppeteer/puppeteer", "papers-we-love/papers-we-love", - "PanJiaChen/vue-element-admin", "iptv-org/iptv", "fatedier/frp", "excalidraw/excalidraw", "tauri-apps/tauri", - "Hack-with-Github/Awesome-Hacking", - "nvbn/thefuck", - "mtdvio/every-programmer-should-know", - "storybookjs/storybook", "neovim/neovim", - "microsoft/Web-Dev-For-Beginners", "django/django", "florinpop17/app-ideas", "animate-css/animate.css", @@ -121,13 +85,11 @@ "macrozheng/mall", "jaywcjlove/awesome-mac", "tonsky/FiraCode", - "ChatGPTNextWeb/ChatGPT-Next-Web", "rustdesk/rustdesk", "tensorflow/models", "doocs/advanced-java", "shadcn-ui/ui", "gohugoio/hugo", - "MisterBooo/LeetCodeAnimation", "spring-projects/spring-boot", "supabase/supabase", "oven-sh/bun", @@ -138,17 +100,12 @@ "openai/whisper", "netdata/netdata", "vuejs/awesome-vue", - "DopplerHQ/awesome-interview-questions", "3b1b/manim", "2dust/v2rayN", "nomic-ai/gpt4all", "elastic/elasticsearch", - "anuraghazra/github-readme-stats", - "microsoft/ML-For-Beginners", - "MunGell/awesome-for-beginners", "fighting41love/funNLP", "vitejs/vite", - "thedaviddias/Front-End-Checklist", "coder/code-server", "moby/moby", "CompVis/stable-diffusion", @@ -156,13 +113,10 @@ "nestjs/nest", "pallets/flask", "hakimel/reveal.js", - "Anduin2017/HowToCook", "microsoft/playwright", "swiftlang/swift", - "Developer-Y/cs-video-courses", "redis/redis", "bregman-arie/devops-exercises", - "josephmisiti/awesome-machine-learning", "binary-husky/gpt_academic", "junegunn/fzf", "syncthing/syncthing", @@ -173,11 +127,9 @@ "microsoft/generative-ai-for-beginners", "grafana/grafana", "abi/screenshot-to-code", - "ByteByteGoHq/system-design-101", "chartjs/Chart.js", "webpack/webpack", "d2l-ai/d2l-zh", - "sdmg15/Best-websites-a-programmer-should-visit", "strapi/strapi", "python/cpython", "leonardomso/33-js-concepts", @@ -187,9 +139,7 @@ "apache/superset", "tesseract-ocr/tesseract", "lydiahallie/javascript-questions", - "xtekky/gpt4free", "FuelLabs/sway", - "twitter/the-algorithm", "keras-team/keras", "resume/resume.github.com", "swisskyrepo/PayloadsAllTheThings", @@ -214,7 +164,6 @@ "rust-unofficial/awesome-rust", "streamich/react-use", "pocketbase/pocketbase", - "serhii-londar/open-source-mac-os-apps", "lllyasviel/Fooocus", "k88hudson/git-flight-rules", "react-hook-form/react-hook-form", @@ -275,23 +224,21 @@ "eslint/eslint", "nextauthjs/next-auth", "flameshot-org/flameshot", - "envoyproxy/envoy" + "envoyproxy/envoy", + "sourcebot-dev/zoekt" ] }, - { + // Defines the GitLab repositories. + // See: https://docs.sourcebot.dev/docs/connections/gitlab + "gitlab-repos": { "type": "gitlab", - "token": { - "env": "GITLAB_TOKEN" - }, - "groups": [ - "fdroid" - ], - "exclude": { - "projects": [ - "fdroid/wiki", - "Matrixcoffee/internal-twif-preview" - ] - } + "projects": [ + "gnachman/iterm2" + ] } - ] -} \ No newline at end of file + }, + "settings": { + "reindexIntervalMs": 86400000, // 24 hours + "enablePublicAccess": true + } +} diff --git a/docs/docs.json b/docs/docs.json index 64358876..607deb9d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -29,17 +29,44 @@ "group": "Connecting your code", "pages": [ "docs/connections/overview", - "docs/connections/github", - "docs/connections/gitlab", - "docs/connections/gitea", - "docs/connections/gerrit", - "docs/connections/request-new" + { + "group": "Supported platforms", + "pages": [ + "docs/connections/github", + "docs/connections/gitlab", + "docs/connections/bitbucket-cloud", + "docs/connections/bitbucket-data-center", + "docs/connections/gitea", + "docs/connections/gerrit", + "docs/connections/generic-git-host", + "docs/connections/local-repos", + "docs/connections/request-new" + ] + } + ] + }, + { + "group": "Search", + "pages": [ + "docs/search/syntax-reference", + "docs/search/multi-branch-indexing", + "docs/search/code-navigation", + "docs/search/search-contexts" + ] + }, + { + "group": "Agents", + "pages": [ + "docs/agents/overview", + "docs/agents/review-agent" ] }, { "group": "More", "pages": [ - "docs/more/roles-and-permissions" + "docs/more/api-keys", + "docs/more/roles-and-permissions", + "docs/more/mcp-server" ] } ] @@ -52,16 +79,16 @@ "group": "Getting Started", "pages": [ "self-hosting/overview", - "self-hosting/configuration" + "self-hosting/license-key" ] }, { - "group": "More", + "group": "Configuration", "pages": [ - "self-hosting/more/authentication", - "self-hosting/more/tenancy", - "self-hosting/more/transactional-emails", - "self-hosting/more/declarative-config" + "self-hosting/configuration/environment-variables", + "self-hosting/configuration/authentication", + "self-hosting/configuration/transactional-emails", + "self-hosting/configuration/declarative-config" ] }, { @@ -72,6 +99,7 @@ { "group": "Upgrade", "pages": [ + "self-hosting/upgrade/v3-to-v4-guide", "self-hosting/upgrade/v2-to-v3-guide" ] } diff --git a/docs/docs/agents/overview.mdx b/docs/docs/agents/overview.mdx new file mode 100644 index 00000000..86fce12d --- /dev/null +++ b/docs/docs/agents/overview.mdx @@ -0,0 +1,17 @@ +--- +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 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/agents/review-agent.mdx new file mode 100644 index 00000000..1d81d58f --- /dev/null +++ b/docs/docs/agents/review-agent.mdx @@ -0,0 +1,96 @@ +--- +title: AI Code Review Agent +sidebarTitle: AI Code Review Agent +--- + + +This agent sends data to OpenAI (through an API key you supply) to perform code reviews. This data includes code from the PR being reviewed, as well as additional relevant context from your +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) +and then follow the configuration instructions below. + +![AI Code Review Agent Example](/images/review_agent_example.png) + +# Configure + +This agent currently only supports reviewing GitHub PRs. You configure the agent by creating a GitHub app, installing it into your GitHub organization, and then giving your app info to Sourcebot. + +Before you get started, make sure you have an OpenAPI account that you can create an OpenAPI key with. + + + + Follow the official GitHub guide for [registering a GitHub app](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) + + - GitHub App name: You can make this whatever you want (ex. Sourcebot Review Agent) + - Homepage URL: You can make this whatever you want (ex. https://www.sourcebot.dev/) + - Webhook URL (**IMPORTANT**): You must set this to point to your Sourcebot deployment at /api/webhook (ex. https://sourcebot.aperture.com/api/webhook). Your Sourcebot deployment must be able to accept requests from GitHub + (either github.com or your self-hosted enterprise server) for this to work. If you're running Sourcebot locally, you can [use smee](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#step-2-get-a-webhook-proxy-url) to [forward webhooks](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#step-6-start-your-server) to your local deployment. + - Webook Secret: This can be any string (ex. generate a random string `python -c "import secrets; print(secrets.token_hex(10))"`). You'll provide this to Sourcebot to be able to read webhook events from your app. + - Permissions + - Pull requests: Read & Write + - Issues: Read & Write + - Contents: Read + - Events: + - Pull request + - Issue comment + + + Navigate to your new [GitHub app's page](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings) and press `Install` + + + Sourcebot requires the following environment variables to begin reviewing PRs through your new GitHub app: + + - `GITHUB_APP_ID`: The client ID of your GitHub app. Can be found in your [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings) + - `GITHUB_APP_WEBHOOK_SECRET`: The webhook secret you defined in your GitHub app. Can be found in your [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings) + - `GITHUB_APP_PRIVATE_KEY_PATH`: The path to your app's private key. If you're running Sourcebot from a container, this is the path to this file from within your container + (ex `/data/review-agent-key.pem`). You must copy the private key file into the directory you mount to Sourcebot (similar to the config file). + + You can generate a private key file for your app in the [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings). You must copy this private key file into the + 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. + + You can find an example docker compose file below. + - This docker compose file is placed in `~/sourcebot_review_agent_workspace`, and I'm mounting that directory to Sourcebot + - The config and the app private key files are placed in this directory + - The paths to these files are given to Sourcebot relative to `/data` since that's the directory in Sourcebot that I'm mounting to + + ```yaml + services: + sourcebot: + image: ghcr.io/sourcebot-dev/sourcebot:latest + pull_policy: always + container_name: sourcebot + ports: + - "3000:3000" + volumes: + - "/Users/michael/sourcebot_review_agent_workspace:/data" + environment: + CONFIG_PATH: "/data/config.json" + 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" + ``` + + + Navigate to the agents page by pressing `Agents` in the Sourcebot nav menu. If you've configured your environment variables correctly you'll see the following: + + ![Review Agent Configured](/images/review_agent_configured.png) + + + +# Using the agent + +The review agent will not automatically review your PRs by default. To enable this feature, set the `REVIEW_AGENT_AUTO_REVIEW_ENABLED` environment variable to true. + +You can invoke the review agent manually by commenting `/review` on the PR you'd like it to review. You can configure the command that triggers the agent by changing +the `REVIEW_AGENT_REVIEW_COMMAND` environment variable. \ No newline at end of file diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx new file mode 100644 index 00000000..66636b02 --- /dev/null +++ b/docs/docs/connections/bitbucket-cloud.mdx @@ -0,0 +1,104 @@ +--- +title: Linking code from Bitbucket Cloud +sidebarTitle: Bitbucket Cloud +--- + +import BitbucketToken from '/snippets/bitbucket-token.mdx'; +import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; +import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' + +## Examples + + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "repos": [ + "myWorkspace/myRepo" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "workspaces": [ + "myWorkspace" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "projects": [ + "myProject" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + // Include all repos in my-workspace... + "workspaces": [ + "myWorkspace" + ], + // ...except: + "exclude": { + // repos that are archived + "archived": true, + // repos that are forks + "forks": true, + // repos that match these glob patterns + "repos": [ + "myWorkspace/repo1", + "myWorkspace2/*" + ] + } + } + ``` + + + +## Authenticating with Bitbucket Cloud + +In order to index private repositories, you'll need to provide authentication credentials. You can do this using an `App Password` or an `Access Token` + + + + Navigate to the [app password creation page](https://bitbucket.org/account/settings/app-passwords/) and create an app password. Ensure that it has the proper permissions for the scope + of info you want to fetch (i.e. workspace, project, and/or repo level) + ![Bitbucket App Password Permissions](/images/bitbucket_app_password_perms.png) + + Next, provide your username + app password pair to Sourcebot: + + + + + Create an access token for the desired scope (repo, project, or workspace). Visit the official [Bitbucket Cloud docs](https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/) + for more info. + + Next, provide the access token to Sourcebot: + + + + + + +## Schema reference + + +[schemas/v3/bitbucket.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/bitbucket.json) + + + + \ No newline at end of file diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx new file mode 100644 index 00000000..b80afbfd --- /dev/null +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -0,0 +1,83 @@ +--- +title: Linking code from Bitbucket Data Center +sidebarTitle: Bitbucket Data Center +--- + +import BitbucketToken from '/snippets/bitbucket-token.mdx'; +import BitbucketAppPassword from '/snippets/bitbucket-app-password.mdx'; +import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' + +## Examples + + + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + "repos": [ + "myProject/myRepo" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + "projects": [ + "myProject" + ] + } + ``` + + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + // Include all repos in myProject... + "projects": [ + "myProject" + ], + // ...except: + "exclude": { + // repos that are archived + "archived": true, + // repos that are forks + "forks": true, + // repos that match these glob patterns + "repos": [ + "myProject/repo1", + "myProject2/*" + ] + } + } + ``` + + + +## Authenticating with Bitbucket Data Center + +In order to index private repositories, you'll need to provide an access token to Sourcebot. + +Create an access token for the desired scope (repo, project, or workspace). Visit the official [Bitbucket Data Center docs](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) +for more info. + +Next, provide the access token to Sourcebot: + + + + +## Schema reference + + +[schemas/v3/bitbucket.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/bitbucket.json) + + + + \ No newline at end of file diff --git a/docs/docs/connections/generic-git-host.mdx b/docs/docs/connections/generic-git-host.mdx new file mode 100644 index 00000000..cee01aaa --- /dev/null +++ b/docs/docs/connections/generic-git-host.mdx @@ -0,0 +1,29 @@ +--- +title: Other Git hosts +--- + +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). + +## 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: + +```json +{ + "type": "git", + "url": "https://github.com/sourcebot-dev/sourcebot" +} +``` + +Note that only `http` & `https` URLs are supported at this time. + +## Schema reference + + +[schemas/v3/genericGitHost.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/genericGitHost.json) + + + + \ No newline at end of file diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx index 627ac526..29bb627b 100644 --- a/docs/docs/connections/gerrit.mdx +++ b/docs/docs/connections/gerrit.mdx @@ -3,6 +3,8 @@ title: Linking code from Gerrit sidebarTitle: Gerrit --- +import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx' + Authenticating with Gerrit is currently not supported. If you need this capability, please raise a [feature request](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas). Sourcebot can sync code from self-hosted gerrit instances. @@ -51,7 +53,13 @@ To connect to a gerrit instance, provide the `url` property to your config: "projects": [ "project1/foo-project", "project2/sub-project/some-sub-folder/**" - ] + ], + + // projects that have state READ_ONLY + "readOnly": true, + + // projects that have state HIDDEN + "hidden": true } } ``` @@ -63,63 +71,6 @@ To connect to a gerrit instance, provide the `url` property to your config: [schemas/v3/gerrit.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/gerrit.json) -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "title": "GerritConnectionConfig", - "properties": { - "type": { - "const": "gerrit", - "description": "Gerrit Configuration" - }, - "url": { - "type": "string", - "format": "url", - "description": "The URL of the Gerrit host.", - "examples": [ - "https://gerrit.example.com" - ], - "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" - }, - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", - "examples": [ - [ - "project1/repo1", - "project2/**" - ] - ] - }, - "exclude": { - "type": "object", - "properties": { - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "project1/repo1", - "project2/**" - ] - ], - "description": "List of specific projects to exclude from syncing." - } - }, - "additionalProperties": false - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false -} -``` + + \ No newline at end of file diff --git a/docs/docs/connections/gitea.mdx b/docs/docs/connections/gitea.mdx index 17be8275..810085a2 100644 --- a/docs/docs/connections/gitea.mdx +++ b/docs/docs/connections/gitea.mdx @@ -3,6 +3,8 @@ title: Linking code from Gitea sidebarTitle: Gitea --- +import GiteaSchema from '/snippets/schemas/v3/gitea.schema.mdx' + Sourcebot can sync code from Gitea Cloud, and self-hosted. ## Examples @@ -80,7 +82,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](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -105,7 +107,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](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: @@ -143,166 +145,6 @@ To connect to a custom Gitea deployment, provide the `url` property to your conf [schemas/v3/gitea.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/gitea.json) -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "title": "GiteaConnectionConfig", - "properties": { - "type": { - "const": "gitea", - "description": "Gitea Configuration" - }, - "token": { - "description": "A Personal Access Token (PAT).", - "examples": [ - { - "secret": "SECRET_KEY" - } - ], - "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 - } - ] - }, - "url": { - "type": "string", - "format": "url", - "default": "https://gitea.com", - "description": "The URL of the Gitea host. Defaults to https://gitea.com", - "examples": [ - "https://gitea.com", - "https://gitea.example.com" - ], - "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" - }, - "orgs": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "my-org-name" - ] - ], - "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." - }, - "repos": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[\\w.-]+\\/[\\w.-]+$" - }, - "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." - }, - "users": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "username-1", - "username-2" - ] - ], - "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." - }, - "exclude": { - "type": "object", - "properties": { - "forks": { - "type": "boolean", - "default": false, - "description": "Exclude forked repositories from syncing." - }, - "archived": { - "type": "boolean", - "default": false, - "description": "Exclude archived repositories from syncing." - }, - "repos": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." - } - }, - "additionalProperties": false - }, - "revisions": { - "type": "object", - "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", - "properties": { - "branches": { - "type": "array", - "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", - "items": { - "type": "string" - }, - "examples": [ - [ - "main", - "release/*" - ], - [ - "**" - ] - ], - "default": [] - }, - "tags": { - "type": "array", - "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", - "items": { - "type": "string" - }, - "examples": [ - [ - "latest", - "v2.*.*" - ], - [ - "**" - ] - ], - "default": [] - } - }, - "additionalProperties": false - } - }, - "required": [ - "type" - ], - "additionalProperties": false -} -``` + + \ No newline at end of file diff --git a/docs/docs/connections/github.mdx b/docs/docs/connections/github.mdx index 52165c68..52bb2f3e 100644 --- a/docs/docs/connections/github.mdx +++ b/docs/docs/connections/github.mdx @@ -3,6 +3,8 @@ title: Linking code from GitHub sidebarTitle: GitHub --- +import GitHubSchema from '/snippets/schemas/v3/github.schema.mdx' + Sourcebot can sync code from GitHub.com, GitHub Enterprise Server, and GitHub Enterprise Cloud. ## Examples @@ -109,7 +111,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](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -134,7 +136,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](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: @@ -172,220 +174,6 @@ To connect to a GitHub host other than `github.com`, provide the `url` property [schemas/v3/github.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/github.json) -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "title": "GithubConnectionConfig", - "properties": { - "type": { - "const": "github", - "description": "GitHub Configuration" - }, - "token": { - "description": "A Personal Access Token (PAT).", - "examples": [ - { - "secret": "SECRET_KEY" - } - ], - "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 - } - ] - }, - "url": { - "type": "string", - "format": "url", - "default": "https://github.com", - "description": "The URL of the GitHub host. Defaults to https://github.com", - "examples": [ - "https://github.com", - "https://github.example.com" - ], - "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" - }, - "users": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[\\w.-]+$" - }, - "default": [], - "examples": [ - [ - "torvalds", - "DHH" - ] - ], - "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." - }, - "orgs": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[\\w.-]+$" - }, - "default": [], - "examples": [ - [ - "my-org-name" - ], - [ - "sourcebot-dev", - "commaai" - ] - ], - "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." - }, - "repos": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[\\w.-]+\\/[\\w.-]+$" - }, - "default": [], - "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." - }, - "topics": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "default": [], - "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", - "examples": [ - [ - "docs", - "core" - ] - ] - }, - "exclude": { - "type": "object", - "properties": { - "forks": { - "type": "boolean", - "default": false, - "description": "Exclude forked repositories from syncing." - }, - "archived": { - "type": "boolean", - "default": false, - "description": "Exclude archived repositories from syncing." - }, - "repos": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." - }, - "topics": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", - "examples": [ - [ - "tests", - "ci" - ] - ] - }, - "size": { - "type": "object", - "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", - "properties": { - "min": { - "type": "integer", - "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." - }, - "max": { - "type": "integer", - "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "revisions": { - "type": "object", - "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", - "properties": { - "branches": { - "type": "array", - "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", - "items": { - "type": "string" - }, - "examples": [ - [ - "main", - "release/*" - ], - [ - "**" - ] - ], - "default": [] - }, - "tags": { - "type": "array", - "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", - "items": { - "type": "string" - }, - "examples": [ - [ - "latest", - "v2.*.*" - ], - [ - "**" - ] - ], - "default": [] - } - }, - "additionalProperties": false - } - }, - "required": [ - "type" - ], - "additionalProperties": false -} -``` + \ No newline at end of file diff --git a/docs/docs/connections/gitlab.mdx b/docs/docs/connections/gitlab.mdx index 63fe10a8..4740bcc0 100644 --- a/docs/docs/connections/gitlab.mdx +++ b/docs/docs/connections/gitlab.mdx @@ -3,6 +3,8 @@ title: Linking code from GitLab sidebarTitle: GitLab --- +import GitLabSchema from '/snippets/schemas/v3/gitlab.schema.mdx' + Sourcebot can sync code from GitLab.com, Self Managed (CE & EE), and Dedicated. @@ -114,7 +116,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](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. 1. Add the `token` property to your connection config: ```json @@ -139,7 +141,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](/self-hosting/configuration/authentication) is enabled. 1. Navigate to **Secrets** in settings and create a new secret with your PAT: @@ -177,208 +179,6 @@ To connect to a GitLab host other than `gitlab.com`, provide the `url` property [schemas/v3/gitlab.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/gitlab.json) -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "title": "GitlabConnectionConfig", - "properties": { - "type": { - "const": "gitlab", - "description": "GitLab Configuration" - }, - "token": { - "description": "An authentication token.", - "examples": [ - { - "secret": "SECRET_KEY" - } - ], - "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 - } - ] - }, - "url": { - "type": "string", - "format": "url", - "default": "https://gitlab.com", - "description": "The URL of the GitLab host. Defaults to https://gitlab.com", - "examples": [ - "https://gitlab.com", - "https://gitlab.example.com" - ], - "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" - }, - "all": { - "type": "boolean", - "default": false, - "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." - }, - "users": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." - }, - "groups": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "my-group" - ], - [ - "my-group/sub-group-a", - "my-group/sub-group-b" - ] - ], - "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." - }, - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - [ - "my-group/my-project" - ], - [ - "my-group/my-sub-group/my-project" - ] - ], - "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" - }, - "topics": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", - "examples": [ - [ - "docs", - "core" - ] - ] - }, - "exclude": { - "type": "object", - "properties": { - "forks": { - "type": "boolean", - "default": false, - "description": "Exclude forked projects from syncing." - }, - "archived": { - "type": "boolean", - "default": false, - "description": "Exclude archived projects from syncing." - }, - "projects": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "examples": [ - [ - "my-group/my-project" - ] - ], - "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" - }, - "topics": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", - "examples": [ - [ - "tests", - "ci" - ] - ] - } - }, - "additionalProperties": false - }, - "revisions": { - "type": "object", - "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", - "properties": { - "branches": { - "type": "array", - "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", - "items": { - "type": "string" - }, - "examples": [ - [ - "main", - "release/*" - ], - [ - "**" - ] - ], - "default": [] - }, - "tags": { - "type": "array", - "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", - "items": { - "type": "string" - }, - "examples": [ - [ - "latest", - "v2.*.*" - ], - [ - "**" - ] - ], - "default": [] - } - }, - "additionalProperties": false - } - }, - "required": [ - "type" - ], - "additionalProperties": false -} -``` + + \ No newline at end of file diff --git a/docs/docs/connections/local-repos.mdx b/docs/docs/connections/local-repos.mdx new file mode 100644 index 00000000..ebed93b5 --- /dev/null +++ b/docs/docs/connections/local-repos.mdx @@ -0,0 +1,87 @@ +--- +title: Local Git repositories +--- + +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**, meaing Sourcebot will **not** `git fetch` new revisions. + +## Getting Started + + +Only folders containing git repositories at their root **and** have a `remote.origin.url` set in their git config are supported at this time. All other folders will be skipped. + + +Let's assume we have a `repos` directory located at `$(PWD)` with a collection of git repositories: + +```sh +repos/ +├─ repo_1/ +├─ repo_2/ +├─ repo_3/ +├─ ... +``` + +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: + + ``` bash + docker run \ + -v $(pwd)/repos:/repos:ro \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + + + We can now create a new git [connection](/docs/connections/overview), specifying local paths with the `file://` prefix. Glob patterns are supported. For example: + + ```json + { + "type": "git", + "url": "file:///repos/*" + } + ``` + + Sourcebot will expand this glob pattern into paths `/repos/repo_1`, `/repos/repo_2`, etc. and index all valid git repositories. + + + +## Examples + + + + + ```json + { + "type": "git", + "url": "file:///path/to/git_repo" + } + ``` + + + ```json + // Attempt to sync directories contained in `repos/` (non-recursive) + { + "type": "git", + "url": "file:///repos/*" + } + ``` + + + +## Schema reference + + +[schemas/v3/genericGitHost.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/genericGitHost.json) + + + + \ No newline at end of file diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/overview.mdx index a105a3de..65bac506 100644 --- a/docs/docs/connections/overview.mdx +++ b/docs/docs/connections/overview.mdx @@ -3,17 +3,19 @@ title: Overview sidebarTitle: Overview --- +import SupportedPlatforms from '/snippets/platform-support.mdx' + 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. There are two ways to define connections: - 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) + This is only supported when self-hosting, and is the default mechanism to define connections. Connections are defined in a [JSON file](/self-hosting/configuration/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). + This is the only way to define connections when using Sourcebot Cloud, and can be configured when self-hosting by enabling [authentication](/self-hosting/configuration/authentications). In this method, connections are defined and managed within the webapp: @@ -23,11 +25,6 @@ There are two ways to define connections: ### Supported code hosts - - - - - - + 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 diff --git a/docs/docs/more/api-keys.mdx b/docs/docs/more/api-keys.mdx new file mode 100644 index 00000000..4aa31a69 --- /dev/null +++ b/docs/docs/more/api-keys.mdx @@ -0,0 +1,8 @@ +--- +title: API Keys +--- + +An API Key is required when querying Sourcebot outside the context of the web app client (ex. MCP server, review agent). To create an API key, login to your Sourcebot instance and navigate to +**Settings -> API Keys**: + +![API Keys UI](/images/api_key.png) \ No newline at end of file diff --git a/docs/docs/more/mcp-server.mdx b/docs/docs/more/mcp-server.mdx new file mode 100644 index 00000000..378e8280 --- /dev/null +++ b/docs/docs/more/mcp-server.mdx @@ -0,0 +1,182 @@ +--- +title: Sourcebot MCP server (@sourcebot/mcp) +sidebarTitle: Sourcebot MCP server +--- + + +This feature is only available when [self-hosting](/self-hosting) + + +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. + + 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). + + + + + Ensure you have [Node.js](https://nodejs.org/en) >= v18.0.0 installed. + + Next, we can install the [@sourcebot/mcp](https://www.npmjs.com/package/@sourcebot/mcp) MCP server into any supported MCP client: + + + + [Cursor MCP docs](https://docs.cursor.com/context/model-context-protocol) + + Go to: `Settings` -> `Cursor Settings` -> `MCP` -> `Add new global MCP server` + + Paste the following into your `~/.cursor/mcp.json` file. This will install Sourcebot globally within Cursor: + + ```json + { + "mcpServers": { + "sourcebot": { + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest" ], + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" + } + } + } + } + ``` + + Replace `http://localhost:3000` with wherever your Sourcebot instance is hosted. + + + [Windsurf MCP docs](https://docs.windsurf.com/windsurf/mcp) + + Go to: `Windsurf Settings` -> `Cascade` -> `Add Server` -> `Add Custom Server` + + Paste the following into your `mcp_config.json` file: + + ```json + { + "mcpServers": { + "sourcebot": { + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest" ], + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" + } + } + } + } + ``` + + Replace `http://localhost:3000` with wherever your Sourcebot instance is hosted. + + + + [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): + + ```json + { + "mcp": { + "servers": { + "sourcebot": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest"], + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" + } + } + } + } + } + ``` + + Replace `http://localhost:3000` with wherever your Sourcebot instance is hosted. + + + [Claude Code MCP docs](https://docs.anthropic.com/en/docs/claude-code/tutorials#set-up-model-context-protocol-mcp) + + Run the following command: + + ```sh + claude mcp add sourcebot -e SOURCEBOT_HOST=http://localhost:3000 -- npx -y @sourcebot/mcp@latest + ``` + + Replace `http://localhost:3000` with wherever your Sourcebot instance is hosted. + + + [Claude Desktop MCP docs](https://modelcontextprotocol.io/quickstart/user) + + Add the following to your `claude_desktop_config.json`: + + ```json + { + "mcpServers": { + "sourcebot": { + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest"], + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" + } + } + } + } + ``` + + Replace `http://localhost:3000` with wherever your Sourcebot instance is hosted. + + + + + + Tell your LLM to `use sourcebot` when prompting. + + + + + +## Available Tools + + +### `search_code` + +Fetches code that matches the provided regex pattern in `query`. + +Parameters: +| Name | Required | Description | +|:----------------------|:---------|:----------------------------------------------------------------------------------------------------------------------------------| +| `query` | yes | Regex pattern to search for. Escape special characters and spaces with a single backslash (e.g., 'console\.log', 'console\ log'). | +| `filterByRepoIds` | no | Restrict search to specific repository IDs (from 'list_repos'). Leave empty to search all. | +| `filterByLanguages` | no | Restrict search to specific languages (GitHub linguist format, e.g., Python, JavaScript). | +| `caseSensitive` | no | Case sensitive search (default: false). | +| `includeCodeSnippets` | no | Include code snippets in results (default: false). | +| `maxTokens` | no | Max tokens to return (default: env.DEFAULT_MINIMUM_TOKENS). | + + +### `list_repos` + +Lists all repositories indexed by Sourcebot. + +### `get_file_source` + +Fetches the source code for a given file. + +Parameters: +| Name | Required | Description | +|:-------------|:---------|:-----------------------------------------------------------------| +| `fileName` | yes | The file to fetch the source code for. | +| `repoId` | yes | The Sourcebot repository ID. | + + +## Environment Variables + +| 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/more/roles-and-permissions.mdx b/docs/docs/more/roles-and-permissions.mdx index 92ff91a7..43be9319 100644 --- a/docs/docs/more/roles-and-permissions.mdx +++ b/docs/docs/more/roles-and-permissions.mdx @@ -4,8 +4,7 @@ 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: +Each member has a role which defines their permissions within an organization: | Role | Permission | | :--- | :--------- | diff --git a/docs/docs/overview.mdx b/docs/docs/overview.mdx index 8edf17cd..04aa3bd9 100644 --- a/docs/docs/overview.mdx +++ b/docs/docs/overview.mdx @@ -2,8 +2,6 @@ title: "Overview" --- -import ConnectionCards from '/snippets/connection-cards.mdx'; - 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). ## Getting Started diff --git a/docs/docs/search/code-navigation.mdx b/docs/docs/search/code-navigation.mdx new file mode 100644 index 00000000..daced8ba --- /dev/null +++ b/docs/docs/search/code-navigation.mdx @@ -0,0 +1,44 @@ +--- +title: Code navigation +sidebarTitle: Code navigation +--- + +import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' + + +This feature is only available in [Sourcebot cloud](app.sourcebot.dev) or with an active Enterprise license when [self-hosting](/self-hosting). Please add your [license key](/self-hosting/license-key) to activate it. + + +**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/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/search/multi-branch-indexing.mdx b/docs/docs/search/multi-branch-indexing.mdx new file mode 100644 index 00000000..fce1c443 --- /dev/null +++ b/docs/docs/search/multi-branch-indexing.mdx @@ -0,0 +1,94 @@ +--- +title: Searching multiple branches and tags +sidebarTitle: Searching multiple branches +--- + +By default, only the default branch of a repository is indexed and can be searched. Sourcebot can be configured to index additional branches (or tags) enabling multi-branch search. This is useful for scenarios such as: + +- Searching across different releases +- Searching through feature branches during development +- Tracking changes across multiple maintenance branches simultaneously + +## Configuration + + +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: + +```json +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "connections": { + "my-connection": { + "type": "github", + // For each of the repositories defined in this connection... + "repos": [ + "org/repo1", + "org/repo2" + ], + // ... index the default branch, as well as... + "revisions": { + // These branches (if they exist): + "branches": [ + // Exact matches + "dev", + "staging", + + // Glob patterns + "feature/*" // Matches: feature/auth, feature/ui, etc. + ], + + // These tags (if they exist): + "tags": [ + // Exact matches + "v4.0.0-dev", + + // Glob patterns + "v3.*.*", // Matches: v3.0.0, v3.0.1, etc. + "rc-*" // Matches: rc-1, rc-2, etc. + ] + } + } + } +} +``` + +For each repo defined in the connection, any branches or tags matching the patterns in `branches` and `tags` array will be indexed. + +## Search syntax + +To search branches other than the default, the `rev:` prefix can be used followed by the branch (or tag) name: + +| Example | Explanation | +| :--- | :--- | +| `rev:feature/foo repo:A useEffect` | Search for `/useEffect/` on branch `feature/foo` in repo `A` | +| `rev:feature/foo useEffect ` | Search for `/useEffect/` on branch `feature/foo` across all repos | +| `rev:feature/ useEffect` | Search for `/useEffect/` on branches that contain `feature/` across all repos | +| `rev:feature/a rev:feature/b foo` | Search for `/foo/` on branches `feature/a` and `feature/b` | +| `rev:feature/ -rev:feature/a foo` | Search for `/foo/` on branches that contain `feature/` _except_ for `feature/a` across all repos | + +To search across **all** branches, `rev:*`: +| Example | Explanation | +| :--- | :--- | +| `rev:* repo:A "error message"` | Search for `/error message/` across **all** branches in repo `A` | +| `rev:* "error message"` | Search for `/error message/` across **all** branches and **all** repos | + +Additional info: +- `refs/heads/` or `refs/tags/` can be included to fully qualify a branch or a tag, respectively. E.g., `rev:refs/heads/foo` will search the branch `foo`, while `rev:refs/tags/foo` will search the tag `foo`. +- `rev:` does **not** support regular expressions or glob patterns. It uses a simple `contains` call between the branch name and the pattern. See [here](https://github.com/sourcebot-dev/zoekt/blob/7d1896215eea6f97af66c9549c9ec70436356b51/matchtree.go#L1067). + + +## Platform support + +| Platform | Multi-branch indexing support | +|:----------|------------------------------| +| GitHub | ✅ | +| GitLab | ✅ | +| Bitbucket Cloud | ✅ | +| Bitbucket Data Center | ✅ | +| Gitea | ✅ | +| Gerrit | ❌ | +| Generic git host | ✅ | + diff --git a/docs/docs/search/search-contexts.mdx b/docs/docs/search/search-contexts.mdx new file mode 100644 index 00000000..e6afca59 --- /dev/null +++ b/docs/docs/search/search-contexts.mdx @@ -0,0 +1,118 @@ +--- +title: Search contexts +sidebarTitle: Search contexts +--- + +import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.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: + +- `context:data_engineering userId` - search for `userId` across all repos related to Data Engineering. +- `context:k8s ingress` - search for anything related to ingresses in your k8's configs. +- `( context:project1 or context:project2 ) logger\.debug` - search for debug log calls in project1 and project2 + + +Search contexts are defined in the `context` object inside of a [declarative config](/self-hosting/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 + +Let's assume we have a GitLab instance hosted at `https://gitlab.example.com` with three top-level groups, `web`, `backend`, and `shared`: + +```sh +web/ +├─ admin_panel/ +├─ customer_portal/ +├─ pipelines/ +├─ ... +backend/ +├─ billing_server/ +├─ auth_server/ +├─ db_migrations/ +├─ pipelines/ +├─ ... +shared/ +├─ protobufs/ +├─ react/ +├─ pipelines/ +├─ ... +``` + +To make searching easier, we can create three search contexts in our [config.json](/self-hosting/configuration/declarative-config): +- `web`: For all frontend-related code +- `backend`: For backend services and shared APIs +- `pipelines`: For all CI/CD configurations + + +```json +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + "contexts": { + "web": { + // To include repositories in a search context, + // you can reference them... + "include": [ + // ... individually by specifying the repo URL. + "gitlab.example.com/web/admin_panel/core", + + + // ... or as groups using glob patterns. This is + // particularly useful for including entire "sub-folders" + // of repositories in one go. + "gitlab.example.com/web/customer_portal/**", + "gitlab.example.com/shared/react/**", + "gitlab.example.com/shared/protobufs/**" + ], + + // Same with excluding repositories. + "exclude": [ + "gitlab.example.com/web/customer_portal/pipelines", + "gitlab.example.com/shared/react/hooks/**", + ], + + // Optional description of the search context + // that surfaces in the UI. + "description": "Web related repos." + }, + "backend": { /* ... specifies backend replated repos ... */}, + "pipelines": { /* ... specifies pipeline related repos ... */ } + }, + "connections": { + /* ...connection definitions... */ + } +} +``` + + + Repo URLs are expected to be formatted without the leading http(s):// prefix. For example: + - `github.com/sourcebot-dev/sourcebot` ([link](https://github.com/sourcebot-dev/sourcebot)) + - `gitlab.com/gitlab-org/gitlab` ([link](https://gitlab.com/gitlab-org/gitlab)) + - `chromium.googlesource.com/chromium` ([link](https://chromium-review.googlesource.com/admin/repos/chromium,general)) + + + +Once configured, you can use these contexts in the search bar by prefixing your query with the context name. For example: +- `context:web login form` searches for login form code in frontend repositories +- `context:backend auth` searches for authentication code in backend services +- `context:pipelines deploy` searches for deployment configurations + +![Example](/images/search_contexts_example.png) + +Like other prefixes, contexts can be negated using `-` or combined using `or`: +- `-context:web` excludes frontend repositories from results +- `( context:web or context:backend )` searches across both frontend and backend code + +See [this doc](/docs/search/syntax-reference) for more details on the search query syntax. + +## Schema reference + + + +[schemas/v3/searchContext.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/searchContext.json) + + + + diff --git a/docs/docs/search/syntax-reference.mdx b/docs/docs/search/syntax-reference.mdx new file mode 100644 index 00000000..30cfb109 --- /dev/null +++ b/docs/docs/search/syntax-reference.mdx @@ -0,0 +1,35 @@ +--- +title: Writing search queries +--- + +Sourcebot uses a powerful regex-based query language that enabled precise code search within large codebases. + + +## Syntax reference guide + +Queries consist of space-separated regular expressions. Wrapping expressions in `""` combines them. By default, a file must have at least one match for each expression to be included. + +| Example | Explanation | +| :--- | :--- | +| `foo` | Match files with regex `/foo/` | +| `foo bar` | Match files with regex `/foo/` **and** `/bar/` | +| `"foo bar"` | Match files with regex `/foo bar/` | + +Multiple expressions can be or'd together with `or`, negated with `-`, or grouped with `()`. + +| Example | Explanation | +| :--- | :--- | +| `foo or bar` | Match files with regex `/foo/` **or** `/bar/` | +| `foo -bar` | Match files with regex `/foo/` but **not** `/bar/` | +| `foo (bar or baz)` | Match files with regex `/foo/` **and** either `/bar/` **or** `/baz/` | + +Expressions can be prefixed with certain keywords to modify search behavior. Some keywords can be negated using the `-` prefix. + +| Prefix | Description | Example | +| :--- | :--- | :--- | +| `file:` | Filter results from filepaths that match the regex. By default all files are searched. | `file:README` - Filter results to filepaths that match regex `/README/`
`file:"my file"` - Filter results to filepaths that match regex `/my file/`
`-file:test\.ts$` - Ignore results from filepaths match regex `/test\.ts$/` | +| `repo:` | Filter results from repos that match the regex. By default all repos are searched. | `repo:linux` - Filter results to repos that match regex `/linux/`
`-repo:^web/.*` - Ignore results from repos that match regex `/^web\/.*` | +| `rev:` | Filter results from a specific branch or tag. By default **only** the default branch is searched. | `rev:beta` - Filter results to branches that match regex `/beta/` | +| `lang:` | Filter results by language (as defined by [linguist](https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml)). By default all languages are searched. | `lang:TypeScript` - Filter results to TypeScript files
`-lang:YAML` - Ignore results from YAML files | +| `sym:` | Match symbol definitions created by [universal ctags](https://ctags.io/) at index time. | `sym:\bmain\b` - Filter results to symbols that match regex `/\bmain\b/` | +| `context:` | Filter results to a predefined [search context](/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 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/bitbucket_app_password_perms.png b/docs/images/bitbucket_app_password_perms.png new file mode 100644 index 00000000..1fc61955 Binary files /dev/null and b/docs/images/bitbucket_app_password_perms.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/github_app_private_key.png b/docs/images/github_app_private_key.png new file mode 100644 index 00000000..819fae23 Binary files /dev/null and b/docs/images/github_app_private_key.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/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/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/images/review_agent_configured.png b/docs/images/review_agent_configured.png new file mode 100644 index 00000000..6e824566 Binary files /dev/null and b/docs/images/review_agent_configured.png differ diff --git a/docs/images/review_agent_example.png b/docs/images/review_agent_example.png new file mode 100644 index 00000000..d933a199 Binary files /dev/null and b/docs/images/review_agent_example.png differ diff --git a/docs/images/search_contexts_example.png b/docs/images/search_contexts_example.png new file mode 100644 index 00000000..f63eae82 Binary files /dev/null and b/docs/images/search_contexts_example.png differ diff --git a/docs/self-hosting/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/configuration/authentication.mdx b/docs/self-hosting/configuration/authentication.mdx new file mode 100644 index 00000000..0ec2a720 --- /dev/null +++ b/docs/self-hosting/configuration/authentication.mdx @@ -0,0 +1,118 @@ +--- +title: Authentication +sidebarTitle: Authentication +--- + +Make sure the `AUTH_URL` environment variable is [configured correctly](/self-hosting/configuration) when using Sourcebot behind a domain. + +Sourcebot has built-in authentication that gates access to your organization. OAuth, email codes, and email / password are supported. + +The first account that's registered on a Sourcebot deployment is made the owner. All other users who register must be [approved](/self-hosting/configuration/authentication#approving-new-members) by the owner. + +![Login Page](/images/login.png) + + +# Approving New Members + +All account registrations after the first account must be approved by the owner. The owner can see all join requests by going into **Settings -> Members**. + +If you have an [enterprise license](/self-hosting/license-key), you can enable [AUTH_EE_ENABLE_JIT_PROVISIONING](/self-hosting/configuration/authentication#enterprise-authentication-providers) to +have Sourcebot accounts automatically created and approved on registration. + +You can setup emails to be sent when new join requests are created/approved by configurating [transactional emails](/self-hosting/configuration/transactional-emails) +# Authentication Providers + +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. + +## 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](/self-hosting/configuration/transactional-emails) for more details. + +## Enterprise Authentication Providers + +The following authentication providers require an [enterprise license](/self-hosting/license-key) to be enabled. + +By default, a new user registering using these providers must have their join request accepted by the owner of the organization to join. To allow a user to join automatically when +they register for the first time, set the `AUTH_EE_ENABLE_JIT_PROVISIONING` environment variable to `true`. + +### 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` + +### 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` + +--- + +# 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/configuration/declarative-config.mdx b/docs/self-hosting/configuration/declarative-config.mdx new file mode 100644 index 00000000..cdfdb445 --- /dev/null +++ b/docs/self-hosting/configuration/declarative-config.mdx @@ -0,0 +1,37 @@ +--- +title: Configuring Sourcebot from a file (declarative config) +sidebarTitle: Declarative config +--- + +import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' + +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). + + +| Variable | Description | +| :------- | :---------- | +| `CONFIG_PATH` | Path to declarative config. | + + +```json +{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/refs/heads/main/schemas/v3/index.json", + "connections": { + "connection-1": { + "type": "github", + "repos": [ + "sourcebot-dev/sourcebot" + ] + } + } +} +``` + +## Schema reference + + +[schemas/v3/index.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/index.json) + + + + \ No newline at end of file diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx new file mode 100644 index 00000000..8f65fb5e --- /dev/null +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -0,0 +1,64 @@ +--- +title: Environment Variables +sidebarTitle: Environment Variables +--- + +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 [getting started](/self-hosting/overview) 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](/self-hosting/configuration/authentication) 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](/self-hosting/configuration/authentication) 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](/self-hosting/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](/self-hosting/configuration/transactional-emails) for more info.

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

| +| `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](/self-hosting/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_LOG_LEVEL` | `info` |

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

| +| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/self-hosting/security/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 | +| :------- | :------ | :---------- | +| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` |

Enables/disables just-in-time user provisioning for SSO providers.

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

| + + +### 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/self-hosting/more/tenancy.mdx b/docs/self-hosting/configuration/tenancy.mdx similarity index 90% rename from docs/self-hosting/more/tenancy.mdx rename to docs/self-hosting/configuration/tenancy.mdx index bbf3e18e..7dd2cd90 100644 --- a/docs/self-hosting/more/tenancy.mdx +++ b/docs/self-hosting/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](/self-hosting/configuration/authentication) 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/self-hosting/configuration/transactional-emails.mdx similarity index 76% rename from docs/self-hosting/more/transactional-emails.mdx rename to docs/self-hosting/configuration/transactional-emails.mdx index d84c17b7..9a18cf45 100644 --- a/docs/self-hosting/more/transactional-emails.mdx +++ b/docs/self-hosting/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 +- Send emails when organization join requests are created/accepted - Log into the Sourcebot deployment using [email codes](self-hosting/more/authentication#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/self-hosting/license-key.mdx b/docs/self-hosting/license-key.mdx new file mode 100644 index 00000000..751291a8 --- /dev/null +++ b/docs/self-hosting/license-key.mdx @@ -0,0 +1,26 @@ +--- +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). 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](https://www.sourcebot.dev/contact). \ 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 index a07b8082..1d8f0f6d 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -3,6 +3,8 @@ title: Self-host Sourcebot sidebarTitle: Overview --- +import SupportedPlatforms from '/snippets/platform-support.mdx' + 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). @@ -45,6 +47,7 @@ Sourcebot is open source and can be self-hosted using our official [Docker image + If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/self-hosting/configuration/environment-variables) environment variable 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 @@ -69,18 +72,22 @@ Sourcebot is open source and can be self-hosted using our official [Docker image - 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). + 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 has built-in authentication which gates your instance. The first account which is registered on a fresh Sourcebot deployment is made owner. + + Registration is performed using basic credentials which are stored encrypted within your deployment. To setup more authentication providers + check out the [auth docs](/self-hosting/configuration/authentication) + + 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). diff --git a/docs/self-hosting/upgrade/v3-to-v4-guide.mdx b/docs/self-hosting/upgrade/v3-to-v4-guide.mdx new file mode 100644 index 00000000..2c6d83e9 --- /dev/null +++ b/docs/self-hosting/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](/self-hosting/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](/self-hosting/configuration/authentication) to setup additional auth providers. + + + + + Emails can be sent on organization join request/approval by configuring [transactional emails](/self-hosting/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](/self-hosting/configuration/authentication#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](/self-hosting/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](/self-hosting/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/snippets/bitbucket-app-password.mdx b/docs/snippets/bitbucket-app-password.mdx new file mode 100644 index 00000000..34ea7361 --- /dev/null +++ b/docs/snippets/bitbucket-app-password.mdx @@ -0,0 +1,51 @@ + + + Environment variables are only supported in a [declarative config](/self-hosting/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 + { + "type": "bitbucket", + "deploymentType": "cloud", + "user": "myusername", + "token": { + // note: this env var can be named anything. It + // doesn't need to be `BITBUCKET_TOKEN`. + "env": "BITBUCKET_TOKEN" + } + // .. rest of config .. + } + ``` + + 2. Pass this environment variable each time you run Sourcebot: + ```bash + docker run \ + -e BITBUCKET_TOKEN= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + + + Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled. + + 1. Navigate to **Secrets** in settings and create a new secret with your access token: + + ![](/images/secrets_list.png) + + 2. Add the `token` and `user` (username associated with the app password you created) properties to your connection config: + + ```json + { + "type": "bitbucket", + "deploymentType": "cloud", + "user": "myusername", + "token": { + "secret": "mysecret" + } + // .. rest of config .. + } + ``` + + + \ No newline at end of file diff --git a/docs/snippets/bitbucket-token.mdx b/docs/snippets/bitbucket-token.mdx new file mode 100644 index 00000000..262aadfc --- /dev/null +++ b/docs/snippets/bitbucket-token.mdx @@ -0,0 +1,47 @@ + + + Environment variables are only supported in a [declarative config](/self-hosting/configuration/declarative-config) and cannot be used in the web UI. + + 1. Add the `token` property to your connection config: + ```json + { + "type": "bitbucket", + "token": { + // note: this env var can be named anything. It + // doesn't need to be `BITBUCKET_TOKEN`. + "env": "BITBUCKET_TOKEN" + } + // .. rest of config .. + } + ``` + + 2. Pass this environment variable each time you run Sourcebot: + ```bash + docker run \ + -e BITBUCKET_TOKEN= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + + + Secrets are only supported when [authentication](/self-hosting/configuration/authentication) is enabled. + + 1. Navigate to **Secrets** in settings and create a new secret with your PAT: + + ![](/images/secrets_list.png) + + 2. Add the `token` property to your connection config: + + ```json + { + "type": "bitbucket", + "token": { + "secret": "mysecret" + } + // .. rest of config .. + } + ``` + + + \ No newline at end of file diff --git a/docs/snippets/connection-cards.mdx b/docs/snippets/connection-cards.mdx deleted file mode 100644 index 1e224bf4..00000000 --- a/docs/snippets/connection-cards.mdx +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ 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/v1/index.schema.mdx b/docs/snippets/schemas/v1/index.schema.mdx new file mode 100644 index 00000000..c019ed94 --- /dev/null +++ b/docs/snippets/schemas/v1/index.schema.mdx @@ -0,0 +1,353 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "definitions": { + "RepoNameRegexIncludeFilter": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "RepoNameRegexExcludeFilter": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "ZoektConfig": { + "anyOf": [ + { + "type": "object", + "properties": { + "Type": { + "const": "github" + }, + "GitHubUrl": { + "type": "string", + "description": "GitHub Enterprise url. If not set github.com will be used as the host." + }, + "GitHubUser": { + "type": "string", + "description": "The GitHub user to mirror" + }, + "GitHubOrg": { + "type": "string", + "description": "The GitHub organization to mirror" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitHub access token.", + "default": "~/.github-token" + }, + "Topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only mirror repos that have one of the given topics" + }, + "ExcludeTopics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Don't mirror repos that have one of the given topics" + }, + "NoArchived": { + "type": "boolean", + "description": "Mirror repos that are _not_ archived", + "default": false + }, + "IncludeForks": { + "type": "boolean", + "description": "Also mirror forks", + "default": false + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Type": { + "const": "gitlab" + }, + "GitLabURL": { + "type": "string", + "description": "The GitLab API url.", + "default": "https://gitlab.com/api/v4/" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "OnlyPublic": { + "type": "boolean", + "description": "Only mirror public repos", + "default": false + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitLab access token.", + "default": "~/.gitlab-token" + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + } + ] + }, + "GitHubConfig": { + "type": "object", + "properties": { + "Type": { + "const": "github" + }, + "GitHubUrl": { + "type": "string", + "description": "GitHub Enterprise url. If not set github.com will be used as the host." + }, + "GitHubUser": { + "type": "string", + "description": "The GitHub user to mirror" + }, + "GitHubOrg": { + "type": "string", + "description": "The GitHub organization to mirror" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitHub access token.", + "default": "~/.github-token" + }, + "Topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only mirror repos that have one of the given topics" + }, + "ExcludeTopics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Don't mirror repos that have one of the given topics" + }, + "NoArchived": { + "type": "boolean", + "description": "Mirror repos that are _not_ archived", + "default": false + }, + "IncludeForks": { + "type": "boolean", + "description": "Also mirror forks", + "default": false + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + }, + "GitLabConfig": { + "type": "object", + "properties": { + "Type": { + "const": "gitlab" + }, + "GitLabURL": { + "type": "string", + "description": "The GitLab API url.", + "default": "https://gitlab.com/api/v4/" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "OnlyPublic": { + "type": "boolean", + "description": "Only mirror public repos", + "default": false + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitLab access token.", + "default": "~/.gitlab-token" + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + } + }, + "properties": { + "$schema": { + "type": "string" + }, + "Configs": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "Type": { + "const": "github" + }, + "GitHubUrl": { + "type": "string", + "description": "GitHub Enterprise url. If not set github.com will be used as the host." + }, + "GitHubUser": { + "type": "string", + "description": "The GitHub user to mirror" + }, + "GitHubOrg": { + "type": "string", + "description": "The GitHub organization to mirror" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitHub access token.", + "default": "~/.github-token" + }, + "Topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only mirror repos that have one of the given topics" + }, + "ExcludeTopics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Don't mirror repos that have one of the given topics" + }, + "NoArchived": { + "type": "boolean", + "description": "Mirror repos that are _not_ archived", + "default": false + }, + "IncludeForks": { + "type": "boolean", + "description": "Also mirror forks", + "default": false + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Type": { + "const": "gitlab" + }, + "GitLabURL": { + "type": "string", + "description": "The GitLab API url.", + "default": "https://gitlab.com/api/v4/" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "OnlyPublic": { + "type": "boolean", + "description": "Only mirror public repos", + "default": false + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitLab access token.", + "default": "~/.gitlab-token" + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "Configs" + ], + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v2/index.schema.mdx b/docs/snippets/schemas/v2/index.schema.mdx new file mode 100644 index 00000000..4fe85eca --- /dev/null +++ b/docs/snippets/schemas/v2/index.schema.mdx @@ -0,0 +1,2262 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "Sourcebot configuration schema", + "description": "A Sourcebot configuration file outlines which repositories Sourcebot should sync and index.", + "definitions": { + "Token": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "GitRevisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + }, + "GitHubConfig": { + "type": "object", + "properties": { + "type": { + "const": "github", + "description": "GitHub Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://github.com", + "description": "The URL of the GitHub host. Defaults to https://github.com", + "examples": [ + "https://github.com", + "https://github.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "users": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "torvalds", + "DHH" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." + }, + "orgs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "my-org-name" + ], + [ + "sourcebot-dev", + "commaai" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "GitLabConfig": { + "type": "object", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "GiteaConfig": { + "type": "object", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "An access token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "GerritConfig": { + "type": "object", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + "LocalConfig": { + "type": "object", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "GitConfig": { + "type": "object", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + "Repos": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "github", + "description": "GitHub Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://github.com", + "description": "The URL of the GitHub host. Defaults to https://github.com", + "examples": [ + "https://github.com", + "https://github.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "users": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "torvalds", + "DHH" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." + }, + "orgs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "my-org-name" + ], + [ + "sourcebot-dev", + "commaai" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "An access token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + } + ] + }, + "Settings": { + "type": "object", + "description": "Global settings. These settings are applied to all repositories.", + "properties": { + "maxFileSize": { + "type": "integer", + "description": "The maximum size of a file (in bytes) to be indexed. Files that exceed this maximum will not be inexed. Defaults to 2MB (2097152 bytes).", + "default": 2097152, + "minimum": 1 + }, + "maxTrigramCount": { + "type": "integer", + "description": "The maximum amount of trigrams per document. Documents that exceed this maximum will not be indexed. Defaults to 20000", + "default": 20000, + "minimum": 1 + }, + "autoDeleteStaleRepos": { + "type": "boolean", + "description": "Automatically delete stale repositories from the index. Defaults to true.", + "default": true + }, + "reindexInterval": { + "type": "integer", + "description": "The interval (in milliseconds) at which the indexer should re-index all repositories. Repositories are always indexed when first added. Defaults to 1 hour (3600000 milliseconds).", + "default": 3600000, + "minimum": 1 + }, + "resyncInterval": { + "type": "integer", + "description": "The interval (in milliseconds) at which the configuration file should be re-synced. The configuration file is always synced on startup. Defaults to 24 hours (86400000 milliseconds).", + "default": 86400000, + "minimum": 1 + } + }, + "additionalProperties": false + } + }, + "properties": { + "$schema": { + "type": "string" + }, + "settings": { + "type": "object", + "description": "Global settings. These settings are applied to all repositories.", + "properties": { + "maxFileSize": { + "type": "integer", + "description": "The maximum size of a file (in bytes) to be indexed. Files that exceed this maximum will not be inexed. Defaults to 2MB (2097152 bytes).", + "default": 2097152, + "minimum": 1 + }, + "maxTrigramCount": { + "type": "integer", + "description": "The maximum amount of trigrams per document. Documents that exceed this maximum will not be indexed. Defaults to 20000", + "default": 20000, + "minimum": 1 + }, + "autoDeleteStaleRepos": { + "type": "boolean", + "description": "Automatically delete stale repositories from the index. Defaults to true.", + "default": true + }, + "reindexInterval": { + "type": "integer", + "description": "The interval (in milliseconds) at which the indexer should re-index all repositories. Repositories are always indexed when first added. Defaults to 1 hour (3600000 milliseconds).", + "default": 3600000, + "minimum": 1 + }, + "resyncInterval": { + "type": "integer", + "description": "The interval (in milliseconds) at which the configuration file should be re-synced. The configuration file is always synced on startup. Defaults to 24 hours (86400000 milliseconds).", + "default": 86400000, + "minimum": 1 + } + }, + "additionalProperties": false + }, + "repos": { + "type": "array", + "description": "Defines a collection of repositories from varying code hosts that Sourcebot should sync with.", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "github", + "description": "GitHub Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://github.com", + "description": "The URL of the GitHub host. Defaults to https://github.com", + "examples": [ + "https://github.com", + "https://github.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "users": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "torvalds", + "DHH" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." + }, + "orgs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "my-org-name" + ], + [ + "sourcebot-dev", + "commaai" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "An access token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + } + ] + } + } + }, + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/bitbucket.schema.mdx b/docs/snippets/schemas/v3/bitbucket.schema.mdx new file mode 100644 index 00000000..829d0254 --- /dev/null +++ b/docs/snippets/schemas/v3/bitbucket.schema.mdx @@ -0,0 +1,180 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/connection.schema.mdx b/docs/snippets/schemas/v3/connection.schema.mdx new file mode 100644 index 00000000..9731bdeb --- /dev/null +++ b/docs/snippets/schemas/v3/connection.schema.mdx @@ -0,0 +1,896 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ConnectionConfig", + "oneOf": [ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GithubConnectionConfig", + "properties": { + "type": { + "const": "github", + "description": "GitHub Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://github.com", + "description": "The URL of the GitHub host. Defaults to https://github.com", + "examples": [ + "https://github.com", + "https://github.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "users": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "torvalds", + "DHH" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." + }, + "orgs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org-name" + ], + [ + "sourcebot-dev", + "commaai" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "default": [], + "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitlabConnectionConfig", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GiteaConnectionConfig", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Exclude read-only projects from syncing." + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Exclude hidden projects from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GenericGitHostConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Generic Git host configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns.", + "pattern": "^(https?:\\/\\/[^\\s/$.?#].[^\\s]*|file:\\/\\/\\/[^\\s]+)$", + "examples": [ + "https://github.com/sourcebot-dev/sourcebot", + "file:///path/to/repo", + "file:///repos/*" + ] + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + } + ] +} +``` diff --git a/docs/snippets/schemas/v3/genericGitHost.schema.mdx b/docs/snippets/schemas/v3/genericGitHost.schema.mdx new file mode 100644 index 00000000..e763bc22 --- /dev/null +++ b/docs/snippets/schemas/v3/genericGitHost.schema.mdx @@ -0,0 +1,71 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GenericGitHostConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Generic Git host configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns.", + "pattern": "^(https?:\\/\\/[^\\s/$.?#].[^\\s]*|file:\\/\\/\\/[^\\s]+)$", + "examples": [ + "https://github.com/sourcebot-dev/sourcebot", + "file:///path/to/repo", + "file:///repos/*" + ] + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/gerrit.schema.mdx b/docs/snippets/schemas/v3/gerrit.schema.mdx new file mode 100644 index 00000000..561bda80 --- /dev/null +++ b/docs/snippets/schemas/v3/gerrit.schema.mdx @@ -0,0 +1,70 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Exclude read-only projects from syncing." + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Exclude hidden projects from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/gitea.schema.mdx b/docs/snippets/schemas/v3/gitea.schema.mdx new file mode 100644 index 00000000..f236e3fe --- /dev/null +++ b/docs/snippets/schemas/v3/gitea.schema.mdx @@ -0,0 +1,163 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GiteaConnectionConfig", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/github.schema.mdx b/docs/snippets/schemas/v3/github.schema.mdx new file mode 100644 index 00000000..1858eee8 --- /dev/null +++ b/docs/snippets/schemas/v3/github.schema.mdx @@ -0,0 +1,216 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GithubConnectionConfig", + "properties": { + "type": { + "const": "github", + "description": "GitHub Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://github.com", + "description": "The URL of the GitHub host. Defaults to https://github.com", + "examples": [ + "https://github.com", + "https://github.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "users": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "torvalds", + "DHH" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." + }, + "orgs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org-name" + ], + [ + "sourcebot-dev", + "commaai" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "default": [], + "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/gitlab.schema.mdx b/docs/snippets/schemas/v3/gitlab.schema.mdx new file mode 100644 index 00000000..feadeaac --- /dev/null +++ b/docs/snippets/schemas/v3/gitlab.schema.mdx @@ -0,0 +1,205 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitlabConnectionConfig", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} +``` diff --git a/docs/self-hosting/more/declarative-config.mdx b/docs/snippets/schemas/v3/index.schema.mdx similarity index 50% rename from docs/self-hosting/more/declarative-config.mdx rename to docs/snippets/schemas/v3/index.schema.mdx index 2e67d284..11e18f55 100644 --- a/docs/self-hosting/more/declarative-config.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -1,35 +1,4 @@ ---- -title: Configuring Sourcebot from a file (declarative config) -sidebarTitle: Declarative config ---- - -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). - - -| Variable | Description | -| :------- | :---------- | -| `CONFIG_PATH` | Path to declarative config. | - - -```json -{ - "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/refs/heads/main/schemas/v3/index.json", - "connections": { - "connection-1": { - "type": "github", - "repos": [ - "sourcebot-dev/sourcebot" - ] - } - } -} -``` - -## Schema reference - - -[schemas/v3/index.json](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/index.json) - +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} ```json { "$schema": "http://json-schema.org/draft-07/schema#", @@ -38,7 +7,7 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "definitions": { "Settings": { "type": "object", - "description": "Defines the globabl settings for Sourcebot.", + "description": "Defines the global settings for Sourcebot.", "properties": { "maxFileSize": { "type": "number", @@ -94,8 +63,55 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "default": false + } + }, + "additionalProperties": false + }, + "SearchContext": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "SearchContext", + "description": "Search context", + "properties": { + "include": { + "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "exclude": { + "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "description": { + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." } }, + "required": [ + "include" + ], "additionalProperties": false } }, @@ -104,7 +120,120 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "type": "string" }, "settings": { - "$ref": "#/definitions/Settings" + "type": "object", + "description": "Defines the global settings for Sourcebot.", + "properties": { + "maxFileSize": { + "type": "number", + "description": "The maximum size of a file (in bytes) to be indexed. Files that exceed this maximum will not be indexed. Defaults to 2MB.", + "minimum": 1 + }, + "maxTrigramCount": { + "type": "number", + "description": "The maximum number of trigrams per document. Files that exceed this maximum will not be indexed. Default to 20000.", + "minimum": 1 + }, + "reindexIntervalMs": { + "type": "number", + "description": "The interval (in milliseconds) at which the indexer should re-index all repositories. Defaults to 1 hour.", + "minimum": 1 + }, + "resyncConnectionIntervalMs": { + "type": "number", + "description": "The interval (in milliseconds) at which the connection manager should check for connections that need to be re-synced. Defaults to 24 hours.", + "minimum": 1 + }, + "resyncConnectionPollingIntervalMs": { + "type": "number", + "description": "The polling rate (in milliseconds) at which the db should be checked for connections that need to be re-synced. Defaults to 1 second.", + "minimum": 1 + }, + "reindexRepoPollingIntervalMs": { + "type": "number", + "description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.", + "minimum": 1 + }, + "maxConnectionSyncJobConcurrency": { + "type": "number", + "description": "The number of connection sync jobs to run concurrently. Defaults to 8.", + "minimum": 1 + }, + "maxRepoIndexingJobConcurrency": { + "type": "number", + "description": "The number of repo indexing jobs to run concurrently. Defaults to 8.", + "minimum": 1 + }, + "maxRepoGarbageCollectionJobConcurrency": { + "type": "number", + "description": "The number of repo GC jobs to run concurrently. Defaults to 8.", + "minimum": 1 + }, + "repoGarbageCollectionGracePeriodMs": { + "type": "number", + "description": "The grace period (in milliseconds) for garbage collection. Used to prevent deleting shards while they're being loaded. Defaults to 10 seconds.", + "minimum": 1 + }, + "repoIndexTimeoutMs": { + "type": "number", + "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", + "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "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", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "SearchContext", + "description": "Search context", + "properties": { + "include": { + "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "exclude": { + "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "description": { + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." + } + }, + "required": [ + "include" + ], + "additionalProperties": false + } + }, + "additionalProperties": false }, "connections": { "type": "object", @@ -337,12 +466,39 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "description": "GitLab Configuration" }, "token": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/token", "description": "An authentication token.", "examples": [ { "secret": "SECRET_KEY" } + ], + "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 + } ] }, "url": { @@ -456,7 +612,45 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "additionalProperties": false }, "revisions": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/revisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -474,12 +668,39 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "description": "Gitea Configuration" }, "token": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/token", "description": "A Personal Access Token (PAT).", "examples": [ { "secret": "SECRET_KEY" } + ], + "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 + } ] }, "url": { @@ -551,7 +772,45 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "additionalProperties": false }, "revisions": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/revisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -605,6 +864,261 @@ Some teams require Sourcebot to be configured via a file (where it can be stored ] ], "description": "List of specific projects to exclude from syncing." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Exclude read-only projects from syncing." + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Exclude hidden projects from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GenericGitHostConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Generic Git host configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns.", + "pattern": "^(https?:\\/\\/[^\\s/$.?#].[^\\s]*|file:\\/\\/\\/[^\\s]+)$", + "examples": [ + "https://github.com/sourcebot-dev/sourcebot", + "file:///path/to/repo", + "file:///repos/*" + ] + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] } }, "additionalProperties": false @@ -625,5 +1139,3 @@ Some teams require Sourcebot to be configured via a file (where it can be stored "additionalProperties": false } ``` - - \ No newline at end of file diff --git a/docs/snippets/schemas/v3/searchContext.schema.mdx b/docs/snippets/schemas/v3/searchContext.schema.mdx new file mode 100644 index 00000000..01477bef --- /dev/null +++ b/docs/snippets/schemas/v3/searchContext.schema.mdx @@ -0,0 +1,45 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "SearchContext", + "description": "Search context", + "properties": { + "include": { + "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "exclude": { + "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "description": { + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." + } + }, + "required": [ + "include" + ], + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/shared.schema.mdx b/docs/snippets/schemas/v3/shared.schema.mdx new file mode 100644 index 00000000..97fdbabf --- /dev/null +++ b/docs/snippets/schemas/v3/shared.schema.mdx @@ -0,0 +1,80 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "definitions": { + "Token": { + "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 + } + ] + }, + "GitRevisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + } +} +``` diff --git a/ee/LICENSE b/ee/LICENSE new file mode 100644 index 00000000..928443d8 --- /dev/null +++ b/ee/LICENSE @@ -0,0 +1,27 @@ +Sourcebot Enterprise license (the “Enterprise License” or "EE license") +Copyright (c) 2025 Taqla Inc. + +With regard to the Sourcebot Enterprise Software: + +This software and associated documentation files (the "Software") may only be used for +internal business purposes if you (and any entity that you represent) are in compliance +with an agreement governing the use of the Software, as agreed by you and Sourcebot, and otherwise +have a valid Sourcebot Enterprise license for the correct number of user seats. Subject to the foregoing +sentence, you are free to modify this Software and publish patches to the Software. You agree that Sourcebot +and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications +and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, +distributed, or otherwise exploited with a valid Sourcebot Enterprise license for the correct number of user seats. +Notwithstanding the foregoing, you may copy and modify the Software for non-production evaluation or internal +experimentation purposes, without requiring a subscription. You agree that Sourcebot and/or +its licensors (as applicable) retain all right, title and interest in and to all such modifications. +You are not granted any other rights beyond what is expressly stated herein. Subject to the +foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For all third party components incorporated into the Sourcebot Software, those components are +licensed under the original license provided by the owner of the applicable component. \ No newline at end of file diff --git a/package.json b/package.json index 4ad48cb2..e4781586 100644 --- a/package.json +++ b/package.json @@ -4,21 +4,27 @@ "packages/*" ], "scripts": { - "build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces run build", - "test": "yarn workspaces run test", - "dev": "yarn dev:prisma:migrate:dev && npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web", + "build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build", + "test": "yarn workspaces foreach -A run test", + "dev": "yarn dev:prisma:migrate:dev && npm-run-all --print-label --parallel dev:zoekt dev:backend dev:web watch:mcp watch:schemas", "with-env": "cross-env PATH=\"$PWD/bin:$PATH\" dotenv -e .env.development -c --", "dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc", "dev:backend": "yarn with-env yarn workspace @sourcebot/backend dev:watch", "dev:web": "yarn with-env yarn workspace @sourcebot/web dev", + "watch:mcp": "yarn workspace @sourcebot/mcp build:watch", + "watch:schemas": "yarn workspace @sourcebot/schemas watch", "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" + "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" }, "devDependencies": { "cross-env": "^7.0.3", "dotenv-cli": "^8.0.0", "npm-run-all": "^4.1.5" }, - "packageManager": "yarn@4.7.0" + "packageManager": "yarn@4.7.0", + "dependencies": { + "@coderabbitai/bitbucket": "^1.1.3" + } } diff --git a/packages/backend/package.json b/packages/backend/package.json index 9005a7e1..a7adf99a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -5,7 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --cacheDir ../../.sourcebot\"", + "dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev\"", "dev": "node ./dist/index.js", "build": "tsc", "test": "cross-env SKIP_ENV_VALIDATION=1 vitest --config ./vitest.config.ts" @@ -41,6 +41,7 @@ "cross-fetch": "^4.0.0", "dotenv": "^16.4.5", "express": "^4.21.2", + "git-url-parse": "^16.1.0", "gitea-js": "^1.22.0", "glob": "^11.0.0", "ioredis": "^5.4.2", @@ -51,6 +52,6 @@ "simple-git": "^3.27.0", "strip-json-comments": "^5.0.1", "winston": "^3.15.0", - "zod": "^3.24.2" + "zod": "^3.24.3" } } diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts new file mode 100644 index 00000000..5ffdf7e0 --- /dev/null +++ b/packages/backend/src/bitbucket.ts @@ -0,0 +1,553 @@ +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 { PrismaClient } from "@sourcebot/db"; +import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; +import * as Sentry from "@sentry/node"; +import { + SchemaRepository as CloudRepository, +} from "@coderabbitai/bitbucket/cloud/openapi"; +import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; +import { processPromiseResults } from "./connectionUtils.js"; +import { throwIfAnyFailed } from "./connectionUtils.js"; + +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"; +const BITBUCKET_SERVER = "server"; + +export type BitbucketRepository = CloudRepository | ServerRepository; + +interface BitbucketClient { + deploymentType: string; + token: string | undefined; + apiClient: any; + baseUrl: string; + gitUrl: string; + getReposForWorkspace: (client: BitbucketClient, workspaces: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: string[]}>; + getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundProjects: string[]}>; + getRepos: (client: BitbucketClient, repos: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundRepos: string[]}>; + shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean; +} + +type CloudAPI = ReturnType; +type CloudGetRequestPath = ClientPathsWithMethod; + +type ServerAPI = ReturnType; +type ServerGetRequestPath = ClientPathsWithMethod; + +type CloudPaginatedResponse = { + readonly next?: string; + readonly page?: number; + readonly pagelen?: number; + readonly previous?: string; + readonly size?: number; + readonly values?: readonly T[]; +} + +type ServerPaginatedResponse = { + readonly size: number; + readonly limit: number; + readonly isLastPage: boolean; + readonly values: readonly T[]; + readonly start: number; + readonly nextPageStart: number; +} + +export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => { + const token = config.token ? + await getTokenFromConfig(config.token, orgId, db, logger) : + undefined; + + if (config.deploymentType === 'server' && !config.url) { + throw new Error('URL is required for Bitbucket Server'); + } + + const client = config.deploymentType === 'server' ? + serverClient(config.url!, config.user, token) : + cloudClient(config.user, token); + + let allRepos: BitbucketRepository[] = []; + let notFound: { + orgs: string[], + users: string[], + repos: string[], + } = { + orgs: [], + users: [], + repos: [], + }; + + if (config.workspaces) { + const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspaces); + allRepos = allRepos.concat(validRepos); + notFound.orgs = notFoundWorkspaces; + } + + if (config.projects) { + const { validRepos, notFoundProjects } = await client.getReposForProjects(client, config.projects); + allRepos = allRepos.concat(validRepos); + notFound.orgs = notFoundProjects; + } + + if (config.repos) { + const { validRepos, notFoundRepos } = await client.getRepos(client, config.repos); + allRepos = allRepos.concat(validRepos); + notFound.repos = notFoundRepos; + } + + const filteredRepos = allRepos.filter((repo) => { + return !client.shouldExcludeRepo(repo, config); + }); + + return { + validRepos: filteredRepos, + notFound, + }; +} + +function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient { + + const authorizationString = + token + ? !user || user == "x-token-auth" + ? `Bearer ${token}` + : `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}` + : undefined; + + const clientOptions: ClientOptions = { + baseUrl: BITBUCKET_CLOUD_API, + headers: { + Accept: "application/json", + ...(authorizationString ? { Authorization: authorizationString } : {}), + }, + }; + + const apiClient = createBitbucketCloudClient(clientOptions); + var client: BitbucketClient = { + deploymentType: BITBUCKET_CLOUD, + token: token, + apiClient: apiClient, + baseUrl: BITBUCKET_CLOUD_API, + gitUrl: BITBUCKET_CLOUD_GIT, + getReposForWorkspace: cloudGetReposForWorkspace, + getReposForProjects: cloudGetReposForProjects, + getRepos: cloudGetRepos, + shouldExcludeRepo: cloudShouldExcludeRepo, + } + + return client; +} + +/** +* We need to do `V extends CloudGetRequestPath` since we will need to call `apiClient.GET(url, ...)`, which +* expects `url` to be of type `CloudGetRequestPath`. See example. +**/ +const getPaginatedCloud = async ( + path: CloudGetRequestPath, + get: (url: CloudGetRequestPath) => Promise> +): Promise => { + const results: T[] = []; + let url = path; + + while (true) { + const response = await get(url); + + if (!response.values || response.values.length === 0) { + break; + } + + results.push(...response.values); + + if (!response.next) { + break; + } + + url = response.next as CloudGetRequestPath; + } + return results; +} + + +async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> { + const results = await Promise.allSettled(workspaces.map(async (workspace) => { + try { + logger.debug(`Fetching all repos for workspace ${workspace}...`); + + const path = `/repositories/${workspace}` as CloudGetRequestPath; + const { durationMs, data } = await measure(async () => { + const fetchFn = () => getPaginatedCloud(path, async (url) => { + const response = await client.apiClient.GET(url, { + params: { + path: { + workspace, + } + } + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch projects for workspace ${workspace}: ${JSON.stringify(error)}`); + } + return data; + }); + return fetchWithRetry(fetchFn, `workspace ${workspace}`, logger); + }); + logger.debug(`Found ${data.length} repos for workspace ${workspace} in ${durationMs}ms.`); + + return { + type: 'valid' as const, + data: data, + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to get repos for workspace ${workspace}: ${e}`); + + const status = e?.cause?.response?.status; + if (status == 404) { + logger.error(`Workspace ${workspace} not found or invalid access`) + return { + type: 'notFound' as const, + value: workspace + } + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundWorkspaces } = processPromiseResults(results); + return { + validRepos, + notFoundWorkspaces, + }; +} + +async function cloudGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: CloudRepository[], notFoundProjects: string[]}> { + const results = await Promise.allSettled(projects.map(async (project) => { + const [workspace, project_name] = project.split('/'); + if (!workspace || !project_name) { + logger.error(`Invalid project ${project}`); + return { + type: 'notFound' as const, + value: project + } + } + + logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`); + try { + const path = `/repositories/${workspace}` as CloudGetRequestPath; + const repos = await getPaginatedCloud(path, async (url) => { + const response = await client.apiClient.GET(url, { + params: { + query: { + q: `project.key="${project_name}"` + } + } + }); + const { data, error } = response; + if (error) { + throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); + } + return data; + }); + + logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace}.`); + return { + type: 'valid' as const, + data: repos + } + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to fetch repos for project ${project_name}: ${e}`); + + const status = e?.cause?.response?.status; + if (status == 404) { + logger.error(`Project ${project_name} not found in ${workspace} or invalid access`) + return { + type: 'notFound' as const, + value: project + } + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results); + return { + validRepos, + notFoundProjects + } +} + +async function cloudGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: CloudRepository[], notFoundRepos: string[]}> { + const results = await Promise.allSettled(repos.map(async (repo) => { + const [workspace, repo_slug] = repo.split('/'); + if (!workspace || !repo_slug) { + logger.error(`Invalid repo ${repo}`); + return { + type: 'notFound' as const, + value: repo + }; + } + + logger.debug(`Fetching repo ${repo_slug} for workspace ${workspace}...`); + try { + const path = `/repositories/${workspace}/${repo_slug}` as CloudGetRequestPath; + const response = await client.apiClient.GET(path); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + } + return { + type: 'valid' as const, + data: [data] + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to fetch repo ${repo}: ${e}`); + + const status = e?.cause?.response?.status; + if (status === 404) { + logger.error(`Repo ${repo} not found in ${workspace} or invalid access`); + return { + type: 'notFound' as const, + value: repo + }; + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results); + return { + validRepos, + notFoundRepos + }; +} + +function cloudShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConnectionConfig): boolean { + const cloudRepo = repo as CloudRepository; + + const shouldExclude = (() => { + if (config.exclude?.repos && config.exclude.repos.includes(cloudRepo.full_name!)) { + return true; + } + + if (!!config.exclude?.archived) { + logger.warn(`Exclude archived repos flag provided in config but Bitbucket Cloud does not support archived repos. Ignoring...`); + } + + if (!!config.exclude?.forks && cloudRepo.parent !== undefined) { + return true; + } + })(); + + if (shouldExclude) { + logger.debug(`Excluding repo ${cloudRepo.full_name} because it matches the exclude pattern`); + return true; + } + return false; +} + +function serverClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient { + const authorizationString = (() => { + // If we're not given any credentials we return an empty auth string. This will only work if the project/repos are public + if(!user && !token) { + return ""; + } + + // A user must be provided when using basic auth + // https://developer.atlassian.com/server/bitbucket/rest/v906/intro/#authentication + if (!user || user == "x-token-auth") { + return `Bearer ${token}`; + } + return `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`; + })(); + const clientOptions: ClientOptions = { + baseUrl: url, + headers: { + Accept: "application/json", + Authorization: authorizationString, + }, + }; + + const apiClient = createBitbucketServerClient(clientOptions); + var client: BitbucketClient = { + deploymentType: BITBUCKET_SERVER, + token: token, + apiClient: apiClient, + baseUrl: url, + gitUrl: url, + getReposForWorkspace: serverGetReposForWorkspace, + getReposForProjects: serverGetReposForProjects, + getRepos: serverGetRepos, + shouldExcludeRepo: serverShouldExcludeRepo, + } + + return client; +} + +const getPaginatedServer = async ( + path: ServerGetRequestPath, + get: (url: ServerGetRequestPath, start?: number) => Promise> +): Promise => { + const results: T[] = []; + let nextStart: number | undefined; + + while (true) { + const response = await get(path, nextStart); + + if (!response.values || response.values.length === 0) { + break; + } + + results.push(...response.values); + + if (response.isLastPage) { + break; + } + + nextStart = response.nextPageStart; + } + return results; +} + +async function serverGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: ServerRepository[], notFoundWorkspaces: string[]}> { + logger.debug('Workspaces are not supported in Bitbucket Server'); + return { + validRepos: [], + notFoundWorkspaces: workspaces + }; +} + +async function serverGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: ServerRepository[], notFoundProjects: string[]}> { + const results = await Promise.allSettled(projects.map(async (project) => { + try { + logger.debug(`Fetching all repos for project ${project}...`); + + const path = `/rest/api/1.0/projects/${project}/repos` as ServerGetRequestPath; + const { durationMs, data } = await measure(async () => { + const fetchFn = () => getPaginatedServer(path, async (url, start) => { + const response = await client.apiClient.GET(url, { + params: { + query: { + start, + } + } + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repos for project ${project}: ${JSON.stringify(error)}`); + } + return data; + }); + return fetchWithRetry(fetchFn, `project ${project}`, logger); + }); + logger.debug(`Found ${data.length} repos for project ${project} in ${durationMs}ms.`); + + return { + type: 'valid' as const, + data: data, + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to get repos for project ${project}: ${e}`); + + const status = e?.cause?.response?.status; + if (status == 404) { + logger.error(`Project ${project} not found or invalid access`); + return { + type: 'notFound' as const, + value: project + }; + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results); + return { + validRepos, + notFoundProjects + }; +} + +async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: ServerRepository[], notFoundRepos: string[]}> { + const results = await Promise.allSettled(repos.map(async (repo) => { + const [project, repo_slug] = repo.split('/'); + if (!project || !repo_slug) { + logger.error(`Invalid repo ${repo}`); + return { + type: 'notFound' as const, + value: repo + }; + } + + logger.debug(`Fetching repo ${repo_slug} for project ${project}...`); + try { + const path = `/rest/api/1.0/projects/${project}/repos/${repo_slug}` as ServerGetRequestPath; + const response = await client.apiClient.GET(path); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + } + return { + type: 'valid' as const, + data: [data] + }; + } catch (e: any) { + Sentry.captureException(e); + logger.error(`Failed to fetch repo ${repo}: ${e}`); + + const status = e?.cause?.response?.status; + if (status === 404) { + logger.error(`Repo ${repo} not found in project ${project} or invalid access`); + return { + type: 'notFound' as const, + value: repo + }; + } + throw e; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results); + return { + validRepos, + notFoundRepos + }; +} + +function serverShouldExcludeRepo(repo: BitbucketRepository, config: BitbucketConnectionConfig): boolean { + const serverRepo = repo as ServerRepository; + + const projectName = serverRepo.project!.key; + const repoSlug = serverRepo.slug!; + + const shouldExclude = (() => { + if (config.exclude?.repos && config.exclude.repos.includes(`${projectName}/${repoSlug}`)) { + return true; + } + + if (!!config.exclude?.archived && serverRepo.archived) { + return true; + } + + if (!!config.exclude?.forks && serverRepo.origin !== undefined) { + return true; + } + })(); + + if (shouldExclude) { + logger.debug(`Excluding repo ${projectName}/${repoSlug} because it matches the exclude pattern`); + return true; + } + return false; +} \ No newline at end of file diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 6985b2de..132b6f66 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -4,7 +4,7 @@ import { Settings } from "./types.js"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { createLogger } from "./logger.js"; import { Redis } from 'ioredis'; -import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js"; +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"; @@ -170,6 +170,12 @@ export class ConnectionManager implements IConnectionManager { case 'gerrit': { return await compileGerritConfig(config, job.data.connectionId, orgId); } + case 'bitbucket': { + return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db); + } + case 'git': { + return await compileGenericGitHostConfig(config, job.data.connectionId, orgId); + } } })(); } catch (err) { @@ -331,7 +337,6 @@ export class ConnectionManager implements IConnectionManager { }, data: { syncStatus: ConnectionSyncStatus.FAILED, - syncedAt: new Date(), syncStatusMetadata: syncStatusMetadata as Prisma.InputJsonValue, } }); diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index bd6246a0..545419b5 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 + enablePublicAccess: false, } \ No newline at end of file diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts index 472bcec2..512c9dae 100644 --- a/packages/backend/src/env.ts +++ b/packages/backend/src/env.ts @@ -27,6 +27,8 @@ export const env = createEnv({ SOURCEBOT_INSTALL_ID: z.string().default("unknown"), NEXT_PUBLIC_SOURCEBOT_VERSION: z.string().default("unknown"), + DATA_CACHE_DIR: z.string(), + NEXT_PUBLIC_POSTHOG_PAPIK: z.string().optional(), FALLBACK_GITHUB_CLOUD_TOKEN: z.string().optional(), diff --git a/packages/backend/src/gerrit.ts b/packages/backend/src/gerrit.ts index bb8a22ad..1ecb4add 100644 --- a/packages/backend/src/gerrit.ts +++ b/packages/backend/src/gerrit.ts @@ -1,5 +1,5 @@ import fetch from 'cross-fetch'; -import { GerritConfig } from "@sourcebot/schemas/v2/index.type" +import { GerritConnectionConfig } from "@sourcebot/schemas/v3/index.type" import { createLogger } from './logger.js'; import micromatch from "micromatch"; import { measure, fetchWithRetry } from './utils.js'; @@ -12,16 +12,19 @@ interface GerritProjects { [projectName: string]: GerritProjectInfo; } +// https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#:~:text=date%20upon%20submit.-,state,-optional +type GerritProjectState = 'ACTIVE' | 'READ_ONLY' | 'HIDDEN'; + interface GerritProjectInfo { id: string; - state?: string; + state?: GerritProjectState; web_links?: GerritWebLink[]; } interface GerritProject { name: string; id: string; - state?: string; + state?: GerritProjectState; web_links?: GerritWebLink[]; } @@ -32,7 +35,7 @@ interface GerritWebLink { const logger = createLogger('Gerrit'); -export const getGerritReposFromConfig = async (config: GerritConfig): Promise => { +export const getGerritReposFromConfig = async (config: GerritConnectionConfig): Promise => { const url = config.url.endsWith('/') ? config.url : `${config.url}/`; const hostname = new URL(config.url).hostname; @@ -57,24 +60,24 @@ export const getGerritReposFromConfig = async (config: GerritConfig): Promise !excludedProjects.includes(project.name)); - // include repos by glob if specified in config if (config.projects) { projects = projects.filter((project) => { return micromatch.isMatch(project.name, config.projects!); }); } - - if (config.exclude && config.exclude.projects) { - projects = projects.filter((project) => { - return !micromatch.isMatch(project.name, config.exclude!.projects!); + + projects = projects + .filter((project) => { + const isExcluded = shouldExcludeProject({ + project, + exclude: config.exclude, + }); + + return !isExcluded; }); - } - logger.debug(`Fetched ${Object.keys(projects).length} projects in ${durationMs}ms.`); + logger.debug(`Fetched ${projects.length} projects in ${durationMs}ms.`); return projects; }; @@ -137,3 +140,51 @@ const fetchAllProjects = async (url: string): Promise => { return allProjects; }; + +const shouldExcludeProject = ({ + project, + exclude, +}: { + project: GerritProject, + exclude?: GerritConnectionConfig['exclude'], +}) => { + let reason = ''; + + const shouldExclude = (() => { + if ([ + 'All-Projects', + 'All-Users', + 'All-Avatars', + 'All-Archived-Projects' + ].includes(project.name)) { + reason = `Project is a special project.`; + return true; + } + + if (!!exclude?.readOnly && project.state === 'READ_ONLY') { + reason = `\`exclude.readOnly\` is true`; + return true; + } + + if (!!exclude?.hidden && project.state === 'HIDDEN') { + reason = `\`exclude.hidden\` is true`; + return true; + } + + if (exclude?.projects) { + if (micromatch.isMatch(project.name, exclude.projects)) { + reason = `\`exclude.projects\` contains ${project.name}`; + return true; + } + } + + return false; + })(); + + if (shouldExclude) { + logger.debug(`Excluding project ${project.name}. Reason: ${reason}`); + return true; + } + + return false; +} \ No newline at end of file diff --git a/packages/backend/src/git.ts b/packages/backend/src/git.ts index 26ff51b1..d3dfc76f 100644 --- a/packages/backend/src/git.ts +++ b/packages/backend/src/git.ts @@ -1,6 +1,8 @@ -import { simpleGit, SimpleGitProgressEvent } from 'simple-git'; +import { CheckRepoActions, GitConfigScope, simpleGit, SimpleGitProgressEvent } from 'simple-git'; -export const cloneRepository = async (cloneURL: string, path: string, onProgress?: (event: SimpleGitProgressEvent) => void) => { +type onProgressFn = (event: SimpleGitProgressEvent) => void; + +export const cloneRepository = async (cloneURL: string, path: string, onProgress?: onProgressFn) => { const git = simpleGit({ progress: onProgress, }); @@ -16,13 +18,17 @@ export const cloneRepository = async (cloneURL: string, path: string, onProgress await git.cwd({ path, }).addConfig("remote.origin.fetch", "+refs/heads/*:refs/heads/*"); - } catch (error) { - throw new Error(`Failed to clone repository`); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to clone repository: ${error.message}`); + } else { + throw new Error(`Failed to clone repository: ${error}`); + } } } -export const fetchRepository = async (path: string, onProgress?: (event: SimpleGitProgressEvent) => void) => { +export const fetchRepository = async (path: string, onProgress?: onProgressFn) => { const git = simpleGit({ progress: onProgress, }); @@ -37,8 +43,12 @@ export const fetchRepository = async (path: string, onProgress?: (event: SimpleG "--progress" ] ); - } catch (error) { - throw new Error(`Failed to fetch repository ${path}`); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to fetch repository ${path}: ${error.message}`); + } else { + throw new Error(`Failed to fetch repository ${path}: ${error}`); + } } } @@ -48,7 +58,7 @@ export const fetchRepository = async (path: string, onProgress?: (event: SimpleG * that do not exist yet. It will _not_ remove any existing keys that are not * present in gitConfig. */ -export const upsertGitConfig = async (path: string, gitConfig: Record, onProgress?: (event: SimpleGitProgressEvent) => void) => { +export const upsertGitConfig = async (path: string, gitConfig: Record, onProgress?: onProgressFn) => { const git = simpleGit({ progress: onProgress, }).cwd(path); @@ -57,8 +67,58 @@ export const upsertGitConfig = async (path: string, gitConfig: Record { + const git = simpleGit({ + progress: onProgress, + }).cwd(path); + + try { + return git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`isPathAGitRepoRoot failed: ${error.message}`); + } else { + throw new Error(`isPathAGitRepoRoot failed: ${error}`); + } + } +} + +export const isUrlAValidGitRepo = async (url: string) => { + const git = simpleGit(); + + // List the remote heads. If an exception is thrown, the URL is not a valid git repo. + try { + const result = await git.listRemote(['--heads', url]); + return result.trim().length > 0; + } catch (error: unknown) { + return false; + } +} + +export const getOriginUrl = async (path: string) => { + const git = simpleGit().cwd(path); + + try { + const remotes = await git.getConfig('remote.origin.url', GitConfigScope.local); + return remotes.value; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to get origin for ${path}: ${error.message}`); + } else { + throw new Error(`Failed to get origin for ${path}: ${error}`); + } } } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 702cb0e5..149d3bd4 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,13 +1,13 @@ import "./instrument.js"; import * as Sentry from "@sentry/node"; -import { ArgumentParser } from "argparse"; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; import path from 'path'; import { AppContext } from "./types.js"; import { main } from "./main.js" import { PrismaClient } from "@sourcebot/db"; +import { env } from "./env.js"; // Register handler for normal exit process.on('exit', (code) => { @@ -36,22 +36,7 @@ process.on('unhandledRejection', (reason, promise) => { process.exit(1); }); - -const parser = new ArgumentParser({ - description: "Sourcebot backend tool", -}); - -type Arguments = { - cacheDir: string; -} - -parser.add_argument("--cacheDir", { - help: "Path to .sourcebot cache directory", - required: true, -}); -const args = parser.parse_args() as Arguments; - -const cacheDir = args.cacheDir; +const cacheDir = env.DATA_CACHE_DIR; const reposPath = path.join(cacheDir, 'repos'); const indexPath = path.join(cacheDir, 'index'); diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 8787d610..b2814ebf 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -3,13 +3,20 @@ import { getGitHubReposFromConfig } from "./github.js"; import { getGitLabReposFromConfig } from "./gitlab.js"; import { getGiteaReposFromConfig } from "./gitea.js"; import { getGerritReposFromConfig } from "./gerrit.js"; +import { BitbucketRepository, getBitbucketReposFromConfig } from "./bitbucket.js"; +import { SchemaRestRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi"; +import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitbucket/cloud/openapi"; import { Prisma, PrismaClient } from '@sourcebot/db'; import { WithRequired } from "./types.js" import { marshalBool } from "./utils.js"; import { createLogger } from './logger.js'; -import { GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { RepoMetadata } from './types.js'; import path from 'path'; +import { glob } from 'glob'; +import { getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js'; +import assert from 'assert'; +import GitUrlParse from 'git-url-parse'; export type RepoData = WithRequired; @@ -180,7 +187,9 @@ export const compileGiteaConfig = async ( .replace(/^https?:\/\//, ''); const repos = giteaRepos.map((repo) => { + const configUrl = new URL(hostUrl); const cloneUrl = new URL(repo.clone_url!); + cloneUrl.host = configUrl.host const repoDisplayName = repo.full_name!; const repoName = path.join(repoNameRoot, repoDisplayName); @@ -312,4 +321,283 @@ export const compileGerritConfig = async ( repos: [], } }; +} + +export const compileBitbucketConfig = async ( + config: BitbucketConnectionConfig, + connectionId: number, + orgId: number, + db: PrismaClient) => { + + const bitbucketReposResult = await getBitbucketReposFromConfig(config, orgId, db); + const bitbucketRepos = bitbucketReposResult.validRepos; + const notFound = bitbucketReposResult.notFound; + + const hostUrl = config.url ?? 'https://bitbucket.org'; + const repoNameRoot = new URL(hostUrl) + .toString() + .replace(/^https?:\/\//, ''); + + const getCloneUrl = (repo: BitbucketRepository) => { + if (!repo.links) { + throw new Error(`No clone links found for server repo ${repo.name}`); + } + + // In the cloud case we simply fetch the html link and use that as the clone url. For server we + // need to fetch the actual clone url + if (config.deploymentType === 'cloud') { + const htmlLink = repo.links.html as { href: string }; + return htmlLink.href; + } + + const cloneLinks = repo.links.clone as { + href: string; + name: string; + }[]; + + for (const link of cloneLinks) { + if (link.name === 'http') { + return link.href; + } + } + + throw new Error(`No clone links found for repo ${repo.name}`); + } + + const getWebUrl = (repo: BitbucketRepository) => { + const isServer = config.deploymentType === 'server'; + const repoLinks = (repo as BitbucketServerRepository | BitbucketCloudRepository).links; + const repoName = isServer ? (repo as BitbucketServerRepository).name : (repo as BitbucketCloudRepository).full_name; + + if (!repoLinks) { + 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 + // 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) { + throw new Error(`No ${isServer ? 'self' : 'html'} link found for ${isServer ? 'server' : 'cloud'} repo ${repoName}`); + } + + return link.href; + } + + const repos = bitbucketRepos.map((repo) => { + const isServer = config.deploymentType === 'server'; + const codeHostType = isServer ? 'bitbucket-server' : 'bitbucket-cloud'; // zoekt expects bitbucket-server + const displayName = isServer ? (repo as BitbucketServerRepository).name! : (repo as BitbucketCloudRepository).full_name!; + const externalId = isServer ? (repo as BitbucketServerRepository).id!.toString() : (repo as BitbucketCloudRepository).uuid!; + const isPublic = isServer ? (repo as BitbucketServerRepository).public : (repo as BitbucketCloudRepository).is_private === false; + const isArchived = isServer ? (repo as BitbucketServerRepository).archived === true : false; + const isFork = isServer ? (repo as BitbucketServerRepository).origin !== undefined : (repo as BitbucketCloudRepository).parent !== undefined; + const repoName = path.join(repoNameRoot, displayName); + const cloneUrl = getCloneUrl(repo); + const webUrl = getWebUrl(repo); + + const record: RepoData = { + external_id: externalId, + external_codeHostType: codeHostType, + external_codeHostUrl: hostUrl, + cloneUrl: cloneUrl, + webUrl: webUrl, + name: repoName, + displayName: displayName, + isFork: isFork, + isArchived: isArchived, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + gitConfig: { + 'zoekt.web-url-type': codeHostType, + 'zoekt.web-url': webUrl, + 'zoekt.name': repoName, + 'zoekt.archived': marshalBool(isArchived), + 'zoekt.fork': marshalBool(isFork), + 'zoekt.public': marshalBool(isPublic), + 'zoekt.display-name': displayName, + }, + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, + } satisfies RepoMetadata, + }; + + return record; + }) + + return { + repoData: repos, + notFound, + }; +} + +export const compileGenericGitHostConfig = async ( + config: GenericGitHostConnectionConfig, + connectionId: number, + orgId: number, +) => { + const configUrl = new URL(config.url); + if (configUrl.protocol === 'file:') { + return compileGenericGitHostConfig_file(config, orgId, connectionId); + } + else if (configUrl.protocol === 'http:' || configUrl.protocol === 'https:') { + return compileGenericGitHostConfig_url(config, orgId, connectionId); + } + else { + // Schema should prevent this, but throw an error just in case. + throw new Error(`Unsupported protocol: ${configUrl.protocol}`); + } +} + +export const compileGenericGitHostConfig_file = async ( + config: GenericGitHostConnectionConfig, + orgId: number, + connectionId: number, +) => { + const configUrl = new URL(config.url); + assert(configUrl.protocol === 'file:', 'config.url must be a file:// URL'); + + // Resolve the glob pattern to a list of repo-paths + const repoPaths = await glob(configUrl.pathname, { + absolute: true, + }); + + const repos: RepoData[] = []; + const notFound: { + users: string[], + orgs: string[], + repos: string[], + } = { + users: [], + orgs: [], + repos: [], + }; + + await Promise.all(repoPaths.map(async (repoPath) => { + const isGitRepo = await isPathAValidGitRepoRoot(repoPath); + if (!isGitRepo) { + logger.warn(`Skipping ${repoPath} - not a git repository.`); + notFound.repos.push(repoPath); + return; + } + + const origin = await getOriginUrl(repoPath); + if (!origin) { + logger.warn(`Skipping ${repoPath} - remote.origin.url not found in git config.`); + notFound.repos.push(repoPath); + return; + } + + const remoteUrl = GitUrlParse(origin); + + // @note: matches the naming here: + // https://github.com/sourcebot-dev/zoekt/blob/main/gitindex/index.go#L293 + const repoName = path.join(remoteUrl.host, remoteUrl.pathname.replace(/\.git$/, '')); + + const repo: RepoData = { + external_codeHostType: 'generic-git-host', + external_codeHostUrl: remoteUrl.resource, + external_id: remoteUrl.toString(), + cloneUrl: `file://${repoPath}`, + name: repoName, + displayName: repoName, + isFork: false, + isArchived: false, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, + // @NOTE: We don't set a gitConfig here since local repositories + // are readonly. + gitConfig: undefined, + } satisfies RepoMetadata, + } + + repos.push(repo); + })); + + return { + repoData: repos, + notFound, + } +} + +export const compileGenericGitHostConfig_url = async ( + config: GenericGitHostConnectionConfig, + orgId: number, + connectionId: number, +) => { + const remoteUrl = new URL(config.url); + assert(remoteUrl.protocol === 'http:' || remoteUrl.protocol === 'https:', 'config.url must be a http:// or https:// URL'); + + const notFound: { + users: string[], + orgs: string[], + repos: string[], + } = { + users: [], + orgs: [], + repos: [], + }; + + // Validate that we are dealing with a valid git repo. + const isGitRepo = await isUrlAValidGitRepo(remoteUrl.toString()); + if (!isGitRepo) { + notFound.repos.push(remoteUrl.toString()); + return { + repoData: [], + notFound, + } + } + + // @note: matches the naming here: + // https://github.com/sourcebot-dev/zoekt/blob/main/gitindex/index.go#L293 + const repoName = path.join(remoteUrl.host, remoteUrl.pathname.replace(/\.git$/, '')); + + const repo: RepoData = { + external_codeHostType: 'generic-git-host', + external_codeHostUrl: remoteUrl.origin, + external_id: remoteUrl.toString(), + cloneUrl: remoteUrl.toString(), + name: repoName, + displayName: repoName, + isFork: false, + isArchived: false, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, + } + }; + + return { + repoData: [repo], + notFound, + } } \ No newline at end of file diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index ca5db265..6f357d15 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -2,7 +2,7 @@ import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createLogger } from "./logger.js"; import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; -import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { AppContext, Settings, repoMetadataSchema } from "./types.js"; import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js"; import { cloneRepository, fetchRepository, upsertGitConfig } from "./git.js"; @@ -170,81 +170,102 @@ export class RepoManager implements IRepoManager { // 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. - private async getTokenForRepo(repo: RepoWithConnections, db: PrismaClient) { - const repoConnections = repo.connections; - if (repoConnections.length === 0) { - this.logger.error(`Repo ${repo.id} has no connections`); - return; - } + 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); + return { + password: token, + } + } + } + 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); + return { + username: 'oauth2', + password: token, + } + } + } - let token: string | undefined; - for (const repoConnection of repoConnections) { - const connection = repoConnection.connection; - if (connection.connectionType !== 'github' && connection.connectionType !== 'gitlab' && connection.connectionType !== 'gitea') { - continue; + 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); + return { + password: token, + } + } } - const config = connection.config as unknown as GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig; - if (config.token) { - token = await getTokenFromConfig(config.token, connection.orgId, db, this.logger); - if (token) { - break; + 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 username = config.user ?? 'x-token-auth'; + return { + username, + password: token, + } } } } - return token; + return undefined; } private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) { - let fetchDuration_s: number | undefined = undefined; - let cloneDuration_s: number | undefined = undefined; + const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx); - const repoPath = getRepoPath(repo, this.ctx); const metadata = repoMetadataSchema.parse(repo.metadata); // 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)) { + if (repoAlreadyInIndexingState && existsSync(repoPath) && !isReadOnly) { this.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)) { + if (existsSync(repoPath) && !isReadOnly) { this.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}`) })); - fetchDuration_s = durationMs / 1000; + const fetchDuration_s = durationMs / 1000; process.stdout.write('\n'); this.logger.info(`Fetched ${repo.displayName} in ${fetchDuration_s}s`); - } else { + } else if (!isReadOnly) { this.logger.info(`Cloning ${repo.displayName}...`); - const token = await this.getTokenForRepo(repo, this.db); + const auth = await this.getCloneCredentialsForRepo(repo, this.db); const cloneUrl = new URL(repo.cloneUrl); - if (token) { - switch (repo.external_codeHostType) { - case 'gitlab': - cloneUrl.username = 'oauth2'; - cloneUrl.password = token; - break; - case 'gitea': - case 'github': - default: - cloneUrl.username = token; - break; + if (auth) { + // @note: URL has a weird behavior where if you set the password but + // _not_ the username, the ":" delimiter will still be present in the + // URL (e.g., https://:password@example.com). To get around this, if + // we only have a password, we set the username to the password. + // @see: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBArgJwDYwLwzAUwO4wKoBKAMgBQBEAFlFAA4QBcA9I5gB4CGAtjUpgHShOZADQBKANwAoREj412ECNhAIAJmhhl5i5WrJTQkELz5IQAcxIy+UEAGUoCAJZhLo0UA + if (!auth.username) { + cloneUrl.username = auth.password; + } else { + cloneUrl.username = auth.username; + cloneUrl.password = auth.password; } } const { durationMs } = await measure(() => cloneRepository(cloneUrl.toString(), repoPath, ({ method, stage, progress }) => { this.logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`) })); - cloneDuration_s = durationMs / 1000; + const cloneDuration_s = durationMs / 1000; process.stdout.write('\n'); this.logger.info(`Cloned ${repo.displayName} in ${cloneDuration_s}s`); @@ -253,7 +274,7 @@ export class RepoManager implements IRepoManager { // Regardless of clone or fetch, always upsert the git config for the repo. // This ensures that the git config is always up to date for whatever we // have in the DB. - if (metadata.gitConfig) { + if (metadata.gitConfig && !isReadOnly) { await upsertGitConfig(repoPath, metadata.gitConfig); } @@ -261,12 +282,6 @@ export class RepoManager implements IRepoManager { 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`); - - return { - fetchDuration_s, - cloneDuration_s, - indexDuration_s, - } } private async runIndexJob(job: Job) { @@ -300,17 +315,12 @@ export class RepoManager implements IRepoManager { this.promClient.activeRepoIndexingJobs.inc(); this.promClient.pendingRepoIndexingJobs.dec({ repo: repo.id.toString() }); - let indexDuration_s: number | undefined; - let fetchDuration_s: number | undefined; - let cloneDuration_s: number | undefined; - - let stats; let attempts = 0; const maxAttempts = 3; while (attempts < maxAttempts) { try { - stats = await this.syncGitRepository(repo, repoAlreadyInIndexingState); + await this.syncGitRepository(repo, repoAlreadyInIndexingState); break; } catch (error) { Sentry.captureException(error); @@ -318,19 +328,15 @@ export class RepoManager implements IRepoManager { attempts++; this.promClient.repoIndexingReattemptsTotal.inc(); if (attempts === maxAttempts) { - this.logger.error(`Failed to sync repository ${repo.id} after ${maxAttempts} attempts. Error: ${error}`); + this.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.id}, attempt ${attempts}/${maxAttempts}. Sleeping for ${sleepDuration / 1000}s... Error: ${error}`); + this.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)); } } - - indexDuration_s = stats!.indexDuration_s; - fetchDuration_s = stats!.fetchDuration_s; - cloneDuration_s = stats!.cloneDuration_s; } private async onIndexJobCompleted(job: Job) { @@ -369,7 +375,6 @@ export class RepoManager implements IRepoManager { }, data: { repoIndexingStatus: RepoIndexingStatus.FAILED, - indexedAt: new Date(), } }) } @@ -453,7 +458,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.id}`); + this.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; @@ -467,8 +472,8 @@ export class RepoManager implements IRepoManager { }); // delete cloned repo - const repoPath = getRepoPath(repo, this.ctx); - if (existsSync(repoPath)) { + const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx); + if (existsSync(repoPath) && !isReadOnly) { this.logger.info(`Deleting repo directory ${repoPath}`); await promises.rm(repoPath, { recursive: true, force: true }); } diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index 64d1dce9..1d95f0fb 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -94,8 +94,21 @@ export const arraysEqualShallow = (a?: readonly T[], b?: readonly T[]) => { return true; } -export const getRepoPath = (repo: Repo, ctx: AppContext) => { - return path.join(ctx.reposPath, repo.id.toString()); +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. + const cloneUrl = new URL(repo.cloneUrl); + if (repo.external_codeHostType === 'generic-git-host' && cloneUrl.protocol === 'file:') { + return { + path: cloneUrl.pathname, + isReadOnly: true, + } + } + + return { + path: path.join(ctx.reposPath, repo.id.toString()), + isReadOnly: false, + } } export const getShardPrefix = (orgId: number, repoId: number) => { diff --git a/packages/backend/src/zoekt.ts b/packages/backend/src/zoekt.ts index e923b00a..3294fea8 100644 --- a/packages/backend/src/zoekt.ts +++ b/packages/backend/src/zoekt.ts @@ -15,7 +15,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap 'HEAD' ]; - const repoPath = getRepoPath(repo, ctx); + const { path: repoPath } = getRepoPath(repo, ctx); const shardPrefix = getShardPrefix(repo.orgId, repo.id); const metadata = repoMetadataSchema.parse(repo.metadata); @@ -65,6 +65,7 @@ export const indexGitRepository = async (repo: Repo, settings: Settings, ctx: Ap `-file_limit ${settings.maxFileSize}`, `-branches ${revisions.join(',')}`, `-tenant_id ${repo.orgId}`, + `-repo_id ${repo.id}`, `-shard_prefix ${shardPrefix}`, repoPath ].join(' '); diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts index f0128297..6da542f5 100644 --- a/packages/crypto/src/index.ts +++ b/packages/crypto/src/index.ts @@ -24,6 +24,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'); diff --git a/packages/db/prisma/migrations/20250403044104_add_search_contexts/migration.sql b/packages/db/prisma/migrations/20250403044104_add_search_contexts/migration.sql new file mode 100644 index 00000000..87bdb2dd --- /dev/null +++ b/packages/db/prisma/migrations/20250403044104_add_search_contexts/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "SearchContext" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "SearchContext_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_RepoToSearchContext" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL, + + CONSTRAINT "_RepoToSearchContext_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SearchContext_name_orgId_key" ON "SearchContext"("name", "orgId"); + +-- CreateIndex +CREATE INDEX "_RepoToSearchContext_B_index" ON "_RepoToSearchContext"("B"); + +-- AddForeignKey +ALTER TABLE "SearchContext" ADD CONSTRAINT "SearchContext_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RepoToSearchContext" ADD CONSTRAINT "_RepoToSearchContext_A_fkey" FOREIGN KEY ("A") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_RepoToSearchContext" ADD CONSTRAINT "_RepoToSearchContext_B_fkey" FOREIGN KEY ("B") REFERENCES "SearchContext"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/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/schema.prisma b/packages/db/prisma/schema.prisma index 303510d9..0619dcd6 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -41,6 +41,7 @@ model Repo { displayName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// When the repo was last indexed successfully. indexedAt DateTime? isFork Boolean isArchived Boolean @@ -61,9 +62,24 @@ model Repo { org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int + searchContexts SearchContext[] + @@unique([external_id, external_codeHostUrl, orgId]) } +model SearchContext { + id Int @id @default(autoincrement()) + + name String + description String? + repos Repo[] + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@unique([name, orgId]) +} + model Connection { id Int @id @default(autoincrement()) name String @@ -71,6 +87,7 @@ model Connection { isDeclarative Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// When the connection was last synced successfully. syncedAt DateTime? repos RepoToConnection[] syncStatus ConnectionSyncStatus @default(SYNC_NEEDED) @@ -119,6 +136,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 @@ -129,8 +160,10 @@ 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 stripeCustomerId String? stripeSubscriptionStatus StripeSubscriptionStatus? @@ -138,11 +171,16 @@ model Org { /// List of pending invites to this organization invites Invite[] + + accountRequests AccountRequest[] + + searchContexts SearchContext[] } enum OrgRole { OWNER MEMBER + GUEST } model UserToOrg { @@ -174,20 +212,39 @@ 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 + +} + // @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[] + pendingApproval Boolean @default(true) + accountRequest AccountRequest? /// List of pending invites that the user has created invites Invite[] + apiKeys ApiKey[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/packages/mcp/.gitignore b/packages/mcp/.gitignore new file mode 100644 index 00000000..23bfe49c --- /dev/null +++ b/packages/mcp/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/packages/mcp/.npmignore b/packages/mcp/.npmignore new file mode 100644 index 00000000..f09a7667 --- /dev/null +++ b/packages/mcp/.npmignore @@ -0,0 +1,5 @@ +**/* +!/dist/** +!README.md +!package.json +!CHANGELOG.md \ No newline at end of file diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md new file mode 100644 index 00000000..f19f4cb0 --- /dev/null +++ b/packages/mcp/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [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) + +## [1.0.0] - 2025-05-07 + +### Added +- Initial release \ No newline at end of file diff --git a/packages/mcp/Dockerfile b/packages/mcp/Dockerfile new file mode 100644 index 00000000..8e72653d --- /dev/null +++ b/packages/mcp/Dockerfile @@ -0,0 +1,27 @@ +# Generated by https://smithery.ai. See: https://smithery.ai/docs/build/project-config +# syntax=docker/dockerfile:1 + +# Builder stage +FROM node:lts-alpine AS builder +WORKDIR /app + +# Install dependencies and build +COPY package.json tsconfig.json ./ +COPY src ./src +RUN npm install +RUN npm run build + +# Final stage +FROM node:lts-alpine +WORKDIR /app + +# Install only production dependencies +COPY package.json ./ +RUN npm install --production + +# Copy built artifacts +COPY --from=builder /app/dist ./dist + +# Expose no specific port since this is stdio MCP server +# Default command +CMD ["node", "dist/index.js"] diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 00000000..e74f6499 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,226 @@ +# Sourcebot MCP - Fetch code context from GitHub, GitLab, Bitbucket, and more + +[![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) +[![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: + +- Enriching responses to user requests: + - _"What repositories are using internal library X?"_ + - _"Provide usage examples of the CodeMirror component"_ + - _"Where is the `useCodeMirrorTheme` hook defined?"_ + - _"Find all usages of `deprecatedApi` across all repos"_ + +- Improving reasoning ability for existing horizontal agents like AI code review, docs generation, etc. + - _"Find the definitions for all functions in this diff"_ + - _"Document what systems depend on this class"_ + +- 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"_ + + +## Getting Started + +1. Install Node.JS >= v18.0.0. + +2. (optional) Spin up a Sourcebot instance by following [this guide](https://docs.sourcebot.dev/self-hosting/overview). The host url of your instance (e.g., `http://localhost:3000`) is passed to the MCP server via the `SOURCEBOT_HOST` url. This allows you to control which repos Sourcebot MCP fetches context from (including private repos). + + 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). + +3. Install `@sourcebot/mcp` into your MCP client: + +
+ Cursor + + [Cursor MCP docs](https://docs.cursor.com/context/model-context-protocol) + + Go to: `Settings` -> `Cursor Settings` -> `MCP` -> `Add new global MCP server` + + Paste the following into your `~/.cursor/mcp.json` file. This will install Sourcebot globally within Cursor: + + ```json + { + "mcpServers": { + "sourcebot": { + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest" ], + // Optional - if not specified, https://demo.sourcebot.dev is used + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" + } + } + } + } + ``` +
+ +
+ Windsurf + + [Windsurf MCP docs](https://docs.windsurf.com/windsurf/mcp) + + Go to: `Windsurf Settings` -> `Cascade` -> `Add Server` -> `Add Custom Server` + + Paste the following into your `mcp_config.json` file: + + ```json + { + "mcpServers": { + "sourcebot": { + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest" ], + // Optional - if not specified, https://demo.sourcebot.dev is used + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" + } + } + } + } + ``` +
+ +
+ VS Code + + [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): + + ```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" + } + } + } + } + } + ``` + +
+ +
+ Claude Code + + [Claude Code MCP docs](https://docs.anthropic.com/en/docs/claude-code/tutorials#set-up-model-context-protocol-mcp) + + Run the following command: + + ```sh + # SOURCEBOT_HOST env var is optional - if not specified, + # https://demo.sourcebot.dev is used. + claude mcp add sourcebot -e SOURCEBOT_HOST=http://localhost:3000 -- npx -y @sourcebot/mcp@latest + ``` +
+ +
+ Claude Desktop + + [Claude Desktop MCP docs](https://modelcontextprotocol.io/quickstart/user) + + Add the following to your `claude_desktop_config.json`: + + ```json + { + "mcpServers": { + "sourcebot": { + "command": "npx", + "args": ["-y", "@sourcebot/mcp@latest"], + // Optional - if not specified, https://demo.sourcebot.dev is used + "env": { + "SOURCEBOT_HOST": "http://localhost:3000" + } + } + } + } + ``` +
+
+ + Alternatively, you can install using via [Smithery](https://smithery.ai/server/@sourcebot-dev/sourcebot). For example: + + ```bash + npx -y @smithery/cli install @sourcebot-dev/sourcebot --client claude + ``` + +
+ +4. Tell your LLM to `use sourcebot` when prompting. + +
+ +For a more detailed guide, checkout [the docs](https://docs.sourcebot.dev/docs/more/mcp-server). + + +## Available Tools + +### search_code + +Fetches code that matches the provided regex pattern in `query`. + +
+Parameters + +| Name | Required | Description | +|:----------------------|:---------|:----------------------------------------------------------------------------------------------------------------------------------| +| `query` | yes | Regex pattern to search for. Escape special characters and spaces with a single backslash (e.g., 'console\.log', 'console\ log'). | +| `filterByRepoIds` | no | Restrict search to specific repository IDs (from 'list_repos'). Leave empty to search all. | +| `filterByLanguages` | no | Restrict search to specific languages (GitHub linguist format, e.g., Python, JavaScript). | +| `caseSensitive` | no | Case sensitive search (default: false). | +| `includeCodeSnippets` | no | Include code snippets in results (default: false). | +| `maxTokens` | no | Max tokens to return (default: env.DEFAULT_MINIMUM_TOKENS). | +
+ + +### list_repos + +Lists all repositories indexed by Sourcebot. + +### get_file_source + +Fetches the source code for a given file. + +
+Parameters + +| Name | Required | Description | +|:-------------|:---------|:-----------------------------------------------------------------| +| `fileName` | yes | The file to fetch the source code for. | +| `repoId` | yes | The Sourcebot repository ID. | +
+ + +## Supported Code Hosts +Sourcebot supports the following code hosts: +- [GitHub](https://docs.sourcebot.dev/docs/connections/github) +- [GitLab](https://docs.sourcebot.dev/docs/connections/gitlab) +- [Bitbucket Cloud](https://docs.sourcebot.dev/docs/connections/bitbucket-cloud) +- [Bitbucket Data Center](https://docs.sourcebot.dev/docs/connections/bitbucket-data-center) +- [Gitea](https://docs.sourcebot.dev/docs/connections/gitea) +- [Gerrit](https://docs.sourcebot.dev/docs/connections/gerrit) + +| Don't see your code host? Open a [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/ideas). + +## Future Work + +### Semantic Search + +Currently, Sourcebot only supports regex-based code search (powered by [zoekt](https://github.com/sourcegraph/zoekt) under the hood). It is great for scenarios when the agent is searching for is something that is super precise and well-represented in the source code (e.g., a specific function name, a error string, etc.). It is not-so-great for _fuzzy_ searches where the objective is to find some loosely defined _category_ or _concept_ in the code (e.g., find code that verifies JWT tokens). The LLM can approximate this by crafting regex searches that attempt to capture a concept (e.g., it might try a query like `"jwt|token|(verify|validate).*(jwt|token)"`), but often yields sub-optimal search results that aren't related. Tools like Cursor solve this with [embedding models](https://docs.cursor.com/context/codebase-indexing) to capture the semantic meaning of code, allowing for LLMs to search using natural language. We would like to extend Sourcebot to support semantic search and expose this capability over MCP as a tool (e.g., `semantic_search_code` tool). [GitHub Discussion](https://github.com/sourcebot-dev/sourcebot/discussions/297) + +### Code Navigation + +Another idea is to allow LLMs to traverse abstract syntax trees (ASTs) of a codebase to enable reliable code navigation. This could be packaged as tools like `goto_definition`, `find_all_references`, etc., which could be useful for LLMs to get additional code context. [GitHub Discussion](https://github.com/sourcebot-dev/sourcebot/discussions/296) + +### Got an idea? + +Open up a [GitHub discussion](https://github.com/sourcebot-dev/sourcebot/discussions/categories/feature-requests)! diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000..fcd9fa20 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,41 @@ +{ + "name": "@sourcebot/mcp", + "version": "1.0.2", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "node ./dist/index.js", + "build:watch": "tsc-watch --preserveWatchOutput" + }, + "devDependencies": { + "@types/express": "^5.0.1", + "@types/node": "^20.0.0", + "tsc-watch": "6.2.1", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.10.2", + "@t3-oss/env-core": "^0.13.4", + "escape-string-regexp": "^5.0.0", + "express": "^5.1.0", + "zod": "^3.24.3" + }, + "bin": { + "sourcebot-mcp": "./dist/index.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/sourcebot-dev/sourcebot.git", + "directory": "packages/mcp" + }, + "keywords": [ + "mcp", + "modelcontextprotocol", + "code-search", + "sourcebot", + "code-intelligence" + ] +} diff --git a/packages/mcp/smithery.yaml b/packages/mcp/smithery.yaml new file mode 100644 index 00000000..06974cba --- /dev/null +++ b/packages/mcp/smithery.yaml @@ -0,0 +1,24 @@ +# Smithery configuration file: https://smithery.ai/docs/build/project-config + +startCommand: + type: stdio + configSchema: + # JSON Schema defining the configuration options for the MCP. + type: object + required: [] + properties: + sourcebotHost: + type: string + description: Optional URL of the Sourcebot server (e.g., http://localhost:3000). + commandFunction: + # A JS function that produces the CLI command based on the given config to start the MCP on stdio. + |- + (config) => { + const env = {}; + if (config.sourcebotHost) { + env.SOURCEBOT_HOST = config.sourcebotHost; + } + return { command: 'node', args: ['dist/index.js'], env }; + } + exampleConfig: + sourcebotHost: http://localhost:3000 diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts new file mode 100644 index 00000000..ffde2d7b --- /dev/null +++ b/packages/mcp/src/client.ts @@ -0,0 +1,58 @@ +import { env } from './env.js'; +import { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema } from './schemas.js'; +import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse, ServiceError } from './types.js'; +import { isServiceError } from './utils.js'; + +export const search = async (request: SearchRequest): Promise => { + console.error(`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': '~', + ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) + }, + body: JSON.stringify(request) + }).then(response => response.json()); + + if (isServiceError(result)) { + return result; + } + + return searchResponseSchema.parse(result); +} + +export const listRepos = async (): Promise => { + const result = await fetch(`${env.SOURCEBOT_HOST}/api/repos`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Org-Domain': '~', + ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) + }, + }).then(response => response.json()); + + if (isServiceError(result)) { + return result; + } + + return listRepositoriesResponseSchema.parse(result); +} + +export const getFileSource = async (request: FileSourceRequest): Promise => { + const result = await fetch(`${env.SOURCEBOT_HOST}/api/source`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Org-Domain': '~', + ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) + }, + body: JSON.stringify(request) + }).then(response => response.json()); + + if (isServiceError(result)) { + return result; + } + + return fileSourceResponseSchema.parse(result); +} diff --git a/packages/mcp/src/env.ts b/packages/mcp/src/env.ts new file mode 100644 index 00000000..d4cac622 --- /dev/null +++ b/packages/mcp/src/env.ts @@ -0,0 +1,25 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; + +export const numberSchema = z.coerce.number(); + +const SOURCEBOT_DEMO_HOST = "https://demo.sourcebot.dev"; + +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), + + // The number of matches to fetch from the search API. + DEFAULT_MATCHES: numberSchema.default(10000), + + // The number of lines to include above and below a match + DEFAULT_CONTEXT_LINES: numberSchema.default(5), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, +}); \ No newline at end of file diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000..00933bd5 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +// Entry point for the MCP server +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import escapeStringRegexp from 'escape-string-regexp'; +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'; + +// Create MCP server +const server = new McpServer({ + name: 'sourcebot-mcp-server', + version: '0.1.0', +}); + + +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.`, + { + query: z + .string() + .describe(`The regex pattern to search for. RULES: + 1. When a regex special character needs to be escaped, ALWAYS use a single backslash (\) (e.g., 'console\.log') + 2. **ALWAYS** escape spaces with a single backslash (\) (e.g., 'console\ log') + `), + filterByRepoIds: z + .array(z.string()) + .describe(`Scope the search to the provided repositories to the Sourcebot compatible repository IDs. **DO NOT** use this property if you want to search all repositories. **YOU MUST** call 'list_repos' first to obtain the exact repository ID.`) + .optional(), + filterByLanguages: z + .array(z.string()) + .describe(`Scope the search to the provided languages. The language MUST be formatted as a GitHub linguist language. Examples: Python, JavaScript, TypeScript, Java, C#, C++, PHP, Go, Rust, Ruby, Swift, Kotlin, Shell, C, Dart, HTML, CSS, PowerShell, SQL, R`) + .optional(), + caseSensitive: z + .boolean() + .describe(`Whether the search should be case sensitive (default: false).`) + .optional(), + includeCodeSnippets: z + .boolean() + .describe(`Whether to include the code snippets in the response (default: false). If false, only the file's URL, repository, and language will be returned. Set to false to get a more concise response.`) + .optional(), + maxTokens: numberSchema + .describe(`The maximum number of tokens to return (default: ${env.DEFAULT_MINIMUM_TOKENS}). Higher values provide more context but consume more tokens. Values less than ${env.DEFAULT_MINIMUM_TOKENS} will be ignored.`) + .transform((val) => (val < env.DEFAULT_MINIMUM_TOKENS ? env.DEFAULT_MINIMUM_TOKENS : val)) + .optional(), + }, + async ({ + query, + filterByRepoIds: repoIds = [], + filterByLanguages: languages = [], + maxTokens = env.DEFAULT_MINIMUM_TOKENS, + includeCodeSnippets = false, + caseSensitive = false, + }) => { + if (repoIds.length > 0) { + query += ` ( repo:${repoIds.map(id => escapeStringRegexp(id)).join(' or repo:')} )`; + } + + if (languages.length > 0) { + query += ` ( lang:${languages.join(' or lang:')} )`; + } + + if (caseSensitive) { + query += ` case:yes`; + } else { + query += ` case:no`; + } + + console.error(`Executing search request: ${query}`); + + const response = await search({ + query, + matches: env.DEFAULT_MATCHES, + contextLines: env.DEFAULT_CONTEXT_LINES, + }); + + if (isServiceError(response)) { + return { + content: [{ + type: "text", + text: `Error searching code: ${response.message}`, + }], + }; + } + + if (response.files.length === 0) { + return { + content: [{ + type: "text", + text: `No results found for the query: ${query}`, + }], + }; + } + + const content: TextContent[] = []; + let totalTokens = 0; + let isResponseTruncated = false; + + for (const file of response.files) { + const numMatches = file.chunks.reduce( + (acc, chunk) => acc + chunk.matchRanges.length, + 0, + ); + let text = `file: ${file.webUrl}\nnum_matches: ${numMatches}\nrepository: ${file.repository}\nlanguage: ${file.language}`; + + if (includeCodeSnippets) { + const snippets = file.chunks.map(chunk => { + const content = base64Decode(chunk.content); + return `\`\`\`\n${content}\n\`\`\`` + }).join('\n'); + text += `\n\n${snippets}`; + } + + + // Rough estimate of the number of tokens in the text + // @see: https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them + const tokens = text.length / 4; + + if ((totalTokens + tokens) > maxTokens) { + isResponseTruncated = true; + break; + } + + totalTokens += tokens; + content.push({ + type: "text", + text, + }); + } + + if (isResponseTruncated) { + content.push({ + type: "text", + text: `The response was truncated because the number of tokens exceeded the maximum limit of ${maxTokens}.`, + }); + } + + return { + content, + } + } +); + +server.tool( + "list_repos", + "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)) { + return { + content: [{ + type: "text", + text: `Error listing repositories: ${response.message}`, + }], + }; + } + + const content: TextContent[] = response.repos.map(repo => { + return { + type: "text", + text: `id: ${repo.name}\nurl: ${repo.webUrl}`, + } + }); + + return { + content, + }; + } +); + +server.tool( + "get_file_source", + "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."), + }, + async ({ fileName, repoId }) => { + const response = await getFileSource({ + fileName, + repository: repoId, + }); + + if (isServiceError(response)) { + return { + content: [{ + type: "text", + text: `Error fetching file source: ${response.message}`, + }], + }; + } + + const content: TextContent[] = [{ + type: "text", + text: `file: ${fileName}\nrepository: ${repoId}\nlanguage: ${response.language}\nsource:\n${base64Decode(response.source)}`, + }] + + return { + content, + }; + } +); + + + +const runServer = async () => { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Sourcebot MCP server ready'); +} + +runServer().catch((error) => { + console.error('Failed to start MCP server:', error); + process.exit(1); +}); diff --git a/packages/mcp/src/schemas.ts b/packages/mcp/src/schemas.ts new file mode 100644 index 00000000..ad94b7dc --- /dev/null +++ b/packages/mcp/src/schemas.ts @@ -0,0 +1,121 @@ +// @NOTE : Please keep this file in sync with @sourcebot/web/src/features/search/schemas.ts +// At some point, we should move these to a shared package... +import { z } from "zod"; + +export const locationSchema = z.object({ + // 0-based byte offset from the beginning of the file + byteOffset: z.number(), + // 1-based line number from the beginning of the file + lineNumber: z.number(), + // 1-based column number (in runes) from the beginning of line + column: z.number(), +}); + +export const rangeSchema = z.object({ + start: locationSchema, + end: locationSchema, +}); + +export const symbolSchema = z.object({ + symbol: z.string(), + kind: z.string(), +}); + +export const searchRequestSchema = z.object({ + // The zoekt query to execute. + query: z.string(), + // The number of matches to return. + matches: z.number(), + // The number of context lines to return. + contextLines: z.number().optional(), + // Whether to return the whole file as part of the response. + whole: z.boolean().optional(), +}); + +export const repositoryInfoSchema = z.object({ + id: z.number(), + codeHostType: z.string(), + name: z.string(), + displayName: z.string().optional(), + webUrl: z.string().optional(), +}) + +export const searchResponseSchema = z.object({ + zoektStats: z.object({ + // The duration (in nanoseconds) of the search. + duration: z.number(), + fileCount: z.number(), + matchCount: z.number(), + filesSkipped: z.number(), + contentBytesLoaded: z.number(), + indexBytesLoaded: z.number(), + crashes: z.number(), + shardFilesConsidered: z.number(), + filesConsidered: z.number(), + filesLoaded: z.number(), + shardsScanned: z.number(), + shardsSkipped: z.number(), + shardsSkippedFilter: z.number(), + ngramMatches: z.number(), + ngramLookups: z.number(), + wait: z.number(), + matchTreeConstruction: z.number(), + matchTreeSearch: z.number(), + regexpsConsidered: z.number(), + flushReason: z.number(), + }), + files: z.array(z.object({ + fileName: z.object({ + // The name of the file + text: z.string(), + // Any matching ranges + matchRanges: z.array(rangeSchema), + }), + webUrl: z.string().optional(), + repository: z.string(), + repositoryId: z.number(), + language: z.string(), + chunks: z.array(z.object({ + content: z.string(), + matchRanges: z.array(rangeSchema), + contentStart: locationSchema, + symbols: z.array(z.object({ + ...symbolSchema.shape, + parent: symbolSchema.optional(), + })).optional(), + })), + branches: z.array(z.string()).optional(), + // Set if `whole` is true. + content: z.string().optional(), + })), + repositoryInfo: z.array(repositoryInfoSchema), + isBranchFilteringEnabled: z.boolean(), +}); + +export const repositorySchema = z.object({ + name: z.string(), + branches: z.array(z.string()), + webUrl: z.string().optional(), + rawConfig: z.record(z.string(), z.string()).optional(), +}); + +export const listRepositoriesResponseSchema = z.object({ + repos: z.array(repositorySchema), +}); + +export const fileSourceRequestSchema = z.object({ + fileName: z.string(), + repository: z.string(), + branch: z.string().optional(), +}); + +export const fileSourceResponseSchema = z.object({ + source: z.string(), + language: z.string(), +}); + +export const serviceErrorSchema = z.object({ + statusCode: z.number(), + errorCode: z.string(), + message: z.string(), +}); diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts new file mode 100644 index 00000000..f789c8c1 --- /dev/null +++ b/packages/mcp/src/types.ts @@ -0,0 +1,32 @@ +// @NOTE : Please keep this file in sync with @sourcebot/web/src/features/search/types.ts +// At some point, we should move these to a shared package... +import { + fileSourceResponseSchema, + listRepositoriesResponseSchema, + locationSchema, + searchRequestSchema, + searchResponseSchema, + rangeSchema, + fileSourceRequestSchema, + symbolSchema, + serviceErrorSchema, +} from "./schemas.js"; +import { z } from "zod"; + +export type SearchRequest = z.infer; +export type SearchResponse = z.infer; +export type SearchResultRange = z.infer; +export type SearchResultLocation = z.infer; +export type SearchResultFile = SearchResponse["files"][number]; +export type SearchResultChunk = SearchResultFile["chunks"][number]; +export type SearchSymbol = z.infer; + +export type ListRepositoriesResponse = z.infer; +export type Repository = ListRepositoriesResponse["repos"][number]; + +export type FileSourceRequest = z.infer; +export type FileSourceResponse = z.infer; + +export type TextContent = { type: "text", text: string }; + +export type ServiceError = z.infer; diff --git a/packages/mcp/src/utils.ts b/packages/mcp/src/utils.ts new file mode 100644 index 00000000..a99114c3 --- /dev/null +++ b/packages/mcp/src/utils.ts @@ -0,0 +1,15 @@ +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' && + data !== null && + 'statusCode' in data && + 'errorCode' in data && + 'message' in data; +} \ No newline at end of file diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 00000000..f84ffe8c --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "outDir": "dist", + "incremental": true, + "declaration": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "noEmitOnError": false, + "noImplicitAny": true, + "noUnusedLocals": false, + "pretty": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "lib": [ + "ES2023" + ], + "strict": true, + "sourceMap": true, + "inlineSources": true, + }, + "include": ["src/index.ts"] +} \ No newline at end of file diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 01fadaee..632361fd 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -5,12 +5,14 @@ "scripts": { "build": "yarn generate && tsc", "generate": "tsx tools/generate.ts", + "watch": "nodemon --watch ../../schemas -e json -x 'yarn generate'", "postinstall": "yarn build" }, "devDependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.3", "glob": "^11.0.1", "json-schema-to-typescript": "^15.0.4", + "nodemon": "^3.1.10", "tsx": "^4.19.2", "typescript": "^5.7.3" }, diff --git a/packages/schemas/src/v1/index.schema.ts b/packages/schemas/src/v1/index.schema.ts index f6c3d547..11e1fe11 100644 --- a/packages/schemas/src/v1/index.schema.ts +++ b/packages/schemas/src/v1/index.schema.ts @@ -18,10 +18,108 @@ const schema = { "ZoektConfig": { "anyOf": [ { - "$ref": "#/definitions/GitHubConfig" + "type": "object", + "properties": { + "Type": { + "const": "github" + }, + "GitHubUrl": { + "type": "string", + "description": "GitHub Enterprise url. If not set github.com will be used as the host." + }, + "GitHubUser": { + "type": "string", + "description": "The GitHub user to mirror" + }, + "GitHubOrg": { + "type": "string", + "description": "The GitHub organization to mirror" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitHub access token.", + "default": "~/.github-token" + }, + "Topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only mirror repos that have one of the given topics" + }, + "ExcludeTopics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Don't mirror repos that have one of the given topics" + }, + "NoArchived": { + "type": "boolean", + "description": "Mirror repos that are _not_ archived", + "default": false + }, + "IncludeForks": { + "type": "boolean", + "description": "Also mirror forks", + "default": false + } + }, + "required": [ + "Type" + ], + "additionalProperties": false }, { - "$ref": "#/definitions/GitLabConfig" + "type": "object", + "properties": { + "Type": { + "const": "gitlab" + }, + "GitLabURL": { + "type": "string", + "description": "The GitLab API url.", + "default": "https://gitlab.com/api/v4/" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "OnlyPublic": { + "type": "boolean", + "description": "Only mirror public repos", + "default": false + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitLab access token.", + "default": "~/.gitlab-token" + } + }, + "required": [ + "Type" + ], + "additionalProperties": false } ] }, @@ -44,10 +142,16 @@ const schema = { "description": "The GitHub organization to mirror" }, "Name": { - "$ref": "#/definitions/RepoNameRegexIncludeFilter" + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" }, "Exclude": { - "$ref": "#/definitions/RepoNameRegexExcludeFilter" + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" }, "CredentialPath": { "type": "string", @@ -96,10 +200,16 @@ const schema = { "default": "https://gitlab.com/api/v4/" }, "Name": { - "$ref": "#/definitions/RepoNameRegexIncludeFilter" + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" }, "Exclude": { - "$ref": "#/definitions/RepoNameRegexExcludeFilter" + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" }, "OnlyPublic": { "type": "boolean", @@ -125,7 +235,112 @@ const schema = { "Configs": { "type": "array", "items": { - "$ref": "#/definitions/ZoektConfig" + "anyOf": [ + { + "type": "object", + "properties": { + "Type": { + "const": "github" + }, + "GitHubUrl": { + "type": "string", + "description": "GitHub Enterprise url. If not set github.com will be used as the host." + }, + "GitHubUser": { + "type": "string", + "description": "The GitHub user to mirror" + }, + "GitHubOrg": { + "type": "string", + "description": "The GitHub organization to mirror" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitHub access token.", + "default": "~/.github-token" + }, + "Topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Only mirror repos that have one of the given topics" + }, + "ExcludeTopics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Don't mirror repos that have one of the given topics" + }, + "NoArchived": { + "type": "boolean", + "description": "Mirror repos that are _not_ archived", + "default": false + }, + "IncludeForks": { + "type": "boolean", + "description": "Also mirror forks", + "default": false + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Type": { + "const": "gitlab" + }, + "GitLabURL": { + "type": "string", + "description": "The GitLab API url.", + "default": "https://gitlab.com/api/v4/" + }, + "Name": { + "type": "string", + "description": "Only clone repos whose name matches the given regexp.", + "format": "regexp", + "default": "^(foo|bar)$" + }, + "Exclude": { + "type": "string", + "description": "Don't mirror repos whose names match this regexp.", + "format": "regexp", + "default": "^(fizz|buzz)$" + }, + "OnlyPublic": { + "type": "boolean", + "description": "Only mirror public repos", + "default": false + }, + "CredentialPath": { + "type": "string", + "description": "Path to a file containing a GitLab access token.", + "default": "~/.gitlab-token" + } + }, + "required": [ + "Type" + ], + "additionalProperties": false + } + ] } } }, diff --git a/packages/schemas/src/v2/index.schema.ts b/packages/schemas/src/v2/index.schema.ts index 1773e440..bdedcca4 100644 --- a/packages/schemas/src/v2/index.schema.ts +++ b/packages/schemas/src/v2/index.schema.ts @@ -74,13 +74,30 @@ const schema = { "description": "GitHub Configuration" }, "token": { - "$ref": "#/definitions/Token", "description": "A Personal Access Token (PAT).", "examples": [ "secret-token", { "env": "ENV_VAR_CONTAINING_TOKEN" } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } ] }, "url": { @@ -200,7 +217,45 @@ const schema = { "additionalProperties": false }, "revisions": { - "$ref": "#/definitions/GitRevisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -216,13 +271,30 @@ const schema = { "description": "GitLab Configuration" }, "token": { - "$ref": "#/definitions/Token", "description": "An authentication token.", "examples": [ "secret-token", { "env": "ENV_VAR_CONTAINING_TOKEN" } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } ] }, "url": { @@ -336,7 +408,45 @@ const schema = { "additionalProperties": false }, "revisions": { - "$ref": "#/definitions/GitRevisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -352,13 +462,30 @@ const schema = { "description": "Gitea Configuration" }, "token": { - "$ref": "#/definitions/Token", "description": "An access token.", "examples": [ "secret-token", { "env": "ENV_VAR_CONTAINING_TOKEN" } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } ] }, "url": { @@ -430,7 +557,45 @@ const schema = { "additionalProperties": false }, "revisions": { - "$ref": "#/definitions/GitRevisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -554,7 +719,45 @@ const schema = { "description": "The URL to the git repository." }, "revisions": { - "$ref": "#/definitions/GitRevisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -566,22 +769,704 @@ const schema = { "Repos": { "anyOf": [ { - "$ref": "#/definitions/GitHubConfig" + "type": "object", + "properties": { + "type": { + "const": "github", + "description": "GitHub Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://github.com", + "description": "The URL of the GitHub host. Defaults to https://github.com", + "examples": [ + "https://github.com", + "https://github.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "users": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "torvalds", + "DHH" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." + }, + "orgs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "my-org-name" + ], + [ + "sourcebot-dev", + "commaai" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false }, { - "$ref": "#/definitions/GitLabConfig" + "type": "object", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false }, { - "$ref": "#/definitions/GiteaConfig" + "type": "object", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "An access token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false }, { - "$ref": "#/definitions/GerritConfig" + "type": "object", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false }, { - "$ref": "#/definitions/LocalConfig" + "type": "object", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false }, { - "$ref": "#/definitions/GitConfig" + "type": "object", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false } ] }, @@ -627,13 +1512,747 @@ const schema = { "type": "string" }, "settings": { - "$ref": "#/definitions/Settings" + "type": "object", + "description": "Global settings. These settings are applied to all repositories.", + "properties": { + "maxFileSize": { + "type": "integer", + "description": "The maximum size of a file (in bytes) to be indexed. Files that exceed this maximum will not be inexed. Defaults to 2MB (2097152 bytes).", + "default": 2097152, + "minimum": 1 + }, + "maxTrigramCount": { + "type": "integer", + "description": "The maximum amount of trigrams per document. Documents that exceed this maximum will not be indexed. Defaults to 20000", + "default": 20000, + "minimum": 1 + }, + "autoDeleteStaleRepos": { + "type": "boolean", + "description": "Automatically delete stale repositories from the index. Defaults to true.", + "default": true + }, + "reindexInterval": { + "type": "integer", + "description": "The interval (in milliseconds) at which the indexer should re-index all repositories. Repositories are always indexed when first added. Defaults to 1 hour (3600000 milliseconds).", + "default": 3600000, + "minimum": 1 + }, + "resyncInterval": { + "type": "integer", + "description": "The interval (in milliseconds) at which the configuration file should be re-synced. The configuration file is always synced on startup. Defaults to 24 hours (86400000 milliseconds).", + "default": 86400000, + "minimum": 1 + } + }, + "additionalProperties": false }, "repos": { "type": "array", "description": "Defines a collection of repositories from varying code hosts that Sourcebot should sync with.", "items": { - "$ref": "#/definitions/Repos" + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "github", + "description": "GitHub Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://github.com", + "description": "The URL of the GitHub host. Defaults to https://github.com", + "examples": [ + "https://github.com", + "https://github.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "users": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "torvalds", + "DHH" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property." + }, + "orgs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "examples": [ + [ + "my-org-name" + ], + [ + "sourcebot-dev", + "commaai" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "An authentication token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitlab.com", + "description": "The URL of the GitLab host. Defaults to https://gitlab.com", + "examples": [ + "https://gitlab.com", + "https://gitlab.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "all": { + "type": "boolean", + "default": false, + "description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property." + }, + "groups": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group" + ], + [ + "my-group/sub-group-a", + "my-group/sub-group-b" + ] + ], + "description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-group/my-project" + ], + [ + "my-group/my-sub-group/my-project" + ] + ], + "description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.", + "examples": [ + [ + "docs", + "core" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked projects from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived projects from syncing." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "examples": [ + [ + "my-group/my-project" + ] + ], + "description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/" + }, + "topics": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.", + "examples": [ + [ + "tests", + "ci" + ] + ] + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "An access token.", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + } + ] } } }, diff --git a/packages/schemas/src/v3/bitbucket.schema.ts b/packages/schemas/src/v3/bitbucket.schema.ts new file mode 100644 index 00000000..a7c857ce --- /dev/null +++ b/packages/schemas/src/v3/bitbucket.schema.ts @@ -0,0 +1,179 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false +} as const; +export { schema as bitbucketSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/bitbucket.type.ts b/packages/schemas/src/v3/bitbucket.type.ts new file mode 100644 index 00000000..260d949d --- /dev/null +++ b/packages/schemas/src/v3/bitbucket.type.ts @@ -0,0 +1,76 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface BitbucketConnectionConfig { + /** + * Bitbucket configuration + */ + type: "bitbucket"; + /** + * The username to use for authentication. Only needed if token is an app password. + */ + user?: string; + /** + * An authentication token. + */ + 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; + }; + /** + * Bitbucket URL + */ + url?: string; + /** + * The type of Bitbucket deployment + */ + deploymentType?: "cloud" | "server"; + /** + * List of workspaces to sync. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of projects to sync + */ + projects?: string[]; + /** + * List of repos to sync + */ + repos?: string[]; + exclude?: { + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * List of specific repos to exclude from syncing. + */ + repos?: string[]; + }; + revisions?: GitRevisions; +} +/** + * The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored. + */ +export interface GitRevisions { + /** + * List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored. + */ + branches?: string[]; + /** + * List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored. + */ + tags?: string[]; +} diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 3ab0c8db..d0a72c72 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -226,12 +226,39 @@ const schema = { "description": "GitLab Configuration" }, "token": { - "$ref": "#/oneOf/0/properties/token", "description": "An authentication token.", "examples": [ { "secret": "SECRET_KEY" } + ], + "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 + } ] }, "url": { @@ -345,7 +372,45 @@ const schema = { "additionalProperties": false }, "revisions": { - "$ref": "#/oneOf/0/properties/revisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -363,12 +428,39 @@ const schema = { "description": "Gitea Configuration" }, "token": { - "$ref": "#/oneOf/0/properties/token", "description": "A Personal Access Token (PAT).", "examples": [ { "secret": "SECRET_KEY" } + ], + "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 + } ] }, "url": { @@ -440,7 +532,45 @@ const schema = { "additionalProperties": false }, "revisions": { - "$ref": "#/oneOf/0/properties/revisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -494,6 +624,261 @@ const schema = { ] ], "description": "List of specific projects to exclude from syncing." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Exclude read-only projects from syncing." + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Exclude hidden projects from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GenericGitHostConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Generic Git host configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns.", + "pattern": "^(https?:\\/\\/[^\\s/$.?#].[^\\s]*|file:\\/\\/\\/[^\\s]+)$", + "examples": [ + "https://github.com/sourcebot-dev/sourcebot", + "file:///path/to/repo", + "file:///repos/*" + ] + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] } }, "additionalProperties": false diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 8b1e479e..d1d2bc18 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -4,7 +4,9 @@ export type ConnectionConfig = | GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig - | GerritConnectionConfig; + | GerritConnectionConfig + | BitbucketConnectionConfig + | GenericGitHostConnectionConfig; export interface GithubConnectionConfig { /** @@ -233,5 +235,85 @@ export interface GerritConnectionConfig { * List of specific projects to exclude from syncing. */ projects?: string[]; + /** + * Exclude read-only projects from syncing. + */ + readOnly?: boolean; + /** + * Exclude hidden projects from syncing. + */ + hidden?: boolean; }; } +export interface BitbucketConnectionConfig { + /** + * Bitbucket configuration + */ + type: "bitbucket"; + /** + * The username to use for authentication. Only needed if token is an app password. + */ + user?: string; + /** + * An authentication token. + */ + 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; + }; + /** + * Bitbucket URL + */ + url?: string; + /** + * The type of Bitbucket deployment + */ + deploymentType?: "cloud" | "server"; + /** + * List of workspaces to sync. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of projects to sync + */ + projects?: string[]; + /** + * List of repos to sync + */ + repos?: string[]; + exclude?: { + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * List of specific repos to exclude from syncing. + */ + repos?: string[]; + }; + revisions?: GitRevisions; +} +export interface GenericGitHostConnectionConfig { + /** + * Generic Git host configuration + */ + type: "git"; + /** + * The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns. + */ + url: string; + revisions?: GitRevisions; +} diff --git a/packages/schemas/src/v3/genericGitHost.schema.ts b/packages/schemas/src/v3/genericGitHost.schema.ts new file mode 100644 index 00000000..f1cfdbe4 --- /dev/null +++ b/packages/schemas/src/v3/genericGitHost.schema.ts @@ -0,0 +1,70 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GenericGitHostConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Generic Git host configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns.", + "pattern": "^(https?:\\/\\/[^\\s/$.?#].[^\\s]*|file:\\/\\/\\/[^\\s]+)$", + "examples": [ + "https://github.com/sourcebot-dev/sourcebot", + "file:///path/to/repo", + "file:///repos/*" + ] + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false +} as const; +export { schema as genericGitHostSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/genericGitHost.type.ts b/packages/schemas/src/v3/genericGitHost.type.ts new file mode 100644 index 00000000..ba21e14c --- /dev/null +++ b/packages/schemas/src/v3/genericGitHost.type.ts @@ -0,0 +1,26 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface GenericGitHostConnectionConfig { + /** + * Generic Git host configuration + */ + type: "git"; + /** + * The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns. + */ + url: string; + revisions?: GitRevisions; +} +/** + * The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored. + */ +export interface GitRevisions { + /** + * List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored. + */ + branches?: string[]; + /** + * List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored. + */ + tags?: string[]; +} diff --git a/packages/schemas/src/v3/gerrit.schema.ts b/packages/schemas/src/v3/gerrit.schema.ts index 9ecca34a..b8b99e76 100644 --- a/packages/schemas/src/v3/gerrit.schema.ts +++ b/packages/schemas/src/v3/gerrit.schema.ts @@ -45,6 +45,16 @@ const schema = { ] ], "description": "List of specific projects to exclude from syncing." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Exclude read-only projects from syncing." + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Exclude hidden projects from syncing." } }, "additionalProperties": false diff --git a/packages/schemas/src/v3/gerrit.type.ts b/packages/schemas/src/v3/gerrit.type.ts index 735f87f6..752a63b3 100644 --- a/packages/schemas/src/v3/gerrit.type.ts +++ b/packages/schemas/src/v3/gerrit.type.ts @@ -18,5 +18,13 @@ export interface GerritConnectionConfig { * List of specific projects to exclude from syncing. */ projects?: string[]; + /** + * Exclude read-only projects from syncing. + */ + readOnly?: boolean; + /** + * Exclude hidden projects from syncing. + */ + hidden?: boolean; }; } diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index f8b9258c..925b27a8 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -6,7 +6,7 @@ const schema = { "definitions": { "Settings": { "type": "object", - "description": "Defines the globabl settings for Sourcebot.", + "description": "Defines the global settings for Sourcebot.", "properties": { "maxFileSize": { "type": "number", @@ -62,8 +62,55 @@ const schema = { "type": "number", "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "default": false + } + }, + "additionalProperties": false + }, + "SearchContext": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "SearchContext", + "description": "Search context", + "properties": { + "include": { + "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "exclude": { + "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "description": { + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." } }, + "required": [ + "include" + ], "additionalProperties": false } }, @@ -72,7 +119,120 @@ const schema = { "type": "string" }, "settings": { - "$ref": "#/definitions/Settings" + "type": "object", + "description": "Defines the global settings for Sourcebot.", + "properties": { + "maxFileSize": { + "type": "number", + "description": "The maximum size of a file (in bytes) to be indexed. Files that exceed this maximum will not be indexed. Defaults to 2MB.", + "minimum": 1 + }, + "maxTrigramCount": { + "type": "number", + "description": "The maximum number of trigrams per document. Files that exceed this maximum will not be indexed. Default to 20000.", + "minimum": 1 + }, + "reindexIntervalMs": { + "type": "number", + "description": "The interval (in milliseconds) at which the indexer should re-index all repositories. Defaults to 1 hour.", + "minimum": 1 + }, + "resyncConnectionIntervalMs": { + "type": "number", + "description": "The interval (in milliseconds) at which the connection manager should check for connections that need to be re-synced. Defaults to 24 hours.", + "minimum": 1 + }, + "resyncConnectionPollingIntervalMs": { + "type": "number", + "description": "The polling rate (in milliseconds) at which the db should be checked for connections that need to be re-synced. Defaults to 1 second.", + "minimum": 1 + }, + "reindexRepoPollingIntervalMs": { + "type": "number", + "description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.", + "minimum": 1 + }, + "maxConnectionSyncJobConcurrency": { + "type": "number", + "description": "The number of connection sync jobs to run concurrently. Defaults to 8.", + "minimum": 1 + }, + "maxRepoIndexingJobConcurrency": { + "type": "number", + "description": "The number of repo indexing jobs to run concurrently. Defaults to 8.", + "minimum": 1 + }, + "maxRepoGarbageCollectionJobConcurrency": { + "type": "number", + "description": "The number of repo GC jobs to run concurrently. Defaults to 8.", + "minimum": 1 + }, + "repoGarbageCollectionGracePeriodMs": { + "type": "number", + "description": "The grace period (in milliseconds) for garbage collection. Used to prevent deleting shards while they're being loaded. Defaults to 10 seconds.", + "minimum": 1 + }, + "repoIndexTimeoutMs": { + "type": "number", + "description": "The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours.", + "minimum": 1 + }, + "enablePublicAccess": { + "type": "boolean", + "description": "[Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats.", + "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", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "SearchContext", + "description": "Search context", + "properties": { + "include": { + "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "exclude": { + "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "description": { + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." + } + }, + "required": [ + "include" + ], + "additionalProperties": false + } + }, + "additionalProperties": false }, "connections": { "type": "object", @@ -305,12 +465,39 @@ const schema = { "description": "GitLab Configuration" }, "token": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/token", "description": "An authentication token.", "examples": [ { "secret": "SECRET_KEY" } + ], + "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 + } ] }, "url": { @@ -424,7 +611,45 @@ const schema = { "additionalProperties": false }, "revisions": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/revisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -442,12 +667,39 @@ const schema = { "description": "Gitea Configuration" }, "token": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/token", "description": "A Personal Access Token (PAT).", "examples": [ { "secret": "SECRET_KEY" } + ], + "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 + } ] }, "url": { @@ -519,7 +771,45 @@ const schema = { "additionalProperties": false }, "revisions": { - "$ref": "#/properties/connections/patternProperties/%5E%5Ba-zA-Z0-9_-%5D%2B%24/oneOf/0/properties/revisions" + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false } }, "required": [ @@ -573,6 +863,261 @@ const schema = { ] ], "description": "List of specific projects to exclude from syncing." + }, + "readOnly": { + "type": "boolean", + "default": false, + "description": "Exclude read-only projects from syncing." + }, + "hidden": { + "type": "boolean", + "default": false, + "description": "Exclude hidden projects from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "BitbucketConnectionConfig", + "properties": { + "type": { + "const": "bitbucket", + "description": "Bitbucket configuration" + }, + "user": { + "type": "string", + "description": "The username to use for authentication. Only needed if token is an app password." + }, + "token": { + "description": "An authentication token.", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "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 + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://api.bitbucket.org/2.0", + "description": "Bitbucket URL", + "examples": [ + "https://bitbucket.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Bitbucket deployment" + }, + "workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of workspaces to sync. Ignored if deploymentType is server." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of projects to sync" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of repos to sync" + }, + "exclude": { + "type": "object", + "properties": { + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "cloud_workspace/repo1", + "server_project/repo2" + ] + ], + "description": "List of specific repos to exclude from syncing." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "if": { + "properties": { + "deploymentType": { + "const": "server" + } + } + }, + "then": { + "required": [ + "url" + ] + }, + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GenericGitHostConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Generic Git host configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns.", + "pattern": "^(https?:\\/\\/[^\\s/$.?#].[^\\s]*|file:\\/\\/\\/[^\\s]+)$", + "examples": [ + "https://github.com/sourcebot-dev/sourcebot", + "file:///path/to/repo", + "file:///repos/*" + ] + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "properties": { + "branches": { + "type": "array", + "description": "List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] } }, "additionalProperties": false diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 01c6c668..4506c611 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -8,11 +8,19 @@ export type ConnectionConfig = | GithubConnectionConfig | GitlabConnectionConfig | GiteaConnectionConfig - | GerritConnectionConfig; + | GerritConnectionConfig + | BitbucketConnectionConfig + | GenericGitHostConnectionConfig; 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 + */ + contexts?: { + [k: string]: SearchContext; + }; /** * Defines a collection of connections from varying code hosts that Sourcebot should sync with. This is only available in single-tenancy mode. */ @@ -21,7 +29,7 @@ export interface SourcebotConfig { }; } /** - * Defines the globabl settings for Sourcebot. + * Defines the global settings for Sourcebot. * * This interface was referenced by `SourcebotConfig`'s JSON-Schema * via the `definition` "Settings". @@ -71,6 +79,33 @@ export interface Settings { * The timeout (in milliseconds) for a repo indexing to timeout. Defaults to 2 hours. */ repoIndexTimeoutMs?: number; + /** + * [Sourcebot EE] When enabled, allows unauthenticated users to access Sourcebot. Requires an enterprise license with an unlimited number of seats. + */ + enablePublicAccess?: boolean; +} +/** + * Search context + * + * This interface was referenced by `undefined`'s JSON-Schema definition + * via the `patternProperty` "^[a-zA-Z0-9_-]+$". + * + * This interface was referenced by `SourcebotConfig`'s JSON-Schema + * via the `definition` "SearchContext". + */ +export interface SearchContext { + /** + * List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported. + */ + include: string[]; + /** + * List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported. + */ + exclude?: string[]; + /** + * Optional description of the search context that surfaces in the UI. + */ + description?: string; } export interface GithubConnectionConfig { /** @@ -299,5 +334,85 @@ export interface GerritConnectionConfig { * List of specific projects to exclude from syncing. */ projects?: string[]; + /** + * Exclude read-only projects from syncing. + */ + readOnly?: boolean; + /** + * Exclude hidden projects from syncing. + */ + hidden?: boolean; + }; +} +export interface BitbucketConnectionConfig { + /** + * Bitbucket configuration + */ + type: "bitbucket"; + /** + * The username to use for authentication. Only needed if token is an app password. + */ + user?: string; + /** + * An authentication token. + */ + 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; + }; + /** + * Bitbucket URL + */ + url?: string; + /** + * The type of Bitbucket deployment + */ + deploymentType?: "cloud" | "server"; + /** + * List of workspaces to sync. Ignored if deploymentType is server. + */ + workspaces?: string[]; + /** + * List of projects to sync + */ + projects?: string[]; + /** + * List of repos to sync + */ + repos?: string[]; + exclude?: { + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * List of specific repos to exclude from syncing. + */ + repos?: string[]; }; + revisions?: GitRevisions; +} +export interface GenericGitHostConnectionConfig { + /** + * Generic Git host configuration + */ + type: "git"; + /** + * The URL to the git repository. This can either be a remote URL (prefixed with `http://` or `https://`) or a absolute path to a directory on the local machine (prefixed with `file://`). If a local directory is specified, it must point to the root of a git repository. Local directories are treated as read-only modified. Local directories support glob patterns. + */ + url: string; + revisions?: GitRevisions; } diff --git a/packages/schemas/src/v3/searchContext.schema.ts b/packages/schemas/src/v3/searchContext.schema.ts new file mode 100644 index 00000000..c36cb801 --- /dev/null +++ b/packages/schemas/src/v3/searchContext.schema.ts @@ -0,0 +1,44 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "SearchContext", + "description": "Search context", + "properties": { + "include": { + "type": "array", + "description": "List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/**", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "exclude": { + "type": "array", + "description": "List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported.", + "items": { + "type": "string" + }, + "examples": [ + [ + "github.com/sourcebot-dev/sourcebot", + "gerrit.example.org/sub/path/**" + ] + ] + }, + "description": { + "type": "string", + "description": "Optional description of the search context that surfaces in the UI." + } + }, + "required": [ + "include" + ], + "additionalProperties": false +} as const; +export { schema as searchContextSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/searchContext.type.ts b/packages/schemas/src/v3/searchContext.type.ts new file mode 100644 index 00000000..255b5a03 --- /dev/null +++ b/packages/schemas/src/v3/searchContext.type.ts @@ -0,0 +1,19 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +/** + * Search context + */ +export interface SearchContext { + /** + * List of repositories to include in the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported. + */ + include: string[]; + /** + * List of repositories to exclude from the search context. Expected to be formatted as a URL without any leading http(s):// prefix (e.g., 'github.com/sourcebot-dev/sourcebot'). Glob patterns are supported. + */ + exclude?: string[]; + /** + * Optional description of the search context that surfaces in the UI. + */ + description?: string; +} diff --git a/packages/schemas/tools/generate.ts b/packages/schemas/tools/generate.ts index 7ba1b467..d737b5ec 100644 --- a/packages/schemas/tools/generate.ts +++ b/packages/schemas/tools/generate.ts @@ -1,45 +1,60 @@ import path, { dirname } from "path"; -import { mkdir, rm, writeFile } from "fs/promises"; +import { mkdir, writeFile } from "fs/promises"; import $RefParser from "@apidevtools/json-schema-ref-parser"; import { compileFromFile } from "json-schema-to-typescript"; import { glob } from "glob"; -const BANNER_COMMENT = '// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!\n'; +const BANNER_COMMENT = 'THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!'; (async () => { const cwd = process.cwd(); const schemasBasePath = path.resolve(`${cwd}/../../schemas`); - const outDirRoot = path.resolve(`${cwd}/src`); + const srcDir = path.resolve(`${cwd}/src`); + const docsDir = path.resolve(`${cwd}/../../docs/snippets/schemas`); const schemas = await glob(`${schemasBasePath}/**/*.json`); await Promise.all(schemas.map(async (schemaPath) => { const name = path.parse(schemaPath).name; const version = path.basename(path.dirname(schemaPath)); - const outDir = path.join(outDirRoot, version); - await mkdir(outDir, { recursive: true }); + const srcOutDir = path.join(srcDir, version); + const docsOutDir = path.join(docsDir, version); + + await mkdir(srcOutDir, { recursive: true }); + await mkdir(docsOutDir, { recursive: true }); // Generate schema - const schema = JSON.stringify(await $RefParser.bundle(schemaPath), null, 2); + const schema = JSON.stringify(await $RefParser.dereference(schemaPath), null, 2); + + // Write to src await writeFile( - path.join(outDir, `${name}.schema.ts`), - BANNER_COMMENT + + path.join(srcOutDir, `${name}.schema.ts`), + `// ${BANNER_COMMENT}\n` + 'const schema = ' + - schema + - ` as const;\nexport { schema as ${name}Schema };`, + schema + + ` as const;\nexport { schema as ${name}Schema };`, + ); + + // Write to docs + await writeFile( + path.join(docsOutDir, `${name}.schema.mdx`), + `{/* ${BANNER_COMMENT} */}\n` + + '```json\n' + + schema + + '\n```\n' ); // Generate types const content = await compileFromFile(schemaPath, { - bannerComment: BANNER_COMMENT, + bannerComment: `// ${BANNER_COMMENT}\n`, cwd: dirname(schemaPath), ignoreMinAndMaxItems: true, declareExternallyReferenced: true, unreachableDefinitions: true, }); await writeFile( - path.join(outDir, `${name}.type.ts`), + path.join(srcOutDir, `${name}.type.ts`), content, ) })); diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json index c381b6b5..1808f80a 100644 --- a/packages/web/.eslintrc.json +++ b/packages/web/.eslintrc.json @@ -7,8 +7,8 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", - "plugin:react-hooks/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 68790d38..15e17fe4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "cross-env SKIP_ENV_VALIDATION=1 next lint", "test": "vitest", "dev:emails": "email dev --dir ./src/emails", "stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe" @@ -34,6 +34,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", @@ -44,6 +45,7 @@ "@iizukak/codemirror-lang-wgsl": "^0.3.0", "@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-dialog": "^1.1.4", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.6", @@ -55,6 +57,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", @@ -79,6 +82,7 @@ "@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", "@viz-js/lang-dot": "^1.0.4", @@ -113,10 +117,14 @@ "http-status-codes": "^2.3.0", "input-otp": "^1.4.2", "lucide-react": "^0.435.0", - "next": "14.2.25", + "micromatch": "^4.0.8", + "next": "14.2.26", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.3.0", "nodemailer": "^6.10.0", + "octokit": "^4.1.3", + "openai": "^4.98.0", + "parse-diff": "^0.11.1", "posthog-js": "^1.161.5", "pretty-bytes": "^6.1.1", "psl": "^1.15.0", @@ -134,9 +142,12 @@ "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "usehooks-ts": "^3.1.0", - "zod": "^3.24.2" + "zod": "^3.24.3", + "zod-to-json-schema": "^3.24.5" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.74.7", + "@types/micromatch": "^4.0.9", "@types/node": "^20", "@types/nodemailer": "^6.4.17", "@types/psl": "^1.1.3", @@ -144,6 +155,7 @@ "@types/react-dom": "^18", "@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/parser": "^8.3.0", + "cross-env": "^7.0.3", "eslint": "^8", "eslint-config-next": "14.2.6", "eslint-plugin-react": "^7.35.0", diff --git a/packages/web/public/bitbucket.svg b/packages/web/public/bitbucket.svg new file mode 100644 index 00000000..da2f871c --- /dev/null +++ b/packages/web/public/bitbucket.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/packages/web/public/git.svg b/packages/web/public/git.svg new file mode 100644 index 00000000..3c871a34 --- /dev/null +++ b/packages/web/public/git.svg @@ -0,0 +1,4 @@ + + + + \ 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/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/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/src/actions.ts b/packages/web/src/actions.ts index 0d98daff..3c4c37c6 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,33 +1,38 @@ 'use server'; -import Ajv from "ajv"; -import * as Sentry from '@sentry/nextjs'; -import { auth } from "./auth"; -import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists, stripeClientNotInitialized } from "@/lib/serviceError"; -import { prisma } from "@/prisma"; -import { StatusCodes } from "http-status-codes"; +import { env } from "@/env.mjs"; import { ErrorCode } from "@/lib/errorCodes"; -import { isServiceError } from "@/lib/utils"; +import { notAuthenticated, notFound, 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, generateApiKey, hashSecret } 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 { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema"; -import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema"; -import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; -import { decrypt, encrypt } from "@sourcebot/crypto" +import Ajv from "ajv"; +import { StatusCodes } from "http-status-codes"; +import { cookies, headers } from "next/headers"; +import { createTransport } from "nodemailer"; +import { auth } from "./auth"; import { getConnection } from "./data/connection"; -import { ConnectionSyncStatus, Prisma, OrgRole, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; -import { cookies, headers } from "next/headers" -import { Session } from "next-auth"; -import { env } from "@/env.mjs"; -import Stripe from "stripe"; -import { render } from "@react-email/components"; +import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe"; import InviteUserEmail from "./emails/inviteUserEmail"; -import { createTransport } from "nodemailer"; +import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_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 { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_USER_EMAIL, SINGLE_TENANT_USER_ID } from "./lib/constants"; -import { stripeClient } from "./lib/stripe"; -import { IS_BILLING_ENABLED } from "./lib/stripe"; +import { TenancyMode, ApiKeyPayload } from "./lib/types"; +import { decrementOrgSeatCount, getSubscriptionForOrg, incrementOrgSeatCount } from "./ee/features/billing/serverUtils"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; +import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; +import { getPlan, getSeats, SOURCEBOT_UNLIMITED_SEATS } from "./features/entitlements/server"; +import { hasEntitlement } from "./features/entitlements/server"; +import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess"; +import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; +import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; const ajv = new Ajv({ validateFormats: false, @@ -49,33 +54,91 @@ export const sew = async (fn: () => Promise): Promise => } } -export const withAuth = async (fn: (session: Session) => Promise, allowSingleTenantUnauthedAccess: boolean = false) => { +export const withAuth = async (fn: (userId: string) => Promise, allowSingleTenantUnauthedAccess: 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, + // 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 publicAccessEnabled = await getPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN); + if (apiKey) { + const apiKeyOrError = await verifyApiKey(apiKey); + if (isServiceError(apiKeyOrError)) { + console.error(`Invalid API key: ${JSON.stringify(apiKey)}. Error: ${JSON.stringify(apiKeyOrError)}`); + return notAuthenticated(); + } + + const user = await prisma.user.findUnique({ + where: { + id: apiKeyOrError.apiKey.createdById, }, - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(), }); - } + if (!user) { + console.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); + } else if ( + env.SOURCEBOT_TENANCY_MODE === 'single' && + allowSingleTenantUnauthedAccess && + !isServiceError(publicAccessEnabled) && + publicAccessEnabled + ) { + if (!hasEntitlement("public-access")) { + const plan = getPlan(); + console.error(`Public access isn't 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 notAuthenticated(); + } + + // To support unauthed access a guest user is created in initialize.ts, which we return here + return fn(SOURCEBOT_GUEST_USER_ID); + } return notAuthenticated(); } - return fn(session); + return fn(session.user.id); +} + +export const orgHasAvailability = async (domain: string): Promise => { + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); + + if (!org) { + return false; + } + const members = await prisma.userToOrg.findMany({ + where: { + orgId: org.id, + role: { + not: OrgRole.GUEST, + }, + }, + }); + + const maxSeats = getSeats(); + const memberCount = members.length; + + if (maxSeats !== SOURCEBOT_UNLIMITED_SEATS && memberCount >= maxSeats) { + return false; + } + + return true; } -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, @@ -83,28 +146,30 @@ 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 => { switch (role) { - case OrgRole.MEMBER: + case OrgRole.GUEST: return 0; - case OrgRole.OWNER: + case OrgRole.MEMBER: return 1; + case OrgRole.OWNER: + return 2; } } @@ -118,7 +183,7 @@ export const withOrgMembership = async (session: Session, domain: string, fn: } return fn({ - orgId: org.id, + org: org, userRole: membership.role, }); } @@ -138,7 +203,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, @@ -148,7 +213,7 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number } role: "OWNER", user: { connect: { - id: session.user.id, + id: userId, } } } @@ -162,8 +227,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 { @@ -174,7 +239,7 @@ export const updateOrgName = async (name: string, domain: string) => sew(() => } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { name }, }); @@ -186,8 +251,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 { @@ -198,7 +263,7 @@ export const updateOrgDomain = async (newDomain: string, existingDomain: string) } await prisma.org.update({ - where: { id: orgId }, + where: { id: org.id }, data: { domain: newDomain }, }); @@ -209,20 +274,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, } @@ -230,13 +287,13 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo // Else, validate that the org has an active subscription. } else { - const subscriptionOrError = await fetchSubscription(domain); + const subscriptionOrError = await getSubscriptionForOrg(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, @@ -252,11 +309,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, @@ -271,13 +328,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, } } @@ -289,13 +346,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, @@ -303,12 +360,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, } } @@ -318,12 +375,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, } } @@ -334,13 +391,147 @@ 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) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.API_KEY_ALREADY_EXISTS, + message: `API key ${name} already exists`, + } satisfies ServiceError; + } + + const { key, hash } = generateApiKey(); + await prisma.apiKey.create({ + data: { + name, + hash, + orgId: org.id, + createdById: userId, + } + }); + + return { + key, + } + }))); + +export const deleteApiKey = async (name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth((userId) => + withOrgMembership(userId, domain, async () => { + const apiKey = await prisma.apiKey.findFirst({ + where: { + name, + createdById: userId, + }, + }); + + if (!apiKey) { + 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, + }, + }); + + 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 } } : {}), @@ -368,15 +559,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, @@ -400,11 +592,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 } } : {}), @@ -440,13 +632,83 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt indexedAt: repo.indexedAt ?? undefined, repoIndexingStatus: repo.repoIndexingStatus, })); - } - ), /* allowSingleTenantUnauthedAccess = */ true)); + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + )); + +export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() => + 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 + // - gitlab.com/gitlab-org/gitlab + // - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor + // etc. + // + // For most purposes, repo names are unique within an org, so using + // findFirst is equivalent to findUnique. Duplicates _can_ occur when + // a repository is specified by its remote url in a generic `git` + // connection. For example: + // + // ```json + // { + // "connections": { + // "connection-1": { + // "type": "github", + // "repos": [ + // "sourcebot-dev/sourcebot" + // ] + // }, + // "connection-2": { + // "type": "git", + // "url": "file:///tmp/repos/sourcebot" + // } + // } + // } + // ``` + // + // 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 + // these cases. + // @see: repoCompileUtils.ts + const repo = await prisma.repo.findFirst({ + where: { + name: repoName, + orgId: org.id, + }, + }); + + if (!repo) { + return notFound(); + } + + return { + id: repo.id, + name: repo.name, + displayName: repo.displayName ?? undefined, + codeHostType: repo.external_codeHostType, + webUrl: repo.webUrl ?? undefined, + imageUrl: repo.imageUrl ?? undefined, + indexedAt: repo.indexedAt ?? undefined, + repoIndexingStatus: repo.repoIndexingStatus, + } + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true + )); + +export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => + 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; + } -export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const parsedConfig = parseConnectionConfig(type, connectionConfig); + const parsedConfig = parseConnectionConfig(connectionConfig); if (isServiceError(parsedConfig)) { return parsedConfig; } @@ -454,7 +716,7 @@ export const createConnection = async (name: string, type: string, connectionCon const existingConnectionWithName = await prisma.connection.findUnique({ where: { name_orgId: { - orgId, + orgId: org.id, name, } } @@ -470,7 +732,7 @@ export const createConnection = async (name: string, type: string, connectionCon const connection = await prisma.connection.create({ data: { - orgId, + orgId: org.id, name, config: parsedConfig as unknown as Prisma.InputJsonValue, connectionType: type, @@ -484,9 +746,9 @@ export const createConnection = async (name: string, type: string, connectionCon )); 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(); } @@ -494,7 +756,7 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st const existingConnectionWithName = await prisma.connection.findUnique({ where: { name_orgId: { - orgId, + orgId: org.id, name, } } @@ -511,7 +773,7 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st await prisma.connection.update({ where: { id: connectionId, - orgId, + orgId: org.id, }, data: { name, @@ -525,14 +787,14 @@ 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(); } - const parsedConfig = parseConnectionConfig(connection.connectionType, config); + const parsedConfig = parseConnectionConfig(config); if (isServiceError(parsedConfig)) { return parsedConfig; } @@ -550,7 +812,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, @@ -565,10 +827,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(); } @@ -588,12 +850,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, @@ -607,9 +869,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(); } @@ -617,7 +879,7 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr await prisma.connection.delete({ where: { id: connectionId, - orgId, + orgId: org.id, } }); @@ -628,22 +890,36 @@ 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), /* allowSingleTenantUnauthedAccess = */ 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 user = await getMe(); + if (isServiceError(user)) { + throw new ServiceErrorException(user); + } + + const hasAvailability = await orgHasAvailability(domain); + if (!hasAvailability) { + 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, } }); @@ -663,7 +939,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ in: emails, } }, - orgId, + orgId: org.id, }, }); @@ -678,8 +954,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, }); @@ -692,7 +968,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ where: { recipientEmail_orgId: { recipientEmail: email, - orgId, + orgId: org.id, }, }, include: { @@ -712,11 +988,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, @@ -739,6 +1014,8 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ console.error(`Failed to send invite email to ${email}: ${failed}`); } })); + } else { + console.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); } return { @@ -748,12 +1025,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, }, }); @@ -774,10 +1051,10 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{ )); 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: { @@ -796,6 +1073,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, @@ -825,31 +1103,21 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean return user; } + const hasAvailability = await orgHasAvailability(invite.org.domain); + if (!hasAvailability) { + 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) { return notFound(); } const res = await prisma.$transaction(async (tx) => { - if (IS_BILLING_ENABLED) { - // @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check. - const subscription = await _fetchSubscriptionForOrg(invite.orgId, tx); - if (isServiceError(subscription)) { - return subscription; - } - - const existingSeatCount = subscription.items.data[0].quantity; - const newSeatCount = (existingSeatCount || 1) + 1 - - await stripeClient?.subscriptionItems.update( - subscription.items.data[0].id, - { - quantity: newSeatCount, - proration_behavior: 'create_prorations', - } - ) - } - await tx.userToOrg.create({ data: { userId: user.id, @@ -858,11 +1126,27 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } }); + await tx.user.update({ + where: { + id: user.id, + }, + data: { + pendingApproval: false, + } + }); + await tx.invite.delete({ where: { id: invite.id, } }); + + if (IS_BILLING_ENABLED) { + const result = await incrementOrgSeatCount(invite.orgId, tx); + if (isServiceError(result)) { + throw result; + } + } }); if (isServiceError(res)) { @@ -917,9 +1201,9 @@ 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; if (newOwnerId === currentUserId) { return { @@ -933,7 +1217,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: newOwnerId, - orgId, + orgId: org.id, }, }, }); @@ -951,7 +1235,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: newOwnerId, - orgId, + orgId: org.id, }, }, data: { @@ -962,7 +1246,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro where: { orgId_userId: { userId: currentUserId, - orgId, + orgId: org.id, }, }, data: { @@ -977,505 +1261,458 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro }, /* minRequiredRole = */ OrgRole.OWNER) )); -export const createOnboardingSubscription = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ +export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => + withAuth(async () => { + const org = await prisma.org.findFirst({ + where: { + domain, + } + }); + + return !!org; + })); + +export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const targetMember = await prisma.userToOrg.findUnique({ where: { - id: orgId, - }, + orgId_userId: { + orgId: org.id, + userId: memberId, + } + } }); - if (!org) { + if (!targetMember) { return notFound(); } - const user = await getMe(); - if (isServiceError(user)) { - return user; - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const test_clock = env.STRIPE_ENABLE_TEST_CLOCKS === 'true' ? await stripeClient.testHelpers.testClocks.create({ - frozen_time: Math.floor(Date.now() / 1000) - }) : null; - - // Use the existing customer if it exists, otherwise create a new one. - const customerId = await (async () => { - if (org.stripeCustomerId) { - return org.stripeCustomerId; - } - - const customer = await stripeClient.customers.create({ - name: org.name, - email: user.email ?? undefined, - test_clock: test_clock?.id, - description: `Created by ${user.email} on ${domain} (id: ${org.id})`, + await prisma.$transaction(async (tx) => { + await tx.userToOrg.delete({ + where: { + orgId_userId: { + orgId: org.id, + userId: memberId, + } + } }); - await prisma.org.update({ + // TODO: The fact that pendingApproval is set in the user is a bit weird here, since it will prevent approval from working in the multi-tenant case. + // We need to set pendingApproval to be true here though so that if the user tries to sign into the deployment again it will send another request. Without + // this, the user will never be able to request to join the org again. + // TODO(multitenant): Handle this better + await tx.user.update({ where: { - id: org.id, + id: memberId, }, data: { - stripeCustomerId: customer.id, + pendingApproval: true, } }); - return customer.id; - })(); + if (IS_BILLING_ENABLED) { + const result = await decrementOrgSeatCount(org.id, tx); + if (isServiceError(result)) { + throw result; + } + } + }); + + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) + )); - const existingSubscription = await fetchSubscription(domain); - if (!isServiceError(existingSubscription)) { +export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org, userRole }) => { + if (userRole === OrgRole.OWNER) { return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS, - message: "Attemped to create a trial subscription for an organization that already has an active subscription", + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG, + message: "Organization owners cannot leave their own organization", } satisfies ServiceError; } + await prisma.$transaction(async (tx) => { + await tx.userToOrg.delete({ + where: { + orgId_userId: { + orgId: org.id, + userId: userId, + } + } + }); - const prices = await stripeClient.prices.list({ - product: env.STRIPE_PRODUCT_ID, - expand: ['data.product'], + if (IS_BILLING_ENABLED) { + const result = await decrementOrgSeatCount(org.id, tx); + if (isServiceError(result)) { + throw result; + } + } }); - try { - const subscription = await stripeClient.subscriptions.create({ - customer: customerId, - items: [{ - price: prices.data[0].id, - }], - trial_period_days: 14, - trial_settings: { - end_behavior: { - missing_payment_method: 'cancel', - }, - }, - payment_settings: { - save_default_payment_method: 'on_subscription', - }, - }); - - if (!subscription) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create subscription", - } satisfies ServiceError; - } - - return { - subscriptionId: subscription.id, - } - } catch (e) { - console.error(e); - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create subscription", - } satisfies ServiceError; + return { + success: true, } - - - }, /* minRequiredRole = */ OrgRole.OWNER) + }) )); -export const createStripeCheckoutSession = async (domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ + +export const getOrgMembership = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const membership = await prisma.userToOrg.findUnique({ where: { - id: orgId, - }, + orgId_userId: { + orgId: org.id, + userId: userId, + } + } }); - if (!org || !org.stripeCustomerId) { + if (!membership) { return notFound(); } - if (!stripeClient) { - return stripeClientNotInitialized(); - } + return membership; + }) + )); - const orgMembers = await prisma.userToOrg.findMany({ +export const getOrgMembers = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const members = await prisma.userToOrg.findMany({ where: { - orgId, - }, - select: { - userId: true, - } - }); - const numOrgMembers = orgMembers.length; - - const origin = (await headers()).get('origin')!; - const prices = await stripeClient.prices.list({ - product: env.STRIPE_PRODUCT_ID, - expand: ['data.product'], - }); - - const stripeSession = await stripeClient.checkout.sessions.create({ - customer: org.stripeCustomerId as string, - payment_method_types: ['card'], - line_items: [ - { - price: prices.data[0].id, - quantity: numOrgMembers + orgId: org.id, + role: { + not: OrgRole.GUEST, } - ], - mode: 'subscription', - payment_method_collection: 'always', - success_url: `${origin}/${domain}/settings/billing`, - cancel_url: `${origin}/${domain}`, + }, + include: { + user: true, + }, }); - if (!stripeSession.url) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create checkout session", - } satisfies ServiceError; - } - - return { - url: stripeSession.url, - } + return members.map((member) => ({ + id: member.userId, + email: member.user.email!, + name: member.user.name ?? undefined, + avatarUrl: member.user.image ?? undefined, + role: member.role, + joinedAt: member.joinedAt, + })); }) )); -export const getCustomerPortalSessionLink = async (domain: string): Promise => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ +export const getOrgInvites = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const invites = await prisma.invite.findMany({ where: { - id: orgId, + orgId: org.id, }, }); - if (!org || !org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const origin = (await headers()).get('origin')!; - const portalSession = await stripeClient.billingPortal.sessions.create({ - customer: org.stripeCustomerId as string, - return_url: `${origin}/${domain}/settings/billing`, - }); - - return portalSession.url; - }, /* minRequiredRole = */ OrgRole.OWNER) - )); - -export const fetchSubscription = (domain: string): Promise => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - return _fetchSubscriptionForOrg(orgId, prisma); + return invites.map((invite) => ({ + id: invite.id, + email: invite.recipientEmail, + createdAt: invite.createdAt, + })); }) )); -export const getSubscriptionBillingEmail = async (domain: string): Promise => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ +export const getOrgAccountRequests = async (domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const requests = await prisma.accountRequest.findMany({ where: { - id: orgId, + orgId: org.id, + }, + include: { + requestedBy: true, }, }); - if (!org || !org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const customer = await stripeClient.customers.retrieve(org.stripeCustomerId); - if (!('email' in customer) || customer.deleted) { - return notFound(); - } - return customer.email!; + return requests.map((request) => ({ + id: request.id, + email: request.requestedBy.email!, + createdAt: request.createdAt, + name: request.requestedBy.name ?? undefined, + })); }) )); -export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); +export const createAccountRequest = async (userId: string, domain: string) => sew(async () => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); - if (!org || !org.stripeCustomerId) { - return notFound(); - } + if (!user) { + return notFound("User not found"); + } - if (!stripeClient) { - return stripeClientNotInitialized(); - } + if (user.pendingApproval == false) { + console.warn(`User ${userId} isn't pending approval. Skipping account request creation.`); + return { + success: true, + existingRequest: false, + } + } - await stripeClient.customers.update(org.stripeCustomerId, { - email: newEmail, - }); + const org = await prisma.org.findUnique({ + where: { + domain, + }, + }); - return { - success: true, - } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); + if (!org) { + return notFound("Organization not found"); + } -export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => - withAuth(async () => { - const org = await prisma.org.findFirst({ - where: { - domain, - } - }); + const existingRequest = await prisma.accountRequest.findUnique({ + where: { + requestedById_orgId: { + requestedById: userId, + orgId: org.id, + }, + }, + }); - return !!org; - })); + if (existingRequest) { + console.warn(`User ${userId} already has an account request for org ${org.id}. Skipping account request creation.`); + return { + success: true, + existingRequest: true, + } + } -export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const targetMember = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - orgId, - userId: memberId, - } - } - }); + if (!existingRequest) { + await prisma.accountRequest.create({ + data: { + requestedById: userId, + orgId: org.id, + }, + }); - if (!targetMember) { - return notFound(); - } + 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 org = await prisma.org.findUnique({ + const owner = await prisma.user.findFirst({ where: { - id: orgId, + orgs: { + some: { + orgId: org.id, + role: "OWNER", + }, + }, }, }); - if (!org) { - return notFound(); - } - - if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); - if (isServiceError(subscription)) { - return subscription; - } + if (!owner) { + console.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 existingSeatCount = subscription.items.data[0].quantity; - const newSeatCount = (existingSeatCount || 1) - 1; + 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}`, + }); - await stripeClient?.subscriptionItems.update( - subscription.items.data[0].id, - { - quantity: newSeatCount, - proration_behavior: 'create_prorations', - } - ) + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + console.error(`Failed to send account request email to ${owner.email}: ${failed}`); + } } + } else { + console.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping account request email to owner`); + } + } - await prisma.userToOrg.delete({ + return { + success: true, + existingRequest: false, + } +}); + +export const approveAccountRequest = async (requestId: string, domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const request = await prisma.accountRequest.findUnique({ where: { - orgId_userId: { - orgId, - userId: memberId, - } - } + id: requestId, + }, + include: { + requestedBy: true, + }, }); - return { - success: true, + if (!request || request.orgId !== org.id) { + return notFound(); } - }, /* minRequiredRole = */ OrgRole.OWNER) - )); -export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId, userRole }) => { - if (userRole === OrgRole.OWNER) { + const hasAvailability = await orgHasAvailability(domain); + if (!hasAvailability) { return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG, - message: "Organization owners cannot leave their own organization", + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, + message: "Organization is at max capacity", } satisfies ServiceError; } - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } + const res = await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { + id: request.requestedById, + }, + data: { + pendingApproval: false, + }, + }); - if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); - if (isServiceError(subscription)) { - return subscription; - } + await tx.userToOrg.create({ + data: { + userId: request.requestedById, + orgId: org.id, + role: "MEMBER", + }, + }); - const existingSeatCount = subscription.items.data[0].quantity; - const newSeatCount = (existingSeatCount || 1) - 1; + await tx.accountRequest.delete({ + where: { + id: requestId, + }, + }); - await stripeClient?.subscriptionItems.update( - subscription.items.data[0].id, - { - quantity: newSeatCount, - proration_behavior: 'create_prorations', - } - ) - } + const invites = await tx.invite.findMany({ + where: { + recipientEmail: request.requestedBy.email!, + orgId: org.id, + }, + }) - await prisma.userToOrg.delete({ - where: { - orgId_userId: { - orgId, - userId: session.user.id, - } + for (const invite of invites) { + console.log(`Account request approved. Deleting invite ${invite.id} for ${request.requestedBy.email}`); + await tx.invite.delete({ + where: { + id: invite.id, + }, + }); } }); - return { - success: true, + if (isServiceError(res)) { + return res; } - }) - )); -export const getSubscriptionData = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async () => { - const subscription = await fetchSubscription(domain); - if (isServiceError(subscription)) { - return subscription; - } + // 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}`, + }); - if (!subscription) { - return null; + const failed = result.rejected.concat(result.pending).filter(Boolean); + if (failed.length > 0) { + console.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); + } + } else { + console.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); } return { - plan: "Team", - seats: subscription.items.data[0].quantity!, - perSeatPrice: subscription.items.data[0].price.unit_amount! / 100, - nextBillingDate: subscription.current_period_end!, - status: subscription.status, + success: true, } - }) + }, /* minRequiredRole = */ OrgRole.OWNER) )); -export const getOrgMembership = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const membership = await prisma.userToOrg.findUnique({ +export const rejectAccountRequest = async (requestId: string, domain: string) => sew(() => + withAuth(async (userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const request = await prisma.accountRequest.findUnique({ where: { - orgId_userId: { - orgId, - userId: session.user.id, - } - } + id: requestId, + }, }); - if (!membership) { + if (!request || request.orgId !== org.id) { return notFound(); } - return membership; - }) - )); - -export const getOrgMembers = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const members = await prisma.userToOrg.findMany({ + await prisma.accountRequest.delete({ where: { - orgId, - }, - include: { - user: true, + id: requestId, }, }); - return members.map((member) => ({ - id: member.userId, - email: member.user.email!, - name: member.user.name ?? undefined, - avatarUrl: member.user.image ?? undefined, - role: member.role, - joinedAt: member.joinedAt, - })); - }) + return { + success: true, + } + }, /* minRequiredRole = */ OrgRole.OWNER) )); -export const getOrgInvites = async (domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const invites = await prisma.invite.findMany({ +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((userId) => + withOrgMembership(userId, domain, async ({ org }) => { + const searchContexts = await prisma.searchContext.findMany({ where: { - orgId, + orgId: org.id, }, }); - return invites.map((invite) => ({ - id: invite.id, - email: invite.recipientEmail, - createdAt: invite.createdAt, + return searchContexts.map((context) => ({ + name: context.name, + description: context.description ?? undefined, })); - }) + }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true )); -export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => { - await cookies().set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true'); - return true; -}); - ////// Helpers /////// -const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise => { - const org = await prisma.org.findUnique({ - where: { - id: orgId, - }, - }); - - if (!org) { - return notFound(); - } - - if (!org.stripeCustomerId) { - return notFound(); - } - - if (!stripeClient) { - return stripeClientNotInitialized(); - } - - const subscriptions = await stripeClient.subscriptions.list({ - customer: org.stripeCustomerId - }); - - if (subscriptions.data.length === 0) { - return orgInvalidSubscription(); - } - return subscriptions.data[0]; -} - -const parseConnectionConfig = (connectionType: string, config: string) => { +const parseConnectionConfig = (config: string) => { let parsedConfig: ConnectionConfig; try { parsedConfig = JSON.parse(config); @@ -1487,6 +1724,7 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } satisfies ServiceError; } + const connectionType = parsedConfig.type; const schema = (() => { switch (connectionType) { case "github": @@ -1497,6 +1735,10 @@ const parseConnectionConfig = (connectionType: string, config: string) => { return giteaSchema; case 'gerrit': return gerritSchema; + case 'bitbucket': + return bitbucketSchema; + case 'git': + return genericGitHostSchema; } })(); @@ -1526,9 +1768,10 @@ const parseConnectionConfig = (connectionType: string, config: string) => { } const { numRepos, hasToken } = (() => { - switch (parsedConfig.type) { + switch (connectionType) { case "gitea": - case "github": { + case "github": + case "bitbucket": { return { numRepos: parsedConfig.repos?.length, hasToken: !!parsedConfig.token, @@ -1546,6 +1789,12 @@ const parseConnectionConfig = (connectionType: string, config: string) => { hasToken: true, // gerrit doesn't use a token atm } } + case "git": { + return { + numRepos: 1, + hasToken: false, + } + } } })(); diff --git a/packages/web/src/app/[domain]/agents/page.tsx b/packages/web/src/app/[domain]/agents/page.tsx new file mode 100644 index 00000000..7f283ede --- /dev/null +++ b/packages/web/src/app/[domain]/agents/page.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; +import { NavigationMenu } from "../components/navigationMenu"; +import { FaCogs } from "react-icons/fa"; +import { env } from "@/env.mjs"; + +const agents = [ + { + id: "review-agent", + 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" + }, +]; + +export default function AgentsPage({ params: { domain } }: { params: { domain: string } }) { + return ( +
+ +
+
+ {agents.map((agent) => ( +
+ {/* Name and description */} +
+

+ {agent.name} +

+

+ {agent.description} +

+
+ {/* Actions */} +
+ {agent.requiredEnvVars.every(envVar => envVar in env && env[envVar as keyof typeof env] !== undefined) ? ( +
+ Agent is configured and accepting requests on /api/webhook +
+ ) : ( + + Configure + + )} +
+
+ ))} +
+
+
+ ); +} \ No newline at end of file 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..14726b47 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { ResizablePanel } from "@/components/ui/resizable"; +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"; + +interface CodePreviewPanelProps { + path: string; + repoName: string; + revisionName: string; + source: string; + language: string; +} + +export const CodePreviewPanel = ({ + source, + language, + path, + repoName, + revisionName, +}: CodePreviewPanelProps) => { + 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 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_browse_find_references_pressed', {}); + + updateBrowseState({ + selectedSymbolInfo: { + repoName, + symbolName, + revisionName, + language, + }, + isBottomPanelCollapsed: false, + activeExploreMenuTab: "references", + }) + }, [captureEvent, updateBrowseState, repoName, revisionName, language]); + + + // 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_browse_goto_definition_pressed', {}); + + 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]); + + const theme = useCodeMirrorTheme(); + + return ( + + + + {editorRef && editorRef.view && currentSelection && ( + + )} + {editorRef && hasCodeNavEntitlement && ( + + )} + + + + + ) +} + 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..b5bba639 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/[...path]/components/rangeHighlightingExtension.ts @@ -0,0 +1,39 @@ +'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: "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; + + return Decoration.set([ + markDecoration.range(from, to), + ]); + } 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]/page.tsx b/packages/web/src/app/[domain]/browse/[...path]/page.tsx index 804f306b..12a290a7 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/page.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/page.tsx @@ -1,14 +1,17 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { TopBar } from "@/app/[domain]/components/topBar"; import { Separator } from '@/components/ui/separator'; -import { getFileSource, listRepositories } from '@/lib/server/searchService'; -import { base64Decode, isServiceError } from "@/lib/utils"; -import { CodePreview } from "./codePreview"; +import { getFileSource } from '@/features/search/fileSourceApi'; +import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"; +import { base64Decode } from "@/lib/utils"; 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 { CodePreviewPanel } from "./components/codePreviewPanel"; +import Image from "next/image"; + interface BrowsePageProps { params: { path: string[]; @@ -46,18 +49,21 @@ export default async function BrowsePage({ } })(); - const org = await getOrgFromDomain(params.domain); - if (!org) { - notFound(); - } + const repoInfo = await getRepoInfoByName(repoName, params.domain); + if (isServiceError(repoInfo)) { + if (repoInfo.errorCode === ErrorCode.NOT_FOUND) { + return ( +
+
+ + Repository not found +
+
+ ); + } - // @todo (bkellam) : We should probably have a endpoint to fetch repository metadata - // given it's name or id. - const reposResponse = await listRepositories(org.id); - if (isServiceError(reposResponse)) { - throw new ServiceErrorException(reposResponse); + throw new ServiceErrorException(repoInfo); } - const repo = reposResponse.List.Repos.find(r => r.Repository.Name === repoName); if (pathType === 'tree') { // @todo : proper tree handling @@ -68,65 +74,11 @@ export default async function BrowsePage({ ) } - return ( -
-
- - - {repo && ( - <> -
- -
- - - )} -
- {repo === undefined ? ( -
-
- - Repository not found -
-
- ) : ( - - )} -
- ) -} - -interface CodePreviewWrapper { - path: string, - repoName: string, - revisionName: string, - orgId: number, -} - -const CodePreviewWrapper = async ({ - path, - repoName, - revisionName, - orgId, -}: CodePreviewWrapper) => { - // @todo: this will depend on `pathType`. const fileSourceResponse = await getFileSource({ fileName: path, repository: repoName, - branch: revisionName, - }, orgId); + branch: revisionName ?? 'HEAD', + }, params.domain); if (isServiceError(fileSourceResponse)) { if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { @@ -143,13 +95,57 @@ const CodePreviewWrapper = async ({ throw new ServiceErrorException(fileSourceResponse); } + const codeHostInfo = getCodeHostInfoForRepo({ + codeHostType: repoInfo.codeHostType, + name: repoInfo.name, + displayName: repoInfo.displayName, + webUrl: repoInfo.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/browseStateProvider.tsx b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx new file mode 100644 index 00000000..9a4bb4b3 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/browseStateProvider.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; +import { createContext, useCallback, useEffect, useState } from "react"; +import { BOTTOM_PANEL_MIN_SIZE } from "./components/bottomPanel"; + +export interface BrowseState { + selectedSymbolInfo?: { + symbolName: string; + repoName: string; + revisionName: string; + language: string; + } + isBottomPanelCollapsed: boolean; + activeExploreMenuTab: "references" | "definitions"; + bottomPanelSize: number; +} + +const defaultState: BrowseState = { + selectedSymbolInfo: undefined, + isBottomPanelCollapsed: true, + activeExploreMenuTab: "references", + bottomPanelSize: BOTTOM_PANEL_MIN_SIZE, +}; + +export const SET_BROWSE_STATE_QUERY_PARAM = "setBrowseState"; + +export const BrowseStateContext = createContext<{ + state: BrowseState; + updateBrowseState: (state: Partial) => void; +}>({ + state: defaultState, + updateBrowseState: () => {}, +}); + +export const BrowseStateProvider = ({ children }: { children: React.ReactNode }) => { + 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..86147ee7 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/components/bottomPanel.tsx @@ -0,0 +1,136 @@ +'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/search/code-navigation"; + +export const BottomPanel = () => { + 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={2} + 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/hooks/useBrowseNavigation.ts b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts new file mode 100644 index 00000000..83780153 --- /dev/null +++ b/packages/web/src/app/[domain]/browse/hooks/useBrowseNavigation.ts @@ -0,0 +1,59 @@ +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'; + +interface NavigateToPathOptions { + repoName: string; + revisionName?: string; + path: string; + pathType: 'blob' | 'tree'; + highlightRange?: BrowseHighlightRange; + setBrowseState?: Partial; +} + +export const useBrowseNavigation = () => { + const router = useRouter(); + const domain = useDomain(); + + const navigateToPath = useCallback(({ + repoName, + revisionName = 'HEAD', + path, + pathType, + highlightRange, + setBrowseState, + }: NavigateToPathOptions) => { + 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)); + } + + router.push(`/${domain}/browse/${repoName}@${revisionName}/-/${pathType}/${path}?${params.toString()}`); + }, [domain, router]); + + return { + navigateToPath, + }; +}; \ 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/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx new file mode 100644 index 00000000..4f23d9cc --- /dev/null +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -0,0 +1,26 @@ +import { ResizablePanelGroup } from "@/components/ui/resizable"; +import { BottomPanel } from "./components/bottomPanel"; +import { AnimatedResizableHandle } from "@/components/ui/animatedResizableHandle"; +import { BrowseStateProvider } from "./browseStateProvider"; + +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ + children, +}: LayoutProps) { + return ( + +
+ + {children} + + + +
+
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/codeHostIconButton.tsx b/packages/web/src/app/[domain]/components/codeHostIconButton.tsx index bee932fe..796adf4e 100644 --- a/packages/web/src/app/[domain]/components/codeHostIconButton.tsx +++ b/packages/web/src/app/[domain]/components/codeHostIconButton.tsx @@ -19,7 +19,7 @@ export const CodeHostIconButton = ({ const captureEvent = useCaptureEvent(); return ( ) } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx new file mode 100644 index 00000000..52c762fc --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketCloudConnectionCreationForm.tsx @@ -0,0 +1,49 @@ +'use client'; + +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; +import { bitbucketCloudQuickActions } from "../../connections/quickActions"; + +interface BitbucketCloudConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => { + const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0); + const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0); + const hasWorkspaces = config.workspaces && config.workspaces.length > 0 && config.workspaces.some(w => w.trim().length > 0); + + if (!hasProjects && !hasRepos && !hasWorkspaces) { + return { + message: "At least one project, repository, or workspace must be specified", + isValid: false, + } + } + + return { + message: "Valid", + isValid: true, + } +}; + +export const BitbucketCloudConnectionCreationForm = ({ onCreated }: BitbucketCloudConnectionCreationFormProps) => { + const defaultConfig: BitbucketConnectionConfig = { + type: 'bitbucket', + deploymentType: 'cloud', + } + + return ( + + type="bitbucket-cloud" + title="Create a Bitbucket Cloud connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + }} + schema={bitbucketSchema} + additionalConfigValidation={additionalConfigValidation} + quickActions={bitbucketCloudQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx new file mode 100644 index 00000000..5065de00 --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/bitbucketDataCenterConnectionCreationForm.tsx @@ -0,0 +1,48 @@ +'use client'; + +import SharedConnectionCreationForm from "./sharedConnectionCreationForm"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; +import { bitbucketDataCenterQuickActions } from "../../connections/quickActions"; + +interface BitbucketDataCenterConnectionCreationFormProps { + onCreated?: (id: number) => void; +} + +const additionalConfigValidation = (config: BitbucketConnectionConfig): { message: string, isValid: boolean } => { + const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0); + const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0); + + if (!hasProjects && !hasRepos) { + return { + message: "At least one project or repository must be specified", + isValid: false, + } + } + + return { + message: "Valid", + isValid: true, + } +}; + +export const BitbucketDataCenterConnectionCreationForm = ({ onCreated }: BitbucketDataCenterConnectionCreationFormProps) => { + const defaultConfig: BitbucketConnectionConfig = { + type: 'bitbucket', + deploymentType: 'server', + } + + return ( + + type="bitbucket-server" + title="Create a Bitbucket Data Center connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + }} + schema={bitbucketSchema} + additionalConfigValidation={additionalConfigValidation} + quickActions={bitbucketDataCenterQuickActions} + onCreated={onCreated} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts b/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts index 0e22cf0c..db4bcd0f 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/index.ts @@ -2,3 +2,5 @@ export { GitHubConnectionCreationForm } from "./githubConnectionCreationForm"; export { GitLabConnectionCreationForm } from "./gitlabConnectionCreationForm"; export { GiteaConnectionCreationForm } from "./giteaConnectionCreationForm"; export { GerritConnectionCreationForm } from "./gerritConnectionCreationForm"; +export { BitbucketCloudConnectionCreationForm } from "./bitbucketCloudConnectionCreationForm"; +export { BitbucketDataCenterConnectionCreationForm } from "./bitbucketDataCenterConnectionCreationForm"; \ 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/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 index 6b8630d6..3eff5be6 100644 --- a/packages/web/src/app/[domain]/components/fileHeader.tsx +++ b/packages/web/src/app/[domain]/components/fileHeader.tsx @@ -1,17 +1,24 @@ -import { Repository } from "@/lib/types"; -import { getRepoCodeHostInfo } from "@/lib/utils"; +'use client'; + +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"; +import { useBrowseNavigation } from "../browse/hooks/useBrowseNavigation"; interface FileHeaderProps { - repo?: Repository; fileName: string; fileNameHighlightRange?: { from: number; to: number; } + repo: { + name: string; + codeHostType: string; + displayName?: string; + webUrl?: string; + }, branchDisplayName?: string; branchDisplayTitle?: string; } @@ -23,7 +30,14 @@ export const FileHeader = ({ branchDisplayName, branchDisplayTitle, }: FileHeaderProps) => { - const info = getRepoCodeHostInfo(repo); + const info = getCodeHostInfoForRepo({ + name: repo.name, + codeHostType: repo.codeHostType, + displayName: repo.displayName, + webUrl: repo.webUrl, + }); + + const { navigateToPath } = useBrowseNavigation(); return (
@@ -48,17 +62,11 @@ export const FileHeader = ({

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

)} @@ -66,7 +74,17 @@ export const FileHeader = ({
- + { + navigateToPath({ + repoName: repo.name, + path: fileName, + pathType: 'blob', + revisionName: branchDisplayName, + }); + }} + > {!fileNameHighlightRange ? fileName : ( diff --git a/packages/web/src/app/[domain]/components/importSecretDialog.tsx b/packages/web/src/app/[domain]/components/importSecretDialog.tsx index 853b298e..b67fea7a 100644 --- a/packages/web/src/app/[domain]/components/importSecretDialog.tsx +++ b/packages/web/src/app/[domain]/components/importSecretDialog.tsx @@ -88,6 +88,10 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo return ; case 'gitlab': return ; + case 'bitbucket-cloud': + return ; + case 'bitbucket-server': + return ; case 'gitea': return ; case 'gerrit': @@ -179,7 +183,7 @@ export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHo Key @@ -262,11 +266,33 @@ const GiteaPATCreationStep = ({ step }: { step: number }) => { ) } +const BitbucketCloudPATCreationStep = ({ step }: { step: number }) => { + return ( + Please check out our docs for more information on how to create auth credentials for Bitbucket Cloud. + > + + ) +} + +const BitbucketServerPATCreationStep = ({ step }: { step: number }) => { + return ( + Please check out our docs for more information on how to create auth credentials for Bitbucket Data Center. + > + + ) +} + interface SecretCreationStepProps { step: number; title: string; description: string | React.ReactNode; - children: React.ReactNode; + children?: React.ReactNode; } const SecretCreationStep = ({ step, title, description, children }: SecretCreationStepProps) => { 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..1cb01719 --- /dev/null +++ b/packages/web/src/app/[domain]/components/lightweightCodeHighlighter.tsx @@ -0,0 +1,276 @@ +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; +} + +/** + * 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 [highlightedLines, setHighlightedLines] = useState(null); + + const highlightStyle = useCodeMirrorHighlighter(); + + useEffect(() => { + 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 + ]); + + const lineCount = (highlightedLines ?? unhighlightedLines).length + lineNumbersOffset; + const lineNumberDigits = String(lineCount).length; + const lineNumberWidth = `${lineNumberDigits + 2}ch`; // +2 for padding + + 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 a0da71dc..9aa6f5e3 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -6,14 +6,17 @@ import { SettingsDropdown } from "./settingsDropdown"; import { GitHubLogoIcon, DiscordLogoIcon } from "@radix-ui/react-icons"; import { redirect } from "next/navigation"; import { OrgSelector } from "./orgSelector"; -import { getSubscriptionData } from "@/actions"; import { ErrorNavIndicator } from "./errorNavIndicator"; import { WarningNavIndicator } from "./warningNavIndicator"; import { ProgressNavIndicator } from "./progressNavIndicator"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { TrialNavIndicator } from "./trialNavIndicator"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { env } from "@/env.mjs"; +import { getSubscriptionInfo } from "@/ee/features/billing/actions"; +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"; @@ -24,10 +27,12 @@ interface NavigationMenuProps { export const NavigationMenu = async ({ domain, }: NavigationMenuProps) => { - const subscription = IS_BILLING_ENABLED ? await getSubscriptionData(domain) : null; + const subscription = IS_BILLING_ENABLED ? await getSubscriptionInfo(domain) : null; + const session = await auth(); + const isAuthenticated = session?.user !== undefined; return ( -
+
- {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( - - - - Connections - - - - )} - {env.SOURCEBOT_AUTH_ENABLED === 'true' && ( - - - - Settings - - - + {isAuthenticated && ( + <> + {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined && ( + + + + Agents + + + + )} + + + + Connections + + + + + + + Settings + + + + )} @@ -92,6 +106,7 @@ export const NavigationMenu = async ({ +
{ "use server"; @@ -120,7 +135,7 @@ export const NavigationMenu = async ({ - +
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..1910a84a --- /dev/null +++ b/packages/web/src/app/[domain]/components/pendingApproval.tsx @@ -0,0 +1,60 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { HelpCircle } from "lucide-react" +import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch" +import { SourcebotLogo } from "@/app/components/sourcebotLogo" +import { auth } from "@/auth" +import { ResubmitAccountRequestButton } from "./resubmitAccountRequestButton" + +interface PendingApprovalCardProps { + domain: string +} + +export const PendingApprovalCard = async ({ domain }: PendingApprovalCardProps) => { + const session = await auth() + const userId = session?.user?.id + + if (!userId) { + return null + } + + return ( +
+ + +
+ + + + Pending Approval + + Your request to join the organization is being reviewed + + + + +
+ +
+
+
+ +
+

Need help or have questions?

+ + Submit a support request + +
+
+
+
+
+
+
+ ) +} diff --git a/packages/web/src/app/[domain]/components/repositoryCarousel.tsx b/packages/web/src/app/[domain]/components/repositoryCarousel.tsx index 0ce235f2..2bbc9024 100644 --- a/packages/web/src/app/[domain]/components/repositoryCarousel.tsx +++ b/packages/web/src/app/[domain]/components/repositoryCarousel.tsx @@ -6,7 +6,7 @@ import { CarouselItem, } from "@/components/ui/carousel"; import Autoscroll from "embla-carousel-auto-scroll"; -import { getRepoQueryCodeHostInfo } from "@/lib/utils"; +import { getCodeHostInfoForRepo } from "@/lib/utils"; import Image from "next/image"; import { FileIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; @@ -57,7 +57,12 @@ const RepositoryBadge = ({ repo }: RepositoryBadgeProps) => { const { repoIcon, displayName, repoLink } = (() => { - const info = getRepoQueryCodeHostInfo(repo); + const info = getCodeHostInfoForRepo({ + codeHostType: repo.codeHostType, + name: repo.repoName, + displayName: repo.repoDisplayName, + webUrl: repo.webUrl, + }); if (info) { return { diff --git a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx index 486795ef..eb18c945 100644 --- a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx +++ b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx @@ -15,14 +15,22 @@ import { } from "@/components/ui/carousel"; import { RepoIndexingStatus } from "@sourcebot/db"; import { SymbolIcon } from "@radix-ui/react-icons"; +import { RepositoryQuery } from "@/lib/types"; -export function RepositorySnapshot({ authEnabled }: { authEnabled: boolean }) { +interface RepositorySnapshotProps { + repos: RepositoryQuery[]; +} + +export function RepositorySnapshot({ + repos: initialRepos, +}: RepositorySnapshotProps) { const domain = useDomain(); const { data: repos, isPending, isError } = useQuery({ queryKey: ['repos', domain], queryFn: () => unwrapServiceError(getRepos(domain)), refetchInterval: env.NEXT_PUBLIC_POLLING_INTERVAL_MS, + placeholderData: initialRepos, }); if (isPending || isError || !repos) { @@ -33,22 +41,30 @@ export function RepositorySnapshot({ authEnabled }: { authEnabled: boolean }) { ) } - const numIndexedRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXED).length; - const numIndexingRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXING || repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE).length; - if (numIndexedRepos === 0 && numIndexingRepos > 0) { - return ( -
- - indexing in progress... -
- ) - } else if (numIndexedRepos == 0) { - return ( - - ) + // Use `indexedAt` to determine if a repo has __ever__ been indexed. + // The repo indexing status only tells us the repo's current indexing status. + const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined); + + // If there are no indexed repos... + if (indexedRepos.length === 0) { + + // ... show a loading state if repos are being indexed now + if (repos.some((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXING || repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE)) { + return ( +
+ + indexing in progress... +
+ ) + + // ... otherwise, show the empty state. + } else { + return ( + + ) + } } - - const indexedRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXED); + return (
@@ -57,7 +73,7 @@ export function RepositorySnapshot({ authEnabled }: { authEnabled: boolean }) { href={`${domain}/repos`} className="text-blue-500" > - {repos.length > 1 ? 'repositories' : 'repository'} + {indexedRepos.length > 1 ? 'repositories' : 'repository'} @@ -65,7 +81,7 @@ export function RepositorySnapshot({ authEnabled }: { authEnabled: boolean }) { ) } -function EmptyRepoState({ domain, authEnabled }: { domain: string, authEnabled: boolean }) { +function EmptyRepoState() { return (
No repositories found @@ -73,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/resubmitAccountRequestButton.tsx b/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx new file mode 100644 index 00000000..ec4df43e --- /dev/null +++ b/packages/web/src/app/[domain]/components/resubmitAccountRequestButton.tsx @@ -0,0 +1,64 @@ +"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" + +interface ResubmitButtonProps { + domain: string + userId: string +} + +export function ResubmitAccountRequestButton({ domain, userId }: ResubmitButtonProps) { + const { toast } = useToast() + 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 Resubmitted", + description: "Your request to join the organization has been resubmitted.", + variant: "default", + }) + } + } else { + toast({ + title: "Failed to Resubmit", + description: `There was an error resubmitting 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/searchBar/constants.ts b/packages/web/src/app/[domain]/components/searchBar/constants.ts index e08a03fe..c637bee9 100644 --- a/packages/web/src/app/[domain]/components/searchBar/constants.ts +++ b/packages/web/src/app/[domain]/components/searchBar/constants.ts @@ -1,10 +1,10 @@ -import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; +import { Suggestion } from "./searchSuggestionsBox"; /** * List of search prefixes that can be used while the * `refine` suggestion mode is active. */ -enum SearchPrefix { +export enum SearchPrefix { repo = "repo:", r = "r:", lang = "lang:", @@ -18,162 +18,10 @@ enum SearchPrefix { archived = "archived:", case = "case:", fork = "fork:", - public = "public:" + public = "public:", + context = "context:", } -const negate = (prefix: SearchPrefix) => { - return `-${prefix}`; -} - -type SuggestionModeMapping = { - suggestionMode: SuggestionMode, - prefixes: string[], -} - -/** - * Maps search prefixes to a suggestion mode. When a query starts - * with a prefix, the corresponding suggestion mode is enabled. - * @see [searchSuggestionsBox.tsx](./searchSuggestionsBox.tsx) - */ -export const suggestionModeMappings: SuggestionModeMapping[] = [ - { - suggestionMode: "repo", - prefixes: [ - SearchPrefix.repo, negate(SearchPrefix.repo), - SearchPrefix.r, negate(SearchPrefix.r), - ] - }, - { - suggestionMode: "language", - prefixes: [ - SearchPrefix.lang, negate(SearchPrefix.lang), - ] - }, - { - suggestionMode: "file", - prefixes: [ - SearchPrefix.file, negate(SearchPrefix.file), - ] - }, - { - suggestionMode: "content", - prefixes: [ - SearchPrefix.content, negate(SearchPrefix.content), - ] - }, - { - suggestionMode: "revision", - prefixes: [ - SearchPrefix.rev, negate(SearchPrefix.rev), - SearchPrefix.revision, negate(SearchPrefix.revision), - SearchPrefix.branch, negate(SearchPrefix.branch), - SearchPrefix.b, negate(SearchPrefix.b), - ] - }, - { - suggestionMode: "symbol", - prefixes: [ - SearchPrefix.sym, negate(SearchPrefix.sym), - ] - }, - { - suggestionMode: "archived", - prefixes: [ - SearchPrefix.archived - ] - }, - { - suggestionMode: "case", - prefixes: [ - SearchPrefix.case - ] - }, - { - suggestionMode: "fork", - prefixes: [ - SearchPrefix.fork - ] - }, - { - suggestionMode: "public", - prefixes: [ - SearchPrefix.public - ] - } -]; - -export const refineModeSuggestions: Suggestion[] = [ - { - value: SearchPrefix.repo, - description: "Include only results from the given repository.", - spotlight: true, - }, - { - value: negate(SearchPrefix.repo), - description: "Exclude results from the given repository." - }, - { - value: SearchPrefix.lang, - description: "Include only results from the given language.", - spotlight: true, - }, - { - value: negate(SearchPrefix.lang), - description: "Exclude results from the given language." - }, - { - value: SearchPrefix.file, - description: "Include only results from filepaths matching the given search pattern.", - spotlight: true, - }, - { - value: negate(SearchPrefix.file), - description: "Exclude results from file paths matching the given search pattern." - }, - { - value: SearchPrefix.rev, - description: "Search a given branch or tag instead of the default branch.", - spotlight: true, - }, - { - value: negate(SearchPrefix.rev), - description: "Exclude results from the given branch or tag." - }, - { - value: SearchPrefix.sym, - description: "Include only symbols matching the given search pattern.", - spotlight: true, - }, - { - value: negate(SearchPrefix.sym), - description: "Exclude results from symbols matching the given search pattern." - }, - { - value: SearchPrefix.content, - description: "Include only results from files if their content matches the given search pattern." - }, - { - value: negate(SearchPrefix.content), - description: "Exclude results from files if their content matches the given search pattern." - }, - { - value: SearchPrefix.archived, - description: "Include results from archived repositories.", - }, - { - value: SearchPrefix.case, - description: "Control case-sensitivity of search patterns." - }, - { - value: SearchPrefix.fork, - description: "Include only results from forked repositories." - }, - { - value: SearchPrefix.public, - description: "Filter on repository visibility." - }, -]; - export const publicModeSuggestions: Suggestion[] = [ { value: "yes", diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx index cb347d43..450ed74b 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,8 @@ 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 tailwind from "@/tailwind"; interface SearchBarProps { className?: string; @@ -95,7 +95,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 +160,7 @@ export const SearchBar = ({ }, ], }); - }, [tailwind]); + }, []); const extensions = useMemo(() => { return [ @@ -267,7 +266,18 @@ export const SearchBar = ({ indentWithTab={false} autoFocus={autoFocus ?? false} /> - + + +
+ +
+
+ + Focus search bar + +
) => { const [highlightedSuggestionIndex, setHighlightedSuggestionIndex] = useState(0); const { onOpenChanged } = useSyntaxGuide(); + const refineModeSuggestions = useRefineModeSuggestions(); const { suggestions, isHighlightEnabled, descriptionPlacement, DefaultIcon, onSuggestionClicked } = useMemo(() => { if (!isEnabled) { @@ -198,6 +202,13 @@ const SearchSuggestionsBox = forwardRef(({ }, descriptionPlacement: "right", } + case "context": + return { + list: searchContextSuggestions, + onSuggestionClicked: createOnSuggestionClickedHandler(), + descriptionPlacement: "left", + DefaultIcon: VscFilter, + } case "none": case "revision": case "content": @@ -263,6 +274,8 @@ const SearchSuggestionsBox = forwardRef(({ symbolSuggestions, searchHistorySuggestions, languageSuggestions, + searchContextSuggestions, + refineModeSuggestions, ]); // When the list of suggestions change, reset the highlight index @@ -287,6 +300,8 @@ const SearchSuggestionsBox = forwardRef(({ return "Languages"; case "searchHistory": return "Search history" + case "context": + return "Search contexts" default: return ""; } diff --git a/packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts b/packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts new file mode 100644 index 00000000..fdc16d50 --- /dev/null +++ b/packages/web/src/app/[domain]/components/searchBar/useRefineModeSuggestions.ts @@ -0,0 +1,101 @@ +'use client'; + +import { useMemo } from "react"; +import { Suggestion } from "./searchSuggestionsBox"; +import { SearchPrefix } from "./constants"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; + +const negate = (prefix: SearchPrefix) => { + return `-${prefix}`; +} + +export const useRefineModeSuggestions = () => { + const isSearchContextsEnabled = useHasEntitlement('search-contexts'); + + const suggestions = useMemo((): Suggestion[] => { + return [ + ...(isSearchContextsEnabled ? [ + { + value: SearchPrefix.context, + description: "Include only results from the given search context.", + spotlight: true, + }, + { + value: negate(SearchPrefix.context), + description: "Exclude results from the given search context." + }, + ] : []), + { + value: SearchPrefix.public, + description: "Filter on repository visibility." + }, + { + value: SearchPrefix.repo, + description: "Include only results from the given repository.", + spotlight: true, + }, + { + value: negate(SearchPrefix.repo), + description: "Exclude results from the given repository." + }, + { + value: SearchPrefix.lang, + description: "Include only results from the given language.", + spotlight: true, + }, + { + value: negate(SearchPrefix.lang), + description: "Exclude results from the given language." + }, + { + value: SearchPrefix.file, + description: "Include only results from filepaths matching the given search pattern.", + spotlight: true, + }, + { + value: negate(SearchPrefix.file), + description: "Exclude results from file paths matching the given search pattern." + }, + { + value: SearchPrefix.rev, + description: "Search a given branch or tag instead of the default branch.", + spotlight: true, + }, + { + value: negate(SearchPrefix.rev), + description: "Exclude results from the given branch or tag." + }, + { + value: SearchPrefix.sym, + description: "Include only symbols matching the given search pattern.", + spotlight: true, + }, + { + value: negate(SearchPrefix.sym), + description: "Exclude results from symbols matching the given search pattern." + }, + { + value: SearchPrefix.content, + description: "Include only results from files if their content matches the given search pattern." + }, + { + value: negate(SearchPrefix.content), + description: "Exclude results from files if their content matches the given search pattern." + }, + { + value: SearchPrefix.archived, + description: "Include results from archived repositories.", + }, + { + value: SearchPrefix.case, + description: "Control case-sensitivity of search patterns." + }, + { + value: SearchPrefix.fork, + description: "Include only results from forked repositories." + }, + ]; + }, [isSearchContextsEnabled]); + + return suggestions; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts index 555b4c22..42474a17 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeAndQuery.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { splitQuery, SuggestionMode } from "./searchSuggestionsBox"; -import { suggestionModeMappings } from "./constants"; +import { useSuggestionModeMappings } from "./useSuggestionModeMappings"; interface Props { isSuggestionsEnabled: boolean; @@ -18,6 +18,8 @@ export const useSuggestionModeAndQuery = ({ query, }: Props) => { + const suggestionModeMappings = useSuggestionModeMappings(); + const { suggestionQuery, suggestionMode } = useMemo<{ suggestionQuery: string, suggestionMode: SuggestionMode }>(() => { // When suggestions are not enabled, fallback to using a sentinal // suggestion mode of "none". @@ -67,7 +69,7 @@ export const useSuggestionModeAndQuery = ({ suggestionQuery: part, suggestionMode: "refine", } - }, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled]); + }, [cursorPosition, isSuggestionsEnabled, query, isHistorySearchEnabled, suggestionModeMappings]); // Debug logging for tracking mode transitions. const [prevSuggestionMode, setPrevSuggestionMode] = useState("none"); diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeMappings.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeMappings.ts new file mode 100644 index 00000000..da03fd6b --- /dev/null +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionModeMappings.ts @@ -0,0 +1,104 @@ +'use client'; + +import { useMemo } from "react"; +import { SearchPrefix } from "./constants"; +import { SuggestionMode } from "./searchSuggestionsBox"; +import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; + +const negate = (prefix: SearchPrefix) => { + return `-${prefix}`; +} + +type SuggestionModeMapping = { + suggestionMode: SuggestionMode, + prefixes: string[], +} + +/** + * Maps search prefixes to a suggestion mode. When a query starts + * with a prefix, the corresponding suggestion mode is enabled. + * @see [searchSuggestionsBox.tsx](./searchSuggestionsBox.tsx) + */ +export const useSuggestionModeMappings = () => { + const isSearchContextsEnabled = useHasEntitlement('search-contexts'); + + const mappings = useMemo((): SuggestionModeMapping[] => { + return [ + { + suggestionMode: "repo", + prefixes: [ + SearchPrefix.repo, negate(SearchPrefix.repo), + SearchPrefix.r, negate(SearchPrefix.r), + ] + }, + { + suggestionMode: "language", + prefixes: [ + SearchPrefix.lang, negate(SearchPrefix.lang), + ] + }, + { + suggestionMode: "file", + prefixes: [ + SearchPrefix.file, negate(SearchPrefix.file), + ] + }, + { + suggestionMode: "content", + prefixes: [ + SearchPrefix.content, negate(SearchPrefix.content), + ] + }, + { + suggestionMode: "revision", + prefixes: [ + SearchPrefix.rev, negate(SearchPrefix.rev), + SearchPrefix.revision, negate(SearchPrefix.revision), + SearchPrefix.branch, negate(SearchPrefix.branch), + SearchPrefix.b, negate(SearchPrefix.b), + ] + }, + { + suggestionMode: "symbol", + prefixes: [ + SearchPrefix.sym, negate(SearchPrefix.sym), + ] + }, + { + suggestionMode: "archived", + prefixes: [ + SearchPrefix.archived + ] + }, + { + suggestionMode: "case", + prefixes: [ + SearchPrefix.case + ] + }, + { + suggestionMode: "fork", + prefixes: [ + SearchPrefix.fork + ] + }, + { + suggestionMode: "public", + prefixes: [ + SearchPrefix.public + ] + }, + ...(isSearchContextsEnabled ? [ + { + suggestionMode: "context", + prefixes: [ + SearchPrefix.context, + negate(SearchPrefix.context), + ] + } satisfies SuggestionModeMapping, + ] : []), + ] + }, [isSearchContextsEnabled]); + + return mappings; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index a8ed1eb6..04c2514d 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts @@ -3,8 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { Suggestion, SuggestionMode } from "./searchSuggestionsBox"; import { getRepos, search } from "@/app/api/(client)/client"; +import { getSearchContexts } from "@/actions"; import { useMemo } from "react"; -import { Symbol } from "@/lib/types"; +import { SearchSymbol } from "@/features/search/types"; import { languageMetadataMap } from "@/lib/languageMetadata"; import { VscSymbolClass, @@ -18,7 +19,7 @@ import { VscSymbolVariable } from "react-icons/vsc"; import { useSearchHistory } from "@/hooks/useSearchHistory"; -import { getDisplayTime } from "@/lib/utils"; +import { getDisplayTime, isServiceError } from "@/lib/utils"; import { useDomain } from "@/hooks/useDomain"; @@ -36,13 +37,12 @@ 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.List.Repos - .map(r => r.Repository) + return data.repos .map(r => ({ - value: r.Name + value: r.name, })); }, enabled: suggestionMode === "repo", @@ -50,36 +50,46 @@ 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}`, - maxMatchDisplayCount: 15, + matches: 15, + contextLines: 1, }, domain), select: (data): Suggestion[] => { - return data.Result.Files?.map((file) => ({ - value: file.FileName - })) ?? []; + if (isServiceError(data)) { + return []; + } + + return data.files.map((file) => ({ + value: file.fileName.text, + })); }, enabled: suggestionMode === "file" }); 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 : ".*"}`, - maxMatchDisplayCount: 15, + matches: 15, + contextLines: 1, }, domain), select: (data): Suggestion[] => { - const symbols = data.Result.Files?.flatMap((file) => file.ChunkMatches).flatMap((chunk) => chunk.SymbolInfo ?? []); + if (isServiceError(data)) { + return []; + } + + const symbols = data.files.flatMap((file) => file.chunks).flatMap((chunk) => chunk.symbols ?? []); if (!symbols) { return []; } // De-duplicate on symbol name & kind. - const symbolMap = new Map(symbols.map((symbol: Symbol) => [`${symbol.Kind}.${symbol.Sym}`, symbol])); + const symbolMap = new Map(symbols.map((symbol: SearchSymbol) => [`${symbol.kind}.${symbol.symbol}`, symbol])); const suggestions = Array.from(symbolMap.values()).map((symbol) => ({ - value: symbol.Sym, + value: symbol.symbol, Icon: getSymbolIcon(symbol), } satisfies Suggestion)); @@ -89,6 +99,24 @@ export const useSuggestionsData = ({ }); const isLoadingSymbols = useMemo(() => suggestionMode === "symbol" && _isLoadingSymbols, [suggestionMode, _isLoadingSymbols]); + const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({ + queryKey: ["searchContexts", domain], + queryFn: () => getSearchContexts(domain), + select: (data): Suggestion[] => { + if (isServiceError(data)) { + return []; + } + + return data.map((context) => ({ + value: context.name, + description: context.description, + })); + + }, + enabled: suggestionMode === "context", + }); + const isLoadingSearchContexts = useMemo(() => suggestionMode === "context" && _isLoadingSearchContexts, [_isLoadingSearchContexts, suggestionMode]); + const languageSuggestions = useMemo((): Suggestion[] => { return Object.keys(languageMetadataMap).map((lang) => { const spotlight = [ @@ -116,21 +144,22 @@ export const useSuggestionsData = ({ }, [searchHistory]); const isLoadingSuggestions = useMemo(() => { - return isLoadingSymbols || isLoadingFiles || isLoadingRepos; - }, [isLoadingFiles, isLoadingRepos, isLoadingSymbols]); + return isLoadingSymbols || isLoadingFiles || isLoadingRepos || isLoadingSearchContexts; + }, [isLoadingFiles, isLoadingRepos, isLoadingSymbols, isLoadingSearchContexts]); return { repoSuggestions: repoSuggestions ?? [], fileSuggestions: fileSuggestions ?? [], symbolSuggestions: symbolSuggestions ?? [], + searchContextSuggestions: searchContextSuggestions ?? [], languageSuggestions, searchHistorySuggestions, isLoadingSuggestions, } } -const getSymbolIcon = (symbol: Symbol) => { - switch (symbol.Kind) { +const getSymbolIcon = (symbol: SearchSymbol) => { + switch (symbol.kind) { case "methodSpec": case "method": case "function": diff --git a/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts b/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts index 6fa0f4c7..1dad70bc 100644 --- a/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts +++ b/packages/web/src/app/[domain]/components/searchBar/zoektLanguageExtension.ts @@ -47,7 +47,7 @@ export const zoekt = () => { // Check for prefixes first // If these match, we return 'keyword' - if (stream.match(/(archived:|branch:|b:|rev:|c:|case:|content:|f:|file:|fork:|public:|r:|repo:|regex:|lang:|sym:|t:|type:)/)) { + if (stream.match(/(archived:|branch:|b:|rev:|c:|case:|content:|f:|file:|fork:|public:|r:|repo:|regex:|lang:|sym:|t:|type:|context:)/)) { return t.keyword.toString(); } diff --git a/packages/web/src/app/[domain]/components/settingsDropdown.tsx b/packages/web/src/app/[domain]/components/settingsDropdown.tsx index 42925835..242dadad 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,12 +38,10 @@ import { useDomain } from "@/hooks/useDomain"; interface SettingsDropdownProps { menuButtonClassName?: string; - displaySettingsOption: boolean; } export const SettingsDropdown = ({ menuButtonClassName, - displaySettingsOption, }: SettingsDropdownProps) => { const { theme: _theme, setTheme } = useTheme(); @@ -82,7 +81,7 @@ export const SettingsDropdown = ({ - {session?.user && ( + {session?.user ? (
@@ -107,9 +106,18 @@ export const SettingsDropdown = ({ Log out - + ) : ( + { + window.location.href = "/login"; + }} + > + + Sign in + )} + @@ -150,7 +158,7 @@ export const SettingsDropdown = ({ - {displaySettingsOption && ( + {session?.user && ( diff --git a/packages/web/src/app/[domain]/components/topBar.tsx b/packages/web/src/app/[domain]/components/topBar.tsx index 05146a91..351eb60b 100644 --- a/packages/web/src/app/[domain]/components/topBar.tsx +++ b/packages/web/src/app/[domain]/components/topBar.tsx @@ -40,7 +40,6 @@ export const TopBar = ({
) 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/configSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx index a9fc3e42..0da3eb98 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx @@ -13,7 +13,7 @@ import { createZodConnectionConfigValidator } from "../../utils"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; -import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickActions } from "../../quickActions"; +import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickActions, bitbucketCloudQuickActions, bitbucketDataCenterQuickActions } from "../../quickActions"; import { Schema } from "ajv"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema"; @@ -27,11 +27,13 @@ import { useDomain } from "@/hooks/useDomain"; import { SecretCombobox } from "@/app/[domain]/components/connectionCreationForms/secretCombobox"; import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import strings from "@/lib/strings"; +import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; interface ConfigSettingProps { connectionId: number; config: string; - type: string; + type: CodeHostType; disabled?: boolean; } @@ -56,6 +58,24 @@ export const ConfigSetting = (props: ConfigSettingProps) => { />; } + if (type === 'bitbucket-cloud') { + return + {...props} + type="bitbucket-cloud" + quickActions={bitbucketCloudQuickActions} + schema={bitbucketSchema} + />; + } + + if (type === 'bitbucket-server') { + return + {...props} + type="bitbucket-server" + quickActions={bitbucketDataCenterQuickActions} + schema={bitbucketSchema} + />; + } + if (type === 'gitea') { return {...props} 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]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx index d08b97f2..e59dd3e0 100644 --- a/packages/web/src/app/[domain]/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -21,6 +21,9 @@ import { getOrgMembership } from "@/actions" import { isServiceError } from "@/lib/utils" import { notFound } from "next/navigation" import { OrgRole } from "@sourcebot/db" +import { CodeHostType } from "@/lib/utils" +import { env } from "@/env.mjs" + interface ConnectionManagementPageProps { params: { domain: string @@ -43,6 +46,7 @@ export default async function ConnectionManagementPage({ params, searchParams }: } const isOwner = membership.role === OrgRole.OWNER; + const isDisabled = !isOwner || env.CONFIG_PATH !== undefined; const currentTab = searchParams.tab || "overview"; return ( @@ -90,14 +94,14 @@ export default async function ConnectionManagementPage({ params, searchParams }: value="settings" className="flex flex-col gap-6" > - + - + ) diff --git a/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx b/packages/web/src/app/[domain]/connections/components/newConnectionCard.tsx index 30b545a1..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,30 +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/new/[type]/page.tsx b/packages/web/src/app/[domain]/connections/new/[type]/page.tsx index d074e8f3..d5d1d8e5 100644 --- a/packages/web/src/app/[domain]/connections/new/[type]/page.tsx +++ b/packages/web/src/app/[domain]/connections/new/[type]/page.tsx @@ -5,7 +5,9 @@ import { GitHubConnectionCreationForm, GitLabConnectionCreationForm, GiteaConnectionCreationForm, - GerritConnectionCreationForm + GerritConnectionCreationForm, + BitbucketCloudConnectionCreationForm, + BitbucketDataCenterConnectionCreationForm } from "@/app/[domain]/components/connectionCreationForms"; import { useCallback } from "react"; import { useDomain } from "@/hooks/useDomain"; @@ -37,5 +39,14 @@ export default function NewConnectionPage({ return ; } + if (type === 'bitbucket-cloud') { + return ; + } + + if (type === 'bitbucket-server') { + return ; + } + + router.push(`/${domain}/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 50d85947..af0b4f05 100644 --- a/packages/web/src/app/[domain]/connections/quickActions.tsx +++ b/packages/web/src/app/[domain]/connections/quickActions.tsx @@ -1,7 +1,8 @@ import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type" import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; import { QuickAction } from "../components/configEditor"; -import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; +import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type"; import { cn } from "@/lib/utils"; @@ -100,7 +101,7 @@ export const githubQuickActions: QuickAction[] = [ ...previous, url: previous.url ?? "https://github.example.com", }), - name: "Set a custom url", + name: "Set url to GitHub instance", selectionText: "https://github.example.com", description: Set a custom GitHub host. Defaults to https://github.com. }, @@ -290,7 +291,7 @@ export const gitlabQuickActions: QuickAction[] = [ ...previous, url: previous.url ?? "https://gitlab.example.com", }), - name: "Set a custom url", + name: "Set url to GitLab instance", selectionText: "https://gitlab.example.com", description: Set a custom GitLab host. Defaults to https://gitlab.com. }, @@ -360,7 +361,7 @@ export const giteaQuickActions: QuickAction[] = [ ...previous, url: previous.url ?? "https://gitea.example.com", }), - name: "Set a custom url", + name: "Set url to Gitea instance", selectionText: "https://gitea.example.com", } ] @@ -390,3 +391,196 @@ export const gerritQuickActions: QuickAction[] = [ name: "Exclude a project", } ] + +export const bitbucketCloudQuickActions: QuickAction[] = [ + { + // add user + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + user: previous.user ?? "username" + }), + name: "Add username", + selectionText: "username", + description: ( +
+ Username to use for authentication. This is only required if you're using an App Password (stored in token) for authentication. +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + workspaces: [ + ...(previous.workspaces ?? []), + "myWorkspace" + ] + }), + name: "Add a workspace", + selectionText: "myWorkspace", + description: ( +
+ Add a workspace to sync with. Ensure the workspace is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + repos: [ + ...(previous.repos ?? []), + "myWorkspace/myRepo" + ] + }), + name: "Add a repo", + selectionText: "myWorkspace/myRepo", + description: ( +
+ Add an individual repository to sync with. Ensure the repository is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + projects: [ + ...(previous.projects ?? []), + "myProject" + ] + }), + name: "Add a project", + selectionText: "myProject", + description: ( +
+ Add a project to sync with. Ensure the project is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + repos: [...(previous.exclude?.repos ?? []), "myWorkspace/myExcludedRepo"] + } + }), + name: "Exclude a repo", + selectionText: "myWorkspace/myExcludedRepo", + description: ( +
+ Exclude a repository from syncing. Glob patterns are supported. +
+ ) + }, + // exclude forked + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + forks: true + } + }), + name: "Exclude forked repos", + description: Exclude forked repositories from syncing. + } +] + +export const bitbucketDataCenterQuickActions: QuickAction[] = [ + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + url: previous.url ?? "https://bitbucket.example.com", + }), + name: "Set url to Bitbucket DC instance", + selectionText: "https://bitbucket.example.com", + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + repos: [ + ...(previous.repos ?? []), + "myProject/myRepo" + ] + }), + name: "Add a repo", + selectionText: "myProject/myRepo", + description: ( +
+ 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} + ))} +
+
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + projects: [ + ...(previous.projects ?? []), + "myProject" + ] + }), + name: "Add a project", + selectionText: "myProject", + description: ( +
+ Add a project to sync with. Ensure the project is visible to the provided token (if any). +
+ ) + }, + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + repos: [...(previous.exclude?.repos ?? []), "myProject/myExcludedRepo"] + } + }), + name: "Exclude a repo", + selectionText: "myProject/myExcludedRepo", + description: ( +
+ Exclude a repository from syncing. Glob patterns are supported. + Examples: +
+ {[ + "myProject/myExcludedRepo", + "myProject2/*" + ].map((repo) => ( + {repo} + ))} +
+
+ ) + }, + // exclude archived + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + archived: true + } + }), + name: "Exclude archived repos", + }, + // exclude forked + { + fn: (previous: BitbucketConnectionConfig) => ({ + ...previous, + exclude: { + ...previous.exclude, + forks: true + } + }), + name: "Exclude forked repos", + } +] + diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index 693496ba..8b1122a3 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -3,7 +3,6 @@ import { auth } from "@/auth"; import { getOrgFromDomain } from "@/data/org"; import { isServiceError } from "@/lib/utils"; import { OnboardGuard } from "./components/onboardGuard"; -import { fetchSubscription } from "@/actions"; import { UpgradeGuard } from "./components/upgradeGuard"; import { cookies, headers } from "next/headers"; import { getSelectorsByUserAgent } from "react-device-detect"; @@ -11,9 +10,14 @@ import { MobileUnsupportedSplashScreen } from "./components/mobileUnsupportedSpl import { MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME } from "@/lib/constants"; import { SyntaxReferenceGuide } from "./components/syntaxReferenceGuide"; import { SyntaxGuideProvider } from "./components/syntaxGuideProvider"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; -import { env } from "@/env.mjs"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { notFound, redirect } from "next/navigation"; +import { getSubscriptionInfo } from "@/ee/features/billing/actions"; +import { PendingApprovalCard } from "./components/pendingApproval"; +import { hasEntitlement } from "@/features/entitlements/server"; +import { getPublicAccessStatus } from "@/ee/features/publicAccess/publicAccess"; +import { env } from "@/env.mjs"; + interface LayoutProps { children: React.ReactNode, params: { domain: string } @@ -29,7 +33,8 @@ export default async function Layout({ return notFound(); } - if (env.SOURCEBOT_AUTH_ENABLED === 'true') { + const publicAccessEnabled = hasEntitlement("public-access") && await getPublicAccessStatus(domain); + if (!publicAccessEnabled) { const session = await auth(); if (!session) { redirect('/login'); @@ -41,11 +46,25 @@ export default async function Layout({ orgId: org.id, userId: session.user.id } + }, + include: { + user: true } }); if (!membership) { - return notFound(); + const user = await prisma.user.findUnique({ + where: { + id: session.user.id + } + }); + + // TODO: Organization join requests are only supported in single-tenant mode + if (env.SOURCEBOT_TENANCY_MODE === "single" && user?.pendingApproval) { + return + } else { + return notFound(); + } } } @@ -58,7 +77,7 @@ export default async function Layout({ } if (IS_BILLING_ENABLED) { - const subscription = await fetchSubscription(domain); + const subscription = await getSubscriptionInfo(domain); if ( subscription && ( diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx index dc0f4589..00fecdb0 100644 --- a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx +++ b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx @@ -7,7 +7,9 @@ import { GitHubConnectionCreationForm, GitLabConnectionCreationForm, GiteaConnectionCreationForm, - GerritConnectionCreationForm + GerritConnectionCreationForm, + BitbucketCloudConnectionCreationForm, + BitbucketDataCenterConnectionCreationForm } from "@/app/[domain]/components/connectionCreationForms"; import { useRouter } from "next/navigation"; import { useCallback } from "react"; @@ -79,6 +81,24 @@ export const ConnectCodeHost = ({ nextStep, securityCardEnabled }: ConnectCodeHo ) } + if (selectedCodeHost === "bitbucket-cloud") { + return ( + <> + + + + ) + } + + if (selectedCodeHost === "bitbucket-server") { + return ( + <> + + + + ) + } + return null; } @@ -90,7 +110,7 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => { const captureEvent = useCaptureEvent(); return ( -
+
{ captureEvent("wa_onboard_gitlab_selected", {}); }} /> + { + onSelect("bitbucket-cloud"); + captureEvent("wa_onboard_bitbucket_cloud_selected", {}); + }} + /> + { + onSelect("bitbucket-server"); + captureEvent("wa_onboard_bitbucket_server_selected", {}); + }} + /> } + const repos = await getRepos(domain); + return (
- +
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..756ca383 100644 --- a/packages/web/src/app/[domain]/repos/columns.tsx +++ b/packages/web/src/app/[domain]/repos/columns.tsx @@ -93,13 +93,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 }) => { @@ -182,7 +182,7 @@ export const columns = (domain: string, isAddNewRepoButtonVisible: boolean): Col @@ -196,6 +266,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 8c4c7ea2..537ac511 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/index.tsx @@ -1,108 +1,79 @@ '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 { SearchResultFile } from "@/lib/types"; +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 { base64Decode } from "@/lib/utils"; +import { unwrapServiceError } from "@/lib/utils"; + interface CodePreviewPanelProps { - fileMatch?: SearchResultFile; - onClose: () => void; + previewedFile: SearchResultFile; selectedMatchIndex: number; - onSelectedMatchIndexChange: (index: number) => void; - repoUrlTemplates: Record; + onClose: () => void; + onSelectedMatchIndexChange: Dispatch>; } export const CodePreviewPanel = ({ - fileMatch, - onClose, + previewedFile, selectedMatchIndex, + onClose, onSelectedMatchIndexChange, - repoUrlTemplates, }: 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, - 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 link = (() => { - const template = repoUrlTemplates[fileMatch.Repository]; - - // This is a hacky parser for templates generated by - // the go text/template package. Example template: - // {{URLJoinPath "https://github.com/sourcebot-dev/sourcebot" "blob" .Version .Path}} - // @see: https://pkg.go.dev/text/template - if (!template || !template.match(/^{{URLJoinPath\s.*}}(\?.+)?$/)) { - return undefined; - } + ), + select: (data) => { + const decodedSource = base64Decode(data.source); - const url = - template.substring("{{URLJoinPath ".length,template.indexOf("}}")) - .replace(".Version", branch ?? "HEAD") - .replace(".Path", fileMatch.FileName) - .split(" ") - .map((part) => { - // remove wrapping quotes - if (part.startsWith("\"")) part = part.substring(1); - if (part.endsWith("\"")) part = part.substring(0, part.length - 1); - return part; - }) - .join("/"); - - const optionalQueryParams = template.substring(template.indexOf("}}") + 2); - return url + optionalQueryParams; - })(); - - const decodedSource = base64Decode(source); - - // Filter out filename matches - const filteredMatches = fileMatch.ChunkMatches.filter((match) => { - return !match.FileName; - }); - - return { - content: decodedSource, - filepath: fileMatch.FileName, - matches: filteredMatches, - link: link, - language: fileMatch.Language, - revision: branch ?? "HEAD", - }; - }); - }, - enabled: fileMatch !== undefined, + return { + content: decodedSource, + 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..c5081790 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/entry.tsx @@ -8,6 +8,8 @@ export type Entry = { displayName: string; count: number; isSelected: boolean; + isHidden: boolean; + isDisabled: boolean; Icon?: React.ReactNode; } @@ -22,6 +24,7 @@ export const Entry = ({ displayName, count, Icon, + isDisabled, }, onClicked, }: EntryProps) => { @@ -36,6 +39,7 @@ 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()} diff --git a/packages/web/src/app/[domain]/search/components/filterPanel/filter.tsx b/packages/web/src/app/[domain]/search/components/filterPanel/filter.tsx index eae22aa6..82db0127 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/filter.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/filter.tsx @@ -3,7 +3,6 @@ import { useMemo, useState } from "react"; import { compareEntries, Entry } from "./entry"; import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; import Fuse from "fuse.js"; import { cn } from "@/lib/utils" 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 c3ce800b..231cda18 100644 --- a/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/filterPanel/index.tsx @@ -1,35 +1,63 @@ 'use client'; -import { Repository, SearchResultFile } from "@/lib/types"; -import { cn, getRepoCodeHostInfo } from "@/lib/utils"; -import { SetStateAction, useCallback, useEffect, useState } from "react"; +import { FileIcon } from "@/components/ui/fileIcon"; +import { RepositoryInfo, SearchResultFile } from "@/features/search/types"; +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 { useMemo } from "react"; import { Entry } from "./entry"; import { Filter } from "./filter"; -import Image from "next/image"; -import { LaptopIcon } from "@radix-ui/react-icons"; -import { FileIcon } from "@/components/ui/fileIcon"; +import { LANGUAGES_QUERY_PARAM, REPOS_QUERY_PARAM, useFilteredMatches } from "./useFilterMatches"; +import { useGetSelectedFromQuery } from "./useGetSelectedFromQuery"; interface FilePanelProps { matches: SearchResultFile[]; - onFilterChanged: (filteredMatches: SearchResultFile[]) => void, - repoMetadata: Record; + repoInfo: Record; } +/** + * 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, - repoMetadata, + repoInfo, }: FilePanelProps) => { - const [repos, setRepos] = useState>({}); - const [languages, setLanguages] = useState>({}); + const router = useRouter(); + const searchParams = useSearchParams(); - useEffect(() => { - const _repos = aggregateMatches( - "Repository", + 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) => { - const repo: Repository | undefined = repoMetadata[key]; - const info = getRepoCodeHostInfo(repo); + /* createEntry = */ ({ key: repository, match }) => { + const repo: RepositoryInfo | undefined = repoInfo[match.repositoryId]; + + const info = repo ? getCodeHostInfoForRepo({ + name: repo.name, + codeHostType: repo.codeHostType, + displayName: repo.displayName, + webUrl: repo.webUrl, + }) : undefined; + const Icon = info ? ( ); + 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: false, + isSelected, + isDisabled, + isHidden, Icon, }; + }, + /* shouldCount = */ ({ match }) => { + return matchesFilteredByLanguage.some((value) => value.language === match.language) } - ); - - setRepos(_repos); - }, [matches, repoMetadata, setRepos]); + ) + }, [getSelectedFromQuery, matches, repoInfo, matchesFilteredByLanguage]); - useEffect(() => { - const _languages = aggregateMatches( - "Language", + 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: false, + isSelected, + isDisabled, + isHidden, Icon: Icon, } satisfies Entry; - } - ) - - setLanguages(_languages); - }, [matches, setLanguages]); - - const onEntryClicked = useCallback(( - key: string, - setter: (value: SetStateAction>) => void, - ) => { - setter((values) => ({ - ...values, - [key]: { - ...values[key], - isSelected: !values[key].isSelected, }, - })); - }, []); - - useEffect(() => { - const selectedRepos = new Set( - Object.entries(repos) - .filter(([_, { isSelected }]) => isSelected) - .map(([key]) => key) - ); - - const selectedLanguages = new Set( - Object.entries(languages) - .filter(([_, { isSelected }]) => isSelected) - .map(([key]) => key) + /* shouldCount = */ ({ match }) => { + return matchesFilteredByRepository.some((value) => value.repository === match.repository) + } ); + }, [getSelectedFromQuery, matches, matchesFilteredByRepository]); - const filteredMatches = matches.filter((match) => - ( - (selectedRepos.size === 0 ? true : selectedRepos.has(match.Repository)) && - (selectedLanguages.size === 0 ? true : selectedLanguages.has(match.Language)) - ) - ); + const visibleRepos = useMemo(() => Object.values(repos).filter((entry) => !entry.isHidden), [repos]); + const visibleLanguages = useMemo(() => Object.values(languages).filter((entry) => !entry.isHidden), [languages]); - onFilterChanged(filteredMatches); - }, [matches, repos, languages, onFilterChanged]); + const numRepos = useMemo(() => visibleRepos.length > 100 ? '100+' : visibleRepos.length, [visibleRepos]); + const numLanguages = useMemo(() => visibleLanguages.length > 100 ? '100+' : visibleLanguages.length, [visibleLanguages]); - const numRepos = Object.keys(repos).length > 100 ? '100+' : Object.keys(repos).length; - const numLanguages = Object.keys(languages).length > 100 ? '100+' : Object.keys(languages).length; return (
onEntryClicked(key, setRepos)} + entries={visibleRepos} + onEntryClicked={(key) => { + const newRepos = { ...repos }; + newRepos[key].isSelected = !newRepos[key].isSelected; + const selectedRepos = Object.keys(newRepos).filter((key) => newRepos[key].isSelected); + const newParams = new URLSearchParams(searchParams.toString()); + + if (selectedRepos.length > 0) { + newParams.set(REPOS_QUERY_PARAM, selectedRepos.join(',')); + } else { + newParams.delete(REPOS_QUERY_PARAM); + } + + if (newParams.toString() !== searchParams.toString()) { + router.replace(`?${newParams.toString()}`, { scroll: false }); + } + }} className="max-h-[50%]" /> onEntryClicked(key, setLanguages)} + entries={visibleLanguages} + onEntryClicked={(key) => { + const newLanguages = { ...languages }; + newLanguages[key].isSelected = !newLanguages[key].isSelected; + const selectedLanguages = Object.keys(newLanguages).filter((key) => newLanguages[key].isSelected); + const newParams = new URLSearchParams(searchParams.toString()); + + if (selectedLanguages.length > 0) { + newParams.set(LANGUAGES_QUERY_PARAM, selectedLanguages.join(',')); + } else { + newParams.delete(LANGUAGES_QUERY_PARAM); + } + + if (newParams.toString() !== searchParams.toString()) { + router.replace(`?${newParams.toString()}`, { scroll: false }); + } + }} className="overflow-auto" />
@@ -147,18 +192,23 @@ export const FilterPanel = ({ * } */ const aggregateMatches = ( - propName: 'Repository' | 'Language', + propName: 'repository' | 'language', matches: SearchResultFile[], - createEntry: (key: string) => Entry + createEntry: (props: { key: string, match: SearchResultFile }) => Entry, + shouldCount: (props: { key: string, match: SearchResultFile }) => boolean, ) => { return matches - .map((match) => match[propName]) - .filter((key) => key.length > 0) - .reduce((aggregation, key) => { + .map((match) => ({ key: match[propName], match })) + .filter(({ key }) => key.length > 0) + .reduce((aggregation, { key, match }) => { if (!aggregation[key]) { - aggregation[key] = createEntry(key); + aggregation[key] = createEntry({ key, match }); + } + + if (!aggregation[key].isDisabled && shouldCount({ key, match })) { + aggregation[key].count += 1; } - 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 d87cde69..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 "@/lib/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 981ad746..5146c6fe 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatch.tsx @@ -1,49 +1,64 @@ 'use client'; -import { useMemo } from "react"; -import { CodePreview } from "./codePreview"; -import { SearchResultFile, SearchResultFileMatch } from "@/lib/types"; +import { useCallback, useMemo } from "react"; +import { SearchResultFile, SearchResultChunk } from "@/features/search/types"; import { base64Decode } from "@/lib/utils"; +import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"; interface FileMatchProps { - match: SearchResultFileMatch; + 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]); + return base64Decode(match.content); + }, [match.content]); + + const onOpen = useCallback((isCtrlKeyPressed: boolean) => { + const startLineNumber = match.contentStart.lineNumber; + const endLineNumber = content.trimEnd().split('\n').length + startLineNumber - 1; + + _onOpen(startLineNumber, endLineNumber, isCtrlKeyPressed); + }, [content, match.contentStart.lineNumber, _onOpen]); // If it's just the title, don't show a code preview - if (match.FileName) { + if (match.matchRanges.length === 0) { return null; } return (
{ if (e.key !== "Enter") { return; } - onOpen(); + + onOpen(e.metaKey || e.ctrlKey); + }} + onClick={(e) => { + onOpen(e.metaKey || e.ctrlKey); }} - onClick={onOpen} + title="open file: click, open file preview: cmd/ctrl + click" > - + + {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 0a8e74a6..c179aacc 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/fileMatchContainer.tsx @@ -2,42 +2,42 @@ import { FileHeader } from "@/app/[domain]/components/fileHeader"; import { Separator } from "@/components/ui/separator"; -import { Repository, SearchResultFile } from "@/lib/types"; 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; - repoMetadata: Record; + repoInfo: Record; yOffset: number; } export const FileMatchContainer = ({ file, - onOpenFile, - onMatchIndexChanged, + onOpenFilePreview, showAllMatches, onShowAllMatchesButtonClicked, isBranchFilteringEnabled, - repoMetadata, + repoInfo, yOffset, }: FileMatchContainerProps) => { - const matchCount = useMemo(() => { - return file.ChunkMatches.length; + return file.chunks.length; }, [file]); + const { navigateToPath } = useBrowseNavigation(); const matches = useMemo(() => { - const sortedMatches = file.ChunkMatches.sort((a, b) => { - return a.ContentStart.LineNumber - b.ContentStart.LineNumber; + const sortedMatches = file.chunks.sort((a, b) => { + return a.contentStart.lineNumber - b.contentStart.lineNumber; }); if (!showAllMatches) { @@ -48,38 +48,28 @@ export const FileMatchContainer = ({ }, [file, showAllMatches]); const fileNameRange = useMemo(() => { - for (const match of matches) { - if (match.FileName && match.Ranges.length > 0) { - const range = match.Ranges[0]; - return { - from: range.Start.Column - 1, - to: range.End.Column - 1, - } + if (file.fileName.matchRanges.length > 0) { + const range = file.fileName.matchRanges[0]; + return { + from: range.start.column - 1, + to: range.end.column - 1, } } return undefined; - }, [matches]); + }, [file.fileName.matchRanges]); const isMoreContentButtonVisible = useMemo(() => { return matchCount > MAX_MATCHES_TO_PREVIEW; }, [matchCount]); - const onOpenMatch = useCallback((index: number) => { - const matchIndex = matches.slice(0, index).reduce((acc, match) => { - return acc + match.Ranges.length; - }, 0); - onOpenFile(); - onMatchIndexChanged(matchIndex); - }, [matches, onMatchIndexChanged, onOpenFile]); - const branches = useMemo(() => { - if (!file.Branches) { + if (!file.branches) { return []; } - return file.Branches; - }, [file.Branches]); + return file.branches; + }, [file.branches]); const branchDisplayName = useMemo(() => { if (!isBranchFilteringEnabled || branches.length === 0) { @@ -89,26 +79,40 @@ export const FileMatchContainer = ({ return `${branches[0]}${branches.length > 1 ? ` +${branches.length - 1}` : ''}`; }, [isBranchFilteringEnabled, branches]); + const repo = useMemo(() => { + return repoInfo[file.repositoryId]; + }, [repoInfo, file.repositoryId]); return (
{/* Title */}
{ - onOpenFile(); - }} > +
{/* Matches */} @@ -119,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) && ( @@ -133,7 +157,7 @@ export const FileMatchContainer = ({ {isMoreContentButtonVisible && (
{ if (e.key !== "Enter") { return; @@ -143,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 5eb5ea10..61e41332 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPanel/index.tsx @@ -1,36 +1,51 @@ 'use client'; -import { Repository, SearchResultFile } from "@/lib/types"; +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; - repoMetadata: Record; + repoInfo: Record; } 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, - repoMetadata, + 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, @@ -41,9 +56,8 @@ export const SearchResultsPanel = ({ // Quick guesstimation ;) This needs to be quick since the virtualizer will // run this upfront for all items in the list. - const numCodeCells = fileMatch.ChunkMatches - .filter(match => !match.FileName) - .slice(0, showAllMatches ? fileMatch.ChunkMatches.length : MAX_MATCHES_TO_PREVIEW) + const numCodeCells = fileMatch.chunks + .slice(0, showAllMatches ? fileMatch.chunks.length : MAX_MATCHES_TO_PREVIEW) .length; const estimatedSize = @@ -52,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); - - setLastShowAllMatchesButtonClickIndex(-1); - }, [lastShowAllMatchesButtonClickIndex, virtualizer]); + if (prevFileMatches.length !== fileMatches.length) { + setShowAllMatchesStates(Array(fileMatches.length).fill(false)); + virtualizer.scrollToIndex(0); + } + }, [fileMatches.length, prevFileMatches, 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={() => { onShowAllMatchesButtonClicked(virtualRow.index); }} isBranchFilteringEnabled={isBranchFilteringEnabled} - repoMetadata={repoMetadata} + repoInfo={repoInfo} yOffset={virtualRow.start} />
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 0b587903..e307d74c 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"; @@ -9,21 +8,31 @@ import { Separator } from "@/components/ui/separator"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam"; import { useSearchHistory } from "@/hooks/useSearchHistory"; -import { Repository, SearchQueryParams, SearchResultFile } from "@/lib/types"; -import { createPathWithQueryParams, measure } from "@/lib/utils"; +import { SearchQueryParams } from "@/lib/types"; +import { createPathWithQueryParams, measure, unwrapServiceError } from "@/lib/utils"; import { InfoCircledIcon, SymbolIcon } from "@radix-ui/react-icons"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ImperativePanelHandle } from "react-resizable-panels"; -import { getRepos, search } from "../../api/(client)/client"; +import { search } from "../../api/(client)/client"; import { TopBar } from "../components/topBar"; import { CodePreviewPanel } from "./components/codePreviewPanel"; import { FilterPanel } from "./components/filterPanel"; import { SearchResultsPanel } from "./components/searchResultsPanel"; import { useDomain } from "@/hooks/useDomain"; +import { useToast } from "@/components/hooks/use-toast"; +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"; -const DEFAULT_MAX_MATCH_DISPLAY_COUNT = 10000; +const DEFAULT_MAX_MATCH_COUNT = 10000; export default function SearchPage() { // We need a suspense boundary here since we are accessing query params @@ -39,26 +48,41 @@ export default function SearchPage() { const SearchPageInternal = () => { const router = useRouter(); const searchQuery = useNonEmptyQueryParam(SearchQueryParams.query) ?? ""; - const _maxMatchDisplayCount = parseInt(useNonEmptyQueryParam(SearchQueryParams.maxMatchDisplayCount) ?? `${DEFAULT_MAX_MATCH_DISPLAY_COUNT}`); - const maxMatchDisplayCount = isNaN(_maxMatchDisplayCount) ? DEFAULT_MAX_MATCH_DISPLAY_COUNT : _maxMatchDisplayCount; 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 } = useQuery({ - queryKey: ["search", searchQuery, maxMatchDisplayCount], - queryFn: () => measure(() => search({ + const { data: searchResponse, isLoading: isSearchLoading, error } = useQuery({ + queryKey: ["search", searchQuery, maxMatchCount], + queryFn: () => measure(() => unwrapServiceError(search({ query: searchQuery, - maxMatchDisplayCount, - }, domain), "client.search"), + matches: maxMatchCount, + contextLines: 3, + whole: false, + }, domain)), "client.search"), select: ({ data, durationMs }) => ({ ...data, durationMs, }), enabled: searchQuery.length > 0, refetchOnWindowFocus: false, + retry: false, + staleTime: Infinity, }); + useEffect(() => { + if (error) { + toast({ + description: `❌ Search failed. Reason: ${error.message}`, + }); + } + }, [error, toast]); + // Write the query to the search history useEffect(() => { @@ -76,101 +100,74 @@ const SearchPageInternal = () => { ]) }, [searchQuery, setSearchHistory]); - // Use the /api/repos endpoint to get a useful list of - // repository metadata (like host type, repo name, etc.) - // Convert this into a map of repo name to repo metadata - // for easy lookup. - const { data: repoMetadata } = useQuery({ - queryKey: ["repos"], - queryFn: () => getRepos(domain), - select: (data): Record => - data.List.Repos - .map(r => r.Repository) - .reduce( - (acc, repo) => ({ - ...acc, - [repo.Name]: repo, - }), - {}, - ), - refetchOnWindowFocus: false, - }); - useEffect(() => { if (!searchResponse) { return; } - const fileLanguages = searchResponse.Result.Files?.map(file => file.Language) || []; + const fileLanguages = searchResponse.files?.map(file => file.language) || []; captureEvent("search_finished", { - contentBytesLoaded: searchResponse.Result.ContentBytesLoaded, - indexBytesLoaded: searchResponse.Result.IndexBytesLoaded, - crashes: searchResponse.Result.Crashes, - durationMs: searchResponse.Result.Duration / 1000000, - fileCount: searchResponse.Result.FileCount, - shardFilesConsidered: searchResponse.Result.ShardFilesConsidered, - filesConsidered: searchResponse.Result.FilesConsidered, - filesLoaded: searchResponse.Result.FilesLoaded, - filesSkipped: searchResponse.Result.FilesSkipped, - shardsScanned: searchResponse.Result.ShardsScanned, - shardsSkipped: searchResponse.Result.ShardsSkipped, - shardsSkippedFilter: searchResponse.Result.ShardsSkippedFilter, - matchCount: searchResponse.Result.MatchCount, - ngramMatches: searchResponse.Result.NgramMatches, - ngramLookups: searchResponse.Result.NgramLookups, - wait: searchResponse.Result.Wait, - matchTreeConstruction: searchResponse.Result.MatchTreeConstruction, - matchTreeSearch: searchResponse.Result.MatchTreeSearch, - regexpsConsidered: searchResponse.Result.RegexpsConsidered, - flushReason: searchResponse.Result.FlushReason, + durationMs: searchResponse.durationMs, + fileCount: searchResponse.zoektStats.fileCount, + matchCount: searchResponse.zoektStats.matchCount, + filesSkipped: searchResponse.zoektStats.filesSkipped, + contentBytesLoaded: searchResponse.zoektStats.contentBytesLoaded, + indexBytesLoaded: searchResponse.zoektStats.indexBytesLoaded, + crashes: searchResponse.zoektStats.crashes, + shardFilesConsidered: searchResponse.zoektStats.shardFilesConsidered, + filesConsidered: searchResponse.zoektStats.filesConsidered, + filesLoaded: searchResponse.zoektStats.filesLoaded, + shardsScanned: searchResponse.zoektStats.shardsScanned, + shardsSkipped: searchResponse.zoektStats.shardsSkipped, + shardsSkippedFilter: searchResponse.zoektStats.shardsSkippedFilter, + ngramMatches: searchResponse.zoektStats.ngramMatches, + ngramLookups: searchResponse.zoektStats.ngramLookups, + wait: searchResponse.zoektStats.wait, + matchTreeConstruction: searchResponse.zoektStats.matchTreeConstruction, + matchTreeSearch: searchResponse.zoektStats.matchTreeSearch, + regexpsConsidered: searchResponse.zoektStats.regexpsConsidered, + flushReason: searchResponse.zoektStats.flushReason, fileLanguages, }); }, [captureEvent, searchQuery, searchResponse]); - const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repoUrlTemplates } = useMemo(() => { + const { fileMatches, searchDurationMs, totalMatchCount, isBranchFilteringEnabled, repositoryInfo, matchCount } = useMemo(() => { if (!searchResponse) { return { fileMatches: [], searchDurationMs: 0, totalMatchCount: 0, isBranchFilteringEnabled: false, - repoUrlTemplates: {}, + repositoryInfo: {}, + matchCount: 0, }; } return { - fileMatches: searchResponse.Result.Files ?? [], + fileMatches: searchResponse.files ?? [], searchDurationMs: Math.round(searchResponse.durationMs), - totalMatchCount: searchResponse.Result.MatchCount, + totalMatchCount: searchResponse.zoektStats.matchCount, isBranchFilteringEnabled: searchResponse.isBranchFilteringEnabled, - repoUrlTemplates: searchResponse.Result.RepoURLs, + repositoryInfo: searchResponse.repositoryInfo.reduce((acc, repo) => { + acc[repo.id] = repo; + return acc; + }, {} as Record), + matchCount: searchResponse.stats.matchCount, } }, [searchResponse]); const isMoreResultsButtonVisible = useMemo(() => { - return totalMatchCount > maxMatchDisplayCount; - }, [totalMatchCount, maxMatchDisplayCount]); - - const numMatches = useMemo(() => { - // Accumualtes the number of matches across all files - return fileMatches.reduce( - (acc, file) => - acc + file.ChunkMatches.reduce( - (acc, chunk) => acc + chunk.Ranges.length, - 0, - ), - 0, - ); - }, [fileMatches]); + return totalMatchCount > maxMatchCount; + }, [totalMatchCount, maxMatchCount]); const onLoadMoreResults = useCallback(() => { const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, searchQuery], - [SearchQueryParams.maxMatchDisplayCount, `${maxMatchDisplayCount * 2}`], + [SearchQueryParams.matches, `${maxMatchCount * 2}`], ) router.push(url); - }, [maxMatchDisplayCount, router, searchQuery, domain]); + }, [maxMatchCount, router, searchQuery, domain]); return (
@@ -183,7 +180,7 @@ const SearchPageInternal = () => {
- {isLoading ? ( + {(isSearchLoading) ? (

Searching...

@@ -194,10 +191,9 @@ const SearchPageInternal = () => { isMoreResultsButtonVisible={isMoreResultsButtonVisible} onLoadMoreResults={onLoadMoreResults} isBranchFilteringEnabled={isBranchFilteringEnabled} - repoUrlTemplates={repoUrlTemplates} - repoMetadata={repoMetadata ?? {}} + repoInfo={repositoryInfo} searchDurationMs={searchDurationMs} - numMatches={numMatches} + numMatches={matchCount} /> )}
@@ -209,8 +205,7 @@ interface PanelGroupProps { isMoreResultsButtonVisible?: boolean; onLoadMoreResults: () => void; isBranchFilteringEnabled: boolean; - repoUrlTemplates: Record; - repoMetadata: Record; + repoInfo: Record; searchDurationMs: number; numMatches: number; } @@ -220,27 +215,28 @@ const PanelGroup = ({ isMoreResultsButtonVisible, onLoadMoreResults, isBranchFilteringEnabled, - repoUrlTemplates, - repoMetadata, + repoInfo, 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} isBranchFilteringEnabled={isBranchFilteringEnabled} - repoMetadata={repoMetadata} + repoInfo={repoInfo} /> ) : (
@@ -310,26 +329,27 @@ const PanelGroup = ({
)}
- - {/* ~~ Code preview ~~ */} - - setSelectedFile(undefined)} - selectedMatchIndex={selectedMatchIndex} - onSelectedMatchIndexChange={setSelectedMatchIndex} - repoUrlTemplates={repoUrlTemplates} - /> - + {previewedFile && ( + <> + + {/* ~~ Code preview ~~ */} + setPreviewedFile(undefined)} + > + setPreviewedFile(undefined)} + selectedMatchIndex={selectedMatchIndex} + onSelectedMatchIndexChange={setSelectedMatchIndex} + /> + + + )}
) } 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/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/billing/page.tsx b/packages/web/src/app/[domain]/settings/billing/page.tsx index 5f66e6cc..87fccb7c 100644 --- a/packages/web/src/app/[domain]/settings/billing/page.tsx +++ b/packages/web/src/app/[domain]/settings/billing/page.tsx @@ -1,13 +1,15 @@ -import type { Metadata } from "next" -import { CalendarIcon, DollarSign, Users } from "lucide-react" +import { getCurrentUserRole } from "@/actions" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" -import { ManageSubscriptionButton } from "./manageSubscriptionButton" -import { getSubscriptionData, getCurrentUserRole, getSubscriptionBillingEmail } from "@/actions" +import { getSubscriptionBillingEmail, getSubscriptionInfo } from "@/ee/features/billing/actions" +import { ChangeBillingEmailCard } from "@/ee/features/billing/components/changeBillingEmailCard" +import { ManageSubscriptionButton } from "@/ee/features/billing/components/manageSubscriptionButton" +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe" +import { ServiceErrorException } from "@/lib/serviceError" import { isServiceError } from "@/lib/utils" -import { ChangeBillingEmailCard } from "./changeBillingEmailCard" +import { CalendarIcon, DollarSign, Users } from "lucide-react" +import type { Metadata } from "next" import { notFound } from "next/navigation" -import { IS_BILLING_ENABLED } from "@/lib/stripe" -import { ServiceErrorException } from "@/lib/serviceError" + export const metadata: Metadata = { title: "Billing | Settings", description: "Manage your subscription and billing information", @@ -26,7 +28,7 @@ export default async function BillingPage({ notFound(); } - const subscription = await getSubscriptionData(domain) + const subscription = await getSubscriptionInfo(domain) if (isServiceError(subscription)) { throw new ServiceErrorException(subscription); diff --git a/packages/web/src/app/[domain]/settings/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 0f15f9bc..4ad4c387 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,10 +1,17 @@ +import React from "react" import { Metadata } from "next" import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" import { Header } from "./components/header"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; +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", @@ -34,13 +65,32 @@ export default async function SettingsLayout({ } ] : []), { - title: "Members", + title: ( +
+ Members + {userRoleInOrg === OrgRole.OWNER && numJoinRequests !== undefined && numJoinRequests > 0 && ( + + {numJoinRequests} + + )} +
+ ), href: `/${domain}/settings/members`, }, { title: "Secrets", href: `/${domain}/settings/secrets`, - } + }, + { + title: "API Keys", + href: `/${domain}/settings/apiKeys`, + }, + ...(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..f46ecb5e --- /dev/null +++ b/packages/web/src/app/[domain]/settings/license/page.tsx @@ -0,0 +1,126 @@ +import { getEntitlements, getLicenseKey, getPlan, SOURCEBOT_UNLIMITED_SEATS } from "@/features/entitlements/server"; +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 = await getLicenseKey(); + const entitlements = await getEntitlements(); + const plan = await getPlan(); + + if (!licenseKey) { + return ( +
+ ) + } + + 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.toLocaleDateString("en-US", { + hour: "2-digit", + minute: "2-digit", + month: "long", + day: "numeric", + year: "numeric" + })} {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 cab223e6..edb96f35 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -6,9 +6,13 @@ 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 { IS_BILLING_ENABLED } from "@/lib/stripe"; +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 "@/features/entitlements/server"; +import { RequestsList } from "./components/requestsList"; +import { OrgRole } from "@prisma/client"; + interface MembersSettingsPageProps { params: { domain: string @@ -44,18 +48,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 +90,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]/upgrade/page.tsx b/packages/web/src/app/[domain]/upgrade/page.tsx index cd8f238b..8a51aa09 100644 --- a/packages/web/src/app/[domain]/upgrade/page.tsx +++ b/packages/web/src/app/[domain]/upgrade/page.tsx @@ -1,23 +1,23 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { Footer } from "@/app/components/footer"; import { OrgSelector } from "../components/orgSelector"; -import { EnterpriseUpgradeCard } from "./components/enterpriseUpgradeCard"; -import { TeamUpgradeCard } from "./components/teamUpgradeCard"; -import { fetchSubscription } from "@/actions"; +import { EnterpriseUpgradeCard } from "@/ee/features/billing/components/enterpriseUpgradeCard"; +import { TeamUpgradeCard } from "@/ee/features/billing/components/teamUpgradeCard"; import { redirect } from "next/navigation"; import { isServiceError } from "@/lib/utils"; import Link from "next/link"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { env } from "@/env.mjs"; -import { IS_BILLING_ENABLED } from "@/lib/stripe"; +import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; +import { getSubscriptionInfo } from "@/ee/features/billing/actions"; export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) { if (!IS_BILLING_ENABLED) { redirect(`/${domain}`); } - const subscription = await fetchSubscription(domain); + const subscription = await getSubscriptionInfo(domain); if (!subscription) { redirect(`/${domain}`); } diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts index 987a0ad7..c23d0cde 100644 --- a/packages/web/src/app/api/(client)/client.ts +++ b/packages/web/src/app/api/(client)/client.ts @@ -1,9 +1,23 @@ 'use client'; -import { fileSourceResponseSchema, getVersionResponseSchema, listRepositoriesResponseSchema, searchResponseSchema } from "@/lib/schemas"; -import { FileSourceRequest, FileSourceResponse, GetVersionResponse, ListRepositoriesResponse, SearchRequest, SearchResponse } from "@/lib/types"; +import { getVersionResponseSchema } from "@/lib/schemas"; +import { ServiceError } from "@/lib/serviceError"; +import { GetVersionResponse } from "@/lib/types"; +import { isServiceError } from "@/lib/utils"; +import { + FileSourceResponse, + FileSourceRequest, + ListRepositoriesResponse, + SearchRequest, + SearchResponse, +} from "@/features/search/types"; +import { + fileSourceResponseSchema, + listRepositoriesResponseSchema, + searchResponseSchema, +} from "@/features/search/schemas"; -export const search = async (body: SearchRequest, domain: string): Promise => { +export const search = async (body: SearchRequest, domain: string): Promise => { const result = await fetch("/api/search", { method: "POST", headers: { @@ -13,6 +27,10 @@ export const search = async (body: SearchRequest, domain: string): Promise response.json()); + if (isServiceError(result)) { + return result; + } + return searchResponseSchema.parse(result); } diff --git a/packages/web/src/app/api/(server)/repos/route.ts b/packages/web/src/app/api/(server)/repos/route.ts index 829d4f7c..6673f0eb 100644 --- a/packages/web/src/app/api/(server)/repos/route.ts +++ b/packages/web/src/app/api/(server)/repos/route.ts @@ -1,26 +1,26 @@ 'use server'; -import { listRepositories } from "@/lib/server/searchService"; +import { listRepositories } from "@/features/search/listReposApi"; import { NextRequest } from "next/server"; -import { sew, withAuth, withOrgMembership } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { serviceErrorResponse } from "@/lib/serviceError"; +import { StatusCodes } from "http-status-codes"; +import { ErrorCode } from "@/lib/errorCodes"; export const GET = async (request: NextRequest) => { - const domain = request.headers.get("X-Org-Domain")!; - const response = await getRepos(domain); + 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", + }); + } + const response = await listRepositories(domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } return Response.json(response); } - - -const getRepos = (domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const response = await listRepositories(orgId); - return response; - } - ), /* allowSingleTenantUnauthedAccess */ true)); \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/search/route.ts b/packages/web/src/app/api/(server)/search/route.ts index 54161b0d..145d3fa9 100644 --- a/packages/web/src/app/api/(server)/search/route.ts +++ b/packages/web/src/app/api/(server)/search/route.ts @@ -1,15 +1,24 @@ 'use server'; -import { search } from "@/lib/server/searchService"; -import { searchRequestSchema } from "@/lib/schemas"; +import { search } from "@/features/search/searchApi"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { sew, withAuth, withOrgMembership } from "@/actions"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; -import { SearchRequest } from "@/lib/types"; +import { searchRequestSchema } from "@/features/search/schemas"; +import { ErrorCode } from "@/lib/errorCodes"; +import { StatusCodes } from "http-status-codes"; export const POST = async (request: NextRequest) => { - const domain = request.headers.get("X-Org-Domain")!; + 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", + }); + } + const body = await request.json(); const parsed = await searchRequestSchema.safeParseAsync(body); if (!parsed.success) { @@ -18,17 +27,9 @@ export const POST = async (request: NextRequest) => { ); } - const response = await postSearch(parsed.data, domain); + const response = await search(parsed.data, domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } return Response.json(response); -} - -const postSearch = (request: SearchRequest, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const response = await search(request, orgId); - return response; - } - ), /* allowSingleTenantUnauthedAccess */ true)); \ No newline at end of file +} \ No newline at end of file diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 19962d50..a6364b36 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -1,14 +1,24 @@ 'use server'; -import { fileSourceRequestSchema } from "@/lib/schemas"; -import { getFileSource } from "@/lib/server/searchService"; +import { getFileSource } from "@/features/search/fileSourceApi"; import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { sew, withAuth, withOrgMembership } from "@/actions"; -import { FileSourceRequest } from "@/lib/types"; +import { fileSourceRequestSchema } from "@/features/search/schemas"; +import { ErrorCode } from "@/lib/errorCodes"; +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, + errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER, + message: "Missing X-Org-Domain header", + }); + } + const body = await request.json(); const parsed = await fileSourceRequestSchema.safeParseAsync(body); if (!parsed.success) { @@ -16,21 +26,11 @@ export const POST = async (request: NextRequest) => { schemaValidationError(parsed.error) ); } - - - const response = await postSource(parsed.data, request.headers.get("X-Org-Domain")!); + + const response = await getFileSource(parsed.data, domain, apiKey); if (isServiceError(response)) { return serviceErrorResponse(response); } return Response.json(response); } - - -const postSource = (request: FileSourceRequest, domain: string) => sew(() => - withAuth(async (session) => - withOrgMembership(session, domain, async ({ orgId }) => { - const response = await getFileSource(request, orgId); - return response; - } - ), /* allowSingleTenantUnauthedAccess */ true)); diff --git a/packages/web/src/app/api/(server)/stripe/route.ts b/packages/web/src/app/api/(server)/stripe/route.ts index 9219c463..8a466b7a 100644 --- a/packages/web/src/app/api/(server)/stripe/route.ts +++ b/packages/web/src/app/api/(server)/stripe/route.ts @@ -3,7 +3,7 @@ import { NextRequest } from 'next/server'; import Stripe from 'stripe'; import { prisma } from '@/prisma'; import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db'; -import { stripeClient } from '@/lib/stripe'; +import { stripeClient } from '@/ee/features/billing/stripe'; import { env } from '@/env.mjs'; export async function POST(req: NextRequest) { diff --git a/packages/web/src/app/api/(server)/webhook/route.ts b/packages/web/src/app/api/(server)/webhook/route.ts new file mode 100644 index 00000000..4f980d07 --- /dev/null +++ b/packages/web/src/app/api/(server)/webhook/route.ts @@ -0,0 +1,113 @@ +'use server'; + +import { NextRequest } from "next/server"; +import { App, Octokit } from "octokit"; +import { WebhookEventDefinition} from "@octokit/webhooks/types"; +import { EndpointDefaults } from "@octokit/types"; +import { env } from "@/env.mjs"; +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"; + +let githubApp: App | undefined; +if (env.GITHUB_APP_ID && env.GITHUB_APP_WEBHOOK_SECRET && env.GITHUB_APP_PRIVATE_KEY_PATH) { + try { + const privateKey = fs.readFileSync(env.GITHUB_APP_PRIVATE_KEY_PATH, "utf8"); + + const throttledOctokit = Octokit.plugin(throttling); + githubApp = new App({ + appId: env.GITHUB_APP_ID, + privateKey: privateKey, + webhooks: { + secret: env.GITHUB_APP_WEBHOOK_SECRET, + }, + Octokit: throttledOctokit, + throttle: { + onRateLimit: (retryAfter: number, options: Required, octokit: Octokit, retryCount: number) => { + if (retryCount > 3) { + console.log(`Rate limit exceeded: ${retryAfter} seconds`); + return false; + } + + return true; + }, + } + }); + } catch (error) { + console.error(`Error initializing GitHub app: ${error}`); + } +} + +function isPullRequestEvent(eventHeader: string, payload: unknown): payload is WebhookEventDefinition<"pull-request-opened"> | WebhookEventDefinition<"pull-request-synchronize"> { + return eventHeader === "pull_request" && typeof payload === "object" && payload !== null && "action" in payload && typeof payload.action === "string" && (payload.action === "opened" || payload.action === "synchronize"); +} + +function isIssueCommentEvent(eventHeader: string, payload: unknown): payload is WebhookEventDefinition<"issue-comment-created"> { + return eventHeader === "issue_comment" && typeof payload === "object" && payload !== null && "action" in payload && typeof payload.action === "string" && payload.action === "created"; +} + +export const POST = async (request: NextRequest) => { + const body = await request.json(); + const headers = Object.fromEntries(request.headers.entries()); + + const githubEvent = headers['x-github-event'] || headers['X-GitHub-Event']; + if (githubEvent) { + console.log('GitHub event received:', githubEvent); + + if (!githubApp) { + console.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'); + return Response.json({ status: 'ok' }); + } + + if (!body.installation) { + console.error('Received github pull request event but installation is not present'); + return Response.json({ status: 'ok' }); + } + + const installationId = body.installation.id; + const octokit = await githubApp.getInstallationOctokit(installationId); + + const pullRequest = body.pull_request as GitHubPullRequest; + await processGitHubPullRequest(octokit, pullRequest); + } + + if (isIssueCommentEvent(githubEvent, body)) { + const comment = body.comment.body; + if (!comment) { + console.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'); + + if (!body.installation) { + console.error('Received github issue comment event but installation is not present'); + return Response.json({ status: 'ok' }); + } + + const pullRequestNumber = body.issue.number; + const repositoryName = body.repository.name; + const owner = body.repository.owner.login; + + const octokit = await githubApp.getInstallationOctokit(body.installation.id); + const { data: pullRequest } = await octokit.rest.pulls.get({ + owner, + repo: repositoryName, + pull_number: pullRequestNumber, + }); + + await processGitHubPullRequest(octokit, pullRequest); + } + } + } + + return Response.json({ status: 'ok' }); +} \ 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/securityCard.tsx b/packages/web/src/app/components/securityCard.tsx index 22056e92..578060f5 100644 --- a/packages/web/src/app/components/securityCard.tsx +++ b/packages/web/src/app/components/securityCard.tsx @@ -3,6 +3,7 @@ import Link from "next/link" import { Shield, Lock, CheckCircle, ExternalLink, Mail } from "lucide-react" import useCaptureEvent from "@/hooks/useCaptureEvent" +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants" export default function SecurityCard() { const captureEvent = useCaptureEvent(); @@ -62,7 +63,7 @@ export default function SecurityCard() {
Have questions? diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx index 69f503e8..44b5dabe 100644 --- a/packages/web/src/app/error.tsx +++ b/packages/web/src/app/error.tsx @@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Button } from "@/components/ui/button" import { serviceErrorSchema } from '@/lib/serviceError'; import { SourcebotLogo } from './components/sourcebotLogo'; +import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants"; export default function Error({ error, reset }: { error: Error & { digest?: string }, reset: () => void }) { useEffect(() => { @@ -76,7 +77,7 @@ function ErrorCard({ message, errorCode, statusCode, onReloadButtonClicked }: Er Unexpected Error - An unexpected error occurred. Please reload the page and try again. If the issue persists, please contact us. + An unexpected error occurred. Please reload the page and try again. If the issue persists, please contact us. diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index feabe357..d43d68df 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -4,78 +4,163 @@ @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%); + --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%); + + --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; } .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%); + --accent: hsl(217.2 32.6% 17.5%); + --accent-foreground: hsl(210 40% 98%); + --destructive: hsl(0 62.8% 30.6%); + --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%); + + --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; } } @@ -83,6 +168,7 @@ * { @apply border-border; } + body { @apply bg-background text-foreground; } @@ -98,13 +184,23 @@ 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; } .cm-editor.cm-focused { @@ -123,8 +219,9 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; } + body { @apply bg-background text-foreground; } @@ -136,6 +233,22 @@ } .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; */ } \ No newline at end of file diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 859cd8b6..55925c89 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -7,6 +7,8 @@ import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import { SessionProvider } from "next-auth/react"; import { env } from "@/env.mjs"; +import { PlanProvider } from "@/features/entitlements/planProvider"; +import { getEntitlements } from "@/features/entitlements/server"; export const metadata: Metadata = { title: "Sourcebot", @@ -27,20 +29,22 @@ export default function RootLayout({ - - - - - {children} - - - - + + + + + + {children} + + + + + diff --git a/packages/web/src/app/login/components/loginForm.tsx b/packages/web/src/app/login/components/loginForm.tsx index 3d5d953c..132e41ec 100644 --- a/packages/web/src/app/login/components/loginForm.tsx +++ b/packages/web/src/app/login/components/loginForm.tsx @@ -1,12 +1,11 @@ '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 { Card } from "@/components/ui/card"; -import { cn, getCodeHostIcon } from "@/lib/utils"; +import { cn, getAuthProviderInfo } from "@/lib/utils"; import { MagicLinkForm } from "./magicLinkForm"; import { CredentialsForm } from "./credentialsForm"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; @@ -22,15 +21,10 @@ const PRIVACY_POLICY_URL = "https://sourcebot.dev/privacy"; interface LoginFormProps { callbackUrl?: string; error?: string; - enabledMethods: { - github: boolean; - google: boolean; - magicLink: boolean; - credentials: boolean; - } + providers: Array<{ id: string; name: string }>; } -export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps) => { +export const LoginForm = ({ callbackUrl, error, providers }: LoginFormProps) => { const captureEvent = useCaptureEvent(); const onSignInWithOauth = useCallback((provider: string) => { signIn(provider, { redirectTo: callbackUrl ?? "/" }); @@ -50,6 +44,33 @@ export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps } }, [error]); + // 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"); + + // 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 + } + }; + return (
@@ -58,9 +79,11 @@ export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps />

Sign in to your account

-
- -
+ {env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT !== undefined && ( +
+ +
+ )} {error && (
@@ -69,36 +92,28 @@ export const LoginForm = ({ callbackUrl, error, enabledMethods }: LoginFormProps )} - {enabledMethods.github && ( - { - captureEvent("wa_login_with_github", {}); - onSignInWithOauth("github") - }} - /> - )} - {enabledMethods.google && ( - { - captureEvent("wa_login_with_google", {}); - onSignInWithOauth("google") - }} - /> - )} - + ...(oauthProviders.length > 0 ? [ +
+ {oauthProviders.map((provider) => { + const providerInfo = getAuthProviderInfo(provider.id); + return ( + { + captureEvent(getLoginEventName(provider.id), {}); + onSignInWithOauth(provider.id); + }} + /> + ); + })} +
] : []), - ...(enabledMethods.magicLink ? [ + ...(hasMagicLink ? [ ] : []), - ...(enabledMethods.credentials ? [ + ...(hasCredentials ? [ ] : []) ]} @@ -118,7 +133,7 @@ const ProviderButton = ({ className, }: { name: string; - logo: { src: string, className?: string }; + logo: { src: string, className?: string } | null; onClick: () => void; className?: string; }) => { diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx index 50fd3ef8..1487d182 100644 --- a/packages/web/src/app/login/page.tsx +++ b/packages/web/src/app/login/page.tsx @@ -12,17 +12,19 @@ interface LoginProps { } export default async function Login({ searchParams }: LoginProps) { + console.log("Login page loaded"); const session = await auth(); if (session) { + console.log("Session found in login page, redirecting to home"); return redirect("/"); } const providers = getProviders(); - const providerMap = providers + const providerData = providers .map((provider) => { if (typeof provider === "function") { - const providerData = provider() - return { id: providerData.id, name: providerData.name } + const providerInfo = provider() + return { id: providerInfo.id, name: providerInfo.name } } else { return { id: provider.id, name: provider.name } } @@ -34,12 +36,7 @@ export default async function Login({ searchParams }: LoginProps) { 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={providerData} />