mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Merge branch 'main' into bkellam/fix_31
This commit is contained in:
commit
7eb79d31ed
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]
|
||||
|
||||
### 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
|
||||
- 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)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ Copyright (c) 2025 Taqla Inc.
|
|||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "ee/", "packages/web/src/ee/", and "packages/shared/src/ee/" directories of this repository, if these directories exist, is licensed under the license defined in "ee/LICENSE".
|
||||
- All 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.
|
||||
- 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/analytics",
|
||||
"docs/features/mcp-server",
|
||||
"docs/features/permission-syncing",
|
||||
{
|
||||
"group": "Agents",
|
||||
"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
|
||||
|
||||
| Setting | Type | Default | Minimum | Description / Notes |
|
||||
|-------------------------------------------|---------|------------|---------|----------------------------------------------------------------------------------------|
|
||||
| `maxFileSize` | number | 2 MB | 1 | Maximum size (bytes) of a file to index. Files exceeding this are skipped. |
|
||||
| `maxTrigramCount` | number | 20 000 | 1 | Maximum trigrams per document. Larger files are skipped. |
|
||||
| `reindexIntervalMs` | number | 1 hour | 1 | Interval at which all repositories are re‑indexed. |
|
||||
| `resyncConnectionIntervalMs` | number | 24 hours | 1 | Interval for checking connections that need re‑syncing. |
|
||||
| `resyncConnectionPollingIntervalMs` | number | 1 second | 1 | DB polling rate for connections that need re‑syncing. |
|
||||
| `reindexRepoPollingIntervalMs` | number | 1 second | 1 | DB polling rate for repos that should be re‑indexed. |
|
||||
| `maxConnectionSyncJobConcurrency` | number | 8 | 1 | Concurrent connection‑sync jobs. |
|
||||
| `maxRepoIndexingJobConcurrency` | number | 8 | 1 | Concurrent repo‑indexing jobs. |
|
||||
| `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repo‑garbage‑collection jobs. |
|
||||
| `repoGarbageCollectionGracePeriodMs` | number | 10 seconds | 1 | Grace period to avoid deleting shards while loading. |
|
||||
| `repoIndexTimeoutMs` | number | 2 hours | 1 | Timeout for a single repo‑indexing run. |
|
||||
| `enablePublicAccess` **(deprecated)** | boolean | false | — | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. |
|
||||
| Setting | Type | Default | Minimum | Description / Notes |
|
||||
|-------------------------------------------------|---------|------------|---------|----------------------------------------------------------------------------------------|
|
||||
| `maxFileSize` | number | 2 MB | 1 | Maximum size (bytes) of a file to index. Files exceeding this are skipped. |
|
||||
| `maxTrigramCount` | number | 20 000 | 1 | Maximum trigrams per document. Larger files are skipped. |
|
||||
| `reindexIntervalMs` | number | 1 hour | 1 | Interval at which all repositories are re‑indexed. |
|
||||
| `resyncConnectionIntervalMs` | number | 24 hours | 1 | Interval for checking connections that need re‑syncing. |
|
||||
| `resyncConnectionPollingIntervalMs` | number | 1 second | 1 | DB polling rate for connections that need re‑syncing. |
|
||||
| `reindexRepoPollingIntervalMs` | number | 1 second | 1 | DB polling rate for repos that should be re‑indexed. |
|
||||
| `maxConnectionSyncJobConcurrency` | number | 8 | 1 | Concurrent connection‑sync jobs. |
|
||||
| `maxRepoIndexingJobConcurrency` | number | 8 | 1 | Concurrent repo‑indexing jobs. |
|
||||
| `maxRepoGarbageCollectionJobConcurrency` | number | 8 | 1 | Concurrent repo‑garbage‑collection jobs. |
|
||||
| `repoGarbageCollectionGracePeriodMs` | number | 10 seconds | 1 | Grace period to avoid deleting shards while loading. |
|
||||
| `repoIndexTimeoutMs` | number | 2 hours | 1 | Timeout for a single repo‑indexing run. |
|
||||
| `enablePublicAccess` **(deprecated)** | boolean | false | — | Use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead. |
|
||||
| `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_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> |
|
||||
| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` | `false` | <p>Enables [permission syncing](/docs/features/permission-syncing).</p> |
|
||||
|
||||
|
||||
### Review Agent Environment Variables
|
||||
|
|
|
|||
|
|
@ -196,4 +196,8 @@ To connect to a GitHub host other than `github.com`, provide the `url` property
|
|||
|
||||
<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"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
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>
|
||||
import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.mdx'
|
||||
|
||||
<ExperimentalFeatureWarning />
|
||||
|
||||
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.
|
||||
|
|
|
|||
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,
|
||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||
"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
|
||||
|
|
@ -195,6 +205,16 @@
|
|||
"deprecated": true,
|
||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"watch:mcp": "yarn workspace @sourcebot/mcp build:watch",
|
||||
"watch:schemas": "yarn workspace @sourcebot/schemas watch",
|
||||
"dev:prisma:migrate:dev": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:dev",
|
||||
"dev:prisma:generate": "yarn with-env yarn workspace @sourcebot/db prisma:generate",
|
||||
"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: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 { loadConfig, syncSearchContexts } from "@sourcebot/shared";
|
||||
|
||||
interface IConnectionManager {
|
||||
scheduleConnectionSync: (connection: Connection) => Promise<void>;
|
||||
registerPollingCallback: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
const QUEUE_NAME = 'connectionSyncQueue';
|
||||
|
||||
type JobPayload = {
|
||||
|
|
@ -30,10 +24,11 @@ type JobResult = {
|
|||
repoCount: number,
|
||||
}
|
||||
|
||||
export class ConnectionManager implements IConnectionManager {
|
||||
export class ConnectionManager {
|
||||
private worker: Worker;
|
||||
private queue: Queue<JobPayload>;
|
||||
private logger = createLogger('connection-manager');
|
||||
private interval?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private db: PrismaClient,
|
||||
|
|
@ -75,8 +70,9 @@ export class ConnectionManager implements IConnectionManager {
|
|||
});
|
||||
}
|
||||
|
||||
public async registerPollingCallback() {
|
||||
setInterval(async () => {
|
||||
public startScheduler() {
|
||||
this.logger.debug('Starting scheduler');
|
||||
this.interval = setInterval(async () => {
|
||||
const thresholdDate = new Date(Date.now() - this.settings.resyncConnectionIntervalMs);
|
||||
const connections = await this.db.connection.findMany({
|
||||
where: {
|
||||
|
|
@ -369,6 +365,9 @@ export class ConnectionManager implements IConnectionManager {
|
|||
}
|
||||
|
||||
public dispose() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.worker.close();
|
||||
this.queue.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,5 +15,11 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||
maxRepoGarbageCollectionJobConcurrency: 8,
|
||||
repoGarbageCollectionGracePeriodMs: 10 * 1000, // 10 seconds
|
||||
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),
|
||||
|
||||
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,
|
||||
emptyStringAsUndefined: true,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@ import { env } from './env.js';
|
|||
type onProgressFn = (event: SimpleGitProgressEvent) => void;
|
||||
|
||||
export const cloneRepository = async (
|
||||
remoteUrl: URL,
|
||||
path: string,
|
||||
onProgress?: onProgressFn
|
||||
{
|
||||
cloneUrl,
|
||||
path,
|
||||
onProgress,
|
||||
}: {
|
||||
cloneUrl: string,
|
||||
path: string,
|
||||
onProgress?: onProgressFn
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
await mkdir(path, { recursive: true });
|
||||
|
|
@ -19,7 +25,7 @@ export const cloneRepository = async (
|
|||
})
|
||||
|
||||
await git.clone(
|
||||
remoteUrl.toString(),
|
||||
cloneUrl,
|
||||
path,
|
||||
[
|
||||
"--bare",
|
||||
|
|
@ -42,9 +48,15 @@ export const cloneRepository = async (
|
|||
};
|
||||
|
||||
export const fetchRepository = async (
|
||||
remoteUrl: URL,
|
||||
path: string,
|
||||
onProgress?: onProgressFn
|
||||
{
|
||||
cloneUrl,
|
||||
path,
|
||||
onProgress,
|
||||
}: {
|
||||
cloneUrl: string,
|
||||
path: string,
|
||||
onProgress?: onProgressFn
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
const git = simpleGit({
|
||||
|
|
@ -54,7 +66,7 @@ export const fetchRepository = async (
|
|||
})
|
||||
|
||||
await git.fetch([
|
||||
remoteUrl.toString(),
|
||||
cloneUrl,
|
||||
"+refs/heads/*:refs/heads/*",
|
||||
"--prune",
|
||||
"--progress"
|
||||
|
|
|
|||
|
|
@ -30,16 +30,31 @@ export type OctokitRepository = {
|
|||
size?: number,
|
||||
owner: {
|
||||
avatar_url: string,
|
||||
login: string,
|
||||
}
|
||||
}
|
||||
|
||||
const isHttpError = (error: unknown, status: number): boolean => {
|
||||
return error !== null
|
||||
return error !== null
|
||||
&& typeof error === 'object'
|
||||
&& 'status' in error
|
||||
&& 'status' in error
|
||||
&& 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) => {
|
||||
const hostname = config.url ?
|
||||
new URL(config.url).hostname :
|
||||
|
|
@ -48,17 +63,15 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
|||
const token = config.token ?
|
||||
await getTokenFromConfig(config.token, orgId, db, logger) :
|
||||
hostname === GITHUB_CLOUD_HOSTNAME ?
|
||||
env.FALLBACK_GITHUB_CLOUD_TOKEN :
|
||||
undefined;
|
||||
env.FALLBACK_GITHUB_CLOUD_TOKEN :
|
||||
undefined;
|
||||
|
||||
const octokit = new Octokit({
|
||||
auth: token,
|
||||
...(config.url ? {
|
||||
baseUrl: `${config.url}/api/v3`
|
||||
} : {}),
|
||||
const { octokit, isAuthenticated } = await createOctokitFromToken({
|
||||
token,
|
||||
url: config.url,
|
||||
});
|
||||
|
||||
if (token) {
|
||||
if (isAuthenticated) {
|
||||
try {
|
||||
await octokit.rest.users.getAuthenticated();
|
||||
} catch (error) {
|
||||
|
|
@ -127,95 +140,42 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
|||
logger.debug(`Found ${repos.length} total repositories.`);
|
||||
|
||||
return {
|
||||
validRepos: repos,
|
||||
validRepos: repos,
|
||||
notFound,
|
||||
};
|
||||
}
|
||||
|
||||
export const shouldExcludeRepo = ({
|
||||
repo,
|
||||
include,
|
||||
exclude
|
||||
} : {
|
||||
repo: OctokitRepository,
|
||||
include?: {
|
||||
topics?: GithubConnectionConfig['topics']
|
||||
},
|
||||
exclude?: GithubConnectionConfig['exclude']
|
||||
}) => {
|
||||
let reason = '';
|
||||
const repoName = repo.full_name;
|
||||
export const getRepoCollaborators = async (owner: string, repo: string, octokit: Octokit) => {
|
||||
try {
|
||||
const fetchFn = () => octokit.paginate(octokit.repos.listCollaborators, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
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;
|
||||
const collaborators = await fetchWithRetry(fetchFn, `repo ${owner}/${repo}`, logger);
|
||||
return collaborators;
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
logger.error(`Failed to fetch collaborators for repo ${owner}/${repo}.`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
|
@ -369,4 +329,90 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
|
|||
validRepos,
|
||||
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 * 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 { mkdir } from 'fs/promises';
|
||||
import { Redis } from 'ioredis';
|
||||
import path from 'path';
|
||||
import { AppContext } from "./types.js";
|
||||
import { main } from "./main.js"
|
||||
import { PrismaClient } from "@sourcebot/db";
|
||||
import { ConnectionManager } from './connectionManager.js';
|
||||
import { DEFAULT_SETTINGS } from './constants.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 getSettings = async (configPath?: string) => {
|
||||
if (!configPath) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
|
||||
// Register handler for normal exit
|
||||
process.on('exit', (code) => {
|
||||
logger.info(`Process is exiting with code: ${code}`);
|
||||
});
|
||||
const config = await loadConfig(configPath);
|
||||
|
||||
// Register handlers for abnormal terminations
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('Process interrupted (SIGINT)');
|
||||
process.exit(0);
|
||||
});
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...config.settings,
|
||||
}
|
||||
}
|
||||
|
||||
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 reposPath = path.join(cacheDir, 'repos');
|
||||
|
|
@ -59,18 +52,62 @@ const context: AppContext = {
|
|||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
main(prisma, context)
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
logger.error(e);
|
||||
Sentry.captureException(e);
|
||||
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);
|
||||
});
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info("Shutting down...");
|
||||
});
|
||||
const promClient = new PromClient();
|
||||
|
||||
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 repoName = path.join(repoNameRoot, repoDisplayName);
|
||||
const cloneUrl = new URL(repo.clone_url!);
|
||||
const isPublic = repo.private === false;
|
||||
|
||||
logger.debug(`Found github repo ${repoDisplayName} with webUrl: ${repo.html_url}`);
|
||||
|
||||
|
|
@ -64,6 +65,7 @@ export const compileGithubConfig = async (
|
|||
imageUrl: repo.owner.avatar_url,
|
||||
isFork: repo.fork,
|
||||
isArchived: !!repo.archived,
|
||||
isPublic: isPublic,
|
||||
org: {
|
||||
connect: {
|
||||
id: orgId,
|
||||
|
|
@ -85,7 +87,7 @@ export const compileGithubConfig = async (
|
|||
'zoekt.github-forks': (repo.forks_count ?? 0).toString(),
|
||||
'zoekt.archived': marshalBool(repo.archived),
|
||||
'zoekt.fork': marshalBool(repo.fork),
|
||||
'zoekt.public': marshalBool(repo.private === false),
|
||||
'zoekt.public': marshalBool(isPublic),
|
||||
'zoekt.display-name': repoDisplayName,
|
||||
},
|
||||
branches: config.revisions?.branches ?? undefined,
|
||||
|
|
@ -121,6 +123,8 @@ export const compileGitlabConfig = async (
|
|||
const projectUrl = `${hostUrl}/${project.path_with_namespace}`;
|
||||
const cloneUrl = new URL(project.http_url_to_repo);
|
||||
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 repoName = path.join(repoNameRoot, repoDisplayName);
|
||||
// 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,
|
||||
imageUrl: avatarUrl,
|
||||
isFork: isFork,
|
||||
isPublic: isPublic,
|
||||
isArchived: !!project.archived,
|
||||
org: {
|
||||
connect: {
|
||||
|
|
@ -159,7 +164,7 @@ export const compileGitlabConfig = async (
|
|||
'zoekt.gitlab-forks': (project.forks_count ?? 0).toString(),
|
||||
'zoekt.archived': marshalBool(project.archived),
|
||||
'zoekt.fork': marshalBool(isFork),
|
||||
'zoekt.public': marshalBool(project.private === false),
|
||||
'zoekt.public': marshalBool(isPublic),
|
||||
'zoekt.display-name': repoDisplayName,
|
||||
},
|
||||
branches: config.revisions?.branches ?? undefined,
|
||||
|
|
@ -197,6 +202,7 @@ export const compileGiteaConfig = async (
|
|||
cloneUrl.host = configUrl.host
|
||||
const repoDisplayName = repo.full_name!;
|
||||
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}`);
|
||||
|
||||
|
|
@ -210,6 +216,7 @@ export const compileGiteaConfig = async (
|
|||
displayName: repoDisplayName,
|
||||
imageUrl: repo.owner?.avatar_url,
|
||||
isFork: repo.fork!,
|
||||
isPublic: isPublic,
|
||||
isArchived: !!repo.archived,
|
||||
org: {
|
||||
connect: {
|
||||
|
|
@ -228,7 +235,7 @@ export const compileGiteaConfig = async (
|
|||
'zoekt.name': repoName,
|
||||
'zoekt.archived': marshalBool(repo.archived),
|
||||
'zoekt.fork': marshalBool(repo.fork!),
|
||||
'zoekt.public': marshalBool(repo.internal === false && repo.private === false),
|
||||
'zoekt.public': marshalBool(isPublic),
|
||||
'zoekt.display-name': repoDisplayName,
|
||||
},
|
||||
branches: config.revisions?.branches ?? undefined,
|
||||
|
|
@ -411,6 +418,7 @@ export const compileBitbucketConfig = async (
|
|||
name: repoName,
|
||||
displayName: displayName,
|
||||
isFork: isFork,
|
||||
isPublic: isPublic,
|
||||
isArchived: isArchived,
|
||||
org: {
|
||||
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 (
|
||||
config: GenericGitHostConnectionConfig,
|
||||
|
|
@ -688,4 +616,87 @@ export const compileGenericGitHostConfig_url = async (
|
|||
repoData: [repo],
|
||||
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 { 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';
|
||||
|
||||
interface IRepoManager {
|
||||
validateIndexedReposHaveShards: () => Promise<void>;
|
||||
blockingPollLoop: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js";
|
||||
import { PromClient } from './promClient.js';
|
||||
import { AppContext, RepoWithConnections, Settings, repoMetadataSchema } from "./types.js";
|
||||
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, measure } from "./utils.js";
|
||||
import { indexGitRepository } from "./zoekt.js";
|
||||
|
||||
const REPO_INDEXING_QUEUE = 'repoIndexingQueue';
|
||||
const REPO_GC_QUEUE = 'repoGarbageCollectionQueue';
|
||||
|
||||
type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection })[] };
|
||||
type RepoIndexingPayload = {
|
||||
repo: RepoWithConnections,
|
||||
}
|
||||
|
|
@ -32,11 +24,12 @@ type RepoGarbageCollectionPayload = {
|
|||
|
||||
const logger = createLogger('repo-manager');
|
||||
|
||||
export class RepoManager implements IRepoManager {
|
||||
export class RepoManager {
|
||||
private indexWorker: Worker;
|
||||
private indexQueue: Queue<RepoIndexingPayload>;
|
||||
private gcWorker: Worker;
|
||||
private gcQueue: Queue<RepoGarbageCollectionPayload>;
|
||||
private interval?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private db: PrismaClient,
|
||||
|
|
@ -68,14 +61,13 @@ export class RepoManager implements IRepoManager {
|
|||
this.gcWorker.on('failed', this.onGarbageCollectionJobFailed.bind(this));
|
||||
}
|
||||
|
||||
public async blockingPollLoop() {
|
||||
while (true) {
|
||||
public startScheduler() {
|
||||
logger.debug('Starting scheduler');
|
||||
this.interval = setInterval(async () => {
|
||||
await this.fetchAndScheduleRepoIndexing();
|
||||
await this.fetchAndScheduleRepoGarbageCollection();
|
||||
await this.fetchAndScheduleRepoTimeouts();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, this.settings.reindexRepoPollingIntervalMs));
|
||||
}
|
||||
}, 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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const credentials = await this.getCloneCredentialsForRepo(repo, this.db);
|
||||
const remoteUrl = new URL(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;
|
||||
}
|
||||
}
|
||||
const credentials = await getAuthCredentialsForRepo(repo, this.db);
|
||||
const cloneUrlMaybeWithToken = credentials?.cloneUrlWithToken ?? repo.cloneUrl;
|
||||
|
||||
if (existsSync(repoPath) && !isReadOnly) {
|
||||
// @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"]);
|
||||
|
||||
logger.info(`Fetching ${repo.displayName}...`);
|
||||
const { durationMs } = await measure(() => fetchRepository(
|
||||
remoteUrl,
|
||||
repoPath,
|
||||
({ method, stage, progress }) => {
|
||||
const { durationMs } = await measure(() => fetchRepository({
|
||||
cloneUrl: cloneUrlMaybeWithToken,
|
||||
path: repoPath,
|
||||
onProgress: ({ method, stage, progress }) => {
|
||||
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
||||
}
|
||||
));
|
||||
}));
|
||||
const fetchDuration_s = durationMs / 1000;
|
||||
|
||||
process.stdout.write('\n');
|
||||
|
|
@ -284,13 +201,13 @@ export class RepoManager implements IRepoManager {
|
|||
} else if (!isReadOnly) {
|
||||
logger.info(`Cloning ${repo.displayName}...`);
|
||||
|
||||
const { durationMs } = await measure(() => cloneRepository(
|
||||
remoteUrl,
|
||||
repoPath,
|
||||
({ method, stage, progress }) => {
|
||||
const { durationMs } = await measure(() => cloneRepository({
|
||||
cloneUrl: cloneUrlMaybeWithToken,
|
||||
path: repoPath,
|
||||
onProgress: ({ method, stage, progress }) => {
|
||||
logger.debug(`git.${method} ${stage} stage ${progress}% complete for ${repo.displayName}`)
|
||||
}
|
||||
));
|
||||
}));
|
||||
const cloneDuration_s = durationMs / 1000;
|
||||
|
||||
process.stdout.write('\n');
|
||||
|
|
@ -635,6 +552,9 @@ export class RepoManager implements IRepoManager {
|
|||
}
|
||||
|
||||
public async dispose() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.indexWorker.close();
|
||||
this.indexQueue.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 { z } from "zod";
|
||||
|
||||
|
|
@ -50,4 +51,13 @@ export type DeepPartial<T> = T extends object ? {
|
|||
} : T;
|
||||
|
||||
// @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 { AppContext } from "./types.js";
|
||||
import { AppContext, RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
||||
import path from 'path';
|
||||
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
|
||||
import { BackendException, BackendError } from "@sourcebot/error";
|
||||
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>) => {
|
||||
const start = Date.now();
|
||||
|
|
@ -116,4 +117,115 @@ export const fetchWithRetry = async <T>(
|
|||
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 {
|
||||
id Int @id @default(autoincrement())
|
||||
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)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
/// When the repo was last indexed successfully.
|
||||
indexedAt DateTime?
|
||||
id Int @id @default(autoincrement())
|
||||
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)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
indexedAt DateTime? /// When the repo was last indexed successfully.
|
||||
isFork 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
|
||||
webUrl String?
|
||||
connections RepoToConnection[]
|
||||
imageUrl String?
|
||||
repoIndexingStatus RepoIndexingStatus @default(NEW)
|
||||
repoIndexingStatus RepoIndexingStatus @default(NEW)
|
||||
|
||||
// The id of the repo in the external service
|
||||
external_id String
|
||||
// The type of the external service (e.g., github, gitlab, etc.)
|
||||
external_codeHostType String
|
||||
// The base url of the external service (e.g., https://github.com)
|
||||
external_codeHostUrl String
|
||||
permittedUsers UserToRepoPermission[]
|
||||
permissionSyncJobs RepoPermissionSyncJob[]
|
||||
permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
|
||||
|
||||
external_id String /// The id of the repo in the external service
|
||||
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)
|
||||
orgId Int
|
||||
|
|
@ -74,12 +74,32 @@ model Repo {
|
|||
@@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 {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
name String
|
||||
name String
|
||||
description String?
|
||||
repos Repo[]
|
||||
repos Repo[]
|
||||
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
|
|
@ -149,7 +169,7 @@ model AccountRequest {
|
|||
|
||||
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
|
||||
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
|
|
@ -171,7 +191,7 @@ model Org {
|
|||
apiKeys ApiKey[]
|
||||
isOnboarded Boolean @default(false)
|
||||
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)
|
||||
|
||||
|
|
@ -181,10 +201,10 @@ model Org {
|
|||
|
||||
/// List of pending invites to this organization
|
||||
invites Invite[]
|
||||
|
||||
|
||||
/// The invite id for this organization
|
||||
inviteLinkEnabled Boolean @default(false)
|
||||
inviteLinkId String?
|
||||
inviteLinkId String?
|
||||
|
||||
audits Audit[]
|
||||
|
||||
|
|
@ -231,55 +251,53 @@ model Secret {
|
|||
}
|
||||
|
||||
model ApiKey {
|
||||
name String
|
||||
hash String @id @unique
|
||||
name String
|
||||
hash String @id @unique
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime?
|
||||
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdById String
|
||||
|
||||
}
|
||||
|
||||
model Audit {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
action String
|
||||
actorId String
|
||||
actorType String
|
||||
targetId String
|
||||
targetType String
|
||||
|
||||
action String
|
||||
actorId String
|
||||
actorType String
|
||||
targetId String
|
||||
targetType String
|
||||
sourcebotVersion String
|
||||
metadata Json?
|
||||
metadata Json?
|
||||
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
|
||||
@@index([actorId, actorType, targetId, targetType, orgId])
|
||||
|
||||
// Fast path for analytics queries – orgId is first because we assume most deployments are single tenant
|
||||
@@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
|
||||
|
||||
// Fast path for analytics queries for a specific user
|
||||
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
|
||||
}
|
||||
|
||||
// @see : https://authjs.dev/concepts/database-models#user
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
email String? @unique
|
||||
hashedPassword String?
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
accounts Account[]
|
||||
orgs UserToOrg[]
|
||||
accountRequest AccountRequest?
|
||||
accessibleRepos UserToRepoPermission[]
|
||||
|
||||
/// List of pending invites that the user has created
|
||||
invites Invite[]
|
||||
|
|
@ -290,6 +308,41 @@ model User {
|
|||
|
||||
createdAt DateTime @default(now())
|
||||
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
|
||||
|
|
@ -329,17 +382,17 @@ model Chat {
|
|||
|
||||
name String?
|
||||
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdById String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
orgId Int
|
||||
|
||||
visibility ChatVisibility @default(PRIVATE)
|
||||
isReadonly Boolean @default(false)
|
||||
isReadonly Boolean @default(false)
|
||||
|
||||
messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,16 @@ const schema = {
|
|||
"deprecated": true,
|
||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||
"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
|
||||
|
|
@ -194,6 +204,16 @@ const schema = {
|
|||
"deprecated": true,
|
||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ export interface Settings {
|
|||
* This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -38,15 +38,16 @@ const entitlements = [
|
|||
"sso",
|
||||
"code-nav",
|
||||
"audit",
|
||||
"analytics"
|
||||
"analytics",
|
||||
"permission-syncing"
|
||||
] as const;
|
||||
export type Entitlement = (typeof entitlements)[number];
|
||||
|
||||
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
|
||||
oss: ["anonymous-access"],
|
||||
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
|
||||
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
|
||||
"self-hosted:enterprise-unlimited": ["search-contexts", "anonymous-access", "sso", "code-nav", "audit", "analytics"],
|
||||
"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", "permission-syncing"],
|
||||
// Special entitlement for https://demo.sourcebot.dev
|
||||
"cloud:demo": ["anonymous-access", "code-nav", "search-contexts"],
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 { beforeEach } from 'vitest';
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
import { mockDeep, mockReset } from 'vitest-mock-extended';
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -43,6 +43,8 @@ export const MOCK_USER: User = {
|
|||
updatedAt: new Date(),
|
||||
hashedPassword: null,
|
||||
emailVerified: null,
|
||||
image: null
|
||||
image: null,
|
||||
permissionSyncedAt: null
|
||||
}
|
||||
|
||||
export const userScopedPrismaClientExtension = vi.fn();
|
||||
|
|
@ -1,47 +1,46 @@
|
|||
'use server';
|
||||
|
||||
import { getAuditService } from "@/ee/features/audit/factory";
|
||||
import { env } from "@/env.mjs";
|
||||
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
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 { render } from "@react-email/components";
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { decrypt, encrypt, generateApiKey, hashSecret, getTokenFromConfig } from "@sourcebot/crypto";
|
||||
import { ConnectionSyncStatus, OrgRole, Prisma, RepoIndexingStatus, StripeSubscriptionStatus, Org, ApiKey } from "@sourcebot/db";
|
||||
import { decrypt, encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
||||
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 { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
|
||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
||||
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
||||
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||
import { 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 { 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 { StatusCodes } from "http-status-codes";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { createTransport } from "nodemailer";
|
||||
import { auth } from "./auth";
|
||||
import { Octokit } from "octokit";
|
||||
import { auth } from "./auth";
|
||||
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 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 { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
||||
import { TenancyMode, ApiKeyPayload } from "./lib/types";
|
||||
import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/billing/serverUtils";
|
||||
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema";
|
||||
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema";
|
||||
import { getPlan, hasEntitlement } from "@sourcebot/shared";
|
||||
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
||||
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail";
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
import { getAuditService } from "@/ee/features/audit/factory";
|
||||
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
|
||||
import { getOrgMetadata } from "@/lib/utils";
|
||||
import { getOrgFromDomain } from "./data/org";
|
||||
import { withOptionalAuthV2 } from "./withAuthV2";
|
||||
import { ApiKeyPayload, TenancyMode } from "./lib/types";
|
||||
import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
|
||||
|
||||
const ajv = new Ajv({
|
||||
validateFormats: false,
|
||||
|
|
@ -640,7 +639,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
|
|||
})));
|
||||
|
||||
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org }) => {
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const repos = await prisma.repo.findMany({
|
||||
where: {
|
||||
orgId: org.id,
|
||||
|
|
@ -670,67 +669,65 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
|
|||
}))
|
||||
}));
|
||||
|
||||
export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() =>
|
||||
withAuth((userId) =>
|
||||
withOrgMembership(userId, domain, async ({ org }) => {
|
||||
// @note: repo names are represented by their remote url
|
||||
// on the code host. E.g.,:
|
||||
// - github.com/sourcebot-dev/sourcebot
|
||||
// - gitlab.com/gitlab-org/gitlab
|
||||
// - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor
|
||||
// etc.
|
||||
//
|
||||
// For most purposes, repo names are unique within an org, so using
|
||||
// findFirst is equivalent to findUnique. Duplicates _can_ occur when
|
||||
// a repository is specified by its remote url in a generic `git`
|
||||
// connection. For example:
|
||||
//
|
||||
// ```json
|
||||
// {
|
||||
// "connections": {
|
||||
// "connection-1": {
|
||||
// "type": "github",
|
||||
// "repos": [
|
||||
// "sourcebot-dev/sourcebot"
|
||||
// ]
|
||||
// },
|
||||
// "connection-2": {
|
||||
// "type": "git",
|
||||
// "url": "file:///tmp/repos/sourcebot"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot".
|
||||
// We will leave this as an edge case for now since it's unlikely to happen in practice.
|
||||
//
|
||||
// @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate
|
||||
// these cases.
|
||||
// @see: repoCompileUtils.ts
|
||||
const repo = await prisma.repo.findFirst({
|
||||
where: {
|
||||
name: repoName,
|
||||
orgId: org.id,
|
||||
},
|
||||
});
|
||||
export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
// @note: repo names are represented by their remote url
|
||||
// on the code host. E.g.,:
|
||||
// - github.com/sourcebot-dev/sourcebot
|
||||
// - gitlab.com/gitlab-org/gitlab
|
||||
// - gerrit.wikimedia.org/r/mediawiki/extensions/OnionsPorFavor
|
||||
// etc.
|
||||
//
|
||||
// For most purposes, repo names are unique within an org, so using
|
||||
// findFirst is equivalent to findUnique. Duplicates _can_ occur when
|
||||
// a repository is specified by its remote url in a generic `git`
|
||||
// connection. For example:
|
||||
//
|
||||
// ```json
|
||||
// {
|
||||
// "connections": {
|
||||
// "connection-1": {
|
||||
// "type": "github",
|
||||
// "repos": [
|
||||
// "sourcebot-dev/sourcebot"
|
||||
// ]
|
||||
// },
|
||||
// "connection-2": {
|
||||
// "type": "git",
|
||||
// "url": "file:///tmp/repos/sourcebot"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// In this scenario, both repos will be named "github.com/sourcebot-dev/sourcebot".
|
||||
// We will leave this as an edge case for now since it's unlikely to happen in practice.
|
||||
//
|
||||
// @v4-todo: we could add a unique constraint on repo name + orgId to help de-duplicate
|
||||
// these cases.
|
||||
// @see: repoCompileUtils.ts
|
||||
const repo = await prisma.repo.findFirst({
|
||||
where: {
|
||||
name: repoName,
|
||||
orgId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
return notFound();
|
||||
}
|
||||
if (!repo) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName ?? undefined,
|
||||
codeHostType: repo.external_codeHostType,
|
||||
webUrl: repo.webUrl ?? undefined,
|
||||
imageUrl: repo.imageUrl ?? undefined,
|
||||
indexedAt: repo.indexedAt ?? undefined,
|
||||
repoIndexingStatus: repo.repoIndexingStatus,
|
||||
}
|
||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
|
||||
));
|
||||
return {
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
displayName: repo.displayName ?? undefined,
|
||||
codeHostType: repo.external_codeHostType,
|
||||
webUrl: repo.webUrl ?? undefined,
|
||||
imageUrl: repo.imageUrl ?? undefined,
|
||||
indexedAt: repo.indexedAt ?? undefined,
|
||||
repoIndexingStatus: repo.repoIndexingStatus,
|
||||
}
|
||||
}));
|
||||
|
||||
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
|
||||
withAuth((userId) =>
|
||||
|
|
@ -780,143 +777,141 @@ export const createConnection = async (name: string, type: CodeHostType, connect
|
|||
}, OrgRole.OWNER)
|
||||
));
|
||||
|
||||
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string, domain: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
|
||||
withAuth((userId) =>
|
||||
withOrgMembership(userId, domain, async ({ org }) => {
|
||||
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "This feature is not enabled.",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "This feature is not enabled.",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
// Parse repository URL to extract owner/repo
|
||||
const repoInfo = (() => {
|
||||
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;
|
||||
})();
|
||||
// Parse repository URL to extract owner/repo
|
||||
const repoInfo = (() => {
|
||||
const url = repositoryUrl.trim();
|
||||
|
||||
if (!repoInfo) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
// 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_.-]+)$/
|
||||
];
|
||||
|
||||
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;
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 404)) {
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return {
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`,
|
||||
} satisfies ServiceError;
|
||||
owner: match[1],
|
||||
repo: match[2]
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "Only public repositories can be added.",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
// 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',
|
||||
}
|
||||
if (!repoInfo) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (existingRepo) {
|
||||
githubRepo = response.data;
|
||||
} catch (error) {
|
||||
if (isHttpError(error, 404)) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
|
||||
message: "This repository already exists.",
|
||||
statusCode: StatusCodes.NOT_FOUND,
|
||||
errorCode: ErrorCode.INVALID_REQUEST_BODY,
|
||||
message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`,
|
||||
} 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',
|
||||
}
|
||||
});
|
||||
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 {
|
||||
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(() =>
|
||||
withAuth((userId) =>
|
||||
|
|
@ -1022,24 +1017,22 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
|
|||
})
|
||||
));
|
||||
|
||||
export const flagReposForIndex = async (repoIds: number[], domain: string) => sew(() =>
|
||||
withAuth((userId) =>
|
||||
withOrgMembership(userId, domain, async ({ org }) => {
|
||||
await prisma.repo.updateMany({
|
||||
where: {
|
||||
id: { in: repoIds },
|
||||
orgId: org.id,
|
||||
},
|
||||
data: {
|
||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
export const flagReposForIndex = async (repoIds: number[]) => sew(() =>
|
||||
withAuthV2(async ({ org, prisma }) => {
|
||||
await prisma.repo.updateMany({
|
||||
where: {
|
||||
id: { in: repoIds },
|
||||
orgId: org.id,
|
||||
},
|
||||
data: {
|
||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
||||
}
|
||||
})
|
||||
));
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
}));
|
||||
|
||||
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
|
||||
withAuth((userId) =>
|
||||
|
|
@ -2004,75 +1997,73 @@ export const getSearchContexts = async (domain: string) => sew(() =>
|
|||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
|
||||
));
|
||||
|
||||
export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => {
|
||||
return await withAuth(async (userId) => {
|
||||
return await withOrgMembership(userId, domain, async ({ org }) => {
|
||||
const repo = await prisma.repo.findUnique({
|
||||
where: {
|
||||
id: repoId,
|
||||
orgId: org.id,
|
||||
},
|
||||
include: {
|
||||
connections: {
|
||||
include: {
|
||||
connection: true,
|
||||
}
|
||||
export const getRepoImage = async (repoId: number): Promise<ArrayBuffer | ServiceError> => sew(async () => {
|
||||
return await withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const repo = await prisma.repo.findUnique({
|
||||
where: {
|
||||
id: repoId,
|
||||
orgId: org.id,
|
||||
},
|
||||
include: {
|
||||
connections: {
|
||||
include: {
|
||||
connection: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!repo || !repo.imageUrl) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const authHeaders: Record<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();
|
||||
}
|
||||
|
||||
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 (!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);
|
||||
const imageBuffer = await response.arrayBuffer();
|
||||
return imageBuffer;
|
||||
} catch (error) {
|
||||
logger.error(`Error proxying image for repo ${repoId}:`, error);
|
||||
return notFound();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => {
|
||||
|
|
@ -2213,7 +2204,7 @@ const parseConnectionConfig = (config: string) => {
|
|||
switch (connectionType) {
|
||||
case "gitea":
|
||||
case "github":
|
||||
case "bitbucket":
|
||||
case "bitbucket":
|
||||
case "azuredevops": {
|
||||
return {
|
||||
numRepos: parsedConfig.repos?.length,
|
||||
|
|
|
|||
|
|
@ -10,17 +10,16 @@ interface CodePreviewPanelProps {
|
|||
path: string;
|
||||
repoName: 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([
|
||||
getFileSource({
|
||||
fileName: path,
|
||||
repository: repoName,
|
||||
branch: revisionName,
|
||||
}, domain),
|
||||
getRepoInfoByName(repoName, domain),
|
||||
}),
|
||||
getRepoInfoByName(repoName),
|
||||
]);
|
||||
|
||||
if (isServiceError(fileSourceResponse) || isServiceError(repoInfoResponse)) {
|
||||
|
|
|
|||
|
|
@ -10,17 +10,16 @@ interface TreePreviewPanelProps {
|
|||
path: string;
|
||||
repoName: 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([
|
||||
getRepoInfoByName(repoName, domain),
|
||||
getRepoInfoByName(repoName),
|
||||
getFolderContents({
|
||||
repoName,
|
||||
revisionName: revisionName ?? 'HEAD',
|
||||
path,
|
||||
}, domain)
|
||||
})
|
||||
]);
|
||||
|
||||
if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { TreePreviewPanel } from "./components/treePreviewPanel";
|
|||
interface BrowsePageProps {
|
||||
params: Promise<{
|
||||
path: string[];
|
||||
domain: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -16,7 +15,6 @@ export default async function BrowsePage(props: BrowsePageProps) {
|
|||
|
||||
const {
|
||||
path: _rawPath,
|
||||
domain
|
||||
} = params;
|
||||
|
||||
const rawPath = _rawPath.join('/');
|
||||
|
|
@ -35,14 +33,12 @@ export default async function BrowsePage(props: BrowsePageProps) {
|
|||
path={path}
|
||||
repoName={repoName}
|
||||
revisionName={revisionName}
|
||||
domain={domain}
|
||||
/>
|
||||
) : (
|
||||
<TreePreviewPanel
|
||||
path={path}
|
||||
repoName={repoName}
|
||||
revisionName={revisionName}
|
||||
domain={domain}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useHotkeys } from "react-hotkeys-hook";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import { FileTreeItem, getFiles } from "@/features/fileTree/actions";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useBrowseNavigation } from "../hooks/useBrowseNavigation";
|
||||
import { useBrowseState } from "../hooks/useBrowseState";
|
||||
|
|
@ -28,7 +27,6 @@ type SearchResult = {
|
|||
|
||||
export const FileSearchCommandDialog = () => {
|
||||
const { repoName, revisionName } = useBrowseParams();
|
||||
const domain = useDomain();
|
||||
const { state: { isFileSearchOpen }, updateBrowseState } = useBrowseState();
|
||||
|
||||
const commandListRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -57,8 +55,8 @@ export const FileSearchCommandDialog = () => {
|
|||
}, [isFileSearchOpen]);
|
||||
|
||||
const { data: files, isLoading, isError } = useQuery({
|
||||
queryKey: ['files', repoName, revisionName, domain],
|
||||
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' }, domain)),
|
||||
queryKey: ['files', repoName, revisionName],
|
||||
queryFn: () => unwrapServiceError(getFiles({ repoName, revisionName: revisionName ?? 'HEAD' })),
|
||||
enabled: isFileSearchOpen,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
|||
}
|
||||
|
||||
setIsRetryAllFailedReposLoading(true);
|
||||
flagReposForIndex(failedRepos.map((repo) => repo.repoId), domain)
|
||||
flagReposForIndex(failedRepos.map((repo) => repo.repoId))
|
||||
.then((response) => {
|
||||
if (isServiceError(response)) {
|
||||
captureEvent('wa_connection_retry_all_failed_repos_fail', {});
|
||||
|
|
@ -116,7 +116,7 @@ export const RepoList = ({ connectionId }: RepoListProps) => {
|
|||
.finally(() => {
|
||||
setIsRetryAllFailedReposLoading(false);
|
||||
});
|
||||
}, [captureEvent, domain, failedRepos, refetchRepos, toast]);
|
||||
}, [captureEvent, failedRepos, refetchRepos, toast]);
|
||||
|
||||
const filteredRepos = useMemo(() => {
|
||||
if (isServiceError(unfilteredRepos)) {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export const RepoListItem = ({
|
|||
</div>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
{status === RepoIndexingStatus.FAILED && (
|
||||
<RetryRepoIndexButton repoId={repoId} domain={domain} />
|
||||
<RetryRepoIndexButton repoId={repoId} />
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-0">
|
||||
<StatusIcon
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|||
|
||||
interface RetryRepoIndexButtonProps {
|
||||
repoId: number;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => {
|
||||
export const RetryRepoIndexButton = ({ repoId }: RetryRepoIndexButtonProps) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
return (
|
||||
|
|
@ -21,7 +20,7 @@ export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonPro
|
|||
size="sm"
|
||||
className="ml-2"
|
||||
onClick={async () => {
|
||||
const result = await flagReposForIndex([repoId], domain);
|
||||
const result = await flagReposForIndex([repoId]);
|
||||
if (isServiceError(result)) {
|
||||
toast({
|
||||
description: `❌ Failed to flag repository for indexing.`,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { experimental_addGithubRepositoryByUrl } from "@/actions";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -37,7 +36,6 @@ const formSchema = z.object({
|
|||
});
|
||||
|
||||
export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialogProps) => {
|
||||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -52,7 +50,7 @@ export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialo
|
|||
|
||||
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)) {
|
||||
toast({
|
||||
title: "Error adding repository",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CodePreview } from "./codePreview";
|
||||
import { SearchResultFile } from "@/features/search/types";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||
import { SetStateAction, Dispatch, useMemo } from "react";
|
||||
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||
|
|
@ -22,7 +21,6 @@ export const CodePreviewPanel = ({
|
|||
onClose,
|
||||
onSelectedMatchIndexChange,
|
||||
}: CodePreviewPanelProps) => {
|
||||
const domain = useDomain();
|
||||
|
||||
// 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.
|
||||
|
|
@ -31,13 +29,13 @@ export const CodePreviewPanel = ({
|
|||
}, [previewedFile]);
|
||||
|
||||
const { data: file, isLoading, isPending, isError } = useQuery({
|
||||
queryKey: ["source", previewedFile, branch, domain],
|
||||
queryKey: ["source", previewedFile, branch],
|
||||
queryFn: () => unwrapServiceError(
|
||||
getFileSource({
|
||||
fileName: previewedFile.fileName.text,
|
||||
repository: previewedFile.repository,
|
||||
branch,
|
||||
}, domain)
|
||||
})
|
||||
),
|
||||
select: (data) => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,20 +5,8 @@ import { isServiceError } from "@/lib/utils";
|
|||
import { NextRequest } from "next/server";
|
||||
import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
|
||||
import { searchRequestSchema } from "@/features/search/schemas";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const domain = request.headers.get("X-Org-Domain");
|
||||
const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined;
|
||||
if (!domain) {
|
||||
return serviceErrorResponse({
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
|
||||
message: "Missing X-Org-Domain header",
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = await searchRequestSchema.safeParseAsync(body);
|
||||
if (!parsed.success) {
|
||||
|
|
@ -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)) {
|
||||
return serviceErrorResponse(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,8 @@ import { schemaValidationError, serviceErrorResponse } from "@/lib/serviceError"
|
|||
import { isServiceError } from "@/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { fileSourceRequestSchema } from "@/features/search/schemas";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
|
||||
export const POST = async (request: NextRequest) => {
|
||||
const domain = request.headers.get("X-Org-Domain");
|
||||
const apiKey = request.headers.get("X-Sourcebot-Api-Key") ?? undefined;
|
||||
if (!domain) {
|
||||
return serviceErrorResponse({
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.MISSING_ORG_DOMAIN_HEADER,
|
||||
message: "Missing X-Org-Domain header",
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = await fileSourceRequestSchema.safeParseAsync(body);
|
||||
if (!parsed.success) {
|
||||
|
|
@ -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)) {
|
||||
return serviceErrorResponse(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import { isServiceError } from "@/lib/utils";
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
_request: NextRequest,
|
||||
props: { params: Promise<{ domain: string; repoId: string }> }
|
||||
) {
|
||||
const params = await props.params;
|
||||
const { domain, repoId } = params;
|
||||
const { repoId } = params;
|
||||
const repoIdNum = parseInt(repoId);
|
||||
|
||||
if (isNaN(repoIdNum)) {
|
||||
return new Response("Invalid repo ID", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await getRepoImage(repoIdNum, domain);
|
||||
const result = await getRepoImage(repoIdNum);
|
||||
if (isServiceError(result)) {
|
||||
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 { onCreateUser } from "@/lib/authUtils";
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
import { hasEntitlement } from "@sourcebot/shared";
|
||||
|
||||
const logger = createLogger('web-sso');
|
||||
|
||||
|
|
@ -27,7 +28,17 @@ export const getSSOProviders = (): Provider[] => {
|
|||
authorization: {
|
||||
url: `${baseUrl}/login/oauth/authorize`,
|
||||
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: {
|
||||
|
|
@ -103,7 +114,7 @@ export const getSSOProviders = (): Provider[] => {
|
|||
}
|
||||
|
||||
const oauth2Client = new OAuth2Client();
|
||||
|
||||
|
||||
const { pubkeys } = await oauth2Client.getIapPublicKeys();
|
||||
const ticket = await oauth2Client.verifySignedJwtWithCertsAsync(
|
||||
iapAssertion,
|
||||
|
|
@ -136,6 +136,8 @@ export const env = createEnv({
|
|||
EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'),
|
||||
// @NOTE: Take care to update actions.ts when changing the name of this.
|
||||
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:
|
||||
// - 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 { fileSourceResponseSchema } from "@/features/search/schemas";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { env } from "@/env.mjs";
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
|
||||
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));
|
||||
|
||||
const response = await getFileSource(fileSourceRequest, "~", env.REVIEW_AGENT_API_KEY);
|
||||
const response = await getFileSource(fileSourceRequest);
|
||||
if (isServiceError(response)) {
|
||||
throw new Error(`Failed to fetch file content for ${filename} from ${repoPath}: ${response.message}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { env } from "@/env.mjs";
|
||||
import { getFileSource } from "@/features/search/fileSourceApi";
|
||||
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { ProviderOptions } from "@ai-sdk/provider-utils";
|
||||
import { createLogger } from "@sourcebot/logger";
|
||||
|
|
@ -252,7 +251,7 @@ const resolveFileSource = async ({ path, repo, revision }: FileSource) => {
|
|||
repository: repo,
|
||||
branch: revision,
|
||||
// @todo: handle multi-tenancy.
|
||||
}, SINGLE_TENANT_ORG_DOMAIN);
|
||||
});
|
||||
|
||||
if (isServiceError(fileSource)) {
|
||||
// @todo: handle this
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const readFilesTool = tool({
|
|||
repository,
|
||||
branch: revision,
|
||||
// @todo(mt): handle multi-tenancy.
|
||||
}, SINGLE_TENANT_ORG_DOMAIN);
|
||||
});
|
||||
}));
|
||||
|
||||
if (responses.some(isServiceError)) {
|
||||
|
|
@ -187,7 +187,7 @@ Multiple expressions can be or'd together with or, negated with -, or grouped wi
|
|||
contextLines: 3,
|
||||
whole: false,
|
||||
// @todo(mt): handle multi-tenancy.
|
||||
}, SINGLE_TENANT_ORG_DOMAIN);
|
||||
});
|
||||
|
||||
if (isServiceError(response)) {
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const findSearchBasedSymbolReferences = async (
|
|||
query,
|
||||
matches: MAX_REFERENCE_COUNT,
|
||||
contextLines: 0,
|
||||
}, domain);
|
||||
});
|
||||
|
||||
if (isServiceError(searchResult)) {
|
||||
return searchResult;
|
||||
|
|
@ -67,7 +67,7 @@ export const findSearchBasedSymbolDefinitions = async (
|
|||
query,
|
||||
matches: MAX_REFERENCE_COUNT,
|
||||
contextLines: 0,
|
||||
}, domain);
|
||||
});
|
||||
|
||||
if (isServiceError(searchResult)) {
|
||||
return searchResult;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
'use server';
|
||||
|
||||
import { sew, withAuth, withOrgMembership } from '@/actions';
|
||||
import { sew } from '@/actions';
|
||||
import { env } from '@/env.mjs';
|
||||
import { OrgRole, Repo } from '@sourcebot/db';
|
||||
import { prisma } from '@/prisma';
|
||||
import { notFound, unexpectedError } from '@/lib/serviceError';
|
||||
import { simpleGit } from 'simple-git';
|
||||
import path from 'path';
|
||||
import { withOptionalAuthV2 } from '@/withAuthV2';
|
||||
import { Repo } from '@sourcebot/db';
|
||||
import { createLogger } from '@sourcebot/logger';
|
||||
import path from 'path';
|
||||
import { simpleGit } from 'simple-git';
|
||||
|
||||
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,
|
||||
* at a given revision.
|
||||
*/
|
||||
export const getTree = 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,
|
||||
},
|
||||
});
|
||||
export const getTree = async (params: { repoName: string, revisionName: string }) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const { repoName, revisionName } = params;
|
||||
const repo = await prisma.repo.findFirst({
|
||||
where: {
|
||||
name: repoName,
|
||||
orgId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
return notFound();
|
||||
}
|
||||
if (!repo) {
|
||||
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;
|
||||
try {
|
||||
result = await git.raw([
|
||||
'ls-tree',
|
||||
revisionName,
|
||||
// recursive
|
||||
'-r',
|
||||
// include trees when recursing
|
||||
'-t',
|
||||
// format as output as {type},{path}
|
||||
'--format=%(objecttype),%(path)',
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error('git ls-tree failed.', { error });
|
||||
return unexpectedError('git ls-tree command failed.');
|
||||
}
|
||||
let result: string;
|
||||
try {
|
||||
result = await git.raw([
|
||||
'ls-tree',
|
||||
revisionName,
|
||||
// recursive
|
||||
'-r',
|
||||
// include trees when recursing
|
||||
'-t',
|
||||
// format as output as {type},{path}
|
||||
'--format=%(objecttype),%(path)',
|
||||
]);
|
||||
} 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 flatList = lines.map(line => {
|
||||
const [type, path] = line.split(',');
|
||||
return {
|
||||
type,
|
||||
path,
|
||||
}
|
||||
});
|
||||
|
||||
const tree = buildFileTree(flatList);
|
||||
const lines = result.split('\n').filter(line => line.trim());
|
||||
|
||||
const flatList = lines.map(line => {
|
||||
const [type, path] = line.split(',');
|
||||
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,
|
||||
* at a given revision.
|
||||
*/
|
||||
export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ org }) => {
|
||||
const { repoName, revisionName, path } = params;
|
||||
const repo = await prisma.repo.findFirst({
|
||||
where: {
|
||||
name: repoName,
|
||||
orgId: org.id,
|
||||
},
|
||||
});
|
||||
export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const { repoName, revisionName, path } = params;
|
||||
const repo = await prisma.repo.findFirst({
|
||||
where: {
|
||||
name: repoName,
|
||||
orgId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
return notFound();
|
||||
if (!repo) {
|
||||
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
|
||||
// or null bytes in the path.
|
||||
if (path.includes('..') || path.includes('\0')) {
|
||||
return notFound();
|
||||
export const getFiles = async (params: { repoName: string, revisionName: string }) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
// Normalize the path by...
|
||||
let normalizedPath = path;
|
||||
return files;
|
||||
|
||||
// ... 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 root: FileTreeNode = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { getTree } from "../actions";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { unwrapServiceError } from "@/lib/utils";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { ResizablePanel } from "@/components/ui/resizable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useBrowseState } from "@/app/[domain]/browse/hooks/useBrowseState";
|
||||
|
|
@ -41,17 +40,16 @@ export const FileTreePanel = ({ order }: FileTreePanelProps) => {
|
|||
updateBrowseState,
|
||||
} = useBrowseState();
|
||||
|
||||
const domain = useDomain();
|
||||
const { repoName, revisionName, path } = useBrowseParams();
|
||||
|
||||
const fileTreePanelRef = useRef<ImperativePanelHandle>(null);
|
||||
const { data, isPending, isError } = useQuery({
|
||||
queryKey: ['tree', repoName, revisionName, domain],
|
||||
queryKey: ['tree', repoName, revisionName],
|
||||
queryFn: () => unwrapServiceError(
|
||||
getTree({
|
||||
repoName,
|
||||
revisionName: revisionName ?? 'HEAD',
|
||||
}, domain)
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,60 +5,58 @@ import { fileNotFound, ServiceError, unexpectedError } from "../../lib/serviceEr
|
|||
import { FileSourceRequest, FileSourceResponse } from "./types";
|
||||
import { isServiceError } from "../../lib/utils";
|
||||
import { search } from "./searchApi";
|
||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||
import { OrgRole } from "@sourcebot/db";
|
||||
import { sew } from "@/actions";
|
||||
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||
// @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
|
||||
// by zoekt.
|
||||
|
||||
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
||||
withAuth((userId, _apiKeyHash) =>
|
||||
withOrgMembership(userId, domain, async () => {
|
||||
const escapedFileName = escapeStringRegexp(fileName);
|
||||
const escapedRepository = escapeStringRegexp(repository);
|
||||
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
||||
withOptionalAuthV2(async () => {
|
||||
const escapedFileName = escapeStringRegexp(fileName);
|
||||
const escapedRepository = escapeStringRegexp(repository);
|
||||
|
||||
let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
|
||||
if (branch) {
|
||||
query = query.concat(` branch:${branch}`);
|
||||
}
|
||||
let query = `file:${escapedFileName} repo:^${escapedRepository}$`;
|
||||
if (branch) {
|
||||
query = query.concat(` branch:${branch}`);
|
||||
}
|
||||
|
||||
const searchResponse = await search({
|
||||
query,
|
||||
matches: 1,
|
||||
whole: true,
|
||||
}, domain, apiKey);
|
||||
const searchResponse = await search({
|
||||
query,
|
||||
matches: 1,
|
||||
whole: true,
|
||||
});
|
||||
|
||||
if (isServiceError(searchResponse)) {
|
||||
return searchResponse;
|
||||
}
|
||||
if (isServiceError(searchResponse)) {
|
||||
return searchResponse;
|
||||
}
|
||||
|
||||
const files = searchResponse.files;
|
||||
const files = searchResponse.files;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return fileNotFound(fileName, repository);
|
||||
}
|
||||
if (!files || files.length === 0) {
|
||||
return fileNotFound(fileName, repository);
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const source = file.content ?? '';
|
||||
const language = file.language;
|
||||
const file = files[0];
|
||||
const source = file.content ?? '';
|
||||
const language = file.language;
|
||||
|
||||
const repoInfo = searchResponse.repositoryInfo.find((repo) => repo.id === file.repositoryId);
|
||||
if (!repoInfo) {
|
||||
// This should never happen.
|
||||
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;
|
||||
const repoInfo = searchResponse.repositoryInfo.find((repo) => repo.id === file.repositoryId);
|
||||
if (!repoInfo) {
|
||||
// This should never happen.
|
||||
return unexpectedError("Repository info not found");
|
||||
}
|
||||
|
||||
}, /* 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 { isServiceError } from "../../lib/utils";
|
||||
import { zoektFetch } from "./zoektClient";
|
||||
import { prisma } from "@/prisma";
|
||||
import { ErrorCode } from "../../lib/errorCodes";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { zoektSearchResponseSchema } from "./zoektSchema";
|
||||
import { SearchRequest, SearchResponse, SourceRange } from "./types";
|
||||
import { OrgRole, Repo } from "@sourcebot/db";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||
import { sew } from "@/actions";
|
||||
import { base64Decode } from "@sourcebot/shared";
|
||||
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||
|
||||
// List of supported query prefixes in zoekt.
|
||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
||||
|
|
@ -37,7 +36,7 @@ enum zoektPrefixes {
|
|||
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 newQueryParts = [];
|
||||
|
||||
|
|
@ -128,225 +127,219 @@ const getFileWebUrl = (template: string, branch: string, fileName: string): stri
|
|||
return encodeURI(url + optionalQueryParams);
|
||||
}
|
||||
|
||||
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() =>
|
||||
withAuth((userId, _apiKeyHash) =>
|
||||
withOrgMembership(userId, domain, async ({ org }) => {
|
||||
const transformedQuery = await transformZoektQuery(query, org.id);
|
||||
if (isServiceError(transformedQuery)) {
|
||||
return transformedQuery;
|
||||
export const search = async ({ query, matches, contextLines, whole }: SearchRequest) => sew(() =>
|
||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||
const transformedQuery = await transformZoektQuery(query, org.id, prisma);
|
||||
if (isServiceError(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 = (
|
||||
query.includes(zoektPrefixes.branch) ||
|
||||
query.includes(zoektPrefixes.branchShort)
|
||||
);
|
||||
let header: Record<string, string> = {};
|
||||
header = {
|
||||
"X-Tenant-ID": org.id.toString()
|
||||
};
|
||||
|
||||
// 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 searchResponse = await zoektFetch({
|
||||
path: "/api/search",
|
||||
body,
|
||||
header,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
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
|
||||
if (!searchResponse.ok) {
|
||||
return invalidZoektResponse(searchResponse);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
let header: Record<string, string> = {};
|
||||
header = {
|
||||
"X-Tenant-ID": org.id.toString()
|
||||
};
|
||||
(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 searchResponse = await zoektFetch({
|
||||
path: "/api/search",
|
||||
body,
|
||||
header,
|
||||
method: "POST",
|
||||
});
|
||||
const files = Result.Files?.map((file) => {
|
||||
const fileNameChunks = file.ChunkMatches.filter((chunk) => chunk.FileName);
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
return invalidZoektResponse(searchResponse);
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
const webUrl = (() => {
|
||||
const template: string | undefined = Result.RepoURLs[file.Repository];
|
||||
if (!template) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
fileName: {
|
||||
text: file.FileName,
|
||||
matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].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,
|
||||
}
|
||||
})) : [],
|
||||
},
|
||||
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) ?? [];
|
||||
// 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 can happen if the user doesn't have access to the repository.
|
||||
if (!repo) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
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,
|
||||
fileName: {
|
||||
text: file.FileName,
|
||||
matchRanges: fileNameChunks.length === 1 ? fileNameChunks[0].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,
|
||||
}
|
||||
})) : [],
|
||||
},
|
||||
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;
|
||||
});
|
||||
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 parser.parseAsync(searchBody);
|
||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
||||
);
|
||||
return {
|
||||
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 { 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
|
||||
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()
|
||||
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,
|
||||
role: OrgRole.MEMBER,
|
||||
prisma: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -217,6 +218,7 @@ describe('getAuthContext', () => {
|
|||
},
|
||||
org: MOCK_ORG,
|
||||
role: OrgRole.OWNER,
|
||||
prisma: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -241,6 +243,7 @@ describe('getAuthContext', () => {
|
|||
},
|
||||
org: MOCK_ORG,
|
||||
role: OrgRole.GUEST,
|
||||
prisma: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -256,6 +259,7 @@ describe('getAuthContext', () => {
|
|||
user: undefined,
|
||||
org: MOCK_ORG,
|
||||
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 { ApiKey, Org, OrgRole, User } from "@sourcebot/db";
|
||||
import { ApiKey, Org, OrgRole, PrismaClient, User } from "@sourcebot/db";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "./auth";
|
||||
import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError";
|
||||
|
|
@ -14,12 +14,14 @@ interface OptionalAuthContext {
|
|||
user?: User;
|
||||
org: Org;
|
||||
role: OrgRole;
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
|
||||
interface RequiredAuthContext {
|
||||
user: User;
|
||||
org: Org;
|
||||
role: Omit<OrgRole, 'GUEST'>;
|
||||
role: Exclude<OrgRole, 'GUEST'>;
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const { user, org, role } = authContext;
|
||||
const { user, org, role, prisma } = authContext;
|
||||
|
||||
if (!user || role === OrgRole.GUEST) {
|
||||
return notAuthenticated();
|
||||
}
|
||||
|
||||
return fn({ user, org, role });
|
||||
return fn({ user, org, role, prisma });
|
||||
};
|
||||
|
||||
export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) => Promise<T>) => {
|
||||
|
|
@ -44,7 +46,7 @@ export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) =>
|
|||
return authContext;
|
||||
}
|
||||
|
||||
const { user, org, role } = authContext;
|
||||
const { user, org, role, prisma } = authContext;
|
||||
|
||||
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
|
||||
const orgMetadata = getOrgMetadata(org);
|
||||
|
|
@ -61,13 +63,13 @@ export const withOptionalAuthV2 = async <T>(fn: (params: OptionalAuthContext) =>
|
|||
return notAuthenticated();
|
||||
}
|
||||
|
||||
return fn({ user, org, role });
|
||||
return fn({ user, org, role, prisma });
|
||||
};
|
||||
|
||||
export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceError> => {
|
||||
const user = await getAuthenticatedUser();
|
||||
|
||||
const org = await prisma.org.findUnique({
|
||||
const org = await __unsafePrisma.org.findUnique({
|
||||
where: {
|
||||
id: SINGLE_TENANT_ORG_ID,
|
||||
}
|
||||
|
|
@ -77,7 +79,7 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
|
|||
return notFound("Organization not found");
|
||||
}
|
||||
|
||||
const membership = user ? await prisma.userToOrg.findUnique({
|
||||
const membership = user ? await __unsafePrisma.userToOrg.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId: org.id,
|
||||
|
|
@ -86,10 +88,13 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
|
|||
},
|
||||
}) : null;
|
||||
|
||||
const prisma = __unsafePrisma.$extends(userScopedPrismaClientExtension(user?.id)) as PrismaClient;
|
||||
|
||||
return {
|
||||
user: user ?? undefined,
|
||||
org,
|
||||
role: membership?.role ?? OrgRole.GUEST,
|
||||
prisma,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -98,7 +103,7 @@ export const getAuthenticatedUser = async () => {
|
|||
const session = await auth();
|
||||
if (session) {
|
||||
const userId = session.user.id;
|
||||
const user = await prisma.user.findUnique({
|
||||
const user = await __unsafePrisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
}
|
||||
|
|
@ -116,7 +121,7 @@ export const getAuthenticatedUser = async () => {
|
|||
}
|
||||
|
||||
// Attempt to find the user associated with this api key.
|
||||
const user = await prisma.user.findUnique({
|
||||
const user = await __unsafePrisma.user.findUnique({
|
||||
where: {
|
||||
id: apiKey.createdById,
|
||||
},
|
||||
|
|
@ -127,7 +132,7 @@ export const getAuthenticatedUser = async () => {
|
|||
}
|
||||
|
||||
// Update the last used at timestamp for this api key.
|
||||
await prisma.apiKey.update({
|
||||
await __unsafePrisma.apiKey.update({
|
||||
where: {
|
||||
hash: apiKey.hash,
|
||||
},
|
||||
|
|
@ -152,7 +157,7 @@ const getVerifiedApiObject = async (apiKeyString: string): Promise<ApiKey | unde
|
|||
}
|
||||
|
||||
const hash = hashSecret(parts[1]);
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
const apiKey = await __unsafePrisma.apiKey.findUnique({
|
||||
where: {
|
||||
hash,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,6 +67,16 @@
|
|||
"deprecated": true,
|
||||
"description": "This setting is deprecated. Please use the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable instead.",
|
||||
"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
|
||||
|
|
|
|||
Loading…
Reference in a new issue