mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-11 20:05:25 +00:00
[experimental] feat(ee): GitHub permission syncing (#508)
This commit is contained in:
parent
a76ae68c64
commit
5073c7db22
57 changed files with 2177 additions and 1259 deletions
|
|
@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- [Experimental][Sourcebot EE] Added permission syncing repository Access Control Lists (ACLs) between Sourcebot and GitHub. [#508](https://github.com/sourcebot-dev/sourcebot/pull/508)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Improved repository query performance by adding db indices. [#526](https://github.com/sourcebot-dev/sourcebot/pull/526)
|
- Improved repository query performance by adding db indices. [#526](https://github.com/sourcebot-dev/sourcebot/pull/526)
|
||||||
- Improved repository query performance by removing JOIN on `Connection` table. [#527](https://github.com/sourcebot-dev/sourcebot/pull/527)
|
- Improved repository query performance by removing JOIN on `Connection` table. [#527](https://github.com/sourcebot-dev/sourcebot/pull/527)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ Copyright (c) 2025 Taqla Inc.
|
||||||
|
|
||||||
Portions of this software are licensed as follows:
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
- All content that resides under the "ee/", "packages/web/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
|
- All content that resides under the "ee/", "packages/web/src/ee/", "packages/backend/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
|
||||||
- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component.
|
- All third party components incorporated into the Sourcebot Software are licensed under the original license provided by the owner of the applicable component.
|
||||||
- Content outside of the above mentioned directories or restrictions above is available under the "Functional Source License" as defined below.
|
- Content outside of the above mentioned directories or restrictions above is available under the "Functional Source License" as defined below.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
"docs/features/code-navigation",
|
"docs/features/code-navigation",
|
||||||
"docs/features/analytics",
|
"docs/features/analytics",
|
||||||
"docs/features/mcp-server",
|
"docs/features/mcp-server",
|
||||||
|
"docs/features/permission-syncing",
|
||||||
{
|
{
|
||||||
"group": "Agents",
|
"group": "Agents",
|
||||||
"tag": "experimental",
|
"tag": "experimental",
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,19 @@ Sourcebot syncs the config file on startup, and automatically whenever a change
|
||||||
|
|
||||||
The following are settings that can be provided in your config file to modify Sourcebot's behavior
|
The following are settings that can be provided in your config file to modify Sourcebot's behavior
|
||||||
|
|
||||||
| Setting | Type | Default | Minimum | Description / Notes |
|
| Setting | Type | Default | Minimum | Description / Notes |
|
||||||
|-------------------------------------------|---------|------------|---------|----------------------------------------------------------------------------------------|
|
|-------------------------------------------------|---------|------------|---------|----------------------------------------------------------------------------------------|
|
||||||
| `maxFileSize` | number | 2 MB | 1 | Maximum size (bytes) of a file to index. Files exceeding this are skipped. |
|
| `maxFileSize` | number | 2 MB | 1 | Maximum size (bytes) of a file to index. Files exceeding this are skipped. |
|
||||||
| `maxTrigramCount` | number | 20 000 | 1 | Maximum trigrams per document. Larger files are skipped. |
|
| `maxTrigramCount` | number | 20 000 | 1 | Maximum trigrams per document. Larger files are skipped. |
|
||||||
| `reindexIntervalMs` | number | 1 hour | 1 | Interval at which all repositories are re‑indexed. |
|
| `reindexIntervalMs` | number | 1 hour | 1 | Interval at which all repositories are re‑indexed. |
|
||||||
| `resyncConnectionIntervalMs` | number | 24 hours | 1 | Interval for checking connections that need re‑syncing. |
|
| `resyncConnectionIntervalMs` | number | 24 hours | 1 | Interval for checking connections that need re‑syncing. |
|
||||||
| `resyncConnectionPollingIntervalMs` | number | 1 second | 1 | DB polling rate for connections that need re‑syncing. |
|
| `resyncConnectionPollingIntervalMs` | number | 1 second | 1 | DB polling rate for connections that need re‑syncing. |
|
||||||
| `reindexRepoPollingIntervalMs` | number | 1 second | 1 | DB polling rate for repos that should be re‑indexed. |
|
| `reindexRepoPollingIntervalMs` | number | 1 second | 1 | DB polling rate for repos that should be re‑indexed. |
|
||||||
| `maxConnectionSyncJobConcurrency` | number | 8 | 1 | Concurrent connection‑sync jobs. |
|
| `maxConnectionSyncJobConcurrency` | number | 8 | 1 | Concurrent connection‑sync jobs. |
|
||||||
| `maxRepoIndexingJobConcurrency` | number | 8 | 1 | Concurrent repo‑indexing jobs. |
|
| `maxRepoIndexingJobConcurrency` | number | 8 | 1 | Concurrent repo‑indexing jobs. |
|
||||||
| `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repo‑garbage‑collection jobs. |
|
| `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repo‑garbage‑collection jobs. |
|
||||||
| `repoGarbageCollectionGracePeriodMs` | number | 10 seconds | 1 | Grace period to avoid deleting shards while loading. |
|
| `repoGarbageCollectionGracePeriodMs` | number | 10 seconds | 1 | Grace period to avoid deleting shards while loading. |
|
||||||
| `repoIndexTimeoutMs` | number | 2 hours | 1 | Timeout for a single repo‑indexing run. |
|
| `repoIndexTimeoutMs` | number | 2 hours | 1 | Timeout for a single repo‑indexing run. |
|
||||||
| `enablePublicAccess` **(deprecated)** | boolean | false | — | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. |
|
| `enablePublicAccess` **(deprecated)** | boolean | false | — | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. |
|
||||||
|
| `experiment_repoDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the repo permission syncer should run. |
|
||||||
|
| `experiment_userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 | Interval at which the user permission syncer should run. |
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ The following environment variables allow you to configure your Sourcebot deploy
|
||||||
| `AUTH_EE_OKTA_ISSUER` | `-` | <p>The issuer URL for Okta SSO authentication.</p> |
|
| `AUTH_EE_OKTA_ISSUER` | `-` | <p>The issuer URL for Okta SSO authentication.</p> |
|
||||||
| `AUTH_EE_GCP_IAP_ENABLED` | `false` | <p>When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect</p> |
|
| `AUTH_EE_GCP_IAP_ENABLED` | `false` | <p>When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect</p> |
|
||||||
| `AUTH_EE_GCP_IAP_AUDIENCE` | - | <p>The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning</p> |
|
| `AUTH_EE_GCP_IAP_AUDIENCE` | - | <p>The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning</p> |
|
||||||
|
| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` | `false` | <p>Enables [permission syncing](/docs/features/permission-syncing).</p> |
|
||||||
|
|
||||||
|
|
||||||
### Review Agent Environment Variables
|
### Review Agent Environment Variables
|
||||||
|
|
|
||||||
|
|
@ -196,4 +196,8 @@ To connect to a GitHub host other than `github.com`, provide the `url` property
|
||||||
|
|
||||||
<GitHubSchema />
|
<GitHubSchema />
|
||||||
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [Syncing GitHub Access permissions to Sourcebot](/docs/features/permission-syncing#github)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ title: "Agents Overview"
|
||||||
sidebarTitle: "Overview"
|
sidebarTitle: "Overview"
|
||||||
---
|
---
|
||||||
|
|
||||||
<Warning>
|
import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.mdx'
|
||||||
Agents are currently a experimental feature. Have an idea for an agent that we haven't built? Submit a [feature request](https://github.com/sourcebot-dev/sourcebot/issues/new?template=feature_request.md) on our GitHub.
|
|
||||||
</Warning>
|
<ExperimentalFeatureWarning />
|
||||||
|
|
||||||
Agents are automations that leverage the code indexed on Sourcebot to perform a specific task. Once you've setup Sourcebot, check out the
|
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.
|
guides below to configure additional agents.
|
||||||
|
|
|
||||||
72
docs/docs/features/permission-syncing.mdx
Normal file
72
docs/docs/features/permission-syncing.mdx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
title: "Permission syncing"
|
||||||
|
sidebarTitle: "Permission syncing"
|
||||||
|
tag: "experimental"
|
||||||
|
---
|
||||||
|
|
||||||
|
import LicenseKeyRequired from '/snippets/license-key-required.mdx'
|
||||||
|
import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.mdx'
|
||||||
|
|
||||||
|
<LicenseKeyRequired />
|
||||||
|
<ExperimentalFeatureWarning />
|
||||||
|
|
||||||
|
# Overview
|
||||||
|
|
||||||
|
Permission syncing allows you to sync Access Permission Lists (ACLs) from a code host to Sourcebot. When configured, users signed into Sourcebot (via the code host's OAuth provider) will only be able to access repositories that they have access to on the code host. Practically, this means:
|
||||||
|
|
||||||
|
- Code Search results will only include repositories that the user has access to.
|
||||||
|
- Code navigation results will only include repositories that the user has access to.
|
||||||
|
- Ask Sourcebot (and the underlying LLM) will only have access to repositories that the user has access to.
|
||||||
|
- File browsing is scoped to the repositories that the user has access to.
|
||||||
|
|
||||||
|
Permission syncing can be enabled by setting the `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` environment variable to `true`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run \
|
||||||
|
-e EXPERIMENT_EE_PERMISSION_SYNC_ENABLED=true \
|
||||||
|
/* additional args */ \
|
||||||
|
ghcr.io/sourcebot-dev/sourcebot:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform support
|
||||||
|
|
||||||
|
We are actively working on supporting more code hosts. If you'd like to see a specific code host supported, please [reach out](https://www.sourcebot.dev/contact).
|
||||||
|
|
||||||
|
| Platform | Permission syncing |
|
||||||
|
|:----------|------------------------------|
|
||||||
|
| [GitHub (GHEC & GHEC Server)](/docs/features/permission-syncing#github) | ✅ |
|
||||||
|
| GitLab | 🛑 |
|
||||||
|
| Bitbucket Cloud | 🛑 |
|
||||||
|
| Bitbucket Data Center | 🛑 |
|
||||||
|
| Gitea | 🛑 |
|
||||||
|
| Gerrit | 🛑 |
|
||||||
|
| Generic git host | 🛑 |
|
||||||
|
|
||||||
|
# Getting started
|
||||||
|
|
||||||
|
## GitHub
|
||||||
|
|
||||||
|
Prerequisite: [Add GitHub as an OAuth provider](/docs/configuration/auth/providers#github).
|
||||||
|
|
||||||
|
Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and **GitHub Enterprise Server**. For organization-owned repositories, users that have **read-only** access (or above) via the following methods will have their access synced to Sourcebot:
|
||||||
|
- Outside collaborators
|
||||||
|
- Organization members that are direct collaborators
|
||||||
|
- Organization members with access through team memberships
|
||||||
|
- Organization members with access through default organization permissions
|
||||||
|
- Organization owners.
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- A GitHub OAuth provider must be configured to (1) correlate a Sourcebot user with a GitHub user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
|
||||||
|
- OAuth tokens must assume the `repo` scope in order to use the [List repositories for the authenticated user API](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user) during [User driven syncing](/docs/features/permission-syncing#how-it-works). Sourcebot **will only** use this token for **reads**.
|
||||||
|
|
||||||
|
# How it works
|
||||||
|
|
||||||
|
Permission syncing works by periodically syncing ACLs from the code host(s) to Sourcebot to build an internal mapping between Users and Repositories. This mapping is hydrated in two directions:
|
||||||
|
- **User driven** : fetches the list of all repositories that a given user has access to.
|
||||||
|
- **Repo driven** : fetches the list of all users that have access to a given repository.
|
||||||
|
|
||||||
|
User driven and repo driven syncing occurs every 24 hours by default. These intervals can be configured using the following settings in the [config file](/docs/configuration/config-file):
|
||||||
|
| Setting | Type | Default | Minimum |
|
||||||
|
|-------------------------------------------------|---------|------------|---------|
|
||||||
|
| `experiment_repoDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 |
|
||||||
|
| `experiment_userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 |
|
||||||
4
docs/snippets/experimental-feature-warning.mdx
Normal file
4
docs/snippets/experimental-feature-warning.mdx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
This is an experimental feature. Certain functionality may be incomplete and breaking changes may ship in non-major releases. Have feedback? Submit a [issue](https://github.com/sourcebot-dev/sourcebot/issues) on GitHub.
|
||||||
|
</Warning>
|
||||||
|
|
@ -69,6 +69,16 @@
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"experiment_repoDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"experiment_userDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
@ -195,6 +205,16 @@
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"experiment_repoDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"experiment_userDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"watch:mcp": "yarn workspace @sourcebot/mcp build:watch",
|
"watch:mcp": "yarn workspace @sourcebot/mcp build:watch",
|
||||||
"watch:schemas": "yarn workspace @sourcebot/schemas watch",
|
"watch:schemas": "yarn workspace @sourcebot/schemas watch",
|
||||||
"dev:prisma:migrate:dev": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:dev",
|
"dev:prisma:migrate:dev": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:dev",
|
||||||
|
"dev:prisma:generate": "yarn with-env yarn workspace @sourcebot/db prisma:generate",
|
||||||
"dev:prisma:studio": "yarn with-env yarn workspace @sourcebot/db prisma:studio",
|
"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",
|
||||||
"dev:prisma:db:push": "yarn with-env yarn workspace @sourcebot/db prisma:db:push",
|
"dev:prisma:db:push": "yarn with-env yarn workspace @sourcebot/db prisma:db:push",
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@ import { env } from "./env.js";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { loadConfig, syncSearchContexts } from "@sourcebot/shared";
|
import { loadConfig, syncSearchContexts } from "@sourcebot/shared";
|
||||||
|
|
||||||
interface IConnectionManager {
|
|
||||||
scheduleConnectionSync: (connection: Connection) => Promise<void>;
|
|
||||||
registerPollingCallback: () => void;
|
|
||||||
dispose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QUEUE_NAME = 'connectionSyncQueue';
|
const QUEUE_NAME = 'connectionSyncQueue';
|
||||||
|
|
||||||
type JobPayload = {
|
type JobPayload = {
|
||||||
|
|
@ -30,10 +24,11 @@ type JobResult = {
|
||||||
repoCount: number,
|
repoCount: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConnectionManager implements IConnectionManager {
|
export class ConnectionManager {
|
||||||
private worker: Worker;
|
private worker: Worker;
|
||||||
private queue: Queue<JobPayload>;
|
private queue: Queue<JobPayload>;
|
||||||
private logger = createLogger('connection-manager');
|
private logger = createLogger('connection-manager');
|
||||||
|
private interval?: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private db: PrismaClient,
|
private db: PrismaClient,
|
||||||
|
|
@ -75,8 +70,9 @@ export class ConnectionManager implements IConnectionManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async registerPollingCallback() {
|
public startScheduler() {
|
||||||
setInterval(async () => {
|
this.logger.debug('Starting scheduler');
|
||||||
|
this.interval = setInterval(async () => {
|
||||||
const thresholdDate = new Date(Date.now() - this.settings.resyncConnectionIntervalMs);
|
const thresholdDate = new Date(Date.now() - this.settings.resyncConnectionIntervalMs);
|
||||||
const connections = await this.db.connection.findMany({
|
const connections = await this.db.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -369,6 +365,9 @@ export class ConnectionManager implements IConnectionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
this.worker.close();
|
this.worker.close();
|
||||||
this.queue.close();
|
this.queue.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,11 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||||
maxRepoGarbageCollectionJobConcurrency: 8,
|
maxRepoGarbageCollectionJobConcurrency: 8,
|
||||||
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
|
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
|
||||||
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
|
repoIndexTimeoutMs: 1000 * 60 * 60 * 2, // 2 hours
|
||||||
enablePublicAccess: false // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
|
enablePublicAccess: false, // deprected, use FORCE_ENABLE_ANONYMOUS_ACCESS instead
|
||||||
|
experiment_repoDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
experiment_userDrivenPermissionSyncIntervalMs: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
||||||
|
'github',
|
||||||
|
];
|
||||||
274
packages/backend/src/ee/repoPermissionSyncer.ts
Normal file
274
packages/backend/src/ee/repoPermissionSyncer.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import * as Sentry from "@sentry/node";
|
||||||
|
import { PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
import { Job, Queue, Worker } from 'bullmq';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
import { createOctokitFromToken, getRepoCollaborators } from "../github.js";
|
||||||
|
import { Settings } from "../types.js";
|
||||||
|
import { getAuthCredentialsForRepo } from "../utils.js";
|
||||||
|
|
||||||
|
type RepoPermissionSyncJob = {
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUEUE_NAME = 'repoPermissionSyncQueue';
|
||||||
|
|
||||||
|
const logger = createLogger('repo-permission-syncer');
|
||||||
|
|
||||||
|
|
||||||
|
export class RepoPermissionSyncer {
|
||||||
|
private queue: Queue<RepoPermissionSyncJob>;
|
||||||
|
private worker: Worker<RepoPermissionSyncJob>;
|
||||||
|
private interval?: NodeJS.Timeout;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private db: PrismaClient,
|
||||||
|
private settings: Settings,
|
||||||
|
redis: Redis,
|
||||||
|
) {
|
||||||
|
this.queue = new Queue<RepoPermissionSyncJob>(QUEUE_NAME, {
|
||||||
|
connection: redis,
|
||||||
|
});
|
||||||
|
this.worker = new Worker<RepoPermissionSyncJob>(QUEUE_NAME, this.runJob.bind(this), {
|
||||||
|
connection: redis,
|
||||||
|
concurrency: 1,
|
||||||
|
});
|
||||||
|
this.worker.on('completed', this.onJobCompleted.bind(this));
|
||||||
|
this.worker.on('failed', this.onJobFailed.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public startScheduler() {
|
||||||
|
if (!hasEntitlement('permission-syncing')) {
|
||||||
|
throw new Error('Permission syncing is not supported in current plan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Starting scheduler');
|
||||||
|
|
||||||
|
this.interval = setInterval(async () => {
|
||||||
|
// @todo: make this configurable
|
||||||
|
const thresholdDate = new Date(Date.now() - this.settings.experiment_repoDrivenPermissionSyncIntervalMs);
|
||||||
|
|
||||||
|
const repos = await this.db.repo.findMany({
|
||||||
|
// Repos need their permissions to be synced against the code host when...
|
||||||
|
where: {
|
||||||
|
// They belong to a code host that supports permissions syncing
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
external_codeHostType: {
|
||||||
|
in: PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ permissionSyncedAt: null },
|
||||||
|
{ permissionSyncedAt: { lt: thresholdDate } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
NOT: {
|
||||||
|
permissionSyncJobs: {
|
||||||
|
some: {
|
||||||
|
OR: [
|
||||||
|
// Don't schedule if there are active jobs
|
||||||
|
{
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
RepoPermissionSyncJobStatus.PENDING,
|
||||||
|
RepoPermissionSyncJobStatus.IN_PROGRESS,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Don't schedule if there are recent failed jobs (within the threshold date). Note `gt` is used here since this is a inverse condition.
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ status: RepoPermissionSyncJobStatus.FAILED },
|
||||||
|
{ completedAt: { gt: thresholdDate } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.schedulePermissionSync(repos);
|
||||||
|
}, 1000 * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
this.worker.close();
|
||||||
|
this.queue.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async schedulePermissionSync(repos: Repo[]) {
|
||||||
|
await this.db.$transaction(async (tx) => {
|
||||||
|
const jobs = await tx.repoPermissionSyncJob.createManyAndReturn({
|
||||||
|
data: repos.map(repo => ({
|
||||||
|
repoId: repo.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.queue.addBulk(jobs.map((job) => ({
|
||||||
|
name: 'repoPermissionSyncJob',
|
||||||
|
data: {
|
||||||
|
jobId: job.id,
|
||||||
|
},
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
|
||||||
|
removeOnFail: env.REDIS_REMOVE_ON_FAIL,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runJob(job: Job<RepoPermissionSyncJob>) {
|
||||||
|
const id = job.data.jobId;
|
||||||
|
const { repo } = await this.db.repoPermissionSyncJob.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: RepoPermissionSyncJobStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
repo: {
|
||||||
|
include: {
|
||||||
|
connections: {
|
||||||
|
include: {
|
||||||
|
connection: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
throw new Error(`Repo ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Syncing permissions for repo ${repo.displayName}...`);
|
||||||
|
|
||||||
|
const credentials = await getAuthCredentialsForRepo(repo, this.db, logger);
|
||||||
|
if (!credentials) {
|
||||||
|
throw new Error(`No credentials found for repo ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = await (async () => {
|
||||||
|
if (repo.external_codeHostType === 'github') {
|
||||||
|
const { octokit } = await createOctokitFromToken({
|
||||||
|
token: credentials.token,
|
||||||
|
url: credentials.hostUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @note: this is a bit of a hack since the displayName _might_ not be set..
|
||||||
|
// however, this property was introduced many versions ago and _should_ be set
|
||||||
|
// on each connection sync. Let's throw an error just in case.
|
||||||
|
if (!repo.displayName) {
|
||||||
|
throw new Error(`Repo ${id} does not have a displayName`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [owner, repoName] = repo.displayName.split('/');
|
||||||
|
|
||||||
|
const collaborators = await getRepoCollaborators(owner, repoName, octokit);
|
||||||
|
const githubUserIds = collaborators.map(collaborator => collaborator.id.toString());
|
||||||
|
|
||||||
|
const accounts = await this.db.account.findMany({
|
||||||
|
where: {
|
||||||
|
provider: 'github',
|
||||||
|
providerAccountId: {
|
||||||
|
in: githubUserIds,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return accounts.map(account => account.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
await this.db.$transaction([
|
||||||
|
this.db.repo.update({
|
||||||
|
where: {
|
||||||
|
id: repo.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
permittedUsers: {
|
||||||
|
deleteMany: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.db.userToRepoPermission.createMany({
|
||||||
|
data: userIds.map(userId => ({
|
||||||
|
userId,
|
||||||
|
repoId: repo.id,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onJobCompleted(job: Job<RepoPermissionSyncJob>) {
|
||||||
|
const { repo } = await this.db.repoPermissionSyncJob.update({
|
||||||
|
where: {
|
||||||
|
id: job.data.jobId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: RepoPermissionSyncJobStatus.COMPLETED,
|
||||||
|
repo: {
|
||||||
|
update: {
|
||||||
|
permissionSyncedAt: new Date(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
repo: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Permissions synced for repo ${repo.displayName ?? repo.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onJobFailed(job: Job<RepoPermissionSyncJob> | undefined, err: Error) {
|
||||||
|
Sentry.captureException(err, {
|
||||||
|
tags: {
|
||||||
|
jobId: job?.data.jobId,
|
||||||
|
queue: QUEUE_NAME,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage = (repoName: string) => `Repo permission sync job failed for repo ${repoName}: ${err.message}`;
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
const { repo } = await this.db.repoPermissionSyncJob.update({
|
||||||
|
where: {
|
||||||
|
id: job.data.jobId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: RepoPermissionSyncJobStatus.FAILED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
errorMessage: err.message,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
repo: true
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.error(errorMessage(repo.displayName ?? repo.name));
|
||||||
|
} else {
|
||||||
|
logger.error(errorMessage('unknown repo (id not found)'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
266
packages/backend/src/ee/userPermissionSyncer.ts
Normal file
266
packages/backend/src/ee/userPermissionSyncer.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
import * as Sentry from "@sentry/node";
|
||||||
|
import { PrismaClient, User, UserPermissionSyncJobStatus } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { Job, Queue, Worker } from "bullmq";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
|
||||||
|
import { env } from "../env.js";
|
||||||
|
import { createOctokitFromToken, getReposForAuthenticatedUser } from "../github.js";
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
import { Settings } from "../types.js";
|
||||||
|
|
||||||
|
const logger = createLogger('user-permission-syncer');
|
||||||
|
|
||||||
|
const QUEUE_NAME = 'userPermissionSyncQueue';
|
||||||
|
|
||||||
|
type UserPermissionSyncJob = {
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class UserPermissionSyncer {
|
||||||
|
private queue: Queue<UserPermissionSyncJob>;
|
||||||
|
private worker: Worker<UserPermissionSyncJob>;
|
||||||
|
private interval?: NodeJS.Timeout;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private db: PrismaClient,
|
||||||
|
private settings: Settings,
|
||||||
|
redis: Redis,
|
||||||
|
) {
|
||||||
|
this.queue = new Queue<UserPermissionSyncJob>(QUEUE_NAME, {
|
||||||
|
connection: redis,
|
||||||
|
});
|
||||||
|
this.worker = new Worker<UserPermissionSyncJob>(QUEUE_NAME, this.runJob.bind(this), {
|
||||||
|
connection: redis,
|
||||||
|
concurrency: 1,
|
||||||
|
});
|
||||||
|
this.worker.on('completed', this.onJobCompleted.bind(this));
|
||||||
|
this.worker.on('failed', this.onJobFailed.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
public startScheduler() {
|
||||||
|
if (!hasEntitlement('permission-syncing')) {
|
||||||
|
throw new Error('Permission syncing is not supported in current plan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Starting scheduler');
|
||||||
|
|
||||||
|
this.interval = setInterval(async () => {
|
||||||
|
const thresholdDate = new Date(Date.now() - this.settings.experiment_userDrivenPermissionSyncIntervalMs);
|
||||||
|
|
||||||
|
const users = await this.db.user.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
accounts: {
|
||||||
|
some: {
|
||||||
|
provider: {
|
||||||
|
in: PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ permissionSyncedAt: null },
|
||||||
|
{ permissionSyncedAt: { lt: thresholdDate } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
NOT: {
|
||||||
|
permissionSyncJobs: {
|
||||||
|
some: {
|
||||||
|
OR: [
|
||||||
|
// Don't schedule if there are active jobs
|
||||||
|
{
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
UserPermissionSyncJobStatus.PENDING,
|
||||||
|
UserPermissionSyncJobStatus.IN_PROGRESS,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Don't schedule if there are recent failed jobs (within the threshold date). Note `gt` is used here since this is a inverse condition.
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ status: UserPermissionSyncJobStatus.FAILED },
|
||||||
|
{ completedAt: { gt: thresholdDate } },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.schedulePermissionSync(users);
|
||||||
|
}, 1000 * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
|
this.worker.close();
|
||||||
|
this.queue.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async schedulePermissionSync(users: User[]) {
|
||||||
|
await this.db.$transaction(async (tx) => {
|
||||||
|
const jobs = await tx.userPermissionSyncJob.createManyAndReturn({
|
||||||
|
data: users.map(user => ({
|
||||||
|
userId: user.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.queue.addBulk(jobs.map((job) => ({
|
||||||
|
name: 'userPermissionSyncJob',
|
||||||
|
data: {
|
||||||
|
jobId: job.id,
|
||||||
|
},
|
||||||
|
opts: {
|
||||||
|
removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
|
||||||
|
removeOnFail: env.REDIS_REMOVE_ON_FAIL,
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runJob(job: Job<UserPermissionSyncJob>) {
|
||||||
|
const id = job.data.jobId;
|
||||||
|
const { user } = await this.db.userPermissionSyncJob.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: UserPermissionSyncJobStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
accounts: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Syncing permissions for user ${user.email}...`);
|
||||||
|
|
||||||
|
// Get a list of all repos that the user has access to from all connected accounts.
|
||||||
|
const repoIds = await (async () => {
|
||||||
|
const aggregatedRepoIds: Set<number> = new Set();
|
||||||
|
|
||||||
|
for (const account of user.accounts) {
|
||||||
|
if (account.provider === 'github') {
|
||||||
|
if (!account.access_token) {
|
||||||
|
throw new Error(`User '${user.email}' does not have an GitHub OAuth access token associated with their GitHub account.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { octokit } = await createOctokitFromToken({
|
||||||
|
token: account.access_token,
|
||||||
|
url: env.AUTH_EE_GITHUB_BASE_URL,
|
||||||
|
});
|
||||||
|
// @note: we only care about the private repos since we don't need to build a mapping
|
||||||
|
// for public repos.
|
||||||
|
// @see: packages/web/src/prisma.ts
|
||||||
|
const githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit);
|
||||||
|
const gitHubRepoIds = githubRepos.map(repo => repo.id.toString());
|
||||||
|
|
||||||
|
const repos = await this.db.repo.findMany({
|
||||||
|
where: {
|
||||||
|
external_codeHostType: 'github',
|
||||||
|
external_id: {
|
||||||
|
in: gitHubRepoIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(aggregatedRepoIds);
|
||||||
|
})();
|
||||||
|
|
||||||
|
await this.db.$transaction([
|
||||||
|
this.db.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
accessibleRepos: {
|
||||||
|
deleteMany: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.db.userToRepoPermission.createMany({
|
||||||
|
data: repoIds.map(repoId => ({
|
||||||
|
userId: user.id,
|
||||||
|
repoId,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onJobCompleted(job: Job<UserPermissionSyncJob>) {
|
||||||
|
const { user } = await this.db.userPermissionSyncJob.update({
|
||||||
|
where: {
|
||||||
|
id: job.data.jobId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: UserPermissionSyncJobStatus.COMPLETED,
|
||||||
|
user: {
|
||||||
|
update: {
|
||||||
|
permissionSyncedAt: new Date(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Permissions synced for user ${user.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onJobFailed(job: Job<UserPermissionSyncJob> | undefined, err: Error) {
|
||||||
|
Sentry.captureException(err, {
|
||||||
|
tags: {
|
||||||
|
jobId: job?.data.jobId,
|
||||||
|
queue: QUEUE_NAME,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage = (email: string) => `User permission sync job failed for user ${email}: ${err.message}`;
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
const { user } = await this.db.userPermissionSyncJob.update({
|
||||||
|
where: {
|
||||||
|
id: job.data.jobId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: UserPermissionSyncJobStatus.FAILED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
errorMessage: err.message,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
user: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.error(errorMessage(user.email ?? user.id));
|
||||||
|
} else {
|
||||||
|
logger.error(errorMessage('unknown user (id not found)'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,9 @@ export const env = createEnv({
|
||||||
REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60),
|
REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60),
|
||||||
|
|
||||||
GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS: numberSchema.default(60 * 10),
|
GITLAB_CLIENT_QUERY_TIMEOUT_SECONDS: numberSchema.default(60 * 10),
|
||||||
|
|
||||||
|
EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'),
|
||||||
|
AUTH_EE_GITHUB_BASE_URL: z.string().optional(),
|
||||||
},
|
},
|
||||||
runtimeEnv: process.env,
|
runtimeEnv: process.env,
|
||||||
emptyStringAsUndefined: true,
|
emptyStringAsUndefined: true,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,15 @@ import { env } from './env.js';
|
||||||
type onProgressFn = (event: SimpleGitProgressEvent) => void;
|
type onProgressFn = (event: SimpleGitProgressEvent) => void;
|
||||||
|
|
||||||
export const cloneRepository = async (
|
export const cloneRepository = async (
|
||||||
remoteUrl: URL,
|
{
|
||||||
path: string,
|
cloneUrl,
|
||||||
onProgress?: onProgressFn
|
path,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
cloneUrl: string,
|
||||||
|
path: string,
|
||||||
|
onProgress?: onProgressFn
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await mkdir(path, { recursive: true });
|
await mkdir(path, { recursive: true });
|
||||||
|
|
@ -19,7 +25,7 @@ export const cloneRepository = async (
|
||||||
})
|
})
|
||||||
|
|
||||||
await git.clone(
|
await git.clone(
|
||||||
remoteUrl.toString(),
|
cloneUrl,
|
||||||
path,
|
path,
|
||||||
[
|
[
|
||||||
"--bare",
|
"--bare",
|
||||||
|
|
@ -42,9 +48,15 @@ export const cloneRepository = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRepository = async (
|
export const fetchRepository = async (
|
||||||
remoteUrl: URL,
|
{
|
||||||
path: string,
|
cloneUrl,
|
||||||
onProgress?: onProgressFn
|
path,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
cloneUrl: string,
|
||||||
|
path: string,
|
||||||
|
onProgress?: onProgressFn
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const git = simpleGit({
|
const git = simpleGit({
|
||||||
|
|
@ -54,7 +66,7 @@ export const fetchRepository = async (
|
||||||
})
|
})
|
||||||
|
|
||||||
await git.fetch([
|
await git.fetch([
|
||||||
remoteUrl.toString(),
|
cloneUrl,
|
||||||
"+refs/heads/*:refs/heads/*",
|
"+refs/heads/*:refs/heads/*",
|
||||||
"--prune",
|
"--prune",
|
||||||
"--progress"
|
"--progress"
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,31 @@ export type OctokitRepository = {
|
||||||
size?: number,
|
size?: number,
|
||||||
owner: {
|
owner: {
|
||||||
avatar_url: string,
|
avatar_url: string,
|
||||||
|
login: string,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHttpError = (error: unknown, status: number): boolean => {
|
const isHttpError = (error: unknown, status: number): boolean => {
|
||||||
return error !== null
|
return error !== null
|
||||||
&& typeof error === 'object'
|
&& typeof error === 'object'
|
||||||
&& 'status' in error
|
&& 'status' in error
|
||||||
&& error.status === status;
|
&& error.status === status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createOctokitFromToken = async ({ token, url }: { token?: string, url?: string }): Promise<{ octokit: Octokit, isAuthenticated: boolean }> => {
|
||||||
|
const octokit = new Octokit({
|
||||||
|
auth: token,
|
||||||
|
...(url ? {
|
||||||
|
baseUrl: `${url}/api/v3`
|
||||||
|
} : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
octokit,
|
||||||
|
isAuthenticated: !!token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
|
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
|
||||||
const hostname = config.url ?
|
const hostname = config.url ?
|
||||||
new URL(config.url).hostname :
|
new URL(config.url).hostname :
|
||||||
|
|
@ -48,17 +63,15 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
||||||
const token = config.token ?
|
const token = config.token ?
|
||||||
await getTokenFromConfig(config.token, orgId, db, logger) :
|
await getTokenFromConfig(config.token, orgId, db, logger) :
|
||||||
hostname === GITHUB_CLOUD_HOSTNAME ?
|
hostname === GITHUB_CLOUD_HOSTNAME ?
|
||||||
env.FALLBACK_GITHUB_CLOUD_TOKEN :
|
env.FALLBACK_GITHUB_CLOUD_TOKEN :
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
const octokit = new Octokit({
|
const { octokit, isAuthenticated } = await createOctokitFromToken({
|
||||||
auth: token,
|
token,
|
||||||
...(config.url ? {
|
url: config.url,
|
||||||
baseUrl: `${config.url}/api/v3`
|
|
||||||
} : {}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (token) {
|
if (isAuthenticated) {
|
||||||
try {
|
try {
|
||||||
await octokit.rest.users.getAuthenticated();
|
await octokit.rest.users.getAuthenticated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -127,95 +140,42 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos: repos,
|
validRepos: repos,
|
||||||
notFound,
|
notFound,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const shouldExcludeRepo = ({
|
export const getRepoCollaborators = async (owner: string, repo: string, octokit: Octokit) => {
|
||||||
repo,
|
try {
|
||||||
include,
|
const fetchFn = () => octokit.paginate(octokit.repos.listCollaborators, {
|
||||||
exclude
|
owner,
|
||||||
} : {
|
repo,
|
||||||
repo: OctokitRepository,
|
per_page: 100,
|
||||||
include?: {
|
});
|
||||||
topics?: GithubConnectionConfig['topics']
|
|
||||||
},
|
|
||||||
exclude?: GithubConnectionConfig['exclude']
|
|
||||||
}) => {
|
|
||||||
let reason = '';
|
|
||||||
const repoName = repo.full_name;
|
|
||||||
|
|
||||||
const shouldExclude = (() => {
|
const collaborators = await fetchWithRetry(fetchFn, `repo ${owner}/${repo}`, logger);
|
||||||
if (!repo.clone_url) {
|
return collaborators;
|
||||||
reason = 'clone_url is undefined';
|
} catch (error) {
|
||||||
return true;
|
Sentry.captureException(error);
|
||||||
}
|
logger.error(`Failed to fetch collaborators for repo ${owner}/${repo}.`, error);
|
||||||
|
throw error;
|
||||||
if (!!exclude?.forks && repo.fork) {
|
|
||||||
reason = `\`exclude.forks\` is true`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!!exclude?.archived && !!repo.archived) {
|
|
||||||
reason = `\`exclude.archived\` is true`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exclude?.repos) {
|
|
||||||
if (micromatch.isMatch(repoName, exclude.repos)) {
|
|
||||||
reason = `\`exclude.repos\` contains ${repoName}`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exclude?.topics) {
|
|
||||||
const configTopics = exclude.topics.map(topic => topic.toLowerCase());
|
|
||||||
const repoTopics = repo.topics ?? [];
|
|
||||||
|
|
||||||
const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
|
|
||||||
if (matchingTopics.length > 0) {
|
|
||||||
reason = `\`exclude.topics\` matches the following topics: ${matchingTopics.join(', ')}`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (include?.topics) {
|
|
||||||
const configTopics = include.topics.map(topic => topic.toLowerCase());
|
|
||||||
const repoTopics = repo.topics ?? [];
|
|
||||||
|
|
||||||
const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
|
|
||||||
if (matchingTopics.length === 0) {
|
|
||||||
reason = `\`include.topics\` does not match any of the following topics: ${configTopics.join(', ')}`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoSizeInBytes = repo.size ? repo.size * 1000 : undefined;
|
|
||||||
if (exclude?.size && repoSizeInBytes) {
|
|
||||||
const min = exclude.size.min;
|
|
||||||
const max = exclude.size.max;
|
|
||||||
|
|
||||||
if (min && repoSizeInBytes < min) {
|
|
||||||
reason = `repo is less than \`exclude.size.min\`=${min} bytes.`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (max && repoSizeInBytes > max) {
|
|
||||||
reason = `repo is greater than \`exclude.size.max\`=${max} bytes.`;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (shouldExclude) {
|
|
||||||
logger.debug(`Excluding repo ${repoName}. Reason: ${reason}`);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
export const getReposForAuthenticatedUser = async (visibility: 'all' | 'private' | 'public' = 'all', octokit: Octokit) => {
|
||||||
|
try {
|
||||||
|
const fetchFn = () => octokit.paginate(octokit.repos.listForAuthenticatedUser, {
|
||||||
|
per_page: 100,
|
||||||
|
visibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repos = await fetchWithRetry(fetchFn, `authenticated user`, logger);
|
||||||
|
return repos;
|
||||||
|
} catch (error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
logger.error(`Failed to fetch repositories for authenticated user.`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: AbortSignal) => {
|
const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: AbortSignal) => {
|
||||||
|
|
@ -369,4 +329,90 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
|
||||||
validRepos,
|
validRepos,
|
||||||
notFoundRepos,
|
notFoundRepos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const shouldExcludeRepo = ({
|
||||||
|
repo,
|
||||||
|
include,
|
||||||
|
exclude
|
||||||
|
}: {
|
||||||
|
repo: OctokitRepository,
|
||||||
|
include?: {
|
||||||
|
topics?: GithubConnectionConfig['topics']
|
||||||
|
},
|
||||||
|
exclude?: GithubConnectionConfig['exclude']
|
||||||
|
}) => {
|
||||||
|
let reason = '';
|
||||||
|
const repoName = repo.full_name;
|
||||||
|
|
||||||
|
const shouldExclude = (() => {
|
||||||
|
if (!repo.clone_url) {
|
||||||
|
reason = 'clone_url is undefined';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!!exclude?.forks && repo.fork) {
|
||||||
|
reason = `\`exclude.forks\` is true`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!!exclude?.archived && !!repo.archived) {
|
||||||
|
reason = `\`exclude.archived\` is true`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exclude?.repos) {
|
||||||
|
if (micromatch.isMatch(repoName, exclude.repos)) {
|
||||||
|
reason = `\`exclude.repos\` contains ${repoName}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exclude?.topics) {
|
||||||
|
const configTopics = exclude.topics.map(topic => topic.toLowerCase());
|
||||||
|
const repoTopics = repo.topics ?? [];
|
||||||
|
|
||||||
|
const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
|
||||||
|
if (matchingTopics.length > 0) {
|
||||||
|
reason = `\`exclude.topics\` matches the following topics: ${matchingTopics.join(', ')}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (include?.topics) {
|
||||||
|
const configTopics = include.topics.map(topic => topic.toLowerCase());
|
||||||
|
const repoTopics = repo.topics ?? [];
|
||||||
|
|
||||||
|
const matchingTopics = repoTopics.filter((topic) => micromatch.isMatch(topic, configTopics));
|
||||||
|
if (matchingTopics.length === 0) {
|
||||||
|
reason = `\`include.topics\` does not match any of the following topics: ${configTopics.join(', ')}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoSizeInBytes = repo.size ? repo.size * 1000 : undefined;
|
||||||
|
if (exclude?.size && repoSizeInBytes) {
|
||||||
|
const min = exclude.size.min;
|
||||||
|
const max = exclude.size.max;
|
||||||
|
|
||||||
|
if (min && repoSizeInBytes < min) {
|
||||||
|
reason = `repo is less than \`exclude.size.min\`=${min} bytes.`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max && repoSizeInBytes > max) {
|
||||||
|
reason = `repo is greater than \`exclude.size.max\`=${max} bytes.`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (shouldExclude) {
|
||||||
|
logger.debug(`Excluding repo ${repoName}. Reason: ${reason}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,37 @@
|
||||||
import "./instrument.js";
|
import "./instrument.js";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/node";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { hasEntitlement, loadConfig } from '@sourcebot/shared';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { AppContext } from "./types.js";
|
import { ConnectionManager } from './connectionManager.js';
|
||||||
import { main } from "./main.js"
|
import { DEFAULT_SETTINGS } from './constants.js';
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
|
||||||
|
import { PromClient } from './promClient.js';
|
||||||
|
import { RepoManager } from './repoManager.js';
|
||||||
|
import { AppContext } from "./types.js";
|
||||||
|
import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
||||||
|
|
||||||
|
|
||||||
const logger = createLogger('backend-entrypoint');
|
const logger = createLogger('backend-entrypoint');
|
||||||
|
|
||||||
|
const getSettings = async (configPath?: string) => {
|
||||||
|
if (!configPath) {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
// Register handler for normal exit
|
const config = await loadConfig(configPath);
|
||||||
process.on('exit', (code) => {
|
|
||||||
logger.info(`Process is exiting with code: ${code}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register handlers for abnormal terminations
|
return {
|
||||||
process.on('SIGINT', () => {
|
...DEFAULT_SETTINGS,
|
||||||
logger.info('Process interrupted (SIGINT)');
|
...config.settings,
|
||||||
process.exit(0);
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
logger.info('Process terminated (SIGTERM)');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register handlers for uncaught exceptions and unhandled rejections
|
|
||||||
process.on('uncaughtException', (err) => {
|
|
||||||
logger.error(`Uncaught exception: ${err.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cacheDir = env.DATA_CACHE_DIR;
|
const cacheDir = env.DATA_CACHE_DIR;
|
||||||
const reposPath = path.join(cacheDir, 'repos');
|
const reposPath = path.join(cacheDir, 'repos');
|
||||||
|
|
@ -59,18 +52,62 @@ const context: AppContext = {
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
main(prisma, context)
|
const redis = new Redis(env.REDIS_URL, {
|
||||||
.then(async () => {
|
maxRetriesPerRequest: null
|
||||||
await prisma.$disconnect();
|
});
|
||||||
})
|
redis.ping().then(() => {
|
||||||
.catch(async (e) => {
|
logger.info('Connected to redis');
|
||||||
logger.error(e);
|
}).catch((err: unknown) => {
|
||||||
Sentry.captureException(e);
|
logger.error('Failed to connect to redis');
|
||||||
|
logger.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.$disconnect();
|
const promClient = new PromClient();
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
logger.info("Shutting down...");
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const settings = await getSettings(env.CONFIG_PATH);
|
||||||
|
|
||||||
|
const connectionManager = new ConnectionManager(prisma, settings, redis);
|
||||||
|
const repoManager = new RepoManager(prisma, settings, redis, promClient, context);
|
||||||
|
const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
|
||||||
|
const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis);
|
||||||
|
|
||||||
|
await repoManager.validateIndexedReposHaveShards();
|
||||||
|
|
||||||
|
connectionManager.startScheduler();
|
||||||
|
repoManager.startScheduler();
|
||||||
|
|
||||||
|
if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) {
|
||||||
|
logger.error('Permission syncing is not supported in current plan. Please contact support@sourcebot.dev for assistance.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
else if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) {
|
||||||
|
repoPermissionSyncer.startScheduler();
|
||||||
|
userPermissionSyncer.startScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async (signal: string) => {
|
||||||
|
logger.info(`Recieved ${signal}, cleaning up...`);
|
||||||
|
|
||||||
|
connectionManager.dispose();
|
||||||
|
repoManager.dispose();
|
||||||
|
repoPermissionSyncer.dispose();
|
||||||
|
userPermissionSyncer.dispose();
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await redis.quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => cleanup('SIGINT').finally(() => process.exit(0)));
|
||||||
|
process.on('SIGTERM', () => cleanup('SIGTERM').finally(() => process.exit(0)));
|
||||||
|
|
||||||
|
// Register handlers for uncaught exceptions and unhandled rejections
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
logger.error(`Uncaught exception: ${err.message}`);
|
||||||
|
cleanup('uncaughtException').finally(() => process.exit(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
logger.error(`Unhandled rejection at: ${promise}, reason: ${reason}`);
|
||||||
|
cleanup('unhandledRejection').finally(() => process.exit(1));
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { PrismaClient } from '@sourcebot/db';
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
import { AppContext } from "./types.js";
|
|
||||||
import { DEFAULT_SETTINGS } from './constants.js';
|
|
||||||
import { Redis } from 'ioredis';
|
|
||||||
import { ConnectionManager } from './connectionManager.js';
|
|
||||||
import { RepoManager } from './repoManager.js';
|
|
||||||
import { env } from './env.js';
|
|
||||||
import { PromClient } from './promClient.js';
|
|
||||||
import { loadConfig } from '@sourcebot/shared';
|
|
||||||
|
|
||||||
const logger = createLogger('backend-main');
|
|
||||||
|
|
||||||
const getSettings = async (configPath?: string) => {
|
|
||||||
if (!configPath) {
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = await loadConfig(configPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...DEFAULT_SETTINGS,
|
|
||||||
...config.settings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const main = async (db: PrismaClient, context: AppContext) => {
|
|
||||||
const redis = new Redis(env.REDIS_URL, {
|
|
||||||
maxRetriesPerRequest: null
|
|
||||||
});
|
|
||||||
redis.ping().then(() => {
|
|
||||||
logger.info('Connected to redis');
|
|
||||||
}).catch((err: unknown) => {
|
|
||||||
logger.error('Failed to connect to redis');
|
|
||||||
logger.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const settings = await getSettings(env.CONFIG_PATH);
|
|
||||||
|
|
||||||
const promClient = new PromClient();
|
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager(db, settings, redis);
|
|
||||||
connectionManager.registerPollingCallback();
|
|
||||||
|
|
||||||
const repoManager = new RepoManager(db, settings, redis, promClient, context);
|
|
||||||
await repoManager.validateIndexedReposHaveShards();
|
|
||||||
await repoManager.blockingPollLoop();
|
|
||||||
}
|
|
||||||
|
|
@ -50,6 +50,7 @@ export const compileGithubConfig = async (
|
||||||
const repoDisplayName = repo.full_name;
|
const repoDisplayName = repo.full_name;
|
||||||
const repoName = path.join(repoNameRoot, repoDisplayName);
|
const repoName = path.join(repoNameRoot, repoDisplayName);
|
||||||
const cloneUrl = new URL(repo.clone_url!);
|
const cloneUrl = new URL(repo.clone_url!);
|
||||||
|
const isPublic = repo.private === false;
|
||||||
|
|
||||||
logger.debug(`Found github repo ${repoDisplayName} with webUrl: ${repo.html_url}`);
|
logger.debug(`Found github repo ${repoDisplayName} with webUrl: ${repo.html_url}`);
|
||||||
|
|
||||||
|
|
@ -64,6 +65,7 @@ export const compileGithubConfig = async (
|
||||||
imageUrl: repo.owner.avatar_url,
|
imageUrl: repo.owner.avatar_url,
|
||||||
isFork: repo.fork,
|
isFork: repo.fork,
|
||||||
isArchived: !!repo.archived,
|
isArchived: !!repo.archived,
|
||||||
|
isPublic: isPublic,
|
||||||
org: {
|
org: {
|
||||||
connect: {
|
connect: {
|
||||||
id: orgId,
|
id: orgId,
|
||||||
|
|
@ -85,7 +87,7 @@ export const compileGithubConfig = async (
|
||||||
'zoekt.github-forks': (repo.forks_count ?? 0).toString(),
|
'zoekt.github-forks': (repo.forks_count ?? 0).toString(),
|
||||||
'zoekt.archived': marshalBool(repo.archived),
|
'zoekt.archived': marshalBool(repo.archived),
|
||||||
'zoekt.fork': marshalBool(repo.fork),
|
'zoekt.fork': marshalBool(repo.fork),
|
||||||
'zoekt.public': marshalBool(repo.private === false),
|
'zoekt.public': marshalBool(isPublic),
|
||||||
'zoekt.display-name': repoDisplayName,
|
'zoekt.display-name': repoDisplayName,
|
||||||
},
|
},
|
||||||
branches: config.revisions?.branches ?? undefined,
|
branches: config.revisions?.branches ?? undefined,
|
||||||
|
|
@ -121,6 +123,8 @@ export const compileGitlabConfig = async (
|
||||||
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
|
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
|
||||||
const cloneUrl = new URL(project.http_url_to_repo);
|
const cloneUrl = new URL(project.http_url_to_repo);
|
||||||
const isFork = project.forked_from_project !== undefined;
|
const isFork = project.forked_from_project !== undefined;
|
||||||
|
// @todo: we will need to double check whether 'internal' should also be considered public or not.
|
||||||
|
const isPublic = project.visibility === 'public';
|
||||||
const repoDisplayName = project.path_with_namespace;
|
const repoDisplayName = project.path_with_namespace;
|
||||||
const repoName = path.join(repoNameRoot, repoDisplayName);
|
const repoName = path.join(repoNameRoot, repoDisplayName);
|
||||||
// project.avatar_url is not directly accessible with tokens; use the avatar API endpoint if available
|
// project.avatar_url is not directly accessible with tokens; use the avatar API endpoint if available
|
||||||
|
|
@ -139,6 +143,7 @@ export const compileGitlabConfig = async (
|
||||||
displayName: repoDisplayName,
|
displayName: repoDisplayName,
|
||||||
imageUrl: avatarUrl,
|
imageUrl: avatarUrl,
|
||||||
isFork: isFork,
|
isFork: isFork,
|
||||||
|
isPublic: isPublic,
|
||||||
isArchived: !!project.archived,
|
isArchived: !!project.archived,
|
||||||
org: {
|
org: {
|
||||||
connect: {
|
connect: {
|
||||||
|
|
@ -159,7 +164,7 @@ export const compileGitlabConfig = async (
|
||||||
'zoekt.gitlab-forks': (project.forks_count ?? 0).toString(),
|
'zoekt.gitlab-forks': (project.forks_count ?? 0).toString(),
|
||||||
'zoekt.archived': marshalBool(project.archived),
|
'zoekt.archived': marshalBool(project.archived),
|
||||||
'zoekt.fork': marshalBool(isFork),
|
'zoekt.fork': marshalBool(isFork),
|
||||||
'zoekt.public': marshalBool(project.private === false),
|
'zoekt.public': marshalBool(isPublic),
|
||||||
'zoekt.display-name': repoDisplayName,
|
'zoekt.display-name': repoDisplayName,
|
||||||
},
|
},
|
||||||
branches: config.revisions?.branches ?? undefined,
|
branches: config.revisions?.branches ?? undefined,
|
||||||
|
|
@ -197,6 +202,7 @@ export const compileGiteaConfig = async (
|
||||||
cloneUrl.host = configUrl.host
|
cloneUrl.host = configUrl.host
|
||||||
const repoDisplayName = repo.full_name!;
|
const repoDisplayName = repo.full_name!;
|
||||||
const repoName = path.join(repoNameRoot, repoDisplayName);
|
const repoName = path.join(repoNameRoot, repoDisplayName);
|
||||||
|
const isPublic = repo.internal === false && repo.private === false;
|
||||||
|
|
||||||
logger.debug(`Found gitea repo ${repoDisplayName} with webUrl: ${repo.html_url}`);
|
logger.debug(`Found gitea repo ${repoDisplayName} with webUrl: ${repo.html_url}`);
|
||||||
|
|
||||||
|
|
@ -210,6 +216,7 @@ export const compileGiteaConfig = async (
|
||||||
displayName: repoDisplayName,
|
displayName: repoDisplayName,
|
||||||
imageUrl: repo.owner?.avatar_url,
|
imageUrl: repo.owner?.avatar_url,
|
||||||
isFork: repo.fork!,
|
isFork: repo.fork!,
|
||||||
|
isPublic: isPublic,
|
||||||
isArchived: !!repo.archived,
|
isArchived: !!repo.archived,
|
||||||
org: {
|
org: {
|
||||||
connect: {
|
connect: {
|
||||||
|
|
@ -228,7 +235,7 @@ export const compileGiteaConfig = async (
|
||||||
'zoekt.name': repoName,
|
'zoekt.name': repoName,
|
||||||
'zoekt.archived': marshalBool(repo.archived),
|
'zoekt.archived': marshalBool(repo.archived),
|
||||||
'zoekt.fork': marshalBool(repo.fork!),
|
'zoekt.fork': marshalBool(repo.fork!),
|
||||||
'zoekt.public': marshalBool(repo.internal === false && repo.private === false),
|
'zoekt.public': marshalBool(isPublic),
|
||||||
'zoekt.display-name': repoDisplayName,
|
'zoekt.display-name': repoDisplayName,
|
||||||
},
|
},
|
||||||
branches: config.revisions?.branches ?? undefined,
|
branches: config.revisions?.branches ?? undefined,
|
||||||
|
|
@ -411,6 +418,7 @@ export const compileBitbucketConfig = async (
|
||||||
name: repoName,
|
name: repoName,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
isFork: isFork,
|
isFork: isFork,
|
||||||
|
isPublic: isPublic,
|
||||||
isArchived: isArchived,
|
isArchived: isArchived,
|
||||||
org: {
|
org: {
|
||||||
connect: {
|
connect: {
|
||||||
|
|
@ -546,86 +554,6 @@ export const compileGenericGitHostConfig_file = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compileAzureDevOpsConfig = async (
|
|
||||||
config: AzureDevOpsConnectionConfig,
|
|
||||||
connectionId: number,
|
|
||||||
orgId: number,
|
|
||||||
db: PrismaClient,
|
|
||||||
abortController: AbortController) => {
|
|
||||||
|
|
||||||
const azureDevOpsReposResult = await getAzureDevOpsReposFromConfig(config, orgId, db);
|
|
||||||
const azureDevOpsRepos = azureDevOpsReposResult.validRepos;
|
|
||||||
const notFound = azureDevOpsReposResult.notFound;
|
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://dev.azure.com';
|
|
||||||
const repoNameRoot = new URL(hostUrl)
|
|
||||||
.toString()
|
|
||||||
.replace(/^https?:\/\//, '');
|
|
||||||
|
|
||||||
const repos = azureDevOpsRepos.map((repo) => {
|
|
||||||
if (!repo.project) {
|
|
||||||
throw new Error(`No project found for repository ${repo.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const repoDisplayName = `${repo.project.name}/${repo.name}`;
|
|
||||||
const repoName = path.join(repoNameRoot, repoDisplayName);
|
|
||||||
|
|
||||||
if (!repo.remoteUrl) {
|
|
||||||
throw new Error(`No remoteUrl found for repository ${repoDisplayName}`);
|
|
||||||
}
|
|
||||||
if (!repo.id) {
|
|
||||||
throw new Error(`No id found for repository ${repoDisplayName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct web URL for the repository
|
|
||||||
const webUrl = repo.webUrl || `${hostUrl}/${repo.project.name}/_git/${repo.name}`;
|
|
||||||
|
|
||||||
logger.debug(`Found Azure DevOps repo ${repoDisplayName} with webUrl: ${webUrl}`);
|
|
||||||
|
|
||||||
const record: RepoData = {
|
|
||||||
external_id: repo.id.toString(),
|
|
||||||
external_codeHostType: 'azuredevops',
|
|
||||||
external_codeHostUrl: hostUrl,
|
|
||||||
cloneUrl: webUrl,
|
|
||||||
webUrl: webUrl,
|
|
||||||
name: repoName,
|
|
||||||
displayName: repoDisplayName,
|
|
||||||
imageUrl: null,
|
|
||||||
isFork: !!repo.isFork,
|
|
||||||
isArchived: false,
|
|
||||||
org: {
|
|
||||||
connect: {
|
|
||||||
id: orgId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connections: {
|
|
||||||
create: {
|
|
||||||
connectionId: connectionId,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
gitConfig: {
|
|
||||||
'zoekt.web-url-type': 'azuredevops',
|
|
||||||
'zoekt.web-url': webUrl,
|
|
||||||
'zoekt.name': repoName,
|
|
||||||
'zoekt.archived': marshalBool(false),
|
|
||||||
'zoekt.fork': marshalBool(!!repo.isFork),
|
|
||||||
'zoekt.public': marshalBool(repo.project.visibility === ProjectVisibility.Public),
|
|
||||||
'zoekt.display-name': repoDisplayName,
|
|
||||||
},
|
|
||||||
branches: config.revisions?.branches ?? undefined,
|
|
||||||
tags: config.revisions?.tags ?? undefined,
|
|
||||||
} satisfies RepoMetadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
return record;
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
repoData: repos,
|
|
||||||
notFound,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const compileGenericGitHostConfig_url = async (
|
export const compileGenericGitHostConfig_url = async (
|
||||||
config: GenericGitHostConnectionConfig,
|
config: GenericGitHostConnectionConfig,
|
||||||
|
|
@ -688,4 +616,87 @@ export const compileGenericGitHostConfig_url = async (
|
||||||
repoData: [repo],
|
repoData: [repo],
|
||||||
notFound,
|
notFound,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const compileAzureDevOpsConfig = async (
|
||||||
|
config: AzureDevOpsConnectionConfig,
|
||||||
|
connectionId: number,
|
||||||
|
orgId: number,
|
||||||
|
db: PrismaClient,
|
||||||
|
abortController: AbortController) => {
|
||||||
|
|
||||||
|
const azureDevOpsReposResult = await getAzureDevOpsReposFromConfig(config, orgId, db);
|
||||||
|
const azureDevOpsRepos = azureDevOpsReposResult.validRepos;
|
||||||
|
const notFound = azureDevOpsReposResult.notFound;
|
||||||
|
|
||||||
|
const hostUrl = config.url ?? 'https://dev.azure.com';
|
||||||
|
const repoNameRoot = new URL(hostUrl)
|
||||||
|
.toString()
|
||||||
|
.replace(/^https?:\/\//, '');
|
||||||
|
|
||||||
|
const repos = azureDevOpsRepos.map((repo) => {
|
||||||
|
if (!repo.project) {
|
||||||
|
throw new Error(`No project found for repository ${repo.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoDisplayName = `${repo.project.name}/${repo.name}`;
|
||||||
|
const repoName = path.join(repoNameRoot, repoDisplayName);
|
||||||
|
const isPublic = repo.project.visibility === ProjectVisibility.Public;
|
||||||
|
|
||||||
|
if (!repo.remoteUrl) {
|
||||||
|
throw new Error(`No remoteUrl found for repository ${repoDisplayName}`);
|
||||||
|
}
|
||||||
|
if (!repo.id) {
|
||||||
|
throw new Error(`No id found for repository ${repoDisplayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct web URL for the repository
|
||||||
|
const webUrl = repo.webUrl || `${hostUrl}/${repo.project.name}/_git/${repo.name}`;
|
||||||
|
|
||||||
|
logger.debug(`Found Azure DevOps repo ${repoDisplayName} with webUrl: ${webUrl}`);
|
||||||
|
|
||||||
|
const record: RepoData = {
|
||||||
|
external_id: repo.id.toString(),
|
||||||
|
external_codeHostType: 'azuredevops',
|
||||||
|
external_codeHostUrl: hostUrl,
|
||||||
|
cloneUrl: webUrl,
|
||||||
|
webUrl: webUrl,
|
||||||
|
name: repoName,
|
||||||
|
displayName: repoDisplayName,
|
||||||
|
imageUrl: null,
|
||||||
|
isFork: !!repo.isFork,
|
||||||
|
isArchived: false,
|
||||||
|
isPublic: isPublic,
|
||||||
|
org: {
|
||||||
|
connect: {
|
||||||
|
id: orgId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: {
|
||||||
|
create: {
|
||||||
|
connectionId: connectionId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
gitConfig: {
|
||||||
|
'zoekt.web-url-type': 'azuredevops',
|
||||||
|
'zoekt.web-url': webUrl,
|
||||||
|
'zoekt.name': repoName,
|
||||||
|
'zoekt.archived': marshalBool(false),
|
||||||
|
'zoekt.fork': marshalBool(!!repo.isFork),
|
||||||
|
'zoekt.public': marshalBool(isPublic),
|
||||||
|
'zoekt.display-name': repoDisplayName,
|
||||||
|
},
|
||||||
|
branches: config.revisions?.branches ?? undefined,
|
||||||
|
tags: config.revisions?.tags ?? undefined,
|
||||||
|
} satisfies RepoMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
return record;
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoData: repos,
|
||||||
|
notFound,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,19 @@
|
||||||
import { Job, Queue, Worker } from 'bullmq';
|
|
||||||
import { Redis } from 'ioredis';
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
|
||||||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
|
||||||
import { AppContext, Settings, repoMetadataSchema } from "./types.js";
|
|
||||||
import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js";
|
|
||||||
import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js";
|
|
||||||
import { existsSync, readdirSync, promises } from 'fs';
|
|
||||||
import { indexGitRepository } from "./zoekt.js";
|
|
||||||
import { PromClient } from './promClient.js';
|
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
|
import { PrismaClient, Repo, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { Job, Queue, Worker } from 'bullmq';
|
||||||
|
import { existsSync, promises, readdirSync } from 'fs';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js";
|
||||||
interface IRepoManager {
|
import { PromClient } from './promClient.js';
|
||||||
validateIndexedReposHaveShards: () => Promise<void>;
|
import { AppContext, RepoWithConnections, Settings, repoMetadataSchema } from "./types.js";
|
||||||
blockingPollLoop: () => void;
|
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, measure } from "./utils.js";
|
||||||
dispose: () => void;
|
import { indexGitRepository } from "./zoekt.js";
|
||||||
}
|
|
||||||
|
|
||||||
const REPO_INDEXING_QUEUE = 'repoIndexingQueue';
|
const REPO_INDEXING_QUEUE = 'repoIndexingQueue';
|
||||||
const REPO_GC_QUEUE = 'repoGarbageCollectionQueue';
|
const REPO_GC_QUEUE = 'repoGarbageCollectionQueue';
|
||||||
|
|
||||||
type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection })[] };
|
|
||||||
type RepoIndexingPayload = {
|
type RepoIndexingPayload = {
|
||||||
repo: RepoWithConnections,
|
repo: RepoWithConnections,
|
||||||
}
|
}
|
||||||
|
|
@ -32,11 +24,12 @@ type RepoGarbageCollectionPayload = {
|
||||||
|
|
||||||
const logger = createLogger('repo-manager');
|
const logger = createLogger('repo-manager');
|
||||||
|
|
||||||
export class RepoManager implements IRepoManager {
|
export class RepoManager {
|
||||||
private indexWorker: Worker;
|
private indexWorker: Worker;
|
||||||
private indexQueue: Queue<RepoIndexingPayload>;
|
private indexQueue: Queue<RepoIndexingPayload>;
|
||||||
private gcWorker: Worker;
|
private gcWorker: Worker;
|
||||||
private gcQueue: Queue<RepoGarbageCollectionPayload>;
|
private gcQueue: Queue<RepoGarbageCollectionPayload>;
|
||||||
|
private interval?: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private db: PrismaClient,
|
private db: PrismaClient,
|
||||||
|
|
@ -68,14 +61,13 @@ export class RepoManager implements IRepoManager {
|
||||||
this.gcWorker.on('failed', this.onGarbageCollectionJobFailed.bind(this));
|
this.gcWorker.on('failed', this.onGarbageCollectionJobFailed.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async blockingPollLoop() {
|
public startScheduler() {
|
||||||
while (true) {
|
logger.debug('Starting scheduler');
|
||||||
|
this.interval = setInterval(async () => {
|
||||||
await this.fetchAndScheduleRepoIndexing();
|
await this.fetchAndScheduleRepoIndexing();
|
||||||
await this.fetchAndScheduleRepoGarbageCollection();
|
await this.fetchAndScheduleRepoGarbageCollection();
|
||||||
await this.fetchAndScheduleRepoTimeouts();
|
await this.fetchAndScheduleRepoTimeouts();
|
||||||
|
}, this.settings.reindexRepoPollingIntervalMs);
|
||||||
await new Promise(resolve => setTimeout(resolve, this.settings.reindexRepoPollingIntervalMs));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
///////////////////////////
|
///////////////////////////
|
||||||
|
|
@ -169,68 +161,6 @@ export class RepoManager implements IRepoManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// TODO: do this better? ex: try using the tokens from all the connections
|
|
||||||
// We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to
|
|
||||||
// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
|
|
||||||
// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
|
|
||||||
// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing.
|
|
||||||
private async getCloneCredentialsForRepo(repo: RepoWithConnections, db: PrismaClient): Promise<{ username?: string, password: string } | undefined> {
|
|
||||||
|
|
||||||
for (const { connection } of repo.connections) {
|
|
||||||
if (connection.connectionType === 'github') {
|
|
||||||
const config = connection.config as unknown as GithubConnectionConfig;
|
|
||||||
if (config.token) {
|
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, db, 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, logger);
|
|
||||||
return {
|
|
||||||
username: 'oauth2',
|
|
||||||
password: token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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, logger);
|
|
||||||
return {
|
|
||||||
password: token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} 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, logger);
|
|
||||||
const username = config.user ?? 'x-token-auth';
|
|
||||||
return {
|
|
||||||
username,
|
|
||||||
password: token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (connection.connectionType === 'azuredevops') {
|
|
||||||
const config = connection.config as unknown as AzureDevOpsConnectionConfig;
|
|
||||||
if (config.token) {
|
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
|
|
||||||
return {
|
|
||||||
// @note: If we don't provide a username, the password will be set as the username. This seems to work
|
|
||||||
// for ADO cloud but not for ADO server. To fix this, we set a placeholder username to ensure the password
|
|
||||||
// is set correctly
|
|
||||||
username: 'user',
|
|
||||||
password: token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) {
|
private async syncGitRepository(repo: RepoWithConnections, repoAlreadyInIndexingState: boolean) {
|
||||||
const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx);
|
const { path: repoPath, isReadOnly } = getRepoPath(repo, this.ctx);
|
||||||
|
|
||||||
|
|
@ -243,21 +173,8 @@ export class RepoManager implements IRepoManager {
|
||||||
await promises.rm(repoPath, { recursive: true, force: true });
|
await promises.rm(repoPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = await this.getCloneCredentialsForRepo(repo, this.db);
|
const credentials = await getAuthCredentialsForRepo(repo, this.db);
|
||||||
const remoteUrl = new URL(repo.cloneUrl);
|
const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl;
|
||||||
if (credentials) {
|
|
||||||
// @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 (!credentials.username) {
|
|
||||||
remoteUrl.username = credentials.password;
|
|
||||||
} else {
|
|
||||||
remoteUrl.username = credentials.username;
|
|
||||||
remoteUrl.password = credentials.password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(repoPath) && !isReadOnly) {
|
if (existsSync(repoPath) && !isReadOnly) {
|
||||||
// @NOTE: in #483, we changed the cloning method s.t., we _no longer_
|
// @NOTE: in #483, we changed the cloning method s.t., we _no longer_
|
||||||
|
|
@ -269,13 +186,13 @@ export class RepoManager implements IRepoManager {
|
||||||
await unsetGitConfig(repoPath, ["remote.origin.url"]);
|
await unsetGitConfig(repoPath, ["remote.origin.url"]);
|
||||||
|
|
||||||
logger.info(`Fetching ${repo.displayName}...`);
|
logger.info(`Fetching ${repo.displayName}...`);
|
||||||
const { durationMs } = await measure(() => fetchRepository(
|
const { durationMs } = await measure(() => fetchRepository({
|
||||||
remoteUrl,
|
cloneUrl: cloneUrlMaybeWithToken,
|
||||||
repoPath,
|
path: repoPath,
|
||||||
({ method, stage, progress }) => {
|
onProgress: ({ method, stage, progress }) => {
|
||||||
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
||||||
}
|
}
|
||||||
));
|
}));
|
||||||
const fetchDuration_s = durationMs / 1000;
|
const fetchDuration_s = durationMs / 1000;
|
||||||
|
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
|
|
@ -284,13 +201,13 @@ export class RepoManager implements IRepoManager {
|
||||||
} else if (!isReadOnly) {
|
} else if (!isReadOnly) {
|
||||||
logger.info(`Cloning ${repo.displayName}...`);
|
logger.info(`Cloning ${repo.displayName}...`);
|
||||||
|
|
||||||
const { durationMs } = await measure(() => cloneRepository(
|
const { durationMs } = await measure(() => cloneRepository({
|
||||||
remoteUrl,
|
cloneUrl: cloneUrlMaybeWithToken,
|
||||||
repoPath,
|
path: repoPath,
|
||||||
({ method, stage, progress }) => {
|
onProgress: ({ method, stage, progress }) => {
|
||||||
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
||||||
}
|
}
|
||||||
));
|
}));
|
||||||
const cloneDuration_s = durationMs / 1000;
|
const cloneDuration_s = durationMs / 1000;
|
||||||
|
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
|
|
@ -635,6 +552,9 @@ export class RepoManager implements IRepoManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async dispose() {
|
public async dispose() {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval);
|
||||||
|
}
|
||||||
this.indexWorker.close();
|
this.indexWorker.close();
|
||||||
this.indexQueue.close();
|
this.indexQueue.close();
|
||||||
this.gcQueue.close();
|
this.gcQueue.close();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Connection, Repo, RepoToConnection } from "@sourcebot/db";
|
||||||
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|
@ -50,4 +51,13 @@ export type DeepPartial<T> = T extends object ? {
|
||||||
} : T;
|
} : T;
|
||||||
|
|
||||||
// @see: https://stackoverflow.com/a/69328045
|
// @see: https://stackoverflow.com/a/69328045
|
||||||
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
|
||||||
|
|
||||||
|
export type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection })[] };
|
||||||
|
|
||||||
|
|
||||||
|
export type RepoAuthCredentials = {
|
||||||
|
hostUrl?: string;
|
||||||
|
token: string;
|
||||||
|
cloneUrlWithToken: string;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Logger } from "winston";
|
import { Logger } from "winston";
|
||||||
import { AppContext } from "./types.js";
|
import { AppContext, RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { PrismaClient, Repo } from "@sourcebot/db";
|
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||||
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
|
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
|
||||||
import { BackendException, BackendError } from "@sourcebot/error";
|
import { BackendException, BackendError } from "@sourcebot/error";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
|
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||||
|
|
||||||
export const measure = async <T>(cb: () => Promise<T>) => {
|
export const measure = async <T>(cb: () => Promise<T>) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
@ -116,4 +117,115 @@ export const fetchWithRetry = async <T>(
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: do this better? ex: try using the tokens from all the connections
|
||||||
|
// We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to
|
||||||
|
// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
|
||||||
|
// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
|
||||||
|
// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing.
|
||||||
|
export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: PrismaClient, logger?: Logger): Promise<RepoAuthCredentials | 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, logger);
|
||||||
|
return {
|
||||||
|
hostUrl: config.url,
|
||||||
|
token,
|
||||||
|
cloneUrlWithToken: createGitCloneUrlWithToken(
|
||||||
|
repo.cloneUrl,
|
||||||
|
{
|
||||||
|
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, logger);
|
||||||
|
return {
|
||||||
|
hostUrl: config.url,
|
||||||
|
token,
|
||||||
|
cloneUrlWithToken: createGitCloneUrlWithToken(
|
||||||
|
repo.cloneUrl,
|
||||||
|
{
|
||||||
|
username: 'oauth2',
|
||||||
|
password: token
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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, logger);
|
||||||
|
return {
|
||||||
|
hostUrl: config.url,
|
||||||
|
token,
|
||||||
|
cloneUrlWithToken: createGitCloneUrlWithToken(
|
||||||
|
repo.cloneUrl,
|
||||||
|
{
|
||||||
|
password: token
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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, logger);
|
||||||
|
const username = config.user ?? 'x-token-auth';
|
||||||
|
return {
|
||||||
|
hostUrl: config.url,
|
||||||
|
token,
|
||||||
|
cloneUrlWithToken: createGitCloneUrlWithToken(
|
||||||
|
repo.cloneUrl,
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
password: token
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (connection.connectionType === 'azuredevops') {
|
||||||
|
const config = connection.config as unknown as AzureDevOpsConnectionConfig;
|
||||||
|
if (config.token) {
|
||||||
|
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
|
||||||
|
return {
|
||||||
|
hostUrl: config.url,
|
||||||
|
token,
|
||||||
|
cloneUrlWithToken: createGitCloneUrlWithToken(
|
||||||
|
repo.cloneUrl,
|
||||||
|
{
|
||||||
|
// @note: If we don't provide a username, the password will be set as the username. This seems to work
|
||||||
|
// for ADO cloud but not for ADO server. To fix this, we set a placeholder username to ensure the password
|
||||||
|
// is set correctly
|
||||||
|
username: 'user',
|
||||||
|
password: token
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createGitCloneUrlWithToken = (cloneUrl: string, credentials: { username?: string, password: string }) => {
|
||||||
|
const url = new URL(cloneUrl);
|
||||||
|
// @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 (!credentials.username) {
|
||||||
|
url.username = credentials.password;
|
||||||
|
} else {
|
||||||
|
url.username = credentials.username;
|
||||||
|
url.password = credentials.password;
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RepoPermissionSyncJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserPermissionSyncJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Repo" ADD COLUMN "isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "permissionSyncedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "permissionSyncedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RepoPermissionSyncJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"status" "RepoPermissionSyncJobStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"repoId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "RepoPermissionSyncJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserPermissionSyncJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"status" "UserPermissionSyncJobStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserPermissionSyncJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserToRepoPermission" (
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"repoId" INTEGER NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "UserToRepoPermission_pkey" PRIMARY KEY ("repoId","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RepoPermissionSyncJob" ADD CONSTRAINT "RepoPermissionSyncJob_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserPermissionSyncJob" ADD CONSTRAINT "UserPermissionSyncJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserToRepoPermission" ADD CONSTRAINT "UserToRepoPermission_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserToRepoPermission" ADD CONSTRAINT "UserToRepoPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -41,29 +41,29 @@ enum ChatVisibility {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Repo {
|
model Repo {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String // Full repo name, including the vcs hostname (ex. github.com/sourcebot-dev/sourcebot)
|
name String /// Full repo name, including the vcs hostname (ex. github.com/sourcebot-dev/sourcebot)
|
||||||
displayName String? // Display name of the repo for UI (ex. sourcebot-dev/sourcebot)
|
displayName String? /// Display name of the repo for UI (ex. sourcebot-dev/sourcebot)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
indexedAt DateTime? /// When the repo was last indexed successfully.
|
||||||
/// When the repo was last indexed successfully.
|
|
||||||
indexedAt DateTime?
|
|
||||||
isFork Boolean
|
isFork Boolean
|
||||||
isArchived Boolean
|
isArchived Boolean
|
||||||
metadata Json // For schema see repoMetadataSchema in packages/backend/src/types.ts
|
isPublic Boolean @default(false)
|
||||||
|
metadata Json /// For schema see repoMetadataSchema in packages/backend/src/types.ts
|
||||||
cloneUrl String
|
cloneUrl String
|
||||||
webUrl String?
|
webUrl String?
|
||||||
connections RepoToConnection[]
|
connections RepoToConnection[]
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
repoIndexingStatus RepoIndexingStatus @default(NEW)
|
repoIndexingStatus RepoIndexingStatus @default(NEW)
|
||||||
|
|
||||||
// The id of the repo in the external service
|
permittedUsers UserToRepoPermission[]
|
||||||
external_id String
|
permissionSyncJobs RepoPermissionSyncJob[]
|
||||||
// The type of the external service (e.g., github, gitlab, etc.)
|
permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
|
||||||
external_codeHostType String
|
|
||||||
// The base url of the external service (e.g., https://github.com)
|
external_id String /// The id of the repo in the external service
|
||||||
external_codeHostUrl String
|
external_codeHostType String /// The type of the external service (e.g., github, gitlab, etc.)
|
||||||
|
external_codeHostUrl String /// The base url of the external service (e.g., https://github.com)
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
orgId Int
|
orgId Int
|
||||||
|
|
@ -74,12 +74,32 @@ model Repo {
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RepoPermissionSyncJobStatus {
|
||||||
|
PENDING
|
||||||
|
IN_PROGRESS
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model RepoPermissionSyncJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
status RepoPermissionSyncJobStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
errorMessage String?
|
||||||
|
|
||||||
|
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
|
||||||
|
repoId Int
|
||||||
|
}
|
||||||
|
|
||||||
model SearchContext {
|
model SearchContext {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
repos Repo[]
|
repos Repo[]
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
orgId Int
|
orgId Int
|
||||||
|
|
@ -149,7 +169,7 @@ model AccountRequest {
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
|
requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
|
||||||
requestedById String @unique
|
requestedById String @unique
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
@ -171,7 +191,7 @@ model Org {
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
isOnboarded Boolean @default(false)
|
isOnboarded Boolean @default(false)
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
|
metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
|
||||||
|
|
||||||
memberApprovalRequired Boolean @default(true)
|
memberApprovalRequired Boolean @default(true)
|
||||||
|
|
||||||
|
|
@ -181,10 +201,10 @@ model Org {
|
||||||
|
|
||||||
/// List of pending invites to this organization
|
/// List of pending invites to this organization
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
||||||
/// The invite id for this organization
|
/// The invite id for this organization
|
||||||
inviteLinkEnabled Boolean @default(false)
|
inviteLinkEnabled Boolean @default(false)
|
||||||
inviteLinkId String?
|
inviteLinkId String?
|
||||||
|
|
||||||
audits Audit[]
|
audits Audit[]
|
||||||
|
|
||||||
|
|
@ -231,55 +251,53 @@ model Secret {
|
||||||
}
|
}
|
||||||
|
|
||||||
model ApiKey {
|
model ApiKey {
|
||||||
name String
|
name String
|
||||||
hash String @id @unique
|
hash String @id @unique
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastUsedAt DateTime?
|
lastUsedAt DateTime?
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
orgId Int
|
orgId Int
|
||||||
|
|
||||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
createdById String
|
createdById String
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Audit {
|
model Audit {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
timestamp DateTime @default(now())
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
action String
|
action String
|
||||||
actorId String
|
actorId String
|
||||||
actorType String
|
actorType String
|
||||||
targetId String
|
targetId String
|
||||||
targetType String
|
targetType String
|
||||||
sourcebotVersion String
|
sourcebotVersion String
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
orgId Int
|
orgId Int
|
||||||
|
|
||||||
@@index([actorId, actorType, targetId, targetType, orgId])
|
@@index([actorId, actorType, targetId, targetType, orgId])
|
||||||
|
|
||||||
// Fast path for analytics queries – orgId is first because we assume most deployments are single tenant
|
// Fast path for analytics queries – orgId is first because we assume most deployments are single tenant
|
||||||
@@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
|
@@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
|
||||||
|
|
||||||
// Fast path for analytics queries for a specific user
|
// Fast path for analytics queries for a specific user
|
||||||
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
|
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
|
||||||
}
|
}
|
||||||
|
|
||||||
// @see : https://authjs.dev/concepts/database-models#user
|
// @see : https://authjs.dev/concepts/database-models#user
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
email String? @unique
|
email String? @unique
|
||||||
hashedPassword String?
|
hashedPassword String?
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
image String?
|
image String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
orgs UserToOrg[]
|
orgs UserToOrg[]
|
||||||
accountRequest AccountRequest?
|
accountRequest AccountRequest?
|
||||||
|
accessibleRepos UserToRepoPermission[]
|
||||||
|
|
||||||
/// List of pending invites that the user has created
|
/// List of pending invites that the user has created
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
@ -290,6 +308,41 @@ model User {
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
permissionSyncJobs UserPermissionSyncJob[]
|
||||||
|
permissionSyncedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserPermissionSyncJobStatus {
|
||||||
|
PENDING
|
||||||
|
IN_PROGRESS
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserPermissionSyncJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
status UserPermissionSyncJobStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
errorMessage String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserToRepoPermission {
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
|
||||||
|
repoId Int
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
userId String
|
||||||
|
|
||||||
|
@@id([repoId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// @see : https://authjs.dev/concepts/database-models#account
|
// @see : https://authjs.dev/concepts/database-models#account
|
||||||
|
|
@ -329,17 +382,17 @@ model Chat {
|
||||||
|
|
||||||
name String?
|
name String?
|
||||||
|
|
||||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
createdById String
|
createdById String
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
orgId Int
|
orgId Int
|
||||||
|
|
||||||
visibility ChatVisibility @default(PRIVATE)
|
visibility ChatVisibility @default(PRIVATE)
|
||||||
isReadonly Boolean @default(false)
|
isReadonly Boolean @default(false)
|
||||||
|
|
||||||
messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
|
messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,16 @@ const schema = {
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"experiment_repoDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"experiment_userDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
@ -194,6 +204,16 @@ const schema = {
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"experiment_repoDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"experiment_userDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,14 @@ export interface Settings {
|
||||||
* This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.
|
* This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.
|
||||||
*/
|
*/
|
||||||
enablePublicAccess?: boolean;
|
enablePublicAccess?: boolean;
|
||||||
|
/**
|
||||||
|
* The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.
|
||||||
|
*/
|
||||||
|
experiment_repoDrivenPermissionSyncIntervalMs?: number;
|
||||||
|
/**
|
||||||
|
* The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.
|
||||||
|
*/
|
||||||
|
experiment_userDrivenPermissionSyncIntervalMs?: number;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Search context
|
* Search context
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,16 @@ const entitlements = [
|
||||||
"sso",
|
"sso",
|
||||||
"code-nav",
|
"code-nav",
|
||||||
"audit",
|
"audit",
|
||||||
"analytics"
|
"analytics",
|
||||||
|
"permission-syncing"
|
||||||
] as const;
|
] as const;
|
||||||
export type Entitlement = (typeof entitlements)[number];
|
export type Entitlement = (typeof entitlements)[number];
|
||||||
|
|
||||||
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
|
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
|
||||||
oss: ["anonymous-access"],
|
oss: ["anonymous-access"],
|
||||||
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
|
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
|
||||||
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
|
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics", "permission-syncing"],
|
||||||
"self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics"],
|
"self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics", "permission-syncing"],
|
||||||
// Special entitlement for https://demo.sourcebot.dev
|
// Special entitlement for https://demo.sourcebot.dev
|
||||||
"cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
|
"cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
|
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_NAME } from '@/lib/constants';
|
||||||
import { ApiKey, Org, PrismaClient, User } from '@prisma/client';
|
import { ApiKey, Org, PrismaClient, User } from '@prisma/client';
|
||||||
import { beforeEach } from 'vitest';
|
import { beforeEach, vi } from 'vitest';
|
||||||
import { mockDeep, mockReset } from 'vitest-mock-extended';
|
import { mockDeep, mockReset } from 'vitest-mock-extended';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -43,6 +43,8 @@ export const MOCK_USER: User = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
hashedPassword: null,
|
hashedPassword: null,
|
||||||
emailVerified: null,
|
emailVerified: null,
|
||||||
image: null
|
image: null,
|
||||||
|
permissionSyncedAt: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const userScopedPrismaClientExtension = vi.fn();
|
||||||
|
|
@ -1,47 +1,46 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
|
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
|
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
|
||||||
import { CodeHostType, isHttpError, isServiceError } from "@/lib/utils";
|
import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { render } from "@react-email/components";
|
import { render } from "@react-email/components";
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import { decrypt, encrypt, generateApiKey, hashSecret, getTokenFromConfig } from "@sourcebot/crypto";
|
import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
||||||
import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus, Org, ApiKey } from "@sourcebot/db";
|
import { ApiKey, ConnectionSyncStatus, Org, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
|
||||||
|
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
|
||||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
|
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
|
||||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.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 { azuredevopsSchema } from "@sourcebot/schemas/v3/azuredevops.schema";
|
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
|
||||||
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
||||||
|
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
||||||
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||||
|
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||||
|
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
||||||
|
import { getPlan, hasEntitlement } from "@sourcebot/shared";
|
||||||
import Ajv from "ajv";
|
import Ajv from "ajv";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { createTransport } from "nodemailer";
|
import { createTransport } from "nodemailer";
|
||||||
import { auth } from "./auth";
|
|
||||||
import { Octokit } from "octokit";
|
import { Octokit } from "octokit";
|
||||||
|
import { auth } from "./auth";
|
||||||
import { getConnection } from "./data/connection";
|
import { getConnection } from "./data/connection";
|
||||||
|
import { getOrgFromDomain } from "./data/org";
|
||||||
|
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
|
||||||
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
|
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
|
||||||
import InviteUserEmail from "./emails/inviteUserEmail";
|
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||||
|
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
||||||
|
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
||||||
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
||||||
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
||||||
import { TenancyMode, ApiKeyPayload } from "./lib/types";
|
import { ApiKeyPayload, TenancyMode } from "./lib/types";
|
||||||
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
|
import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
|
||||||
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
|
|
||||||
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
|
|
||||||
import { getPlan, hasEntitlement } from "@sourcebot/shared";
|
|
||||||
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
|
||||||
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
import { getAuditService } from "@/ee/features/audit/factory";
|
|
||||||
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
|
||||||
import { getOrgMetadata } from "@/lib/utils";
|
|
||||||
import { getOrgFromDomain } from "./data/org";
|
|
||||||
import { withOptionalAuthV2 } from "./withAuthV2";
|
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
|
|
@ -640,7 +639,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
|
||||||
})));
|
})));
|
||||||
|
|
||||||
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
|
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
|
||||||
withOptionalAuthV2(async ({ org }) => {
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
const repos = await prisma.repo.findMany({
|
const repos = await prisma.repo.findMany({
|
||||||
where: {
|
where: {
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
|
|
@ -670,67 +669,65 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
|
export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
||||||
withAuth((userId) =>
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
// @note: repo names are represented by their remote url
|
||||||
// @note: repo names are represented by their remote url
|
// on the code host. E.g.,:
|
||||||
// on the code host. E.g.,:
|
// - github.com/sourcebot-dev/sourcebot
|
||||||
// - github.com/sourcebot-dev/sourcebot
|
// - gitlab.com/gitlab-org/gitlab
|
||||||
// - gitlab.com/gitlab-org/gitlab
|
// - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor
|
||||||
// - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor
|
// etc.
|
||||||
// etc.
|
//
|
||||||
//
|
// For most purposes, repo names are unique within an org, so using
|
||||||
// For most purposes, repo names are unique within an org, so using
|
// findFirst is equivalent to findUnique. Duplicates _can_ occur when
|
||||||
// findFirst is equivalent to findUnique. Duplicates _can_ occur when
|
// a repository is specified by its remote url in a generic `git`
|
||||||
// a repository is specified by its remote url in a generic `git`
|
// connection. For example:
|
||||||
// connection. For example:
|
//
|
||||||
//
|
// ```json
|
||||||
// ```json
|
// {
|
||||||
// {
|
// "connections": {
|
||||||
// "connections": {
|
// "connection-1": {
|
||||||
// "connection-1": {
|
// "type": "github",
|
||||||
// "type": "github",
|
// "repos": [
|
||||||
// "repos": [
|
// "sourcebot-dev/sourcebot"
|
||||||
// "sourcebot-dev/sourcebot"
|
// ]
|
||||||
// ]
|
// },
|
||||||
// },
|
// "connection-2": {
|
||||||
// "connection-2": {
|
// "type": "git",
|
||||||
// "type": "git",
|
// "url": "file:///tmp/repos/sourcebot"
|
||||||
// "url": "file:///tmp/repos/sourcebot"
|
// }
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// }
|
// ```
|
||||||
// ```
|
//
|
||||||
//
|
// In this scenario, both repos will be named "github.com/sourcebot-dev/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.
|
||||||
// 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 constraint on repo name + orgId to help de-duplicate
|
||||||
// @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate
|
// these cases.
|
||||||
// these cases.
|
// @see: repoCompileUtils.ts
|
||||||
// @see: repoCompileUtils.ts
|
const repo = await prisma.repo.findFirst({
|
||||||
const repo = await prisma.repo.findFirst({
|
where: {
|
||||||
where: {
|
name: repoName,
|
||||||
name: repoName,
|
orgId: org.id,
|
||||||
orgId: org.id,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: repo.id,
|
id: repo.id,
|
||||||
name: repo.name,
|
name: repo.name,
|
||||||
displayName: repo.displayName ?? undefined,
|
displayName: repo.displayName ?? undefined,
|
||||||
codeHostType: repo.external_codeHostType,
|
codeHostType: repo.external_codeHostType,
|
||||||
webUrl: repo.webUrl ?? undefined,
|
webUrl: repo.webUrl ?? undefined,
|
||||||
imageUrl: repo.imageUrl ?? undefined,
|
imageUrl: repo.imageUrl ?? undefined,
|
||||||
indexedAt: repo.indexedAt ?? undefined,
|
indexedAt: repo.indexedAt ?? undefined,
|
||||||
repoIndexingStatus: repo.repoIndexingStatus,
|
repoIndexingStatus: repo.repoIndexingStatus,
|
||||||
}
|
}
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
|
}));
|
||||||
));
|
|
||||||
|
|
||||||
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
|
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
|
|
@ -780,143 +777,141 @@ export const createConnection = async (name: string, type: CodeHostType, connect
|
||||||
}, OrgRole.OWNER)
|
}, OrgRole.OWNER)
|
||||||
));
|
));
|
||||||
|
|
||||||
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string, domain: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
|
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
|
||||||
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
|
return {
|
||||||
return {
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
message: "This feature is not enabled.",
|
||||||
message: "This feature is not enabled.",
|
} satisfies ServiceError;
|
||||||
} satisfies ServiceError;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Parse repository URL to extract owner/repo
|
// Parse repository URL to extract owner/repo
|
||||||
const repoInfo = (() => {
|
const repoInfo = (() => {
|
||||||
const url = repositoryUrl.trim();
|
const url = repositoryUrl.trim();
|
||||||
|
|
||||||
// Handle various GitHub URL formats
|
|
||||||
const patterns = [
|
|
||||||
// https://github.com/owner/repo or https://github.com/owner/repo.git
|
|
||||||
/^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
|
|
||||||
// github.com/owner/repo
|
|
||||||
/^github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
|
|
||||||
// owner/repo
|
|
||||||
/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = url.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
owner: match[1],
|
|
||||||
repo: match[2]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!repoInfo) {
|
// Handle various GitHub URL formats
|
||||||
return {
|
const patterns = [
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
// https://github.com/owner/repo or https://github.com/owner/repo.git
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
/^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
|
||||||
message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
|
// github.com/owner/repo
|
||||||
} satisfies ServiceError;
|
/^github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
|
||||||
}
|
// owner/repo
|
||||||
|
/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/
|
||||||
|
];
|
||||||
|
|
||||||
const { owner, repo } = repoInfo;
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern);
|
||||||
// Use GitHub API to fetch repository information and get the external_id
|
if (match) {
|
||||||
const octokit = new Octokit({
|
|
||||||
auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN
|
|
||||||
});
|
|
||||||
|
|
||||||
let githubRepo;
|
|
||||||
try {
|
|
||||||
const response = await octokit.rest.repos.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
});
|
|
||||||
githubRepo = response.data;
|
|
||||||
} catch (error) {
|
|
||||||
if (isHttpError(error, 404)) {
|
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.NOT_FOUND,
|
owner: match[1],
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
repo: match[2]
|
||||||
message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`,
|
};
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHttpError(error, 403)) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.FORBIDDEN,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`,
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: `Failed to fetch repository information: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (githubRepo.private) {
|
return null;
|
||||||
return {
|
})();
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
|
||||||
message: "Only public repositories can be added.",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this repository is already connected using the external_id
|
if (!repoInfo) {
|
||||||
const existingRepo = await prisma.repo.findFirst({
|
return {
|
||||||
where: {
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
orgId: org.id,
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
external_id: githubRepo.id.toString(),
|
message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
|
||||||
external_codeHostType: 'github',
|
} satisfies ServiceError;
|
||||||
external_codeHostUrl: 'https://github.com',
|
}
|
||||||
}
|
|
||||||
|
const { owner, repo } = repoInfo;
|
||||||
|
|
||||||
|
// Use GitHub API to fetch repository information and get the external_id
|
||||||
|
const octokit = new Octokit({
|
||||||
|
auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN
|
||||||
|
});
|
||||||
|
|
||||||
|
let githubRepo;
|
||||||
|
try {
|
||||||
|
const response = await octokit.rest.repos.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
});
|
});
|
||||||
|
githubRepo = response.data;
|
||||||
if (existingRepo) {
|
} catch (error) {
|
||||||
|
if (isHttpError(error, 404)) {
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
statusCode: StatusCodes.NOT_FOUND,
|
||||||
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
message: "This repository already exists.",
|
message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`,
|
||||||
} satisfies ServiceError;
|
} satisfies ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionName = `${owner}-${repo}-${Date.now()}`;
|
if (isHttpError(error, 403)) {
|
||||||
|
return {
|
||||||
// Create GitHub connection config
|
statusCode: StatusCodes.FORBIDDEN,
|
||||||
const connectionConfig: GithubConnectionConfig = {
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
type: "github" as const,
|
message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`,
|
||||||
repos: [`${owner}/${repo}`],
|
} satisfies ServiceError;
|
||||||
...(env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN ? {
|
}
|
||||||
token: {
|
|
||||||
env: 'EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN'
|
|
||||||
}
|
|
||||||
} : {})
|
|
||||||
};
|
|
||||||
|
|
||||||
const connection = await prisma.connection.create({
|
|
||||||
data: {
|
|
||||||
orgId: org.id,
|
|
||||||
name: connectionName,
|
|
||||||
config: connectionConfig as unknown as Prisma.InputJsonValue,
|
|
||||||
connectionType: 'github',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId: connection.id,
|
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
|
message: `Failed to fetch repository information: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (githubRepo.private) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||||
|
message: "Only public repositories can be added.",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this repository is already connected using the external_id
|
||||||
|
const existingRepo = await prisma.repo.findFirst({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
external_id: githubRepo.id.toString(),
|
||||||
|
external_codeHostType: 'github',
|
||||||
|
external_codeHostUrl: 'https://github.com',
|
||||||
}
|
}
|
||||||
}, OrgRole.GUEST), /* allowAnonymousAccess = */ true
|
});
|
||||||
));
|
|
||||||
|
if (existingRepo) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
|
||||||
|
message: "This repository already exists.",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionName = `${owner}-${repo}-${Date.now()}`;
|
||||||
|
|
||||||
|
// Create GitHub connection config
|
||||||
|
const connectionConfig: GithubConnectionConfig = {
|
||||||
|
type: "github" as const,
|
||||||
|
repos: [`${owner}/${repo}`],
|
||||||
|
...(env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN ? {
|
||||||
|
token: {
|
||||||
|
env: 'EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN'
|
||||||
|
}
|
||||||
|
} : {})
|
||||||
|
};
|
||||||
|
|
||||||
|
const connection = await prisma.connection.create({
|
||||||
|
data: {
|
||||||
|
orgId: org.id,
|
||||||
|
name: connectionName,
|
||||||
|
config: connectionConfig as unknown as Prisma.InputJsonValue,
|
||||||
|
connectionType: 'github',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionId: connection.id,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
|
|
@ -1022,24 +1017,22 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
|
|
||||||
export const flagReposForIndex = async (repoIds: number[], domain: string) => sew(() =>
|
export const flagReposForIndex = async (repoIds: number[]) => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuthV2(async ({ org, prisma }) => {
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
await prisma.repo.updateMany({
|
||||||
await prisma.repo.updateMany({
|
where: {
|
||||||
where: {
|
id: { in: repoIds },
|
||||||
id: { in: repoIds },
|
orgId: org.id,
|
||||||
orgId: org.id,
|
},
|
||||||
},
|
data: {
|
||||||
data: {
|
repoIndexingStatus: RepoIndexingStatus.NEW,
|
||||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
));
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
|
|
@ -2004,75 +1997,73 @@ export const getSearchContexts = async (domain: string) => sew(() =>
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
|
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
|
||||||
));
|
));
|
||||||
|
|
||||||
export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => {
|
export const getRepoImage = async (repoId: number): Promise<ArrayBuffer | ServiceError> => sew(async () => {
|
||||||
return await withAuth(async (userId) => {
|
return await withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
return await withOrgMembership(userId, domain, async ({ org }) => {
|
const repo = await prisma.repo.findUnique({
|
||||||
const repo = await prisma.repo.findUnique({
|
where: {
|
||||||
where: {
|
id: repoId,
|
||||||
id: repoId,
|
orgId: org.id,
|
||||||
orgId: org.id,
|
},
|
||||||
},
|
include: {
|
||||||
include: {
|
connections: {
|
||||||
connections: {
|
include: {
|
||||||
include: {
|
connection: true,
|
||||||
connection: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repo || !repo.imageUrl) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders: Record<string, string> = {};
|
||||||
|
for (const { connection } of repo.connections) {
|
||||||
|
try {
|
||||||
|
if (connection.connectionType === 'github') {
|
||||||
|
const config = connection.config as unknown as GithubConnectionConfig;
|
||||||
|
if (config.token) {
|
||||||
|
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
|
||||||
|
authHeaders['Authorization'] = `token ${token}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (connection.connectionType === 'gitlab') {
|
||||||
|
const config = connection.config as unknown as GitlabConnectionConfig;
|
||||||
|
if (config.token) {
|
||||||
|
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
|
||||||
|
authHeaders['PRIVATE-TOKEN'] = token;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (connection.connectionType === 'gitea') {
|
||||||
|
const config = connection.config as unknown as GiteaConnectionConfig;
|
||||||
|
if (config.token) {
|
||||||
|
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
|
||||||
|
authHeaders['Authorization'] = `token ${token}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to get token for connection ${connection.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(repo.imageUrl, {
|
||||||
|
headers: authHeaders,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!repo || !repo.imageUrl) {
|
if (!response.ok) {
|
||||||
|
logger.warn(`Failed to fetch image from ${repo.imageUrl}: ${response.status}`);
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeaders: Record<string, string> = {};
|
const imageBuffer = await response.arrayBuffer();
|
||||||
for (const { connection } of repo.connections) {
|
return imageBuffer;
|
||||||
try {
|
} catch (error) {
|
||||||
if (connection.connectionType === 'github') {
|
logger.error(`Error proxying image for repo ${repoId}:`, error);
|
||||||
const config = connection.config as unknown as GithubConnectionConfig;
|
return notFound();
|
||||||
if (config.token) {
|
}
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
|
})
|
||||||
authHeaders['Authorization'] = `token ${token}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (connection.connectionType === 'gitlab') {
|
|
||||||
const config = connection.config as unknown as GitlabConnectionConfig;
|
|
||||||
if (config.token) {
|
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
|
|
||||||
authHeaders['PRIVATE-TOKEN'] = token;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (connection.connectionType === 'gitea') {
|
|
||||||
const config = connection.config as unknown as GiteaConnectionConfig;
|
|
||||||
if (config.token) {
|
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, prisma);
|
|
||||||
authHeaders['Authorization'] = `token ${token}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Failed to get token for connection ${connection.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(repo.imageUrl, {
|
|
||||||
headers: authHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
logger.warn(`Failed to fetch image from ${repo.imageUrl}: ${response.status}`);
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageBuffer = await response.arrayBuffer();
|
|
||||||
return imageBuffer;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error proxying image for repo ${repoId}:`, error);
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST);
|
|
||||||
}, /* allowAnonymousAccess = */ true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
|
export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
|
||||||
|
|
@ -2213,7 +2204,7 @@ const parseConnectionConfig = (config: string) => {
|
||||||
switch (connectionType) {
|
switch (connectionType) {
|
||||||
case "gitea":
|
case "gitea":
|
||||||
case "github":
|
case "github":
|
||||||
case "bitbucket":
|
case "bitbucket":
|
||||||
case "azuredevops": {
|
case "azuredevops": {
|
||||||
return {
|
return {
|
||||||
numRepos: parsedConfig.repos?.length,
|
numRepos: parsedConfig.repos?.length,
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,16 @@ interface CodePreviewPanelProps {
|
||||||
path: string;
|
path: string;
|
||||||
repoName: string;
|
repoName: string;
|
||||||
revisionName?: string;
|
revisionName?: string;
|
||||||
domain: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodePreviewPanel = async ({ path, repoName, revisionName, domain }: CodePreviewPanelProps) => {
|
export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => {
|
||||||
const [fileSourceResponse, repoInfoResponse] = await Promise.all([
|
const [fileSourceResponse, repoInfoResponse] = await Promise.all([
|
||||||
getFileSource({
|
getFileSource({
|
||||||
fileName: path,
|
fileName: path,
|
||||||
repository: repoName,
|
repository: repoName,
|
||||||
branch: revisionName,
|
branch: revisionName,
|
||||||
}, domain),
|
}),
|
||||||
getRepoInfoByName(repoName, domain),
|
getRepoInfoByName(repoName),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
|
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,16 @@ interface TreePreviewPanelProps {
|
||||||
path: string;
|
path: string;
|
||||||
repoName: string;
|
repoName: string;
|
||||||
revisionName?: string;
|
revisionName?: string;
|
||||||
domain: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TreePreviewPanel = async ({ path, repoName, revisionName, domain }: TreePreviewPanelProps) => {
|
export const TreePreviewPanel = async ({ path, repoName, revisionName }: TreePreviewPanelProps) => {
|
||||||
const [repoInfoResponse, folderContentsResponse] = await Promise.all([
|
const [repoInfoResponse, folderContentsResponse] = await Promise.all([
|
||||||
getRepoInfoByName(repoName, domain),
|
getRepoInfoByName(repoName),
|
||||||
getFolderContents({
|
getFolderContents({
|
||||||
repoName,
|
repoName,
|
||||||
revisionName: revisionName ?? 'HEAD',
|
revisionName: revisionName ?? 'HEAD',
|
||||||
path,
|
path,
|
||||||
}, domain)
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
|
if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { TreePreviewPanel } from "./components/treePreviewPanel";
|
||||||
interface BrowsePageProps {
|
interface BrowsePageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
path: string[];
|
path: string[];
|
||||||
domain: string;
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16,7 +15,6 @@ export default async function BrowsePage(props: BrowsePageProps) {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
path: _rawPath,
|
path: _rawPath,
|
||||||
domain
|
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const rawPath = _rawPath.join('/');
|
const rawPath = _rawPath.join('/');
|
||||||
|
|
@ -35,14 +33,12 @@ export default async function BrowsePage(props: BrowsePageProps) {
|
||||||
path={path}
|
path={path}
|
||||||
repoName={repoName}
|
repoName={repoName}
|
||||||
revisionName={revisionName}
|
revisionName={revisionName}
|
||||||
domain={domain}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TreePreviewPanel
|
<TreePreviewPanel
|
||||||
path={path}
|
path={path}
|
||||||
repoName={repoName}
|
repoName={repoName}
|
||||||
revisionName={revisionName}
|
revisionName={revisionName}
|
||||||
domain={domain}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useHotkeys } from "react-hotkeys-hook";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
import { unwrapServiceError } from "@/lib/utils";
|
||||||
import { FileTreeItem, getFiles } from "@/features/fileTree/actions";
|
import { FileTreeItem, getFiles } from "@/features/fileTree/actions";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
|
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
|
||||||
import { useBrowseState } from "../hooks/useBrowseState";
|
import { useBrowseState } from "../hooks/useBrowseState";
|
||||||
|
|
@ -28,7 +27,6 @@ type SearchResult = {
|
||||||
|
|
||||||
export const FileSearchCommandDialog = () => {
|
export const FileSearchCommandDialog = () => {
|
||||||
const { repoName, revisionName } = useBrowseParams();
|
const { repoName, revisionName } = useBrowseParams();
|
||||||
const domain = useDomain();
|
|
||||||
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();
|
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();
|
||||||
|
|
||||||
const commandListRef = useRef<HTMLDivElement>(null);
|
const commandListRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -57,8 +55,8 @@ export const FileSearchCommandDialog = () => {
|
||||||
}, [isFileSearchOpen]);
|
}, [isFileSearchOpen]);
|
||||||
|
|
||||||
const { data: files, isLoading, isError } = useQuery({
|
const { data: files, isLoading, isError } = useQuery({
|
||||||
queryKey: ['files', repoName, revisionName, domain],
|
queryKey: ['files', repoName, revisionName],
|
||||||
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)),
|
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' })),
|
||||||
enabled: isFileSearchOpen,
|
enabled: isFileSearchOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsRetryAllFailedReposLoading(true);
|
setIsRetryAllFailedReposLoading(true);
|
||||||
flagReposForIndex(failedRepos.map((repo) => repo.repoId), domain)
|
flagReposForIndex(failedRepos.map((repo) => repo.repoId))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
captureEvent('wa_connection_retry_all_failed_repos_fail', {});
|
captureEvent('wa_connection_retry_all_failed_repos_fail', {});
|
||||||
|
|
@ -116,7 +116,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsRetryAllFailedReposLoading(false);
|
setIsRetryAllFailedReposLoading(false);
|
||||||
});
|
});
|
||||||
}, [captureEvent, domain, failedRepos, refetchRepos, toast]);
|
}, [captureEvent, failedRepos, refetchRepos, toast]);
|
||||||
|
|
||||||
const filteredRepos = useMemo(() => {
|
const filteredRepos = useMemo(() => {
|
||||||
if (isServiceError(unfilteredRepos)) {
|
if (isServiceError(unfilteredRepos)) {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export const RepoListItem = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4">
|
||||||
{status === RepoIndexingStatus.FAILED && (
|
{status === RepoIndexingStatus.FAILED && (
|
||||||
<RetryRepoIndexButton repoId={repoId} domain={domain} />
|
<RetryRepoIndexButton repoId={repoId} />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-row items-center gap-0">
|
<div className="flex flex-row items-center gap-0">
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,9 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
|
||||||
interface RetryRepoIndexButtonProps {
|
interface RetryRepoIndexButtonProps {
|
||||||
repoId: number;
|
repoId: number;
|
||||||
domain: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => {
|
export const RetryRepoIndexButton = ({ repoId }: RetryRepoIndexButtonProps) => {
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -21,7 +20,7 @@ export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonPro
|
||||||
size="sm"
|
size="sm"
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const result = await flagReposForIndex([repoId], domain);
|
const result = await flagReposForIndex([repoId]);
|
||||||
if (isServiceError(result)) {
|
if (isServiceError(result)) {
|
||||||
toast({
|
toast({
|
||||||
description: `❌ Failed to flag repository for indexing.`,
|
description: `❌ Failed to flag repository for indexing.`,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { experimental_addGithubRepositoryByUrl } from "@/actions";
|
import { experimental_addGithubRepositoryByUrl } from "@/actions";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
@ -37,7 +36,6 @@ const formSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialogProps) => {
|
export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialogProps) => {
|
||||||
const domain = useDomain();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -52,7 +50,7 @@ export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialo
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||||
|
|
||||||
const result = await experimental_addGithubRepositoryByUrl(data.repositoryUrl.trim(), domain);
|
const result = await experimental_addGithubRepositoryByUrl(data.repositoryUrl.trim());
|
||||||
if (isServiceError(result)) {
|
if (isServiceError(result)) {
|
||||||
toast({
|
toast({
|
||||||
title: "Error adding repository",
|
title: "Error adding repository",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CodePreview } from "./codePreview";
|
import { CodePreview } from "./codePreview";
|
||||||
import { SearchResultFile } from "@/features/search/types";
|
import { SearchResultFile } from "@/features/search/types";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
import { SetStateAction, Dispatch, useMemo } from "react";
|
import { SetStateAction, Dispatch, useMemo } from "react";
|
||||||
import { getFileSource } from "@/features/search/fileSourceApi";
|
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||||
|
|
@ -22,7 +21,6 @@ export const CodePreviewPanel = ({
|
||||||
onClose,
|
onClose,
|
||||||
onSelectedMatchIndexChange,
|
onSelectedMatchIndexChange,
|
||||||
}: CodePreviewPanelProps) => {
|
}: CodePreviewPanelProps) => {
|
||||||
const domain = useDomain();
|
|
||||||
|
|
||||||
// If there are multiple branches pointing to the same revision of this file, it doesn't
|
// 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.
|
// matter which branch we use here, so use the first one.
|
||||||
|
|
@ -31,13 +29,13 @@ export const CodePreviewPanel = ({
|
||||||
}, [previewedFile]);
|
}, [previewedFile]);
|
||||||
|
|
||||||
const { data: file, isLoading, isPending, isError } = useQuery({
|
const { data: file, isLoading, isPending, isError } = useQuery({
|
||||||
queryKey: ["source", previewedFile, branch, domain],
|
queryKey: ["source", previewedFile, branch],
|
||||||
queryFn: () => unwrapServiceError(
|
queryFn: () => unwrapServiceError(
|
||||||
getFileSource({
|
getFileSource({
|
||||||
fileName: previewedFile.fileName.text,
|
fileName: previewedFile.fileName.text,
|
||||||
repository: previewedFile.repository,
|
repository: previewedFile.repository,
|
||||||
branch,
|
branch,
|
||||||
}, domain)
|
})
|
||||||
),
|
),
|
||||||
select: (data) => {
|
select: (data) => {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,8 @@ import { isServiceError } from "@/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||||
import { searchRequestSchema } from "@/features/search/schemas";
|
import { searchRequestSchema } from "@/features/search/schemas";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
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 body = await request.json();
|
||||||
const parsed = await searchRequestSchema.safeParseAsync(body);
|
const parsed = await searchRequestSchema.safeParseAsync(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|
@ -27,7 +15,7 @@ export const POST = async (request: NextRequest) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await search(parsed.data, domain, apiKey);
|
const response = await search(parsed.data);
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
return serviceErrorResponse(response);
|
return serviceErrorResponse(response);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,8 @@ import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { fileSourceRequestSchema } from "@/features/search/schemas";
|
import { fileSourceRequestSchema } from "@/features/search/schemas";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
|
||||||
import { StatusCodes } from "http-status-codes";
|
|
||||||
|
|
||||||
export const POST = async (request: NextRequest) => {
|
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 body = await request.json();
|
||||||
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
|
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|
@ -27,7 +15,7 @@ export const POST = async (request: NextRequest) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getFileSource(parsed.data, domain, apiKey);
|
const response = await getFileSource(parsed.data);
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
return serviceErrorResponse(response);
|
return serviceErrorResponse(response);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,18 @@ import { isServiceError } from "@/lib/utils";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
_request: NextRequest,
|
||||||
props: { params: Promise<{ domain: string; repoId: string }> }
|
props: { params: Promise<{ domain: string; repoId: string }> }
|
||||||
) {
|
) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { domain, repoId } = params;
|
const { repoId } = params;
|
||||||
const repoIdNum = parseInt(repoId);
|
const repoIdNum = parseInt(repoId);
|
||||||
|
|
||||||
if (isNaN(repoIdNum)) {
|
if (isNaN(repoIdNum)) {
|
||||||
return new Response("Invalid repo ID", { status: 400 });
|
return new Response("Invalid repo ID", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getRepoImage(repoIdNum, domain);
|
const result = await getRepoImage(repoIdNum);
|
||||||
if (isServiceError(result)) {
|
if (isServiceError(result)) {
|
||||||
return new Response(result.message, { status: result.statusCode });
|
return new Response(result.message, { status: result.statusCode });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import Credentials from "next-auth/providers/credentials";
|
||||||
import type { User as AuthJsUser } from "next-auth";
|
import type { User as AuthJsUser } from "next-auth";
|
||||||
import { onCreateUser } from "@/lib/authUtils";
|
import { onCreateUser } from "@/lib/authUtils";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
|
||||||
const logger = createLogger('web-sso');
|
const logger = createLogger('web-sso');
|
||||||
|
|
||||||
|
|
@ -27,7 +28,17 @@ export const getSSOProviders = (): Provider[] => {
|
||||||
authorization: {
|
authorization: {
|
||||||
url: `${baseUrl}/login/oauth/authorize`,
|
url: `${baseUrl}/login/oauth/authorize`,
|
||||||
params: {
|
params: {
|
||||||
scope: "read:user user:email",
|
scope: [
|
||||||
|
'read:user',
|
||||||
|
'user:email',
|
||||||
|
// Permission syncing requires the `repo` scope in order to fetch repositories
|
||||||
|
// for the authenticated user.
|
||||||
|
// @see: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user
|
||||||
|
...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ?
|
||||||
|
['repo'] :
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
].join(' '),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
token: {
|
token: {
|
||||||
|
|
@ -103,7 +114,7 @@ export const getSSOProviders = (): Provider[] => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauth2Client = new OAuth2Client();
|
const oauth2Client = new OAuth2Client();
|
||||||
|
|
||||||
const { pubkeys } = await oauth2Client.getIapPublicKeys();
|
const { pubkeys } = await oauth2Client.getIapPublicKeys();
|
||||||
const ticket = await oauth2Client.verifySignedJwtWithCertsAsync(
|
const ticket = await oauth2Client.verifySignedJwtWithCertsAsync(
|
||||||
iapAssertion,
|
iapAssertion,
|
||||||
|
|
@ -136,6 +136,8 @@ export const env = createEnv({
|
||||||
EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'),
|
EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'),
|
||||||
// @NOTE: Take care to update actions.ts when changing the name of this.
|
// @NOTE: Take care to update actions.ts when changing the name of this.
|
||||||
EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN: z.string().optional(),
|
EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN: z.string().optional(),
|
||||||
|
|
||||||
|
EXPERIMENT_EE_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'),
|
||||||
},
|
},
|
||||||
// @NOTE: Please make sure of the following:
|
// @NOTE: Please make sure of the following:
|
||||||
// - Make sure you destructure all client variables in
|
// - Make sure you destructure all client variables in
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { sourcebot_context, sourcebot_pr_payload } from "@/features/agents/revie
|
||||||
import { getFileSource } from "@/features/search/fileSourceApi";
|
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||||
import { fileSourceResponseSchema } from "@/features/search/schemas";
|
import { fileSourceResponseSchema } from "@/features/search/schemas";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
|
||||||
const logger = createLogger('fetch-file-content');
|
const logger = createLogger('fetch-file-content');
|
||||||
|
|
@ -17,7 +16,7 @@ export const fetchFileContent = async (pr_payload: sourcebot_pr_payload, filenam
|
||||||
}
|
}
|
||||||
logger.debug(JSON.stringify(fileSourceRequest, null, 2));
|
logger.debug(JSON.stringify(fileSourceRequest, null, 2));
|
||||||
|
|
||||||
const response = await getFileSource(fileSourceRequest, "~", env.REVIEW_AGENT_API_KEY);
|
const response = await getFileSource(fileSourceRequest);
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
throw new Error(`Failed to fetch file content for ${filename} from ${repoPath}: ${response.message}`);
|
throw new Error(`Failed to fetch file content for ${filename} from ${repoPath}: ${response.message}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { env } from "@/env.mjs";
|
import { env } from "@/env.mjs";
|
||||||
import { getFileSource } from "@/features/search/fileSourceApi";
|
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||||
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { ProviderOptions } from "@ai-sdk/provider-utils";
|
import { ProviderOptions } from "@ai-sdk/provider-utils";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
|
@ -252,7 +251,7 @@ const resolveFileSource = async ({ path, repo, revision }: FileSource) => {
|
||||||
repository: repo,
|
repository: repo,
|
||||||
branch: revision,
|
branch: revision,
|
||||||
// @todo: handle multi-tenancy.
|
// @todo: handle multi-tenancy.
|
||||||
}, SINGLE_TENANT_ORG_DOMAIN);
|
});
|
||||||
|
|
||||||
if (isServiceError(fileSource)) {
|
if (isServiceError(fileSource)) {
|
||||||
// @todo: handle this
|
// @todo: handle this
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ export const readFilesTool = tool({
|
||||||
repository,
|
repository,
|
||||||
branch: revision,
|
branch: revision,
|
||||||
// @todo(mt): handle multi-tenancy.
|
// @todo(mt): handle multi-tenancy.
|
||||||
}, SINGLE_TENANT_ORG_DOMAIN);
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (responses.some(isServiceError)) {
|
if (responses.some(isServiceError)) {
|
||||||
|
|
@ -187,7 +187,7 @@ Multiple expressions can be or'd together with or, negated with -, or grouped wi
|
||||||
contextLines: 3,
|
contextLines: 3,
|
||||||
whole: false,
|
whole: false,
|
||||||
// @todo(mt): handle multi-tenancy.
|
// @todo(mt): handle multi-tenancy.
|
||||||
}, SINGLE_TENANT_ORG_DOMAIN);
|
});
|
||||||
|
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
return response;
|
return response;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export const findSearchBasedSymbolReferences = async (
|
||||||
query,
|
query,
|
||||||
matches: MAX_REFERENCE_COUNT,
|
matches: MAX_REFERENCE_COUNT,
|
||||||
contextLines: 0,
|
contextLines: 0,
|
||||||
}, domain);
|
});
|
||||||
|
|
||||||
if (isServiceError(searchResult)) {
|
if (isServiceError(searchResult)) {
|
||||||
return searchResult;
|
return searchResult;
|
||||||
|
|
@ -67,7 +67,7 @@ export const findSearchBasedSymbolDefinitions = async (
|
||||||
query,
|
query,
|
||||||
matches: MAX_REFERENCE_COUNT,
|
matches: MAX_REFERENCE_COUNT,
|
||||||
contextLines: 0,
|
contextLines: 0,
|
||||||
}, domain);
|
});
|
||||||
|
|
||||||
if (isServiceError(searchResult)) {
|
if (isServiceError(searchResult)) {
|
||||||
return searchResult;
|
return searchResult;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { sew, withAuth, withOrgMembership } from '@/actions';
|
import { sew } from '@/actions';
|
||||||
import { env } from '@/env.mjs';
|
import { env } from '@/env.mjs';
|
||||||
import { OrgRole, Repo } from '@sourcebot/db';
|
|
||||||
import { prisma } from '@/prisma';
|
|
||||||
import { notFound, unexpectedError } from '@/lib/serviceError';
|
import { notFound, unexpectedError } from '@/lib/serviceError';
|
||||||
import { simpleGit } from 'simple-git';
|
import { withOptionalAuthV2 } from '@/withAuthV2';
|
||||||
import path from 'path';
|
import { Repo } from '@sourcebot/db';
|
||||||
import { createLogger } from '@sourcebot/logger';
|
import { createLogger } from '@sourcebot/logger';
|
||||||
|
import path from 'path';
|
||||||
|
import { simpleGit } from 'simple-git';
|
||||||
|
|
||||||
const logger = createLogger('file-tree');
|
const logger = createLogger('file-tree');
|
||||||
|
|
||||||
|
|
@ -25,188 +25,182 @@ export type FileTreeNode = FileTreeItem & {
|
||||||
* Returns the tree of files (blobs) and directories (trees) for a given repository,
|
* Returns the tree of files (blobs) and directories (trees) for a given repository,
|
||||||
* at a given revision.
|
* at a given revision.
|
||||||
*/
|
*/
|
||||||
export const getTree = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
|
export const getTree = async (params: { repoName: string, revisionName: string }) => sew(() =>
|
||||||
withAuth((session) =>
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
withOrgMembership(session, domain, async ({ org }) => {
|
const { repoName, revisionName } = params;
|
||||||
const { repoName, revisionName } = params;
|
const repo = await prisma.repo.findFirst({
|
||||||
const repo = await prisma.repo.findFirst({
|
where: {
|
||||||
where: {
|
name: repoName,
|
||||||
name: repoName,
|
orgId: org.id,
|
||||||
orgId: org.id,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path: repoPath } = getRepoPath(repo);
|
const { path: repoPath } = getRepoPath(repo);
|
||||||
|
|
||||||
const git = simpleGit().cwd(repoPath);
|
const git = simpleGit().cwd(repoPath);
|
||||||
|
|
||||||
let result: string;
|
let result: string;
|
||||||
try {
|
try {
|
||||||
result = await git.raw([
|
result = await git.raw([
|
||||||
'ls-tree',
|
'ls-tree',
|
||||||
revisionName,
|
revisionName,
|
||||||
// recursive
|
// recursive
|
||||||
'-r',
|
'-r',
|
||||||
// include trees when recursing
|
// include trees when recursing
|
||||||
'-t',
|
'-t',
|
||||||
// format as output as {type},{path}
|
// format as output as {type},{path}
|
||||||
'--format=%(objecttype),%(path)',
|
'--format=%(objecttype),%(path)',
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('git ls-tree failed.', { error });
|
logger.error('git ls-tree failed.', { error });
|
||||||
return unexpectedError('git ls-tree command failed.');
|
return unexpectedError('git ls-tree command failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = result.split('\n').filter(line => line.trim());
|
const lines = result.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
const flatList = lines.map(line => {
|
|
||||||
const [type, path] = line.split(',');
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const tree = buildFileTree(flatList);
|
|
||||||
|
|
||||||
|
const flatList = lines.map(line => {
|
||||||
|
const [type, path] = line.split(',');
|
||||||
return {
|
return {
|
||||||
tree,
|
type,
|
||||||
|
path,
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
|
const tree = buildFileTree(flatList);
|
||||||
);
|
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
}
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the contents of a folder at a given path in a given repository,
|
* Returns the contents of a folder at a given path in a given repository,
|
||||||
* at a given revision.
|
* at a given revision.
|
||||||
*/
|
*/
|
||||||
export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() =>
|
export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }) => sew(() =>
|
||||||
withAuth((session) =>
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
withOrgMembership(session, domain, async ({ org }) => {
|
const { repoName, revisionName, path } = params;
|
||||||
const { repoName, revisionName, path } = params;
|
const repo = await prisma.repo.findFirst({
|
||||||
const repo = await prisma.repo.findFirst({
|
where: {
|
||||||
where: {
|
name: repoName,
|
||||||
name: repoName,
|
orgId: org.id,
|
||||||
orgId: org.id,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!repo) {
|
if (!repo) {
|
||||||
return notFound();
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path: repoPath } = getRepoPath(repo);
|
||||||
|
|
||||||
|
// @note: we don't allow directory traversal
|
||||||
|
// or null bytes in the path.
|
||||||
|
if (path.includes('..') || path.includes('\0')) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the path by...
|
||||||
|
let normalizedPath = path;
|
||||||
|
|
||||||
|
// ... adding a trailing slash if it doesn't have one.
|
||||||
|
// This is important since ls-tree won't return the contents
|
||||||
|
// of a directory if it doesn't have a trailing slash.
|
||||||
|
if (!normalizedPath.endsWith('/')) {
|
||||||
|
normalizedPath = `${normalizedPath}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... removing any leading slashes. This is needed since
|
||||||
|
// the path is relative to the repository's root, so we
|
||||||
|
// need a relative path.
|
||||||
|
if (normalizedPath.startsWith('/')) {
|
||||||
|
normalizedPath = normalizedPath.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = simpleGit().cwd(repoPath);
|
||||||
|
|
||||||
|
let result: string;
|
||||||
|
try {
|
||||||
|
result = await git.raw([
|
||||||
|
'ls-tree',
|
||||||
|
revisionName,
|
||||||
|
// format as output as {type},{path}
|
||||||
|
'--format=%(objecttype),%(path)',
|
||||||
|
...(normalizedPath.length === 0 ? [] : [normalizedPath]),
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('git ls-tree failed.', { error });
|
||||||
|
return unexpectedError('git ls-tree command failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = result.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
const contents: FileTreeItem[] = lines.map(line => {
|
||||||
|
const [type, path] = line.split(',');
|
||||||
|
const name = path.split('/').pop() ?? '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
path,
|
||||||
|
name,
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const { path: repoPath } = getRepoPath(repo);
|
return contents;
|
||||||
|
}));
|
||||||
|
|
||||||
// @note: we don't allow directory traversal
|
export const getFiles = async (params: { repoName: string, revisionName: string }) => sew(() =>
|
||||||
// or null bytes in the path.
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
if (path.includes('..') || path.includes('\0')) {
|
const { repoName, revisionName } = params;
|
||||||
return notFound();
|
|
||||||
|
const repo = await prisma.repo.findFirst({
|
||||||
|
where: {
|
||||||
|
name: repoName,
|
||||||
|
orgId: org.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repo) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path: repoPath } = getRepoPath(repo);
|
||||||
|
|
||||||
|
const git = simpleGit().cwd(repoPath);
|
||||||
|
|
||||||
|
let result: string;
|
||||||
|
try {
|
||||||
|
result = await git.raw([
|
||||||
|
'ls-tree',
|
||||||
|
revisionName,
|
||||||
|
// recursive
|
||||||
|
'-r',
|
||||||
|
// only return the names of the files
|
||||||
|
'--name-only',
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('git ls-tree failed.', { error });
|
||||||
|
return unexpectedError('git ls-tree command failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = result.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
const files: FileTreeItem[] = paths.map(path => {
|
||||||
|
const name = path.split('/').pop() ?? '';
|
||||||
|
return {
|
||||||
|
type: 'blob',
|
||||||
|
path,
|
||||||
|
name,
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Normalize the path by...
|
return files;
|
||||||
let normalizedPath = path;
|
|
||||||
|
|
||||||
// ... adding a trailing slash if it doesn't have one.
|
}));
|
||||||
// This is important since ls-tree won't return the contents
|
|
||||||
// of a directory if it doesn't have a trailing slash.
|
|
||||||
if (!normalizedPath.endsWith('/')) {
|
|
||||||
normalizedPath = `${normalizedPath}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... removing any leading slashes. This is needed since
|
|
||||||
// the path is relative to the repository's root, so we
|
|
||||||
// need a relative path.
|
|
||||||
if (normalizedPath.startsWith('/')) {
|
|
||||||
normalizedPath = normalizedPath.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const git = simpleGit().cwd(repoPath);
|
|
||||||
|
|
||||||
let result: string;
|
|
||||||
try {
|
|
||||||
result = await git.raw([
|
|
||||||
'ls-tree',
|
|
||||||
revisionName,
|
|
||||||
// format as output as {type},{path}
|
|
||||||
'--format=%(objecttype),%(path)',
|
|
||||||
...(normalizedPath.length === 0 ? [] : [normalizedPath]),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('git ls-tree failed.', { error });
|
|
||||||
return unexpectedError('git ls-tree command failed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = result.split('\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
const contents: FileTreeItem[] = lines.map(line => {
|
|
||||||
const [type, path] = line.split(',');
|
|
||||||
const name = path.split('/').pop() ?? '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
path,
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return contents;
|
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
|
|
||||||
withAuth((session) =>
|
|
||||||
withOrgMembership(session, domain, async ({ org }) => {
|
|
||||||
const { repoName, revisionName } = params;
|
|
||||||
|
|
||||||
const repo = await prisma.repo.findFirst({
|
|
||||||
where: {
|
|
||||||
name: repoName,
|
|
||||||
orgId: org.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!repo) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { path: repoPath } = getRepoPath(repo);
|
|
||||||
|
|
||||||
const git = simpleGit().cwd(repoPath);
|
|
||||||
|
|
||||||
let result: string;
|
|
||||||
try {
|
|
||||||
result = await git.raw([
|
|
||||||
'ls-tree',
|
|
||||||
revisionName,
|
|
||||||
// recursive
|
|
||||||
'-r',
|
|
||||||
// only return the names of the files
|
|
||||||
'--name-only',
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('git ls-tree failed.', { error });
|
|
||||||
return unexpectedError('git ls-tree command failed.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const paths = result.split('\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
const files: FileTreeItem[] = paths.map(path => {
|
|
||||||
const name = path.split('/').pop() ?? '';
|
|
||||||
return {
|
|
||||||
type: 'blob',
|
|
||||||
path,
|
|
||||||
name,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return files;
|
|
||||||
|
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true)
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {
|
const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => {
|
||||||
const root: FileTreeNode = {
|
const root: FileTreeNode = {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { getTree } from "../actions";
|
import { getTree } from "../actions";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { unwrapServiceError } from "@/lib/utils";
|
import { unwrapServiceError } from "@/lib/utils";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import { ResizablePanel } from "@/components/ui/resizable";
|
import { ResizablePanel } from "@/components/ui/resizable";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState";
|
import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState";
|
||||||
|
|
@ -41,17 +40,16 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
|
||||||
updateBrowseState,
|
updateBrowseState,
|
||||||
} = useBrowseState();
|
} = useBrowseState();
|
||||||
|
|
||||||
const domain = useDomain();
|
|
||||||
const { repoName, revisionName, path } = useBrowseParams();
|
const { repoName, revisionName, path } = useBrowseParams();
|
||||||
|
|
||||||
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
|
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
|
||||||
const { data, isPending, isError } = useQuery({
|
const { data, isPending, isError } = useQuery({
|
||||||
queryKey: ['tree', repoName, revisionName, domain],
|
queryKey: ['tree', repoName, revisionName],
|
||||||
queryFn: () => unwrapServiceError(
|
queryFn: () => unwrapServiceError(
|
||||||
getTree({
|
getTree({
|
||||||
repoName,
|
repoName,
|
||||||
revisionName: revisionName ?? 'HEAD',
|
revisionName: revisionName ?? 'HEAD',
|
||||||
}, domain)
|
})
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,60 +5,58 @@ import { fileNotFound, ServiceError, unexpectedError } from "../../lib/serviceEr
|
||||||
import { FileSourceRequest, FileSourceResponse } from "./types";
|
import { FileSourceRequest, FileSourceResponse } from "./types";
|
||||||
import { isServiceError } from "../../lib/utils";
|
import { isServiceError } from "../../lib/utils";
|
||||||
import { search } from "./searchApi";
|
import { search } from "./searchApi";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew } from "@/actions";
|
||||||
import { OrgRole } from "@sourcebot/db";
|
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||||
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
|
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
|
||||||
// This will allow us to support permalinks to files at a specific revision that may not be indexed
|
// This will allow us to support permalinks to files at a specific revision that may not be indexed
|
||||||
// by zoekt.
|
// by zoekt.
|
||||||
|
|
||||||
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
||||||
withAuth((userId, _apiKeyHash) =>
|
withOptionalAuthV2(async () => {
|
||||||
withOrgMembership(userId, domain, async () => {
|
const escapedFileName = escapeStringRegexp(fileName);
|
||||||
const escapedFileName = escapeStringRegexp(fileName);
|
const escapedRepository = escapeStringRegexp(repository);
|
||||||
const escapedRepository = escapeStringRegexp(repository);
|
|
||||||
|
|
||||||
let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
|
let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
|
||||||
if (branch) {
|
if (branch) {
|
||||||
query = query.concat(` branch:${branch}`);
|
query = query.concat(` branch:${branch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResponse = await search({
|
const searchResponse = await search({
|
||||||
query,
|
query,
|
||||||
matches: 1,
|
matches: 1,
|
||||||
whole: true,
|
whole: true,
|
||||||
}, domain, apiKey);
|
});
|
||||||
|
|
||||||
if (isServiceError(searchResponse)) {
|
if (isServiceError(searchResponse)) {
|
||||||
return searchResponse;
|
return searchResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = searchResponse.files;
|
const files = searchResponse.files;
|
||||||
|
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
return fileNotFound(fileName, repository);
|
return fileNotFound(fileName, repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
const source = file.content ?? '';
|
const source = file.content ?? '';
|
||||||
const language = file.language;
|
const language = file.language;
|
||||||
|
|
||||||
const repoInfo = searchResponse.repositoryInfo.find((repo) => repo.id === file.repositoryId);
|
const repoInfo = searchResponse.repositoryInfo.find((repo) => repo.id === file.repositoryId);
|
||||||
if (!repoInfo) {
|
if (!repoInfo) {
|
||||||
// This should never happen.
|
// This should never happen.
|
||||||
return unexpectedError("Repository info not found");
|
return unexpectedError("Repository info not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
source,
|
|
||||||
language,
|
|
||||||
path: fileName,
|
|
||||||
repository,
|
|
||||||
repositoryCodeHostType: repoInfo.codeHostType,
|
|
||||||
repositoryDisplayName: repoInfo.displayName,
|
|
||||||
repositoryWebUrl: repoInfo.webUrl,
|
|
||||||
branch,
|
|
||||||
webUrl: file.webUrl,
|
|
||||||
} satisfies FileSourceResponse;
|
|
||||||
|
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
return {
|
||||||
);
|
source,
|
||||||
|
language,
|
||||||
|
path: fileName,
|
||||||
|
repository,
|
||||||
|
repositoryCodeHostType: repoInfo.codeHostType,
|
||||||
|
repositoryDisplayName: repoInfo.displayName,
|
||||||
|
repositoryWebUrl: repoInfo.webUrl,
|
||||||
|
branch,
|
||||||
|
webUrl: file.webUrl,
|
||||||
|
} satisfies FileSourceResponse;
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,14 @@ import { env } from "@/env.mjs";
|
||||||
import { invalidZoektResponse, ServiceError } from "../../lib/serviceError";
|
import { invalidZoektResponse, ServiceError } from "../../lib/serviceError";
|
||||||
import { isServiceError } from "../../lib/utils";
|
import { isServiceError } from "../../lib/utils";
|
||||||
import { zoektFetch } from "./zoektClient";
|
import { zoektFetch } from "./zoektClient";
|
||||||
import { prisma } from "@/prisma";
|
|
||||||
import { ErrorCode } from "../../lib/errorCodes";
|
import { ErrorCode } from "../../lib/errorCodes";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { zoektSearchResponseSchema } from "./zoektSchema";
|
import { zoektSearchResponseSchema } from "./zoektSchema";
|
||||||
import { SearchRequest, SearchResponse, SourceRange } from "./types";
|
import { SearchRequest, SearchResponse, SourceRange } from "./types";
|
||||||
import { OrgRole, Repo } from "@sourcebot/db";
|
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import { sew } from "@/actions";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
|
||||||
import { base64Decode } from "@sourcebot/shared";
|
import { base64Decode } from "@sourcebot/shared";
|
||||||
|
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||||
|
|
||||||
// List of supported query prefixes in zoekt.
|
// List of supported query prefixes in zoekt.
|
||||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
||||||
|
|
@ -37,7 +36,7 @@ enum zoektPrefixes {
|
||||||
reposet = "reposet:",
|
reposet = "reposet:",
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformZoektQuery = async (query: string, orgId: number): Promise<string | ServiceError> => {
|
const transformZoektQuery = async (query: string, orgId: number, prisma: PrismaClient): Promise<string | ServiceError> => {
|
||||||
const prevQueryParts = query.split(" ");
|
const prevQueryParts = query.split(" ");
|
||||||
const newQueryParts = [];
|
const newQueryParts = [];
|
||||||
|
|
||||||
|
|
@ -128,225 +127,219 @@ const getFileWebUrl = (template: string, branch: string, fileName: string): stri
|
||||||
return encodeURI(url + optionalQueryParams);
|
return encodeURI(url + optionalQueryParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() =>
|
export const search = async ({ query, matches, contextLines, whole }: SearchRequest) => sew(() =>
|
||||||
withAuth((userId, _apiKeyHash) =>
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
const transformedQuery = await transformZoektQuery(query, org.id, prisma);
|
||||||
const transformedQuery = await transformZoektQuery(query, org.id);
|
if (isServiceError(transformedQuery)) {
|
||||||
if (isServiceError(transformedQuery)) {
|
return transformedQuery;
|
||||||
return transformedQuery;
|
}
|
||||||
|
query = transformedQuery;
|
||||||
|
|
||||||
|
const isBranchFilteringEnabled = (
|
||||||
|
query.includes(zoektPrefixes.branch) ||
|
||||||
|
query.includes(zoektPrefixes.branchShort)
|
||||||
|
);
|
||||||
|
|
||||||
|
// We only want to show matches for the default branch when
|
||||||
|
// the user isn't explicitly filtering by branch.
|
||||||
|
if (!isBranchFilteringEnabled) {
|
||||||
|
query = query.concat(` branch:HEAD`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
q: query,
|
||||||
|
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L892
|
||||||
|
opts: {
|
||||||
|
ChunkMatches: true,
|
||||||
|
MaxMatchDisplayCount: matches,
|
||||||
|
NumContextLines: contextLines,
|
||||||
|
Whole: !!whole,
|
||||||
|
TotalMaxMatchCount: env.TOTAL_MAX_MATCH_COUNT,
|
||||||
|
ShardMaxMatchCount: env.SHARD_MAX_MATCH_COUNT,
|
||||||
|
MaxWallTime: env.ZOEKT_MAX_WALL_TIME_MS * 1000 * 1000, // zoekt expects a duration in nanoseconds
|
||||||
}
|
}
|
||||||
query = transformedQuery;
|
});
|
||||||
|
|
||||||
const isBranchFilteringEnabled = (
|
let header: Record<string, string> = {};
|
||||||
query.includes(zoektPrefixes.branch) ||
|
header = {
|
||||||
query.includes(zoektPrefixes.branchShort)
|
"X-Tenant-ID": org.id.toString()
|
||||||
);
|
};
|
||||||
|
|
||||||
// We only want to show matches for the default branch when
|
const searchResponse = await zoektFetch({
|
||||||
// the user isn't explicitly filtering by branch.
|
path: "/api/search",
|
||||||
if (!isBranchFilteringEnabled) {
|
body,
|
||||||
query = query.concat(` branch:HEAD`);
|
header,
|
||||||
}
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
const body = JSON.stringify({
|
if (!searchResponse.ok) {
|
||||||
q: query,
|
return invalidZoektResponse(searchResponse);
|
||||||
// @see: https://github.com/sourcebot-dev/zoekt/blob/main/api.go#L892
|
}
|
||||||
opts: {
|
|
||||||
ChunkMatches: true,
|
const searchBody = await searchResponse.json();
|
||||||
MaxMatchDisplayCount: matches,
|
|
||||||
NumContextLines: contextLines,
|
const parser = zoektSearchResponseSchema.transform(async ({ Result }) => {
|
||||||
Whole: !!whole,
|
// @note (2025-05-12): in zoekt, repositories are identified by the `RepositoryID` field
|
||||||
TotalMaxMatchCount: env.TOTAL_MAX_MATCH_COUNT,
|
// which corresponds to the `id` in the Repo table. In order to efficiently fetch repository
|
||||||
ShardMaxMatchCount: env.SHARD_MAX_MATCH_COUNT,
|
// metadata when transforming (potentially thousands) of file matches, we aggregate a unique
|
||||||
MaxWallTime: env.ZOEKT_MAX_WALL_TIME_MS * 1000 * 1000, // zoekt expects a duration in nanoseconds
|
// set of repository ids* and map them to their corresponding Repo record.
|
||||||
|
//
|
||||||
|
// *Q: Why is `RepositoryID` optional? And why are we falling back to `Repository`?
|
||||||
|
// A: Prior to this change, the repository id was not plumbed into zoekt, so RepositoryID was
|
||||||
|
// always undefined. To make this a non-breaking change, we fallback to using the repository's name
|
||||||
|
// (`Repository`) as the identifier in these cases. This is not guaranteed to be unique, but in
|
||||||
|
// practice it is since the repository name includes the host and path (e.g., 'github.com/org/repo',
|
||||||
|
// 'gitea.com/org/repo', etc.).
|
||||||
|
//
|
||||||
|
// Note: When a repository is re-indexed (every hour) this ID will be populated.
|
||||||
|
// @see: https://github.com/sourcebot-dev/zoekt/pull/6
|
||||||
|
const repoIdentifiers = new Set(Result.Files?.map((file) => file.RepositoryID ?? file.Repository) ?? []);
|
||||||
|
const repos = new Map<string | number, Repo>();
|
||||||
|
|
||||||
|
(await prisma.repo.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: Array.from(repoIdentifiers).filter((id) => typeof id === "number"),
|
||||||
|
},
|
||||||
|
orgId: org.id,
|
||||||
}
|
}
|
||||||
});
|
})).forEach(repo => repos.set(repo.id, repo));
|
||||||
|
|
||||||
let header: Record<string, string> = {};
|
(await prisma.repo.findMany({
|
||||||
header = {
|
where: {
|
||||||
"X-Tenant-ID": org.id.toString()
|
name: {
|
||||||
};
|
in: Array.from(repoIdentifiers).filter((id) => typeof id === "string"),
|
||||||
|
},
|
||||||
|
orgId: org.id,
|
||||||
|
}
|
||||||
|
})).forEach(repo => repos.set(repo.name, repo));
|
||||||
|
|
||||||
const searchResponse = await zoektFetch({
|
const files = Result.Files?.map((file) => {
|
||||||
path: "/api/search",
|
const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName);
|
||||||
body,
|
|
||||||
header,
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!searchResponse.ok) {
|
const webUrl = (() => {
|
||||||
return invalidZoektResponse(searchResponse);
|
const template: string | undefined = Result.RepoURLs[file.Repository];
|
||||||
}
|
if (!template) {
|
||||||
|
|
||||||
const searchBody = await searchResponse.json();
|
|
||||||
|
|
||||||
const parser = zoektSearchResponseSchema.transform(async ({ Result }) => {
|
|
||||||
// @note (2025-05-12): in zoekt, repositories are identified by the `RepositoryID` field
|
|
||||||
// which corresponds to the `id` in the Repo table. In order to efficiently fetch repository
|
|
||||||
// metadata when transforming (potentially thousands) of file matches, we aggregate a unique
|
|
||||||
// set of repository ids* and map them to their corresponding Repo record.
|
|
||||||
//
|
|
||||||
// *Q: Why is `RepositoryID` optional? And why are we falling back to `Repository`?
|
|
||||||
// A: Prior to this change, the repository id was not plumbed into zoekt, so RepositoryID was
|
|
||||||
// always undefined. To make this a non-breaking change, we fallback to using the repository's name
|
|
||||||
// (`Repository`) as the identifier in these cases. This is not guaranteed to be unique, but in
|
|
||||||
// practice it is since the repository name includes the host and path (e.g., 'github.com/org/repo',
|
|
||||||
// 'gitea.com/org/repo', etc.).
|
|
||||||
//
|
|
||||||
// Note: When a repository is re-indexed (every hour) this ID will be populated.
|
|
||||||
// @see: https://github.com/sourcebot-dev/zoekt/pull/6
|
|
||||||
const repoIdentifiers = new Set(Result.Files?.map((file) => file.RepositoryID ?? file.Repository) ?? []);
|
|
||||||
const repos = new Map<string | number, Repo>();
|
|
||||||
|
|
||||||
(await prisma.repo.findMany({
|
|
||||||
where: {
|
|
||||||
id: {
|
|
||||||
in: Array.from(repoIdentifiers).filter((id) => typeof id === "number"),
|
|
||||||
},
|
|
||||||
orgId: org.id,
|
|
||||||
}
|
|
||||||
})).forEach(repo => repos.set(repo.id, repo));
|
|
||||||
|
|
||||||
(await prisma.repo.findMany({
|
|
||||||
where: {
|
|
||||||
name: {
|
|
||||||
in: Array.from(repoIdentifiers).filter((id) => typeof id === "string"),
|
|
||||||
},
|
|
||||||
orgId: org.id,
|
|
||||||
}
|
|
||||||
})).forEach(repo => repos.set(repo.name, repo));
|
|
||||||
|
|
||||||
const files = Result.Files?.map((file) => {
|
|
||||||
const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName);
|
|
||||||
|
|
||||||
const webUrl = (() => {
|
|
||||||
const template: string | undefined = Result.RepoURLs[file.Repository];
|
|
||||||
if (!template) {
|
|
||||||
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 = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD";
|
|
||||||
return getFileWebUrl(template, branch, file.FileName);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const identifier = file.RepositoryID ?? file.Repository;
|
|
||||||
const repo = repos.get(identifier);
|
|
||||||
|
|
||||||
// This should never happen... but if it does, we skip the file.
|
|
||||||
if (!repo) {
|
|
||||||
Sentry.captureMessage(
|
|
||||||
`Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`,
|
|
||||||
'warning'
|
|
||||||
);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// If there are multiple branches pointing to the same revision of this file, it doesn't
|
||||||
fileName: {
|
// matter which branch we use here, so use the first one.
|
||||||
text: file.FileName,
|
const branch = file.Branches && file.Branches.length > 0 ? file.Branches[0] : "HEAD";
|
||||||
matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({
|
return getFileWebUrl(template, branch, file.FileName);
|
||||||
start: {
|
})();
|
||||||
byteOffset: range.Start.ByteOffset,
|
|
||||||
column: range.Start.Column,
|
const identifier = file.RepositoryID ?? file.Repository;
|
||||||
lineNumber: range.Start.LineNumber,
|
const repo = repos.get(identifier);
|
||||||
},
|
|
||||||
end: {
|
// This can happen if the user doesn't have access to the repository.
|
||||||
byteOffset: range.End.ByteOffset,
|
if (!repo) {
|
||||||
column: range.End.Column,
|
return undefined;
|
||||||
lineNumber: range.End.LineNumber,
|
}
|
||||||
}
|
|
||||||
})) : [],
|
|
||||||
},
|
|
||||||
repository: repo.name,
|
|
||||||
repositoryId: repo.id,
|
|
||||||
webUrl: webUrl,
|
|
||||||
language: file.Language,
|
|
||||||
chunks: file.ChunkMatches
|
|
||||||
.filter((chunk) => !chunk.FileName) // Filter out filename chunks.
|
|
||||||
.map((chunk) => {
|
|
||||||
return {
|
|
||||||
content: base64Decode(chunk.Content),
|
|
||||||
matchRanges: chunk.Ranges.map((range) => ({
|
|
||||||
start: {
|
|
||||||
byteOffset: range.Start.ByteOffset,
|
|
||||||
column: range.Start.Column,
|
|
||||||
lineNumber: range.Start.LineNumber,
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
byteOffset: range.End.ByteOffset,
|
|
||||||
column: range.End.Column,
|
|
||||||
lineNumber: range.End.LineNumber,
|
|
||||||
}
|
|
||||||
}) satisfies SourceRange),
|
|
||||||
contentStart: {
|
|
||||||
byteOffset: chunk.ContentStart.ByteOffset,
|
|
||||||
column: chunk.ContentStart.Column,
|
|
||||||
lineNumber: chunk.ContentStart.LineNumber,
|
|
||||||
},
|
|
||||||
symbols: chunk.SymbolInfo?.map((symbol) => {
|
|
||||||
return {
|
|
||||||
symbol: symbol.Sym,
|
|
||||||
kind: symbol.Kind,
|
|
||||||
parent: symbol.Parent.length > 0 ? {
|
|
||||||
symbol: symbol.Parent,
|
|
||||||
kind: symbol.ParentKind,
|
|
||||||
} : undefined,
|
|
||||||
}
|
|
||||||
}) ?? undefined,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
branches: file.Branches,
|
|
||||||
content: file.Content ? base64Decode(file.Content) : undefined,
|
|
||||||
}
|
|
||||||
}).filter((file) => file !== undefined) ?? [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zoektStats: {
|
fileName: {
|
||||||
duration: Result.Duration,
|
text: file.FileName,
|
||||||
fileCount: Result.FileCount,
|
matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].Ranges.map((range) => ({
|
||||||
matchCount: Result.MatchCount,
|
start: {
|
||||||
filesSkipped: Result.FilesSkipped,
|
byteOffset: range.Start.ByteOffset,
|
||||||
contentBytesLoaded: Result.ContentBytesLoaded,
|
column: range.Start.Column,
|
||||||
indexBytesLoaded: Result.IndexBytesLoaded,
|
lineNumber: range.Start.LineNumber,
|
||||||
crashes: Result.Crashes,
|
},
|
||||||
shardFilesConsidered: Result.ShardFilesConsidered,
|
end: {
|
||||||
filesConsidered: Result.FilesConsidered,
|
byteOffset: range.End.ByteOffset,
|
||||||
filesLoaded: Result.FilesLoaded,
|
column: range.End.Column,
|
||||||
shardsScanned: Result.ShardsScanned,
|
lineNumber: range.End.LineNumber,
|
||||||
shardsSkipped: Result.ShardsSkipped,
|
}
|
||||||
shardsSkippedFilter: Result.ShardsSkippedFilter,
|
})) : [],
|
||||||
ngramMatches: Result.NgramMatches,
|
|
||||||
ngramLookups: Result.NgramLookups,
|
|
||||||
wait: Result.Wait,
|
|
||||||
matchTreeConstruction: Result.MatchTreeConstruction,
|
|
||||||
matchTreeSearch: Result.MatchTreeSearch,
|
|
||||||
regexpsConsidered: Result.RegexpsConsidered,
|
|
||||||
flushReason: Result.FlushReason,
|
|
||||||
},
|
},
|
||||||
files,
|
repository: repo.name,
|
||||||
repositoryInfo: Array.from(repos.values()).map((repo) => ({
|
repositoryId: repo.id,
|
||||||
id: repo.id,
|
webUrl: webUrl,
|
||||||
codeHostType: repo.external_codeHostType,
|
language: file.Language,
|
||||||
name: repo.name,
|
chunks: file.ChunkMatches
|
||||||
displayName: repo.displayName ?? undefined,
|
.filter((chunk) => !chunk.FileName) // Filter out filename chunks.
|
||||||
webUrl: repo.webUrl ?? undefined,
|
.map((chunk) => {
|
||||||
})),
|
return {
|
||||||
isBranchFilteringEnabled: isBranchFilteringEnabled,
|
content: base64Decode(chunk.Content),
|
||||||
stats: {
|
matchRanges: chunk.Ranges.map((range) => ({
|
||||||
matchCount: files.reduce(
|
start: {
|
||||||
(acc, file) =>
|
byteOffset: range.Start.ByteOffset,
|
||||||
acc + file.chunks.reduce(
|
column: range.Start.Column,
|
||||||
(acc, chunk) => acc + chunk.matchRanges.length,
|
lineNumber: range.Start.LineNumber,
|
||||||
0,
|
},
|
||||||
),
|
end: {
|
||||||
0,
|
byteOffset: range.End.ByteOffset,
|
||||||
)
|
column: range.End.Column,
|
||||||
}
|
lineNumber: range.End.LineNumber,
|
||||||
} satisfies SearchResponse;
|
}
|
||||||
});
|
}) satisfies SourceRange),
|
||||||
|
contentStart: {
|
||||||
|
byteOffset: chunk.ContentStart.ByteOffset,
|
||||||
|
column: chunk.ContentStart.Column,
|
||||||
|
lineNumber: chunk.ContentStart.LineNumber,
|
||||||
|
},
|
||||||
|
symbols: chunk.SymbolInfo?.map((symbol) => {
|
||||||
|
return {
|
||||||
|
symbol: symbol.Sym,
|
||||||
|
kind: symbol.Kind,
|
||||||
|
parent: symbol.Parent.length > 0 ? {
|
||||||
|
symbol: symbol.Parent,
|
||||||
|
kind: symbol.ParentKind,
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
}) ?? undefined,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
branches: file.Branches,
|
||||||
|
content: file.Content ? base64Decode(file.Content) : undefined,
|
||||||
|
}
|
||||||
|
}).filter((file) => file !== undefined) ?? [];
|
||||||
|
|
||||||
return parser.parseAsync(searchBody);
|
return {
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
zoektStats: {
|
||||||
);
|
duration: Result.Duration,
|
||||||
|
fileCount: Result.FileCount,
|
||||||
|
matchCount: Result.MatchCount,
|
||||||
|
filesSkipped: Result.FilesSkipped,
|
||||||
|
contentBytesLoaded: Result.ContentBytesLoaded,
|
||||||
|
indexBytesLoaded: Result.IndexBytesLoaded,
|
||||||
|
crashes: Result.Crashes,
|
||||||
|
shardFilesConsidered: Result.ShardFilesConsidered,
|
||||||
|
filesConsidered: Result.FilesConsidered,
|
||||||
|
filesLoaded: Result.FilesLoaded,
|
||||||
|
shardsScanned: Result.ShardsScanned,
|
||||||
|
shardsSkipped: Result.ShardsSkipped,
|
||||||
|
shardsSkippedFilter: Result.ShardsSkippedFilter,
|
||||||
|
ngramMatches: Result.NgramMatches,
|
||||||
|
ngramLookups: Result.NgramLookups,
|
||||||
|
wait: Result.Wait,
|
||||||
|
matchTreeConstruction: Result.MatchTreeConstruction,
|
||||||
|
matchTreeSearch: Result.MatchTreeSearch,
|
||||||
|
regexpsConsidered: Result.RegexpsConsidered,
|
||||||
|
flushReason: Result.FlushReason,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
repositoryInfo: Array.from(repos.values()).map((repo) => ({
|
||||||
|
id: repo.id,
|
||||||
|
codeHostType: repo.external_codeHostType,
|
||||||
|
name: repo.name,
|
||||||
|
displayName: repo.displayName ?? undefined,
|
||||||
|
webUrl: repo.webUrl ?? undefined,
|
||||||
|
})),
|
||||||
|
isBranchFilteringEnabled: isBranchFilteringEnabled,
|
||||||
|
stats: {
|
||||||
|
matchCount: files.reduce(
|
||||||
|
(acc, file) =>
|
||||||
|
acc + file.chunks.reduce(
|
||||||
|
(acc, chunk) => acc + chunk.matchRanges.length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} satisfies SearchResponse;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parser.parseAsync(searchBody);
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,60 @@
|
||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { env } from "@/env.mjs";
|
||||||
|
import { Prisma, PrismaClient } from "@sourcebot/db";
|
||||||
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
|
||||||
// @see: https://authjs.dev/getting-started/adapters/prisma
|
// @see: https://authjs.dev/getting-started/adapters/prisma
|
||||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||||
|
|
||||||
|
// @NOTE: In almost all cases, the userScopedPrismaClientExtension should be used
|
||||||
|
// (since actions & queries are scoped to a particular user). There are some exceptions
|
||||||
|
// (e.g., in initialize.ts).
|
||||||
|
//
|
||||||
|
// @todo: we can mark this as `__unsafePrisma` in the future once we've migrated
|
||||||
|
// all of the actions & queries to use the userScopedPrismaClientExtension to avoid
|
||||||
|
// accidental misuse.
|
||||||
export const prisma = globalForPrisma.prisma || new PrismaClient()
|
export const prisma = globalForPrisma.prisma || new PrismaClient()
|
||||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a prisma client extension that scopes queries to striclty information
|
||||||
|
* a given user should be able to access.
|
||||||
|
*/
|
||||||
|
export const userScopedPrismaClientExtension = (userId?: string) => {
|
||||||
|
return Prisma.defineExtension(
|
||||||
|
(prisma) => {
|
||||||
|
return prisma.$extends({
|
||||||
|
query: {
|
||||||
|
...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') ? {
|
||||||
|
repo: {
|
||||||
|
$allOperations({ args, query }) {
|
||||||
|
if ('where' in args) {
|
||||||
|
args.where = {
|
||||||
|
...args.where,
|
||||||
|
OR: [
|
||||||
|
// Only include repos that are permitted to the user
|
||||||
|
...(userId ? [
|
||||||
|
{
|
||||||
|
permittedUsers: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
] : []),
|
||||||
|
// or are public.
|
||||||
|
{
|
||||||
|
isPublic: true,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -188,6 +188,7 @@ describe('getAuthContext', () => {
|
||||||
},
|
},
|
||||||
org: MOCK_ORG,
|
org: MOCK_ORG,
|
||||||
role: OrgRole.MEMBER,
|
role: OrgRole.MEMBER,
|
||||||
|
prisma: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -217,6 +218,7 @@ describe('getAuthContext', () => {
|
||||||
},
|
},
|
||||||
org: MOCK_ORG,
|
org: MOCK_ORG,
|
||||||
role: OrgRole.OWNER,
|
role: OrgRole.OWNER,
|
||||||
|
prisma: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -241,6 +243,7 @@ describe('getAuthContext', () => {
|
||||||
},
|
},
|
||||||
org: MOCK_ORG,
|
org: MOCK_ORG,
|
||||||
role: OrgRole.GUEST,
|
role: OrgRole.GUEST,
|
||||||
|
prisma: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -256,6 +259,7 @@ describe('getAuthContext', () => {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
org: MOCK_ORG,
|
org: MOCK_ORG,
|
||||||
role: OrgRole.GUEST,
|
role: OrgRole.GUEST,
|
||||||
|
prisma: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { prisma } from "@/prisma";
|
import { prisma as __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma";
|
||||||
import { hashSecret } from "@sourcebot/crypto";
|
import { hashSecret } from "@sourcebot/crypto";
|
||||||
import { ApiKey, Org, OrgRole, User } from "@sourcebot/db";
|
import { ApiKey, Org, OrgRole, PrismaClient, User } from "@sourcebot/db";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError";
|
import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError";
|
||||||
|
|
@ -14,12 +14,14 @@ interface OptionalAuthContext {
|
||||||
user?: User;
|
user?: User;
|
||||||
org: Org;
|
org: Org;
|
||||||
role: OrgRole;
|
role: OrgRole;
|
||||||
|
prisma: PrismaClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequiredAuthContext {
|
interface RequiredAuthContext {
|
||||||
user: User;
|
user: User;
|
||||||
org: Org;
|
org: Org;
|
||||||
role: Omit<OrgRole, 'GUEST'>;
|
role: Exclude<OrgRole, 'GUEST'>;
|
||||||
|
prisma: PrismaClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const withAuthV2 = async <T>(fn: (params: RequiredAuthContext) => Promise<T>) => {
|
export const withAuthV2 = async <T>(fn: (params: RequiredAuthContext) => Promise<T>) => {
|
||||||
|
|
@ -29,13 +31,13 @@ export const withAuthV2 = async <T>(fn: (params: RequiredAuthContext) => Promise
|
||||||
return authContext;
|
return authContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user, org, role } = authContext;
|
const { user, org, role, prisma } = authContext;
|
||||||
|
|
||||||
if (!user || role === OrgRole.GUEST) {
|
if (!user || role === OrgRole.GUEST) {
|
||||||
return notAuthenticated();
|
return notAuthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
return fn({ user, org, role });
|
return fn({ user, org, role, prisma });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) => Promise<T>) => {
|
export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) => Promise<T>) => {
|
||||||
|
|
@ -44,7 +46,7 @@ export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) =>
|
||||||
return authContext;
|
return authContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user, org, role } = authContext;
|
const { user, org, role, prisma } = authContext;
|
||||||
|
|
||||||
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
|
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
|
||||||
const orgMetadata = getOrgMetadata(org);
|
const orgMetadata = getOrgMetadata(org);
|
||||||
|
|
@ -61,13 +63,13 @@ export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) =>
|
||||||
return notAuthenticated();
|
return notAuthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
return fn({ user, org, role });
|
return fn({ user, org, role, prisma });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceError> => {
|
export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceError> => {
|
||||||
const user = await getAuthenticatedUser();
|
const user = await getAuthenticatedUser();
|
||||||
|
|
||||||
const org = await prisma.org.findUnique({
|
const org = await __unsafePrisma.org.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +79,7 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
|
||||||
return notFound("Organization not found");
|
return notFound("Organization not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = user ? await prisma.userToOrg.findUnique({
|
const membership = user ? await __unsafePrisma.userToOrg.findUnique({
|
||||||
where: {
|
where: {
|
||||||
orgId_userId: {
|
orgId_userId: {
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
|
|
@ -86,10 +88,13 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
|
||||||
},
|
},
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
|
const prisma = __unsafePrisma.$extends(userScopedPrismaClientExtension(user?.id)) as PrismaClient;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: user ?? undefined,
|
user: user ?? undefined,
|
||||||
org,
|
org,
|
||||||
role: membership?.role ?? OrgRole.GUEST,
|
role: membership?.role ?? OrgRole.GUEST,
|
||||||
|
prisma,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -98,7 +103,7 @@ export const getAuthenticatedUser = async () => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session) {
|
if (session) {
|
||||||
const userId = session.user.id;
|
const userId = session.user.id;
|
||||||
const user = await prisma.user.findUnique({
|
const user = await __unsafePrisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +121,7 @@ export const getAuthenticatedUser = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to find the user associated with this api key.
|
// Attempt to find the user associated with this api key.
|
||||||
const user = await prisma.user.findUnique({
|
const user = await __unsafePrisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: apiKey.createdById,
|
id: apiKey.createdById,
|
||||||
},
|
},
|
||||||
|
|
@ -127,7 +132,7 @@ export const getAuthenticatedUser = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the last used at timestamp for this api key.
|
// Update the last used at timestamp for this api key.
|
||||||
await prisma.apiKey.update({
|
await __unsafePrisma.apiKey.update({
|
||||||
where: {
|
where: {
|
||||||
hash: apiKey.hash,
|
hash: apiKey.hash,
|
||||||
},
|
},
|
||||||
|
|
@ -152,7 +157,7 @@ const getVerifiedApiObject = async (apiKeyString: string): Promise<ApiKey | unde
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = hashSecret(parts[1]);
|
const hash = hashSecret(parts[1]);
|
||||||
const apiKey = await prisma.apiKey.findUnique({
|
const apiKey = await __unsafePrisma.apiKey.findUnique({
|
||||||
where: {
|
where: {
|
||||||
hash,
|
hash,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,16 @@
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"experiment_repoDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the repo permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"experiment_userDrivenPermissionSyncIntervalMs": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The interval (in milliseconds) at which the user permission syncer should run. Defaults to 24 hours.",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue