mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
feat(worker,web): Improved connection management (#579)
This commit is contained in:
parent
3ff88da33b
commit
a167accd7e
63 changed files with 2451 additions and 1215 deletions
|
|
@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Changed navbar indexing indicator to only report progress for first time indexing jobs. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
- Changed navbar indexing indicator to only report progress for first time indexing jobs. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
- Improved repo indexing job stability and robustness. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
- Improved repo indexing job stability and robustness. [#563](https://github.com/sourcebot-dev/sourcebot/pull/563)
|
||||||
- Improved repositories table. [#572](https://github.com/sourcebot-dev/sourcebot/pull/572)
|
- Improved repositories table. [#572](https://github.com/sourcebot-dev/sourcebot/pull/572)
|
||||||
|
- Improved connections table. [#579](https://github.com/sourcebot-dev/sourcebot/pull/579)
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
- Removed spam "login page loaded" log. [#552](https://github.com/sourcebot-dev/sourcebot/pull/552)
|
- Removed spam "login page loaded" log. [#552](https://github.com/sourcebot-dev/sourcebot/pull/552)
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@
|
||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"title": "AppConfig",
|
"title": "AppConfig",
|
||||||
"oneOf": [
|
"definitions": {
|
||||||
{
|
"GitHubAppConfig": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "GithubAppConfig",
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "githubApp",
|
||||||
|
|
@ -61,19 +59,70 @@
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
"type",
|
||||||
"id"
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"type": "object",
|
||||||
"privateKey"
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "githubApp",
|
||||||
|
"description": "GitHub App Configuration"
|
||||||
|
},
|
||||||
|
"deploymentHostname": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "hostname",
|
||||||
|
"default": "github.com",
|
||||||
|
"description": "The hostname of the GitHub App deployment.",
|
||||||
|
"examples": [
|
||||||
|
"github.com",
|
||||||
|
"github.example.com"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the GitHub App."
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"description": "The private key of the GitHub App.",
|
||||||
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
|
}
|
||||||
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"privateKeyPath"
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"env": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"env"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4280,11 +4280,9 @@
|
||||||
"items": {
|
"items": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"title": "AppConfig",
|
"title": "AppConfig",
|
||||||
"oneOf": [
|
"definitions": {
|
||||||
{
|
"GitHubAppConfig": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "GithubAppConfig",
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "githubApp",
|
||||||
|
|
@ -4338,19 +4336,70 @@
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
"type",
|
||||||
"id"
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"type": "object",
|
||||||
"privateKey"
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "githubApp",
|
||||||
|
"description": "GitHub App Configuration"
|
||||||
|
},
|
||||||
|
"deploymentHostname": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "hostname",
|
||||||
|
"default": "github.com",
|
||||||
|
"description": "The hostname of the GitHub App deployment.",
|
||||||
|
"examples": [
|
||||||
|
"github.com",
|
||||||
|
"github.example.com"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the GitHub App."
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"type": "object",
|
||||||
"privateKeyPath"
|
"properties": {
|
||||||
]
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"env": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"env"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The private key of the GitHub App."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
"azure-devops-node-api": "^15.1.1",
|
"azure-devops-node-api": "^15.1.1",
|
||||||
"bullmq": "^5.34.10",
|
"bullmq": "^5.34.10",
|
||||||
|
"chokidar": "^4.0.3",
|
||||||
"cross-fetch": "^4.0.0",
|
"cross-fetch": "^4.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type";
|
import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
import { measure, fetchWithRetry } from "./utils.js";
|
||||||
import micromatch from "micromatch";
|
import micromatch from "micromatch";
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { BackendException, BackendError } from "@sourcebot/error";
|
import { BackendException, BackendError } from "@sourcebot/error";
|
||||||
|
|
@ -8,6 +8,7 @@ import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import * as azdev from "azure-devops-node-api";
|
import * as azdev from "azure-devops-node-api";
|
||||||
import { GitRepository } from "azure-devops-node-api/interfaces/GitInterfaces.js";
|
import { GitRepository } from "azure-devops-node-api/interfaces/GitInterfaces.js";
|
||||||
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
|
|
||||||
const logger = createLogger('azuredevops');
|
const logger = createLogger('azuredevops');
|
||||||
const AZUREDEVOPS_CLOUD_HOSTNAME = "dev.azure.com";
|
const AZUREDEVOPS_CLOUD_HOSTNAME = "dev.azure.com";
|
||||||
|
|
@ -34,7 +35,7 @@ export const getAzureDevOpsReposFromConfig = async (
|
||||||
const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`;
|
const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`;
|
||||||
|
|
||||||
const token = config.token ?
|
const token = config.token ?
|
||||||
await getTokenFromConfig(config.token, orgId, db, logger) :
|
await getTokenFromConfig(config.token, orgId, db) :
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
@ -47,47 +48,39 @@ export const getAzureDevOpsReposFromConfig = async (
|
||||||
|
|
||||||
const useTfsPath = config.useTfsPath || false;
|
const useTfsPath = config.useTfsPath || false;
|
||||||
let allRepos: GitRepository[] = [];
|
let allRepos: GitRepository[] = [];
|
||||||
let notFound: {
|
let allWarnings: string[] = [];
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
} = {
|
|
||||||
users: [],
|
|
||||||
orgs: [],
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.orgs) {
|
if (config.orgs) {
|
||||||
const { validRepos, notFoundOrgs } = await getReposForOrganizations(
|
const { repos, warnings } = await getReposForOrganizations(
|
||||||
config.orgs,
|
config.orgs,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token,
|
token,
|
||||||
useTfsPath
|
useTfsPath
|
||||||
);
|
);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.orgs = notFoundOrgs;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.projects) {
|
if (config.projects) {
|
||||||
const { validRepos, notFoundProjects } = await getReposForProjects(
|
const { repos, warnings } = await getReposForProjects(
|
||||||
config.projects,
|
config.projects,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token,
|
token,
|
||||||
useTfsPath
|
useTfsPath
|
||||||
);
|
);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.repos = notFound.repos.concat(notFoundProjects);
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.repos) {
|
if (config.repos) {
|
||||||
const { validRepos, notFoundRepos } = await getRepos(
|
const { repos, warnings } = await getRepos(
|
||||||
config.repos,
|
config.repos,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
token,
|
token,
|
||||||
useTfsPath
|
useTfsPath
|
||||||
);
|
);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.repos = notFound.repos.concat(notFoundRepos);
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
let repos = allRepos
|
let repos = allRepos
|
||||||
|
|
@ -103,8 +96,8 @@ export const getAzureDevOpsReposFromConfig = async (
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos: repos,
|
repos,
|
||||||
notFound,
|
warnings: allWarnings,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -221,10 +214,11 @@ async function getReposForOrganizations(
|
||||||
|
|
||||||
// Check if it's a 404-like error (organization not found)
|
// Check if it's a 404-like error (organization not found)
|
||||||
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
|
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
|
||||||
logger.error(`Organization ${org} not found or no access`);
|
const warning = `Organization ${org} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: org
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -232,11 +226,11 @@ async function getReposForOrganizations(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults<GitRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<GitRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundOrgs,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,10 +268,11 @@ async function getReposForProjects(
|
||||||
logger.error(`Failed to fetch repositories for project ${project}.`, error);
|
logger.error(`Failed to fetch repositories for project ${project}.`, error);
|
||||||
|
|
||||||
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
|
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
|
||||||
logger.error(`Project ${project} not found or no access`);
|
const warning = `Project ${project} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: project
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -285,11 +280,11 @@ async function getReposForProjects(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults<GitRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<GitRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundProjects,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,10 +323,11 @@ async function getRepos(
|
||||||
logger.error(`Failed to fetch repository ${repo}.`, error);
|
logger.error(`Failed to fetch repository ${repo}.`, error);
|
||||||
|
|
||||||
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
|
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
|
||||||
logger.error(`Repository ${repo} not found or no access`);
|
const warning = `Repository ${repo} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: repo
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -339,10 +335,10 @@ async function getRepos(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<GitRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<GitRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundRepos,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"
|
||||||
import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch";
|
import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
import { measure, fetchWithRetry } from "./utils.js";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import {
|
import {
|
||||||
SchemaRepository as CloudRepository,
|
SchemaRepository as CloudRepository,
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
|
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
|
||||||
import { processPromiseResults } from "./connectionUtils.js";
|
import { processPromiseResults } from "./connectionUtils.js";
|
||||||
import { throwIfAnyFailed } from "./connectionUtils.js";
|
import { throwIfAnyFailed } from "./connectionUtils.js";
|
||||||
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
|
|
||||||
const logger = createLogger('bitbucket');
|
const logger = createLogger('bitbucket');
|
||||||
const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org';
|
const BITBUCKET_CLOUD_GIT = 'https://bitbucket.org';
|
||||||
|
|
@ -27,9 +28,9 @@ interface BitbucketClient {
|
||||||
apiClient: any;
|
apiClient: any;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
gitUrl: string;
|
gitUrl: string;
|
||||||
getReposForWorkspace: (client: BitbucketClient, workspaces: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundWorkspaces: string[]}>;
|
getReposForWorkspace: (client: BitbucketClient, workspaces: string[]) => Promise<{repos: BitbucketRepository[], warnings: string[]}>;
|
||||||
getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundProjects: string[]}>;
|
getReposForProjects: (client: BitbucketClient, projects: string[]) => Promise<{repos: BitbucketRepository[], warnings: string[]}>;
|
||||||
getRepos: (client: BitbucketClient, repos: string[]) => Promise<{validRepos: BitbucketRepository[], notFoundRepos: string[]}>;
|
getRepos: (client: BitbucketClient, repos: string[]) => Promise<{repos: BitbucketRepository[], warnings: string[]}>;
|
||||||
shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean;
|
shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ type ServerPaginatedResponse<T> = {
|
||||||
|
|
||||||
export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => {
|
export const getBitbucketReposFromConfig = async (config: BitbucketConnectionConfig, orgId: number, db: PrismaClient) => {
|
||||||
const token = config.token ?
|
const token = config.token ?
|
||||||
await getTokenFromConfig(config.token, orgId, db, logger) :
|
await getTokenFromConfig(config.token, orgId, db) :
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
if (config.deploymentType === 'server' && !config.url) {
|
if (config.deploymentType === 'server' && !config.url) {
|
||||||
|
|
@ -71,32 +72,24 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon
|
||||||
cloudClient(config.user, token);
|
cloudClient(config.user, token);
|
||||||
|
|
||||||
let allRepos: BitbucketRepository[] = [];
|
let allRepos: BitbucketRepository[] = [];
|
||||||
let notFound: {
|
let allWarnings: string[] = [];
|
||||||
orgs: string[],
|
|
||||||
users: string[],
|
|
||||||
repos: string[],
|
|
||||||
} = {
|
|
||||||
orgs: [],
|
|
||||||
users: [],
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.workspaces) {
|
if (config.workspaces) {
|
||||||
const { validRepos, notFoundWorkspaces } = await client.getReposForWorkspace(client, config.workspaces);
|
const { repos, warnings } = await client.getReposForWorkspace(client, config.workspaces);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.orgs = notFoundWorkspaces;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.projects) {
|
if (config.projects) {
|
||||||
const { validRepos, notFoundProjects } = await client.getReposForProjects(client, config.projects);
|
const { repos, warnings } = await client.getReposForProjects(client, config.projects);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.orgs = notFoundProjects;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.repos) {
|
if (config.repos) {
|
||||||
const { validRepos, notFoundRepos } = await client.getRepos(client, config.repos);
|
const { repos, warnings } = await client.getRepos(client, config.repos);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.repos = notFoundRepos;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredRepos = allRepos.filter((repo) => {
|
const filteredRepos = allRepos.filter((repo) => {
|
||||||
|
|
@ -104,8 +97,8 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos: filteredRepos,
|
repos: filteredRepos,
|
||||||
notFound,
|
warnings: allWarnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,7 +179,7 @@ function parseUrl(url: string): { path: string; query: Record<string, string>; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: CloudRepository[], notFoundWorkspaces: string[]}> {
|
async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{repos: CloudRepository[], warnings: string[]}> {
|
||||||
const results = await Promise.allSettled(workspaces.map(async (workspace) => {
|
const results = await Promise.allSettled(workspaces.map(async (workspace) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching all repos for workspace ${workspace}...`);
|
logger.debug(`Fetching all repos for workspace ${workspace}...`);
|
||||||
|
|
@ -221,10 +214,11 @@ async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: st
|
||||||
|
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
if (status == 404) {
|
if (status == 404) {
|
||||||
logger.error(`Workspace ${workspace} not found or invalid access`)
|
const warning = `Workspace ${workspace} not found or invalid access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: workspace
|
warning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -232,21 +226,22 @@ async function cloudGetReposForWorkspace(client: BitbucketClient, workspaces: st
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundWorkspaces } = processPromiseResults(results);
|
const { validItems: repos, warnings } = processPromiseResults(results);
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundWorkspaces,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cloudGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: CloudRepository[], notFoundProjects: string[]}> {
|
async function cloudGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{repos: CloudRepository[], warnings: string[]}> {
|
||||||
const results = await Promise.allSettled(projects.map(async (project) => {
|
const results = await Promise.allSettled(projects.map(async (project) => {
|
||||||
const [workspace, project_name] = project.split('/');
|
const [workspace, project_name] = project.split('/');
|
||||||
if (!workspace || !project_name) {
|
if (!workspace || !project_name) {
|
||||||
logger.error(`Invalid project ${project}`);
|
const warning = `Invalid project ${project}`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: project
|
warning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -282,10 +277,11 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin
|
||||||
|
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
if (status == 404) {
|
if (status == 404) {
|
||||||
logger.error(`Project ${project_name} not found in ${workspace} or invalid access`)
|
const warning = `Project ${project_name} not found in ${workspace} or invalid access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: project
|
warning
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -293,21 +289,22 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results);
|
const { validItems: repos, warnings } = processPromiseResults(results);
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundProjects
|
warnings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cloudGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: CloudRepository[], notFoundRepos: string[]}> {
|
async function cloudGetRepos(client: BitbucketClient, repoList: string[]): Promise<{repos: CloudRepository[], warnings: string[]}> {
|
||||||
const results = await Promise.allSettled(repos.map(async (repo) => {
|
const results = await Promise.allSettled(repoList.map(async (repo) => {
|
||||||
const [workspace, repo_slug] = repo.split('/');
|
const [workspace, repo_slug] = repo.split('/');
|
||||||
if (!workspace || !repo_slug) {
|
if (!workspace || !repo_slug) {
|
||||||
logger.error(`Invalid repo ${repo}`);
|
const warning = `Invalid repo ${repo}`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: repo
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,10 +326,11 @@ async function cloudGetRepos(client: BitbucketClient, repos: string[]): Promise<
|
||||||
|
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
logger.error(`Repo ${repo} not found in ${workspace} or invalid access`);
|
const warning = `Repo ${repo} not found in ${workspace} or invalid access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: repo
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -340,10 +338,10 @@ async function cloudGetRepos(client: BitbucketClient, repos: string[]): Promise<
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
|
const { validItems: repos, warnings } = processPromiseResults(results);
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundRepos
|
warnings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,15 +432,16 @@ const getPaginatedServer = async <T>(
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serverGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{validRepos: ServerRepository[], notFoundWorkspaces: string[]}> {
|
async function serverGetReposForWorkspace(client: BitbucketClient, workspaces: string[]): Promise<{repos: ServerRepository[], warnings: string[]}> {
|
||||||
|
const warnings = workspaces.map(workspace => `Workspaces are not supported in Bitbucket Server: ${workspace}`);
|
||||||
logger.debug('Workspaces are not supported in Bitbucket Server');
|
logger.debug('Workspaces are not supported in Bitbucket Server');
|
||||||
return {
|
return {
|
||||||
validRepos: [],
|
repos: [],
|
||||||
notFoundWorkspaces: workspaces
|
warnings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serverGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{validRepos: ServerRepository[], notFoundProjects: string[]}> {
|
async function serverGetReposForProjects(client: BitbucketClient, projects: string[]): Promise<{repos: ServerRepository[], warnings: string[]}> {
|
||||||
const results = await Promise.allSettled(projects.map(async (project) => {
|
const results = await Promise.allSettled(projects.map(async (project) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching all repos for project ${project}...`);
|
logger.debug(`Fetching all repos for project ${project}...`);
|
||||||
|
|
@ -477,10 +476,11 @@ async function serverGetReposForProjects(client: BitbucketClient, projects: stri
|
||||||
|
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
if (status == 404) {
|
if (status == 404) {
|
||||||
logger.error(`Project ${project} not found or invalid access`);
|
const warning = `Project ${project} not found or invalid access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: project
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -488,21 +488,22 @@ async function serverGetReposForProjects(client: BitbucketClient, projects: stri
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results);
|
const { validItems: repos, warnings } = processPromiseResults(results);
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundProjects
|
warnings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise<{validRepos: ServerRepository[], notFoundRepos: string[]}> {
|
async function serverGetRepos(client: BitbucketClient, repoList: string[]): Promise<{repos: ServerRepository[], warnings: string[]}> {
|
||||||
const results = await Promise.allSettled(repos.map(async (repo) => {
|
const results = await Promise.allSettled(repoList.map(async (repo) => {
|
||||||
const [project, repo_slug] = repo.split('/');
|
const [project, repo_slug] = repo.split('/');
|
||||||
if (!project || !repo_slug) {
|
if (!project || !repo_slug) {
|
||||||
logger.error(`Invalid repo ${repo}`);
|
const warning = `Invalid repo ${repo}`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: repo
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,10 +525,11 @@ async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise
|
||||||
|
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
logger.error(`Repo ${repo} not found in project ${project} or invalid access`);
|
const warning = `Repo ${repo} not found in project ${project} or invalid access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: repo
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -535,10 +537,10 @@ async function serverGetRepos(client: BitbucketClient, repos: string[]): Promise
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
|
const { validItems: repos, warnings } = processPromiseResults(results);
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundRepos
|
warnings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
126
packages/backend/src/configManager.ts
Normal file
126
packages/backend/src/configManager.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { Prisma, PrismaClient } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
|
import { loadConfig } from "@sourcebot/shared";
|
||||||
|
import chokidar, { FSWatcher } from 'chokidar';
|
||||||
|
import { ConnectionManager } from "./connectionManager.js";
|
||||||
|
import { SINGLE_TENANT_ORG_ID } from "./constants.js";
|
||||||
|
import { syncSearchContexts } from "./ee/syncSearchContexts.js";
|
||||||
|
|
||||||
|
const logger = createLogger('config-manager');
|
||||||
|
|
||||||
|
export class ConfigManager {
|
||||||
|
private watcher: FSWatcher;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private db: PrismaClient,
|
||||||
|
private connectionManager: ConnectionManager,
|
||||||
|
configPath: string,
|
||||||
|
) {
|
||||||
|
this.watcher = chokidar.watch(configPath, {
|
||||||
|
ignoreInitial: true, // Don't fire events for existing files
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 100, // File size stable for 100ms
|
||||||
|
pollInterval: 100 // Check every 100ms
|
||||||
|
},
|
||||||
|
atomic: true // Handle atomic writes (temp file + rename)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.watcher.on('change', async () => {
|
||||||
|
logger.info(`Config file ${configPath} changed. Syncing config.`);
|
||||||
|
try {
|
||||||
|
await this.syncConfig(configPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to sync config: ${error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncConfig(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncConfig = async (configPath: string) => {
|
||||||
|
const config = await loadConfig(configPath);
|
||||||
|
|
||||||
|
await this.syncConnections(config.connections);
|
||||||
|
await syncSearchContexts({
|
||||||
|
contexts: config.contexts,
|
||||||
|
orgId: SINGLE_TENANT_ORG_ID,
|
||||||
|
db: this.db,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncConnections = async (connections?: { [key: string]: ConnectionConfig }) => {
|
||||||
|
if (connections) {
|
||||||
|
for (const [key, newConnectionConfig] of Object.entries(connections)) {
|
||||||
|
const existingConnection = await this.db.connection.findUnique({
|
||||||
|
where: {
|
||||||
|
name_orgId: {
|
||||||
|
name: key,
|
||||||
|
orgId: SINGLE_TENANT_ORG_ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const existingConnectionConfig = existingConnection ? existingConnection.config as unknown as ConnectionConfig : undefined;
|
||||||
|
const connectionNeedsSyncing =
|
||||||
|
!existingConnection ||
|
||||||
|
(JSON.stringify(existingConnectionConfig) !== JSON.stringify(newConnectionConfig));
|
||||||
|
|
||||||
|
// Either update the existing connection or create a new one.
|
||||||
|
const connection = existingConnection ?
|
||||||
|
await this.db.connection.update({
|
||||||
|
where: {
|
||||||
|
id: existingConnection.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
config: newConnectionConfig as unknown as Prisma.InputJsonValue,
|
||||||
|
isDeclarative: true,
|
||||||
|
}
|
||||||
|
}) :
|
||||||
|
await this.db.connection.create({
|
||||||
|
data: {
|
||||||
|
name: key,
|
||||||
|
config: newConnectionConfig as unknown as Prisma.InputJsonValue,
|
||||||
|
connectionType: newConnectionConfig.type,
|
||||||
|
isDeclarative: true,
|
||||||
|
org: {
|
||||||
|
connect: {
|
||||||
|
id: SINGLE_TENANT_ORG_ID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (connectionNeedsSyncing) {
|
||||||
|
const [jobId] = await this.connectionManager.createJobs([connection]);
|
||||||
|
logger.info(`Change detected for connection '${key}' (id: ${connection.id}). Created sync job ${jobId}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete any connections that are no longer in the config.
|
||||||
|
const deletedConnections = await this.db.connection.findMany({
|
||||||
|
where: {
|
||||||
|
isDeclarative: true,
|
||||||
|
name: {
|
||||||
|
notIn: Object.keys(connections ?? {}),
|
||||||
|
},
|
||||||
|
orgId: SINGLE_TENANT_ORG_ID,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const connection of deletedConnections) {
|
||||||
|
logger.info(`Deleting connection with name '${connection.name}'. Connection ID: ${connection.id}`);
|
||||||
|
await this.db.connection.delete({
|
||||||
|
where: {
|
||||||
|
id: connection.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose = async () => {
|
||||||
|
await this.watcher.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,162 +1,182 @@
|
||||||
import { Connection, ConnectionSyncStatus, PrismaClient, Prisma } from "@sourcebot/db";
|
|
||||||
import { Job, Queue, Worker } from 'bullmq';
|
|
||||||
import { Settings } from "./types.js";
|
|
||||||
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
import { Redis } from 'ioredis';
|
|
||||||
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig, compileBitbucketConfig, compileAzureDevOpsConfig, compileGenericGitHostConfig } from "./repoCompileUtils.js";
|
|
||||||
import { BackendError, BackendException } from "@sourcebot/error";
|
|
||||||
import { captureEvent } from "./posthog.js";
|
|
||||||
import { env } from "./env.js";
|
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { loadConfig, syncSearchContexts } from "@sourcebot/shared";
|
import { Connection, ConnectionSyncJobStatus, PrismaClient } from "@sourcebot/db";
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
|
import { loadConfig } from "@sourcebot/shared";
|
||||||
|
import { Job, Queue, ReservedJob, Worker } from "groupmq";
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { env } from "./env.js";
|
||||||
|
import { compileAzureDevOpsConfig, compileBitbucketConfig, compileGenericGitHostConfig, compileGerritConfig, compileGiteaConfig, compileGithubConfig, compileGitlabConfig } from "./repoCompileUtils.js";
|
||||||
|
import { Settings } from "./types.js";
|
||||||
|
import { groupmqLifecycleExceptionWrapper } from "./utils.js";
|
||||||
|
import { syncSearchContexts } from "./ee/syncSearchContexts.js";
|
||||||
|
import { captureEvent } from "./posthog.js";
|
||||||
|
import { PromClient } from "./promClient.js";
|
||||||
|
|
||||||
const QUEUE_NAME = 'connectionSyncQueue';
|
const LOG_TAG = 'connection-manager';
|
||||||
|
const logger = createLogger(LOG_TAG);
|
||||||
|
const createJobLogger = (jobId: string) => createLogger(`${LOG_TAG}:job:${jobId}`);
|
||||||
|
|
||||||
type JobPayload = {
|
type JobPayload = {
|
||||||
|
jobId: string,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
connectionName: string,
|
connectionName: string,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
config: ConnectionConfig,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type JobResult = {
|
type JobResult = {
|
||||||
repoCount: number,
|
repoCount: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JOB_TIMEOUT_MS = 1000 * 60 * 60 * 2; // 2 hour timeout
|
||||||
|
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
private worker: Worker;
|
private worker: Worker;
|
||||||
private queue: Queue<JobPayload>;
|
private queue: Queue<JobPayload>;
|
||||||
private logger = createLogger('connection-manager');
|
|
||||||
private interval?: NodeJS.Timeout;
|
private interval?: NodeJS.Timeout;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private db: PrismaClient,
|
private db: PrismaClient,
|
||||||
private settings: Settings,
|
private settings: Settings,
|
||||||
redis: Redis,
|
redis: Redis,
|
||||||
|
private promClient: PromClient,
|
||||||
) {
|
) {
|
||||||
this.queue = new Queue<JobPayload>(QUEUE_NAME, {
|
this.queue = new Queue<JobPayload>({
|
||||||
connection: redis,
|
redis,
|
||||||
|
namespace: 'connection-sync-queue',
|
||||||
|
jobTimeoutMs: JOB_TIMEOUT_MS,
|
||||||
|
maxAttempts: 3,
|
||||||
|
logger: env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true',
|
||||||
});
|
});
|
||||||
this.worker = new Worker(QUEUE_NAME, this.runSyncJob.bind(this), {
|
|
||||||
connection: redis,
|
this.worker = new Worker<JobPayload>({
|
||||||
|
queue: this.queue,
|
||||||
|
maxStalledCount: 1,
|
||||||
|
handler: this.runJob.bind(this),
|
||||||
concurrency: this.settings.maxConnectionSyncJobConcurrency,
|
concurrency: this.settings.maxConnectionSyncJobConcurrency,
|
||||||
});
|
...(env.DEBUG_ENABLE_GROUPMQ_LOGGING === 'true' ? {
|
||||||
this.worker.on('completed', this.onSyncJobCompleted.bind(this));
|
logger: true,
|
||||||
this.worker.on('failed', this.onSyncJobFailed.bind(this));
|
} : {}),
|
||||||
}
|
|
||||||
|
|
||||||
public async scheduleConnectionSync(connection: Connection) {
|
|
||||||
await this.db.$transaction(async (tx) => {
|
|
||||||
await tx.connection.update({
|
|
||||||
where: { id: connection.id },
|
|
||||||
data: { syncStatus: ConnectionSyncStatus.IN_SYNC_QUEUE },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const connectionConfig = connection.config as unknown as ConnectionConfig;
|
this.worker.on('completed', this.onJobCompleted.bind(this));
|
||||||
|
this.worker.on('failed', this.onJobFailed.bind(this));
|
||||||
await this.queue.add('connectionSyncJob', {
|
this.worker.on('stalled', this.onJobStalled.bind(this));
|
||||||
connectionId: connection.id,
|
this.worker.on('error', this.onWorkerError.bind(this));
|
||||||
connectionName: connection.name,
|
|
||||||
orgId: connection.orgId,
|
|
||||||
config: connectionConfig,
|
|
||||||
}, {
|
|
||||||
removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
|
|
||||||
removeOnFail: env.REDIS_REMOVE_ON_FAIL,
|
|
||||||
});
|
|
||||||
this.logger.info(`Added job to queue for connection ${connection.name} (id: ${connection.id})`);
|
|
||||||
}).catch((err: unknown) => {
|
|
||||||
this.logger.error(`Failed to add job to queue for connection ${connection.name} (id: ${connection.id}): ${err}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public startScheduler() {
|
public startScheduler() {
|
||||||
this.logger.debug('Starting scheduler');
|
logger.debug('Starting scheduler');
|
||||||
this.interval = setInterval(async () => {
|
this.interval = setInterval(async () => {
|
||||||
const thresholdDate = new Date(Date.now() - this.settings.resyncConnectionIntervalMs);
|
const thresholdDate = new Date(Date.now() - this.settings.resyncConnectionIntervalMs);
|
||||||
|
const timeoutDate = new Date(Date.now() - JOB_TIMEOUT_MS);
|
||||||
|
|
||||||
const connections = await this.db.connection.findMany({
|
const connections = await this.db.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
|
||||||
// When the connection needs to be synced, we want to sync it immediately.
|
|
||||||
{
|
|
||||||
syncStatus: ConnectionSyncStatus.SYNC_NEEDED,
|
|
||||||
},
|
|
||||||
// When the connection has already been synced, we only want to re-sync if the re-sync interval has elapsed
|
|
||||||
// (or if the date isn't set for some reason).
|
|
||||||
{
|
|
||||||
AND: [
|
AND: [
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{ syncStatus: ConnectionSyncStatus.SYNCED },
|
|
||||||
{ syncStatus: ConnectionSyncStatus.SYNCED_WITH_WARNINGS },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ syncedAt: null },
|
{ syncedAt: null },
|
||||||
{ syncedAt: { lt: thresholdDate } },
|
{ syncedAt: { lt: thresholdDate } },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
NOT: {
|
||||||
|
syncJobs: {
|
||||||
|
some: {
|
||||||
|
OR: [
|
||||||
|
// Don't schedule if there are active jobs that were created within the threshold date.
|
||||||
|
// This handles the case where a job is stuck in a pending state and will never be scheduled.
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ status: { in: [ConnectionSyncJobStatus.PENDING, ConnectionSyncJobStatus.IN_PROGRESS] } },
|
||||||
|
{ createdAt: { gt: timeoutDate } },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Don't schedule if there are recent failed jobs (within the threshold date).
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ status: ConnectionSyncJobStatus.FAILED },
|
||||||
|
{ completedAt: { gt: thresholdDate } },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
for (const connection of connections) {
|
|
||||||
await this.scheduleConnectionSync(connection);
|
if (connections.length > 0) {
|
||||||
|
await this.createJobs(connections);
|
||||||
}
|
}
|
||||||
}, this.settings.resyncConnectionPollingIntervalMs);
|
}, this.settings.resyncConnectionPollingIntervalMs);
|
||||||
|
|
||||||
|
this.worker.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runSyncJob(job: Job<JobPayload>): Promise<JobResult> {
|
|
||||||
const { config, orgId, connectionName } = job.data;
|
public async createJobs(connections: Connection[]) {
|
||||||
|
const jobs = await this.db.connectionSyncJob.createManyAndReturn({
|
||||||
|
data: connections.map(connection => ({
|
||||||
|
connectionId: connection.id,
|
||||||
|
})),
|
||||||
|
include: {
|
||||||
|
connection: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
await this.queue.add({
|
||||||
|
groupId: `connection:${job.connectionId}`,
|
||||||
|
data: {
|
||||||
|
jobId: job.id,
|
||||||
|
connectionId: job.connectionId,
|
||||||
|
connectionName: job.connection.name,
|
||||||
|
orgId: job.connection.orgId,
|
||||||
|
},
|
||||||
|
jobId: job.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.promClient.pendingConnectionSyncJobs.inc({ connection: job.connection.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobs.map(job => job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runJob(job: ReservedJob<JobPayload>): Promise<JobResult> {
|
||||||
|
const { jobId, connectionName } = job.data;
|
||||||
|
const logger = createJobLogger(jobId);
|
||||||
|
logger.info(`Running connection sync job ${jobId} for connection ${connectionName} (id: ${job.data.connectionId}) (attempt ${job.attempts + 1} / ${job.maxAttempts})`);
|
||||||
|
|
||||||
|
this.promClient.pendingConnectionSyncJobs.dec({ connection: connectionName });
|
||||||
|
this.promClient.activeConnectionSyncJobs.inc({ connection: connectionName });
|
||||||
|
|
||||||
// @note: We aren't actually doing anything with this atm.
|
// @note: We aren't actually doing anything with this atm.
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
const connection = await this.db.connection.findUnique({
|
const { connection: { config: rawConnectionConfig, orgId } } = await this.db.connectionSyncJob.update({
|
||||||
where: {
|
where: {
|
||||||
id: job.data.connectionId,
|
id: jobId,
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!connection) {
|
|
||||||
const e = new BackendException(BackendError.CONNECTION_SYNC_CONNECTION_NOT_FOUND, {
|
|
||||||
message: `Connection ${job.data.connectionId} not found`,
|
|
||||||
});
|
|
||||||
Sentry.captureException(e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the syncStatusMetadata to an empty object at the start of the sync job
|
|
||||||
await this.db.connection.update({
|
|
||||||
where: {
|
|
||||||
id: job.data.connectionId,
|
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
syncStatus: ConnectionSyncStatus.SYNCING,
|
status: ConnectionSyncJobStatus.IN_PROGRESS,
|
||||||
syncStatusMetadata: {}
|
},
|
||||||
|
select: {
|
||||||
|
connection: {
|
||||||
|
select: {
|
||||||
|
config: true,
|
||||||
|
orgId: true,
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
let result: {
|
|
||||||
repoData: RepoData[],
|
|
||||||
notFound: {
|
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
}
|
}
|
||||||
} = {
|
},
|
||||||
repoData: [],
|
});
|
||||||
notFound: {
|
|
||||||
users: [],
|
|
||||||
orgs: [],
|
|
||||||
repos: [],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
const config = rawConnectionConfig as unknown as ConnectionConfig;
|
||||||
result = await (async () => {
|
|
||||||
|
const result = await (async () => {
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case 'github': {
|
case 'github': {
|
||||||
return await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController);
|
return await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController);
|
||||||
|
|
@ -174,39 +194,26 @@ export class ConnectionManager {
|
||||||
return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db);
|
return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db);
|
||||||
}
|
}
|
||||||
case 'azuredevops': {
|
case 'azuredevops': {
|
||||||
return await compileAzureDevOpsConfig(config, job.data.connectionId, orgId, this.db, abortController);
|
return await compileAzureDevOpsConfig(config, job.data.connectionId, orgId, this.db);
|
||||||
}
|
}
|
||||||
case 'git': {
|
case 'git': {
|
||||||
return await compileGenericGitHostConfig(config, job.data.connectionId, orgId);
|
return await compileGenericGitHostConfig(config, job.data.connectionId, orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Failed to compile repo data for connection ${job.data.connectionId} (${connectionName}): ${err}`);
|
|
||||||
Sentry.captureException(err);
|
|
||||||
|
|
||||||
if (err instanceof BackendException) {
|
let { repoData, warnings } = result;
|
||||||
throw err;
|
|
||||||
} else {
|
|
||||||
throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
|
|
||||||
message: `Failed to compile repo data for connection ${job.data.connectionId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let { repoData, notFound } = result;
|
await this.db.connectionSyncJob.update({
|
||||||
|
|
||||||
// Push the information regarding not found users, orgs, and repos to the connection's syncStatusMetadata. Note that
|
|
||||||
// this won't be overwritten even if the connection job fails
|
|
||||||
await this.db.connection.update({
|
|
||||||
where: {
|
where: {
|
||||||
id: job.data.connectionId,
|
id: jobId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
syncStatusMetadata: { notFound }
|
warningMessages: warnings,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Filter out any duplicates by external_id and external_codeHostUrl.
|
// Filter out any duplicates by external_id and external_codeHostUrl.
|
||||||
repoData = repoData.filter((repo, index, self) => {
|
repoData = repoData.filter((repo, index, self) => {
|
||||||
return index === self.findIndex(r =>
|
return index === self.findIndex(r =>
|
||||||
|
|
@ -233,7 +240,7 @@ export class ConnectionManager {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const deleteDuration = performance.now() - deleteStart;
|
const deleteDuration = performance.now() - deleteStart;
|
||||||
this.logger.info(`Deleted all RepoToConnection records for connection ${connectionName} (id: ${job.data.connectionId}) in ${deleteDuration}ms`);
|
logger.info(`Deleted all RepoToConnection records for connection ${connectionName} (id: ${job.data.connectionId}) in ${deleteDuration}ms`);
|
||||||
|
|
||||||
const totalUpsertStart = performance.now();
|
const totalUpsertStart = performance.now();
|
||||||
for (const repo of repoData) {
|
for (const repo of repoData) {
|
||||||
|
|
@ -250,10 +257,10 @@ export class ConnectionManager {
|
||||||
create: repo,
|
create: repo,
|
||||||
})
|
})
|
||||||
const upsertDuration = performance.now() - upsertStart;
|
const upsertDuration = performance.now() - upsertStart;
|
||||||
this.logger.debug(`Upserted repo ${repo.displayName} (id: ${repo.external_id}) in ${upsertDuration}ms`);
|
logger.debug(`Upserted repo ${repo.displayName} (id: ${repo.external_id}) in ${upsertDuration}ms`);
|
||||||
}
|
}
|
||||||
const totalUpsertDuration = performance.now() - totalUpsertStart;
|
const totalUpsertDuration = performance.now() - totalUpsertStart;
|
||||||
this.logger.info(`Upserted ${repoData.length} repos for connection ${connectionName} (id: ${job.data.connectionId}) in ${totalUpsertDuration}ms`);
|
logger.info(`Upserted ${repoData.length} repos for connection ${connectionName} (id: ${job.data.connectionId}) in ${totalUpsertDuration}ms`);
|
||||||
}, { timeout: env.CONNECTION_MANAGER_UPSERT_TIMEOUT_MS });
|
}, { timeout: env.CONNECTION_MANAGER_UPSERT_TIMEOUT_MS });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -262,32 +269,23 @@ export class ConnectionManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async onSyncJobCompleted(job: Job<JobPayload>, result: JobResult) {
|
private onJobCompleted = async (job: Job<JobPayload>) =>
|
||||||
this.logger.info(`Connection sync job for connection ${job.data.connectionName} (id: ${job.data.connectionId}, jobId: ${job.id}) completed`);
|
groupmqLifecycleExceptionWrapper('onJobCompleted', logger, async () => {
|
||||||
const { connectionId, orgId } = job.data;
|
const logger = createJobLogger(job.id);
|
||||||
|
const { connectionId, connectionName, orgId } = job.data;
|
||||||
|
|
||||||
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
|
await this.db.connectionSyncJob.update({
|
||||||
where: { id: connectionId },
|
|
||||||
select: { syncStatusMetadata: true }
|
|
||||||
}))?.syncStatusMetadata as Record<string, unknown> ?? {};
|
|
||||||
const { notFound } = syncStatusMetadata as {
|
|
||||||
notFound: {
|
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.db.connection.update({
|
|
||||||
where: {
|
where: {
|
||||||
id: connectionId,
|
id: job.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
syncStatus:
|
status: ConnectionSyncJobStatus.COMPLETED,
|
||||||
notFound.users.length > 0 ||
|
completedAt: new Date(),
|
||||||
notFound.orgs.length > 0 ||
|
connection: {
|
||||||
notFound.repos.length > 0 ? ConnectionSyncStatus.SYNCED_WITH_WARNINGS : ConnectionSyncStatus.SYNCED,
|
update: {
|
||||||
syncedAt: new Date()
|
syncedAt: new Date(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -303,65 +301,92 @@ export class ConnectionManager {
|
||||||
contexts: config.contexts,
|
contexts: config.contexts,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to sync search contexts for connection ${connectionId}: ${err}`);
|
logger.error(`Failed to sync search contexts for connection ${connectionId}: ${err}`);
|
||||||
Sentry.captureException(err);
|
Sentry.captureException(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(`Connection sync job ${job.id} for connection ${job.data.connectionName} (id: ${job.data.connectionId}) completed`);
|
||||||
|
|
||||||
|
this.promClient.activeConnectionSyncJobs.dec({ connection: connectionName });
|
||||||
|
this.promClient.connectionSyncJobSuccessTotal.inc({ connection: connectionName });
|
||||||
|
|
||||||
|
const result = job.returnvalue as JobResult;
|
||||||
captureEvent('backend_connection_sync_job_completed', {
|
captureEvent('backend_connection_sync_job_completed', {
|
||||||
connectionId: connectionId,
|
connectionId: connectionId,
|
||||||
repoCount: result.repoCount,
|
repoCount: result.repoCount,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
private async onSyncJobFailed(job: Job<JobPayload> | undefined, err: unknown) {
|
private onJobFailed = async (job: Job<JobPayload>) =>
|
||||||
this.logger.info(`Connection sync job for connection ${job?.data.connectionName} (id: ${job?.data.connectionId}, jobId: ${job?.id}) failed with error: ${err}`);
|
groupmqLifecycleExceptionWrapper('onJobFailed', logger, async () => {
|
||||||
Sentry.captureException(err, {
|
const logger = createJobLogger(job.id);
|
||||||
tags: {
|
|
||||||
connectionid: job?.data.connectionId,
|
const attempt = job.attemptsMade + 1;
|
||||||
jobId: job?.id,
|
const wasLastAttempt = attempt >= job.opts.attempts;
|
||||||
queue: QUEUE_NAME,
|
|
||||||
|
if (wasLastAttempt) {
|
||||||
|
const { connection } = await this.db.connectionSyncJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: ConnectionSyncJobStatus.FAILED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
errorMessage: job.failedReason,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
connection: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (job) {
|
this.promClient.activeConnectionSyncJobs.dec({ connection: connection.name });
|
||||||
const { connectionId } = job.data;
|
this.promClient.connectionSyncJobFailTotal.inc({ connection: connection.name });
|
||||||
|
|
||||||
|
logger.error(`Failed job ${job.id} for connection ${connection.name} (id: ${connection.id}). Attempt ${attempt} / ${job.opts.attempts}. Failing job.`);
|
||||||
|
} else {
|
||||||
|
const connection = await this.db.connection.findUniqueOrThrow({
|
||||||
|
where: { id: job.data.connectionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.promClient.connectionSyncJobReattemptsTotal.inc({ connection: connection.name });
|
||||||
|
|
||||||
|
logger.warn(`Failed job ${job.id} for connection ${connection.name} (id: ${connection.id}). Attempt ${attempt} / ${job.opts.attempts}. Retrying.`);
|
||||||
|
}
|
||||||
|
|
||||||
captureEvent('backend_connection_sync_job_failed', {
|
captureEvent('backend_connection_sync_job_failed', {
|
||||||
connectionId: connectionId,
|
connectionId: job.data.connectionId,
|
||||||
error: err instanceof BackendException ? err.code : 'UNKNOWN',
|
error: job.failedReason,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// We may have pushed some metadata during the execution of the job, so we make sure to not overwrite the metadata here
|
private onJobStalled = async (jobId: string) =>
|
||||||
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
|
groupmqLifecycleExceptionWrapper('onJobStalled', logger, async () => {
|
||||||
where: { id: connectionId },
|
const logger = createJobLogger(jobId);
|
||||||
select: { syncStatusMetadata: true }
|
const { connection } = await this.db.connectionSyncJob.update({
|
||||||
}))?.syncStatusMetadata as Record<string, unknown> ?? {};
|
where: { id: jobId },
|
||||||
|
|
||||||
if (err instanceof BackendException) {
|
|
||||||
syncStatusMetadata = {
|
|
||||||
...syncStatusMetadata,
|
|
||||||
error: err.code,
|
|
||||||
...err.metadata,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
syncStatusMetadata = {
|
|
||||||
...syncStatusMetadata,
|
|
||||||
error: 'UNKNOWN',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.db.connection.update({
|
|
||||||
where: {
|
|
||||||
id: connectionId,
|
|
||||||
},
|
|
||||||
data: {
|
data: {
|
||||||
syncStatus: ConnectionSyncStatus.FAILED,
|
status: ConnectionSyncJobStatus.FAILED,
|
||||||
syncStatusMetadata: syncStatusMetadata as Prisma.InputJsonValue,
|
completedAt: new Date(),
|
||||||
|
errorMessage: 'Job stalled',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
connection: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
this.promClient.activeConnectionSyncJobs.dec({ connection: connection.name });
|
||||||
|
this.promClient.connectionSyncJobFailTotal.inc({ connection: connection.name });
|
||||||
|
|
||||||
|
logger.error(`Job ${jobId} stalled for connection ${connection.name} (id: ${connection.id})`);
|
||||||
|
|
||||||
|
captureEvent('backend_connection_sync_job_failed', {
|
||||||
|
connectionId: connection.id,
|
||||||
|
error: 'Job stalled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
private async onWorkerError(error: Error) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
logger.error(`Connection syncer worker error.`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async dispose() {
|
public async dispose() {
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,21 @@ type ValidResult<T> = {
|
||||||
data: T[];
|
data: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotFoundResult = {
|
type WarningResult = {
|
||||||
type: 'notFound';
|
type: 'warning';
|
||||||
value: string;
|
warning: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CustomResult<T> = ValidResult<T> | NotFoundResult;
|
type CustomResult<T> = ValidResult<T> | WarningResult;
|
||||||
|
|
||||||
export function processPromiseResults<T>(
|
export function processPromiseResults<T>(
|
||||||
results: PromiseSettledResult<CustomResult<T>>[],
|
results: PromiseSettledResult<CustomResult<T>>[],
|
||||||
): {
|
): {
|
||||||
validItems: T[];
|
validItems: T[];
|
||||||
notFoundItems: string[];
|
warnings: string[];
|
||||||
} {
|
} {
|
||||||
const validItems: T[] = [];
|
const validItems: T[] = [];
|
||||||
const notFoundItems: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
results.forEach(result => {
|
results.forEach(result => {
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
|
|
@ -27,14 +27,14 @@ export function processPromiseResults<T>(
|
||||||
if (value.type === 'valid') {
|
if (value.type === 'valid') {
|
||||||
validItems.push(...value.data);
|
validItems.push(...value.data);
|
||||||
} else {
|
} else {
|
||||||
notFoundItems.push(value.value);
|
warnings.push(value.warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validItems,
|
validItems,
|
||||||
notFoundItems,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
export const SINGLE_TENANT_ORG_ID = 1;
|
||||||
|
|
||||||
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES = [
|
||||||
'github',
|
'github',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { GithubAppConfig, SourcebotConfig } from "@sourcebot/schemas/v3/index.type";
|
|
||||||
import { loadConfig } from "@sourcebot/shared";
|
import { loadConfig } from "@sourcebot/shared";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { getTokenFromConfig } from "../utils.js";
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { App } from "@octokit/app";
|
import { App } from "@octokit/app";
|
||||||
|
import { GitHubAppConfig } from "@sourcebot/schemas/v3/index.type";
|
||||||
|
|
||||||
const logger = createLogger('githubAppManager');
|
const logger = createLogger('githubAppManager');
|
||||||
const GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME = 'github.com';
|
const GITHUB_DEFAULT_DEPLOYMENT_HOSTNAME = 'github.com';
|
||||||
|
|
@ -53,7 +53,7 @@ export class GithubAppManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const githubApps = config.apps.filter(app => app.type === 'githubApp') as GithubAppConfig[];
|
const githubApps = config.apps.filter(app => app.type === 'githubApp') as GitHubAppConfig[];
|
||||||
logger.info(`Found ${githubApps.length} GitHub apps in config`);
|
logger.info(`Found ${githubApps.length} GitHub apps in config`);
|
||||||
|
|
||||||
for (const app of githubApps) {
|
for (const app of githubApps) {
|
||||||
|
|
@ -62,7 +62,7 @@ export class GithubAppManager {
|
||||||
// @todo: we should move SINGLE_TENANT_ORG_ID to shared package or just remove the need to pass this in
|
// @todo: we should move SINGLE_TENANT_ORG_ID to shared package or just remove the need to pass this in
|
||||||
// when resolving tokens
|
// when resolving tokens
|
||||||
const SINGLE_TENANT_ORG_ID = 1;
|
const SINGLE_TENANT_ORG_ID = 1;
|
||||||
const privateKey = await getTokenFromConfig(app.privateKey, SINGLE_TENANT_ORG_ID, this.db!);
|
const privateKey = await getTokenFromConfig(app.privateKey, SINGLE_TENANT_ORG_ID, this.db);
|
||||||
|
|
||||||
const octokitApp = new App({
|
const octokitApp = new App({
|
||||||
appId: Number(app.id),
|
appId: Number(app.id),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import micromatch from "micromatch";
|
import micromatch from "micromatch";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { getPlan, hasEntitlement } from "../entitlements.js";
|
import { getPlan, hasEntitlement, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared";
|
||||||
import { SOURCEBOT_SUPPORT_EMAIL } from "../constants.js";
|
|
||||||
import { SearchContext } from "@sourcebot/schemas/v3/index.type";
|
import { SearchContext } from "@sourcebot/schemas/v3/index.type";
|
||||||
|
|
||||||
const logger = createLogger('sync-search-contexts');
|
const logger = createLogger('sync-search-contexts');
|
||||||
|
|
@ -47,7 +47,7 @@ export const env = createEnv({
|
||||||
DEBUG_ENABLE_GROUPMQ_LOGGING: booleanSchema.default('false'),
|
DEBUG_ENABLE_GROUPMQ_LOGGING: booleanSchema.default('false'),
|
||||||
|
|
||||||
DATABASE_URL: z.string().url().default("postgresql://postgres:postgres@localhost:5432/postgres"),
|
DATABASE_URL: z.string().url().default("postgresql://postgres:postgres@localhost:5432/postgres"),
|
||||||
CONFIG_PATH: z.string().optional(),
|
CONFIG_PATH: z.string(),
|
||||||
|
|
||||||
CONNECTION_MANAGER_UPSERT_TIMEOUT_MS: numberSchema.default(300000),
|
CONNECTION_MANAGER_UPSERT_TIMEOUT_MS: numberSchema.default(300000),
|
||||||
REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60),
|
REPO_SYNC_RETRY_BASE_SLEEP_SECONDS: numberSchema.default(60),
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ const logger = createLogger('gerrit');
|
||||||
|
|
||||||
export const getGerritReposFromConfig = async (config: GerritConnectionConfig): Promise<GerritProject[]> => {
|
export const getGerritReposFromConfig = async (config: GerritConnectionConfig): Promise<GerritProject[]> => {
|
||||||
const url = config.url.endsWith('/') ? config.url : `${config.url}/`;
|
const url = config.url.endsWith('/') ? config.url : `${config.url}/`;
|
||||||
const hostname = new URL(config.url).hostname;
|
|
||||||
|
|
||||||
let { durationMs, data: projects } = await measure(async () => {
|
let { durationMs, data: projects } = await measure(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,11 @@ const createGitClientForPath = (path: string, onProgress?: onProgressFn, signal?
|
||||||
* parent directory.
|
* parent directory.
|
||||||
*/
|
*/
|
||||||
GIT_CEILING_DIRECTORIES: parentPath,
|
GIT_CEILING_DIRECTORIES: parentPath,
|
||||||
|
/**
|
||||||
|
* Disable git credential prompts. This ensures that git operations will fail
|
||||||
|
* immediately if credentials are not available, rather than prompting for input.
|
||||||
|
*/
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
})
|
})
|
||||||
.cwd({
|
.cwd({
|
||||||
path,
|
path,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Api, giteaApi, HttpResponse, Repository as GiteaRepository } from 'gitea-js';
|
import { Api, giteaApi, HttpResponse, Repository as GiteaRepository } from 'gitea-js';
|
||||||
import { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type';
|
import { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type';
|
||||||
import { getTokenFromConfig, measure } from './utils.js';
|
import { measure } from './utils.js';
|
||||||
import fetch from 'cross-fetch';
|
import fetch from 'cross-fetch';
|
||||||
import { createLogger } from '@sourcebot/logger';
|
import { createLogger } from '@sourcebot/logger';
|
||||||
import micromatch from 'micromatch';
|
import micromatch from 'micromatch';
|
||||||
|
|
@ -8,6 +8,7 @@ import { PrismaClient } from '@sourcebot/db';
|
||||||
import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js';
|
import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js';
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
|
|
||||||
const logger = createLogger('gitea');
|
const logger = createLogger('gitea');
|
||||||
const GITEA_CLOUD_HOSTNAME = "gitea.com";
|
const GITEA_CLOUD_HOSTNAME = "gitea.com";
|
||||||
|
|
@ -18,7 +19,7 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
|
||||||
GITEA_CLOUD_HOSTNAME;
|
GITEA_CLOUD_HOSTNAME;
|
||||||
|
|
||||||
const token = config.token ?
|
const token = config.token ?
|
||||||
await getTokenFromConfig(config.token, orgId, db, logger) :
|
await getTokenFromConfig(config.token, orgId, db) :
|
||||||
hostname === GITEA_CLOUD_HOSTNAME ?
|
hostname === GITEA_CLOUD_HOSTNAME ?
|
||||||
env.FALLBACK_GITEA_CLOUD_TOKEN :
|
env.FALLBACK_GITEA_CLOUD_TOKEN :
|
||||||
undefined;
|
undefined;
|
||||||
|
|
@ -29,32 +30,24 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
|
||||||
});
|
});
|
||||||
|
|
||||||
let allRepos: GiteaRepository[] = [];
|
let allRepos: GiteaRepository[] = [];
|
||||||
let notFound: {
|
let allWarnings: string[] = [];
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
} = {
|
|
||||||
users: [],
|
|
||||||
orgs: [],
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.orgs) {
|
if (config.orgs) {
|
||||||
const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, api);
|
const { repos, warnings } = await getReposForOrgs(config.orgs, api);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.orgs = notFoundOrgs;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.repos) {
|
if (config.repos) {
|
||||||
const { validRepos, notFoundRepos } = await getRepos(config.repos, api);
|
const { repos, warnings } = await getRepos(config.repos, api);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.repos = notFoundRepos;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.users) {
|
if (config.users) {
|
||||||
const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, api);
|
const { repos, warnings } = await getReposOwnedByUsers(config.users, api);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.users = notFoundUsers;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
allRepos = allRepos.filter(repo => repo.full_name !== undefined);
|
allRepos = allRepos.filter(repo => repo.full_name !== undefined);
|
||||||
|
|
@ -78,8 +71,8 @@ export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, org
|
||||||
|
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
return {
|
return {
|
||||||
validRepos: repos,
|
repos,
|
||||||
notFound,
|
warnings: allWarnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,10 +138,11 @@ const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
|
||||||
Sentry.captureException(e);
|
Sentry.captureException(e);
|
||||||
|
|
||||||
if (e?.status === 404) {
|
if (e?.status === 404) {
|
||||||
logger.error(`User ${user} not found or no access`);
|
const warning = `User ${user} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: user
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -156,11 +150,11 @@ const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults<GiteaRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<GiteaRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundUsers,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,10 +179,11 @@ const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
|
||||||
Sentry.captureException(e);
|
Sentry.captureException(e);
|
||||||
|
|
||||||
if (e?.status === 404) {
|
if (e?.status === 404) {
|
||||||
logger.error(`Organization ${org} not found or no access`);
|
const warning = `Organization ${org} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: org
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -196,16 +191,16 @@ const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults<GiteaRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<GiteaRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundOrgs,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRepos = async <T>(repos: string[], api: Api<T>) => {
|
const getRepos = async <T>(repoList: string[], api: Api<T>) => {
|
||||||
const results = await Promise.allSettled(repos.map(async (repo) => {
|
const results = await Promise.allSettled(repoList.map(async (repo) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`Fetching repository info for ${repo}...`);
|
logger.debug(`Fetching repository info for ${repo}...`);
|
||||||
|
|
||||||
|
|
@ -223,10 +218,11 @@ const getRepos = async <T>(repos: string[], api: Api<T>) => {
|
||||||
Sentry.captureException(e);
|
Sentry.captureException(e);
|
||||||
|
|
||||||
if (e?.status === 404) {
|
if (e?.status === 404) {
|
||||||
logger.error(`Repository ${repo} not found or no access`);
|
const warning = `Repository ${repo} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: repo
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -234,11 +230,11 @@ const getRepos = async <T>(repos: string[], api: Api<T>) => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<GiteaRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<GiteaRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundRepos,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
|
||||||
import micromatch from "micromatch";
|
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
|
||||||
import { BackendException, BackendError } from "@sourcebot/error";
|
|
||||||
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { env } from "./env.js";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { GithubAppManager } from "./ee/githubAppManager.js";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||||
import { hasEntitlement } from "@sourcebot/shared";
|
import { hasEntitlement } from "@sourcebot/shared";
|
||||||
|
import micromatch from "micromatch";
|
||||||
|
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
||||||
|
import { GithubAppManager } from "./ee/githubAppManager.js";
|
||||||
|
import { env } from "./env.js";
|
||||||
|
import { fetchWithRetry, measure } from "./utils.js";
|
||||||
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
|
|
||||||
export const GITHUB_CLOUD_HOSTNAME = "github.com";
|
export const GITHUB_CLOUD_HOSTNAME = "github.com";
|
||||||
const logger = createLogger('github');
|
const logger = createLogger('github');
|
||||||
|
|
@ -92,13 +92,13 @@ const getOctokitWithGithubApp = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
|
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal): Promise<{ repos: OctokitRepository[], warnings: string[] }> => {
|
||||||
const hostname = config.url ?
|
const hostname = config.url ?
|
||||||
new URL(config.url).hostname :
|
new URL(config.url).hostname :
|
||||||
GITHUB_CLOUD_HOSTNAME;
|
GITHUB_CLOUD_HOSTNAME;
|
||||||
|
|
||||||
const token = config.token ?
|
const token = config.token ?
|
||||||
await getTokenFromConfig(config.token, orgId, db, logger) :
|
await getTokenFromConfig(config.token, orgId, db) :
|
||||||
hostname === GITHUB_CLOUD_HOSTNAME ?
|
hostname === GITHUB_CLOUD_HOSTNAME ?
|
||||||
env.FALLBACK_GITHUB_CLOUD_TOKEN :
|
env.FALLBACK_GITHUB_CLOUD_TOKEN :
|
||||||
undefined;
|
undefined;
|
||||||
|
|
@ -108,57 +108,36 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
||||||
url: config.url,
|
url: config.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
try {
|
try {
|
||||||
await octokit.rest.users.getAuthenticated();
|
await octokit.rest.users.getAuthenticated();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
|
logger.error(`Failed to authenticate with GitHub`, error);
|
||||||
if (isHttpError(error, 401)) {
|
throw error;
|
||||||
const e = new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, {
|
|
||||||
...(config.token && 'secret' in config.token ? {
|
|
||||||
secretKey: config.token.secret,
|
|
||||||
} : {}),
|
|
||||||
});
|
|
||||||
Sentry.captureException(e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const e = new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
|
|
||||||
message: `Failed to authenticate with GitHub`,
|
|
||||||
});
|
|
||||||
Sentry.captureException(e);
|
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let allRepos: OctokitRepository[] = [];
|
let allRepos: OctokitRepository[] = [];
|
||||||
let notFound: {
|
let allWarnings: string[] = [];
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
} = {
|
|
||||||
users: [],
|
|
||||||
orgs: [],
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.orgs) {
|
if (config.orgs) {
|
||||||
const { validRepos, notFoundOrgs } = await getReposForOrgs(config.orgs, octokit, signal, config.url);
|
const { repos, warnings } = await getReposForOrgs(config.orgs, octokit, signal, config.url);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.orgs = notFoundOrgs;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.repos) {
|
if (config.repos) {
|
||||||
const { validRepos, notFoundRepos } = await getRepos(config.repos, octokit, signal, config.url);
|
const { repos, warnings } = await getRepos(config.repos, octokit, signal, config.url);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.repos = notFoundRepos;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.users) {
|
if (config.users) {
|
||||||
const { validRepos, notFoundUsers } = await getReposOwnedByUsers(config.users, octokit, signal, config.url);
|
const { repos, warnings } = await getReposOwnedByUsers(config.users, octokit, signal, config.url);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(repos);
|
||||||
notFound.users = notFoundUsers;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
let repos = allRepos
|
let repos = allRepos
|
||||||
|
|
@ -177,8 +156,8 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos: repos,
|
repos,
|
||||||
notFound,
|
warnings: allWarnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,10 +235,11 @@ const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: A
|
||||||
logger.error(`Failed to fetch repositories for user ${user}.`, error);
|
logger.error(`Failed to fetch repositories for user ${user}.`, error);
|
||||||
|
|
||||||
if (isHttpError(error, 404)) {
|
if (isHttpError(error, 404)) {
|
||||||
logger.error(`User ${user} not found or no access`);
|
const warning = `User ${user} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: user
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -267,18 +247,18 @@ const getReposOwnedByUsers = async (users: string[], octokit: Octokit, signal: A
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults<OctokitRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<OctokitRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundUsers,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSignal, url?: string) => {
|
const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSignal, url?: string) => {
|
||||||
const results = await Promise.allSettled(orgs.map(async (org) => {
|
const results = await Promise.allSettled(orgs.map(async (org) => {
|
||||||
try {
|
try {
|
||||||
logger.info(`Fetching repository info for org ${org}...`);
|
logger.debug(`Fetching repository info for org ${org}...`);
|
||||||
|
|
||||||
const octokitToUse = await getOctokitWithGithubApp(octokit, org, url, `org ${org}`);
|
const octokitToUse = await getOctokitWithGithubApp(octokit, org, url, `org ${org}`);
|
||||||
const { durationMs, data } = await measure(async () => {
|
const { durationMs, data } = await measure(async () => {
|
||||||
|
|
@ -293,7 +273,7 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi
|
||||||
return fetchWithRetry(fetchFn, `org ${org}`, logger);
|
return fetchWithRetry(fetchFn, `org ${org}`, logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found ${data.length} in org ${org} in ${durationMs}ms.`);
|
logger.debug(`Found ${data.length} in org ${org} in ${durationMs}ms.`);
|
||||||
return {
|
return {
|
||||||
type: 'valid' as const,
|
type: 'valid' as const,
|
||||||
data
|
data
|
||||||
|
|
@ -303,10 +283,11 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi
|
||||||
logger.error(`Failed to fetch repositories for org ${org}.`, error);
|
logger.error(`Failed to fetch repositories for org ${org}.`, error);
|
||||||
|
|
||||||
if (isHttpError(error, 404)) {
|
if (isHttpError(error, 404)) {
|
||||||
logger.error(`Organization ${org} not found or no access`);
|
const warning = `Organization ${org} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: org
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -314,11 +295,11 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults<OctokitRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<OctokitRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundOrgs,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,7 +307,7 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
|
||||||
const results = await Promise.allSettled(repoList.map(async (repo) => {
|
const results = await Promise.allSettled(repoList.map(async (repo) => {
|
||||||
try {
|
try {
|
||||||
const [owner, repoName] = repo.split('/');
|
const [owner, repoName] = repo.split('/');
|
||||||
logger.info(`Fetching repository info for ${repo}...`);
|
logger.debug(`Fetching repository info for ${repo}...`);
|
||||||
|
|
||||||
const octokitToUse = await getOctokitWithGithubApp(octokit, owner, url, `repo ${repo}`);
|
const octokitToUse = await getOctokitWithGithubApp(octokit, owner, url, `repo ${repo}`);
|
||||||
const { durationMs, data: result } = await measure(async () => {
|
const { durationMs, data: result } = await measure(async () => {
|
||||||
|
|
@ -341,7 +322,7 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
|
||||||
return fetchWithRetry(fetchFn, repo, logger);
|
return fetchWithRetry(fetchFn, repo, logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Found info for repository ${repo} in ${durationMs}ms`);
|
logger.debug(`Found info for repository ${repo} in ${durationMs}ms`);
|
||||||
return {
|
return {
|
||||||
type: 'valid' as const,
|
type: 'valid' as const,
|
||||||
data: [result.data]
|
data: [result.data]
|
||||||
|
|
@ -352,10 +333,11 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
|
||||||
logger.error(`Failed to fetch repository ${repo}.`, error);
|
logger.error(`Failed to fetch repository ${repo}.`, error);
|
||||||
|
|
||||||
if (isHttpError(error, 404)) {
|
if (isHttpError(error, 404)) {
|
||||||
logger.error(`Repository ${repo} not found or no access`);
|
const warning = `Repository ${repo} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: repo
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -363,11 +345,11 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults<OctokitRepository>(results);
|
const { validItems: repos, warnings } = processPromiseResults<OctokitRepository>(results);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos,
|
repos,
|
||||||
notFoundRepos,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import { Gitlab, ProjectSchema } from "@gitbeaker/rest";
|
||||||
import micromatch from "micromatch";
|
import micromatch from "micromatch";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"
|
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"
|
||||||
import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
|
import { measure, fetchWithRetry } from "./utils.js";
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
|
|
||||||
const logger = createLogger('gitlab');
|
const logger = createLogger('gitlab');
|
||||||
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
||||||
|
|
@ -17,7 +18,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
GITLAB_CLOUD_HOSTNAME;
|
GITLAB_CLOUD_HOSTNAME;
|
||||||
|
|
||||||
const token = config.token ?
|
const token = config.token ?
|
||||||
await getTokenFromConfig(config.token, orgId, db, logger) :
|
await getTokenFromConfig(config.token, orgId, db) :
|
||||||
hostname === GITLAB_CLOUD_HOSTNAME ?
|
hostname === GITLAB_CLOUD_HOSTNAME ?
|
||||||
env.FALLBACK_GITLAB_CLOUD_TOKEN :
|
env.FALLBACK_GITLAB_CLOUD_TOKEN :
|
||||||
undefined;
|
undefined;
|
||||||
|
|
@ -33,15 +34,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
});
|
});
|
||||||
|
|
||||||
let allRepos: ProjectSchema[] = [];
|
let allRepos: ProjectSchema[] = [];
|
||||||
let notFound: {
|
let allWarnings: string[] = [];
|
||||||
orgs: string[],
|
|
||||||
users: string[],
|
|
||||||
repos: string[],
|
|
||||||
} = {
|
|
||||||
orgs: [],
|
|
||||||
users: [],
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.all === true) {
|
if (config.all === true) {
|
||||||
if (hostname !== GITLAB_CLOUD_HOSTNAME) {
|
if (hostname !== GITLAB_CLOUD_HOSTNAME) {
|
||||||
|
|
@ -61,7 +54,9 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Ignoring option all:true in config : host is ${GITLAB_CLOUD_HOSTNAME}`);
|
const warning = `Ignoring option all:true in config : host is ${GITLAB_CLOUD_HOSTNAME}`;
|
||||||
|
logger.warn(warning);
|
||||||
|
allWarnings = allWarnings.concat(warning);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,10 +82,11 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
|
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
logger.error(`Group ${group} not found or no access`);
|
const warning = `Group ${group} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: group
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -98,9 +94,9 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults(results);
|
const { validItems: validRepos, warnings } = processPromiseResults(results);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(validRepos);
|
||||||
notFound.orgs = notFoundOrgs;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.users) {
|
if (config.users) {
|
||||||
|
|
@ -124,10 +120,11 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
|
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
logger.error(`User ${user} not found or no access`);
|
const warning = `User ${user} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: user
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -135,9 +132,9 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundUsers } = processPromiseResults(results);
|
const { validItems: validRepos, warnings } = processPromiseResults(results);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(validRepos);
|
||||||
notFound.users = notFoundUsers;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.projects) {
|
if (config.projects) {
|
||||||
|
|
@ -160,10 +157,11 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
const status = e?.cause?.response?.status;
|
const status = e?.cause?.response?.status;
|
||||||
|
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
logger.error(`Project ${project} not found or no access`);
|
const warning = `Project ${project} not found or no access`;
|
||||||
|
logger.warn(warning);
|
||||||
return {
|
return {
|
||||||
type: 'notFound' as const,
|
type: 'warning' as const,
|
||||||
value: project
|
warning
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -171,9 +169,9 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
}));
|
}));
|
||||||
|
|
||||||
throwIfAnyFailed(results);
|
throwIfAnyFailed(results);
|
||||||
const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results);
|
const { validItems: validRepos, warnings } = processPromiseResults(results);
|
||||||
allRepos = allRepos.concat(validRepos);
|
allRepos = allRepos.concat(validRepos);
|
||||||
notFound.repos = notFoundRepos;
|
allWarnings = allWarnings.concat(warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
let repos = allRepos
|
let repos = allRepos
|
||||||
|
|
@ -192,8 +190,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
|
||||||
logger.debug(`Found ${repos.length} total repositories.`);
|
logger.debug(`Found ${repos.length} total repositories.`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validRepos: repos,
|
repos,
|
||||||
notFound,
|
warnings: allWarnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,15 @@ import { getConfigSettings, hasEntitlement } from '@sourcebot/shared';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { mkdir } from 'fs/promises';
|
import { mkdir } from 'fs/promises';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
|
import { ConfigManager } from "./configManager.js";
|
||||||
import { ConnectionManager } from './connectionManager.js';
|
import { ConnectionManager } from './connectionManager.js';
|
||||||
import { INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js';
|
import { INDEX_CACHE_DIR, REPOS_CACHE_DIR } from './constants.js';
|
||||||
|
import { GithubAppManager } from "./ee/githubAppManager.js";
|
||||||
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
|
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
|
||||||
import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
import { UserPermissionSyncer } from "./ee/userPermissionSyncer.js";
|
||||||
import { GithubAppManager } from "./ee/githubAppManager.js";
|
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { RepoIndexManager } from "./repoIndexManager.js";
|
|
||||||
import { PromClient } from './promClient.js';
|
import { PromClient } from './promClient.js';
|
||||||
|
import { RepoIndexManager } from "./repoIndexManager.js";
|
||||||
|
|
||||||
|
|
||||||
const logger = createLogger('backend-entrypoint');
|
const logger = createLogger('backend-entrypoint');
|
||||||
|
|
@ -49,10 +50,11 @@ if (hasEntitlement('github-app')) {
|
||||||
await GithubAppManager.getInstance().init(prisma);
|
await GithubAppManager.getInstance().init(prisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager(prisma, settings, redis);
|
const connectionManager = new ConnectionManager(prisma, settings, redis, promClient);
|
||||||
const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
|
const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
|
||||||
const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis);
|
const userPermissionSyncer = new UserPermissionSyncer(prisma, settings, redis);
|
||||||
const repoIndexManager = new RepoIndexManager(prisma, settings, redis, promClient);
|
const repoIndexManager = new RepoIndexManager(prisma, settings, redis, promClient);
|
||||||
|
const configManager = new ConfigManager(prisma, connectionManager, env.CONFIG_PATH);
|
||||||
|
|
||||||
connectionManager.startScheduler();
|
connectionManager.startScheduler();
|
||||||
repoIndexManager.startScheduler();
|
repoIndexManager.startScheduler();
|
||||||
|
|
@ -66,6 +68,8 @@ else if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement(
|
||||||
userPermissionSyncer.startScheduler();
|
userPermissionSyncer.startScheduler();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Worker started.');
|
||||||
|
|
||||||
const cleanup = async (signal: string) => {
|
const cleanup = async (signal: string) => {
|
||||||
logger.info(`Received ${signal}, cleaning up...`);
|
logger.info(`Received ${signal}, cleaning up...`);
|
||||||
|
|
||||||
|
|
@ -79,6 +83,7 @@ const cleanup = async (signal: string) => {
|
||||||
repoPermissionSyncer.dispose(),
|
repoPermissionSyncer.dispose(),
|
||||||
userPermissionSyncer.dispose(),
|
userPermissionSyncer.dispose(),
|
||||||
promClient.dispose(),
|
promClient.dispose(),
|
||||||
|
configManager.dispose(),
|
||||||
]),
|
]),
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Shutdown timeout')), shutdownTimeout)
|
setTimeout(() => reject(new Error('Shutdown timeout')), shutdownTimeout)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ export class PromClient {
|
||||||
public repoIndexJobFailTotal: Counter<string>;
|
public repoIndexJobFailTotal: Counter<string>;
|
||||||
public repoIndexJobSuccessTotal: Counter<string>;
|
public repoIndexJobSuccessTotal: Counter<string>;
|
||||||
|
|
||||||
|
public activeConnectionSyncJobs: Gauge<string>;
|
||||||
|
public pendingConnectionSyncJobs: Gauge<string>;
|
||||||
|
public connectionSyncJobReattemptsTotal: Counter<string>;
|
||||||
|
public connectionSyncJobFailTotal: Counter<string>;
|
||||||
|
public connectionSyncJobSuccessTotal: Counter<string>;
|
||||||
|
|
||||||
public readonly PORT = 3060;
|
public readonly PORT = 3060;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -56,6 +62,41 @@ export class PromClient {
|
||||||
});
|
});
|
||||||
this.registry.registerMetric(this.repoIndexJobSuccessTotal);
|
this.registry.registerMetric(this.repoIndexJobSuccessTotal);
|
||||||
|
|
||||||
|
this.activeConnectionSyncJobs = new Gauge({
|
||||||
|
name: 'active_connection_sync_jobs',
|
||||||
|
help: 'The number of connection sync jobs in progress',
|
||||||
|
labelNames: ['connection'],
|
||||||
|
});
|
||||||
|
this.registry.registerMetric(this.activeConnectionSyncJobs);
|
||||||
|
|
||||||
|
this.pendingConnectionSyncJobs = new Gauge({
|
||||||
|
name: 'pending_connection_sync_jobs',
|
||||||
|
help: 'The number of connection sync jobs waiting in queue',
|
||||||
|
labelNames: ['connection'],
|
||||||
|
});
|
||||||
|
this.registry.registerMetric(this.pendingConnectionSyncJobs);
|
||||||
|
|
||||||
|
this.connectionSyncJobReattemptsTotal = new Counter({
|
||||||
|
name: 'connection_sync_job_reattempts',
|
||||||
|
help: 'The number of connection sync job reattempts',
|
||||||
|
labelNames: ['connection'],
|
||||||
|
});
|
||||||
|
this.registry.registerMetric(this.connectionSyncJobReattemptsTotal);
|
||||||
|
|
||||||
|
this.connectionSyncJobFailTotal = new Counter({
|
||||||
|
name: 'connection_sync_job_fails',
|
||||||
|
help: 'The number of connection sync job fails',
|
||||||
|
labelNames: ['connection'],
|
||||||
|
});
|
||||||
|
this.registry.registerMetric(this.connectionSyncJobFailTotal);
|
||||||
|
|
||||||
|
this.connectionSyncJobSuccessTotal = new Counter({
|
||||||
|
name: 'connection_sync_job_successes',
|
||||||
|
help: 'The number of connection sync job successes',
|
||||||
|
labelNames: ['connection'],
|
||||||
|
});
|
||||||
|
this.registry.registerMetric(this.connectionSyncJobSuccessTotal);
|
||||||
|
|
||||||
client.collectDefaultMetrics({
|
client.collectDefaultMetrics({
|
||||||
register: this.registry,
|
register: this.registry,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,22 +24,20 @@ export type RepoData = WithRequired<Prisma.RepoCreateInput, 'connections'>;
|
||||||
|
|
||||||
const logger = createLogger('repo-compile-utils');
|
const logger = createLogger('repo-compile-utils');
|
||||||
|
|
||||||
|
type CompileResult = {
|
||||||
|
repoData: RepoData[],
|
||||||
|
warnings: string[],
|
||||||
|
}
|
||||||
|
|
||||||
export const compileGithubConfig = async (
|
export const compileGithubConfig = async (
|
||||||
config: GithubConnectionConfig,
|
config: GithubConnectionConfig,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient,
|
db: PrismaClient,
|
||||||
abortController: AbortController): Promise<{
|
abortController: AbortController): Promise<CompileResult> => {
|
||||||
repoData: RepoData[],
|
|
||||||
notFound: {
|
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
}
|
|
||||||
}> => {
|
|
||||||
const gitHubReposResult = await getGitHubReposFromConfig(config, orgId, db, abortController.signal);
|
const gitHubReposResult = await getGitHubReposFromConfig(config, orgId, db, abortController.signal);
|
||||||
const gitHubRepos = gitHubReposResult.validRepos;
|
const gitHubRepos = gitHubReposResult.repos;
|
||||||
const notFound = gitHubReposResult.notFound;
|
const warnings = gitHubReposResult.warnings;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://github.com';
|
const hostUrl = config.url ?? 'https://github.com';
|
||||||
const repoNameRoot = new URL(hostUrl)
|
const repoNameRoot = new URL(hostUrl)
|
||||||
|
|
@ -100,7 +98,7 @@ export const compileGithubConfig = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: repos,
|
repoData: repos,
|
||||||
notFound,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,11 +106,11 @@ export const compileGitlabConfig = async (
|
||||||
config: GitlabConnectionConfig,
|
config: GitlabConnectionConfig,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient) => {
|
db: PrismaClient): Promise<CompileResult> => {
|
||||||
|
|
||||||
const gitlabReposResult = await getGitLabReposFromConfig(config, orgId, db);
|
const gitlabReposResult = await getGitLabReposFromConfig(config, orgId, db);
|
||||||
const gitlabRepos = gitlabReposResult.validRepos;
|
const gitlabRepos = gitlabReposResult.repos;
|
||||||
const notFound = gitlabReposResult.notFound;
|
const warnings = gitlabReposResult.warnings;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://gitlab.com';
|
const hostUrl = config.url ?? 'https://gitlab.com';
|
||||||
const repoNameRoot = new URL(hostUrl)
|
const repoNameRoot = new URL(hostUrl)
|
||||||
|
|
@ -177,7 +175,7 @@ export const compileGitlabConfig = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: repos,
|
repoData: repos,
|
||||||
notFound,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,11 +183,11 @@ export const compileGiteaConfig = async (
|
||||||
config: GiteaConnectionConfig,
|
config: GiteaConnectionConfig,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient) => {
|
db: PrismaClient): Promise<CompileResult> => {
|
||||||
|
|
||||||
const giteaReposResult = await getGiteaReposFromConfig(config, orgId, db);
|
const giteaReposResult = await getGiteaReposFromConfig(config, orgId, db);
|
||||||
const giteaRepos = giteaReposResult.validRepos;
|
const giteaRepos = giteaReposResult.repos;
|
||||||
const notFound = giteaReposResult.notFound;
|
const warnings = giteaReposResult.warnings;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://gitea.com';
|
const hostUrl = config.url ?? 'https://gitea.com';
|
||||||
const repoNameRoot = new URL(hostUrl)
|
const repoNameRoot = new URL(hostUrl)
|
||||||
|
|
@ -248,14 +246,14 @@ export const compileGiteaConfig = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: repos,
|
repoData: repos,
|
||||||
notFound,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compileGerritConfig = async (
|
export const compileGerritConfig = async (
|
||||||
config: GerritConnectionConfig,
|
config: GerritConnectionConfig,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number) => {
|
orgId: number): Promise<CompileResult> => {
|
||||||
|
|
||||||
const gerritRepos = await getGerritReposFromConfig(config);
|
const gerritRepos = await getGerritReposFromConfig(config);
|
||||||
const hostUrl = config.url;
|
const hostUrl = config.url;
|
||||||
|
|
@ -329,11 +327,7 @@ export const compileGerritConfig = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: repos,
|
repoData: repos,
|
||||||
notFound: {
|
warnings: [],
|
||||||
users: [],
|
|
||||||
orgs: [],
|
|
||||||
repos: [],
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -341,11 +335,11 @@ export const compileBitbucketConfig = async (
|
||||||
config: BitbucketConnectionConfig,
|
config: BitbucketConnectionConfig,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient) => {
|
db: PrismaClient): Promise<CompileResult> => {
|
||||||
|
|
||||||
const bitbucketReposResult = await getBitbucketReposFromConfig(config, orgId, db);
|
const bitbucketReposResult = await getBitbucketReposFromConfig(config, orgId, db);
|
||||||
const bitbucketRepos = bitbucketReposResult.validRepos;
|
const bitbucketRepos = bitbucketReposResult.repos;
|
||||||
const notFound = bitbucketReposResult.notFound;
|
const warnings = bitbucketReposResult.warnings;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://bitbucket.org';
|
const hostUrl = config.url ?? 'https://bitbucket.org';
|
||||||
const repoNameRoot = new URL(hostUrl)
|
const repoNameRoot = new URL(hostUrl)
|
||||||
|
|
@ -450,7 +444,7 @@ export const compileBitbucketConfig = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: repos,
|
repoData: repos,
|
||||||
notFound,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,7 +452,7 @@ export const compileGenericGitHostConfig = async (
|
||||||
config: GenericGitHostConnectionConfig,
|
config: GenericGitHostConnectionConfig,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
) => {
|
): Promise<CompileResult> => {
|
||||||
const configUrl = new URL(config.url);
|
const configUrl = new URL(config.url);
|
||||||
if (configUrl.protocol === 'file:') {
|
if (configUrl.protocol === 'file:') {
|
||||||
return compileGenericGitHostConfig_file(config, orgId, connectionId);
|
return compileGenericGitHostConfig_file(config, orgId, connectionId);
|
||||||
|
|
@ -476,7 +470,7 @@ export const compileGenericGitHostConfig_file = async (
|
||||||
config: GenericGitHostConnectionConfig,
|
config: GenericGitHostConnectionConfig,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
) => {
|
): Promise<CompileResult> => {
|
||||||
const configUrl = new URL(config.url);
|
const configUrl = new URL(config.url);
|
||||||
assert(configUrl.protocol === 'file:', 'config.url must be a file:// URL');
|
assert(configUrl.protocol === 'file:', 'config.url must be a file:// URL');
|
||||||
|
|
||||||
|
|
@ -486,30 +480,24 @@ export const compileGenericGitHostConfig_file = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
const repos: RepoData[] = [];
|
const repos: RepoData[] = [];
|
||||||
const notFound: {
|
const warnings: string[] = [];
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
} = {
|
|
||||||
users: [],
|
|
||||||
orgs: [],
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.all(repoPaths.map(async (repoPath) => {
|
await Promise.all(repoPaths.map(async (repoPath) => {
|
||||||
const isGitRepo = await isPathAValidGitRepoRoot({
|
const isGitRepo = await isPathAValidGitRepoRoot({
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
});
|
});
|
||||||
if (!isGitRepo) {
|
if (!isGitRepo) {
|
||||||
logger.warn(`Skipping ${repoPath} - not a git repository.`);
|
const warning = `Skipping ${repoPath} - not a git repository.`;
|
||||||
notFound.repos.push(repoPath);
|
logger.warn(warning);
|
||||||
|
warnings.push(warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin = await getOriginUrl(repoPath);
|
const origin = await getOriginUrl(repoPath);
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
logger.warn(`Skipping ${repoPath} - remote.origin.url not found in git config.`);
|
const warning = `Skipping ${repoPath} - remote.origin.url not found in git config.`;
|
||||||
notFound.repos.push(repoPath);
|
logger.warn(warning);
|
||||||
|
warnings.push(warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,7 +540,7 @@ export const compileGenericGitHostConfig_file = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: repos,
|
repoData: repos,
|
||||||
notFound,
|
warnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -561,27 +549,21 @@ export const compileGenericGitHostConfig_url = async (
|
||||||
config: GenericGitHostConnectionConfig,
|
config: GenericGitHostConnectionConfig,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
) => {
|
): Promise<CompileResult> => {
|
||||||
const remoteUrl = new URL(config.url);
|
const remoteUrl = new URL(config.url);
|
||||||
assert(remoteUrl.protocol === 'http:' || remoteUrl.protocol === 'https:', 'config.url must be a http:// or https:// URL');
|
assert(remoteUrl.protocol === 'http:' || remoteUrl.protocol === 'https:', 'config.url must be a http:// or https:// URL');
|
||||||
|
|
||||||
const notFound: {
|
const warnings: string[] = [];
|
||||||
users: string[],
|
|
||||||
orgs: string[],
|
|
||||||
repos: string[],
|
|
||||||
} = {
|
|
||||||
users: [],
|
|
||||||
orgs: [],
|
|
||||||
repos: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate that we are dealing with a valid git repo.
|
// Validate that we are dealing with a valid git repo.
|
||||||
const isGitRepo = await isUrlAValidGitRepo(remoteUrl.toString());
|
const isGitRepo = await isUrlAValidGitRepo(remoteUrl.toString());
|
||||||
if (!isGitRepo) {
|
if (!isGitRepo) {
|
||||||
notFound.repos.push(remoteUrl.toString());
|
const warning = `Skipping ${remoteUrl.toString()} - not a git repository.`;
|
||||||
|
logger.warn(warning);
|
||||||
|
warnings.push(warning);
|
||||||
return {
|
return {
|
||||||
repoData: [],
|
repoData: [],
|
||||||
notFound,
|
warnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -616,7 +598,7 @@ export const compileGenericGitHostConfig_url = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: [repo],
|
repoData: [repo],
|
||||||
notFound,
|
warnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,12 +606,11 @@ export const compileAzureDevOpsConfig = async (
|
||||||
config: AzureDevOpsConnectionConfig,
|
config: AzureDevOpsConnectionConfig,
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
orgId: number,
|
orgId: number,
|
||||||
db: PrismaClient,
|
db: PrismaClient): Promise<CompileResult> => {
|
||||||
abortController: AbortController) => {
|
|
||||||
|
|
||||||
const azureDevOpsReposResult = await getAzureDevOpsReposFromConfig(config, orgId, db);
|
const azureDevOpsReposResult = await getAzureDevOpsReposFromConfig(config, orgId, db);
|
||||||
const azureDevOpsRepos = azureDevOpsReposResult.validRepos;
|
const azureDevOpsRepos = azureDevOpsReposResult.repos;
|
||||||
const notFound = azureDevOpsReposResult.notFound;
|
const warnings = azureDevOpsReposResult.warnings;
|
||||||
|
|
||||||
const hostUrl = config.url ?? 'https://dev.azure.com';
|
const hostUrl = config.url ?? 'https://dev.azure.com';
|
||||||
const repoNameRoot = new URL(hostUrl)
|
const repoNameRoot = new URL(hostUrl)
|
||||||
|
|
@ -699,6 +680,6 @@ export const compileAzureDevOpsConfig = async (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoData: repos,
|
repoData: repos,
|
||||||
notFound,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,8 @@ export class RepoIndexManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async scheduleCleanupJobs() {
|
private async scheduleCleanupJobs() {
|
||||||
const thresholdDate = new Date(Date.now() - this.settings.repoGarbageCollectionGracePeriodMs);
|
const gcGracePeriodMs = new Date(Date.now() - this.settings.repoGarbageCollectionGracePeriodMs);
|
||||||
|
const timeoutDate = new Date(Date.now() - this.settings.repoIndexTimeoutMs);
|
||||||
|
|
||||||
const reposToCleanup = await this.db.repo.findMany({
|
const reposToCleanup = await this.db.repo.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -158,9 +159,8 @@ export class RepoIndexManager {
|
||||||
},
|
},
|
||||||
OR: [
|
OR: [
|
||||||
{ indexedAt: null },
|
{ indexedAt: null },
|
||||||
{ indexedAt: { lt: thresholdDate } },
|
{ indexedAt: { lt: gcGracePeriodMs } },
|
||||||
],
|
],
|
||||||
// Don't schedule if there are active jobs that were created within the threshold date.
|
|
||||||
NOT: {
|
NOT: {
|
||||||
jobs: {
|
jobs: {
|
||||||
some: {
|
some: {
|
||||||
|
|
@ -178,7 +178,7 @@ export class RepoIndexManager {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gt: thresholdDate,
|
gt: timeoutDate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import { Logger } from "winston";
|
||||||
import { RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
import { RepoAuthCredentials, RepoWithConnections } from "./types.js";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { PrismaClient, Repo } from "@sourcebot/db";
|
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||||
import { getTokenFromConfig as getTokenFromConfigBase } from "@sourcebot/crypto";
|
import { getTokenFromConfig } from "@sourcebot/crypto";
|
||||||
import { BackendException, BackendError } from "@sourcebot/error";
|
|
||||||
import * as Sentry from "@sentry/node";
|
import * as Sentry from "@sentry/node";
|
||||||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||||
import { GithubAppManager } from "./ee/githubAppManager.js";
|
import { GithubAppManager } from "./ee/githubAppManager.js";
|
||||||
|
|
@ -24,22 +23,6 @@ export const marshalBool = (value?: boolean) => {
|
||||||
return !!value ? '1' : '0';
|
return !!value ? '1' : '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTokenFromConfig = async (token: any, orgId: number, db: PrismaClient, logger?: Logger) => {
|
|
||||||
try {
|
|
||||||
return await getTokenFromConfigBase(token, orgId, db);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
Sentry.captureException(e);
|
|
||||||
logger?.error(error.message);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => {
|
export const resolvePathRelativeToConfig = (localPath: string, configPath: string) => {
|
||||||
let absolutePath = localPath;
|
let absolutePath = localPath;
|
||||||
if (!path.isAbsolute(absolutePath)) {
|
if (!path.isAbsolute(absolutePath)) {
|
||||||
|
|
@ -156,7 +139,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
|
||||||
if (connection.connectionType === 'github') {
|
if (connection.connectionType === 'github') {
|
||||||
const config = connection.config as unknown as GithubConnectionConfig;
|
const config = connection.config as unknown as GithubConnectionConfig;
|
||||||
if (config.token) {
|
if (config.token) {
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
|
const token = await getTokenFromConfig(config.token, connection.orgId, db);
|
||||||
return {
|
return {
|
||||||
hostUrl: config.url,
|
hostUrl: config.url,
|
||||||
token,
|
token,
|
||||||
|
|
@ -171,7 +154,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
|
||||||
} else if (connection.connectionType === 'gitlab') {
|
} else if (connection.connectionType === 'gitlab') {
|
||||||
const config = connection.config as unknown as GitlabConnectionConfig;
|
const config = connection.config as unknown as GitlabConnectionConfig;
|
||||||
if (config.token) {
|
if (config.token) {
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
|
const token = await getTokenFromConfig(config.token, connection.orgId, db);
|
||||||
return {
|
return {
|
||||||
hostUrl: config.url,
|
hostUrl: config.url,
|
||||||
token,
|
token,
|
||||||
|
|
@ -187,7 +170,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
|
||||||
} else if (connection.connectionType === 'gitea') {
|
} else if (connection.connectionType === 'gitea') {
|
||||||
const config = connection.config as unknown as GiteaConnectionConfig;
|
const config = connection.config as unknown as GiteaConnectionConfig;
|
||||||
if (config.token) {
|
if (config.token) {
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
|
const token = await getTokenFromConfig(config.token, connection.orgId, db);
|
||||||
return {
|
return {
|
||||||
hostUrl: config.url,
|
hostUrl: config.url,
|
||||||
token,
|
token,
|
||||||
|
|
@ -202,7 +185,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
|
||||||
} else if (connection.connectionType === 'bitbucket') {
|
} else if (connection.connectionType === 'bitbucket') {
|
||||||
const config = connection.config as unknown as BitbucketConnectionConfig;
|
const config = connection.config as unknown as BitbucketConnectionConfig;
|
||||||
if (config.token) {
|
if (config.token) {
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
|
const token = await getTokenFromConfig(config.token, connection.orgId, db);
|
||||||
const username = config.user ?? 'x-token-auth';
|
const username = config.user ?? 'x-token-auth';
|
||||||
return {
|
return {
|
||||||
hostUrl: config.url,
|
hostUrl: config.url,
|
||||||
|
|
@ -219,7 +202,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, db: P
|
||||||
} else if (connection.connectionType === 'azuredevops') {
|
} else if (connection.connectionType === 'azuredevops') {
|
||||||
const config = connection.config as unknown as AzureDevOpsConnectionConfig;
|
const config = connection.config as unknown as AzureDevOpsConnectionConfig;
|
||||||
if (config.token) {
|
if (config.token) {
|
||||||
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
|
const token = await getTokenFromConfig(config.token, connection.orgId, db);
|
||||||
|
|
||||||
// For ADO server, multiple auth schemes may be supported. If the ADO deployment supports NTLM, the git clone will default
|
// For ADO server, multiple auth schemes may be supported. If the ADO deployment supports NTLM, the git clone will default
|
||||||
// to this over basic auth. As a result, we cannot embed the token in the clone URL and must force basic auth by passing in the token
|
// to this over basic auth. As a result, we cannot embed the token in the clone URL and must force basic auth by passing in the token
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `syncStatus` on the `Connection` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `syncStatusMetadata` on the `Connection` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ConnectionSyncJobStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Connection" DROP COLUMN "syncStatus",
|
||||||
|
DROP COLUMN "syncStatusMetadata";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ConnectionSyncJob" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"status" "ConnectionSyncJobStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"warningMessages" TEXT[],
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"connectionId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ConnectionSyncJob_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ConnectionSyncJob" ADD CONSTRAINT "ConnectionSyncJob_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "Connection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Installs the pgcrypto extension. Required for the gen_random_uuid() function.
|
||||||
|
-- @see: https://www.prisma.io/docs/orm/prisma-migrate/workflows/native-database-functions#how-to-install-a-postgresql-extension-as-part-of-a-migration
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- Ensure single tenant organization exists
|
||||||
|
INSERT INTO "Org" (id, name, domain, "inviteLinkId", "createdAt", "updatedAt")
|
||||||
|
VALUES (1, 'default', '~', gen_random_uuid(), NOW(), NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Backfill inviteLinkId for any existing orgs that don't have one
|
||||||
|
UPDATE "Org"
|
||||||
|
SET "inviteLinkId" = gen_random_uuid()
|
||||||
|
WHERE "inviteLinkId" IS NULL;
|
||||||
|
|
@ -132,15 +132,15 @@ model Connection {
|
||||||
isDeclarative Boolean @default(false)
|
isDeclarative Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
/// When the connection was last synced successfully.
|
|
||||||
syncedAt DateTime?
|
|
||||||
repos RepoToConnection[]
|
repos RepoToConnection[]
|
||||||
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
|
|
||||||
syncStatusMetadata Json?
|
|
||||||
|
|
||||||
// The type of connection (e.g., github, gitlab, etc.)
|
// The type of connection (e.g., github, gitlab, etc.)
|
||||||
connectionType String
|
connectionType String
|
||||||
|
|
||||||
|
syncJobs ConnectionSyncJob[]
|
||||||
|
/// When the connection was last synced successfully.
|
||||||
|
syncedAt DateTime?
|
||||||
|
|
||||||
// The organization that owns this connection
|
// The organization that owns this connection
|
||||||
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
orgId Int
|
orgId Int
|
||||||
|
|
@ -148,6 +148,27 @@ model Connection {
|
||||||
@@unique([name, orgId])
|
@@unique([name, orgId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ConnectionSyncJobStatus {
|
||||||
|
PENDING
|
||||||
|
IN_PROGRESS
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
model ConnectionSyncJob {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
status ConnectionSyncJobStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
warningMessages String[]
|
||||||
|
errorMessage String?
|
||||||
|
|
||||||
|
connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
||||||
|
connectionId Int
|
||||||
|
}
|
||||||
|
|
||||||
model RepoToConnection {
|
model RepoToConnection {
|
||||||
addedAt DateTime @default(now())
|
addedAt DateTime @default(now())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@
|
||||||
const schema = {
|
const schema = {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"title": "AppConfig",
|
"title": "AppConfig",
|
||||||
"oneOf": [
|
"definitions": {
|
||||||
{
|
"GitHubAppConfig": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "GithubAppConfig",
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "githubApp",
|
||||||
|
|
@ -60,19 +58,70 @@ const schema = {
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
"type",
|
||||||
"id"
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"type": "object",
|
||||||
"privateKey"
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "githubApp",
|
||||||
|
"description": "GitHub App Configuration"
|
||||||
|
},
|
||||||
|
"deploymentHostname": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "hostname",
|
||||||
|
"default": "github.com",
|
||||||
|
"description": "The hostname of the GitHub App deployment.",
|
||||||
|
"examples": [
|
||||||
|
"github.com",
|
||||||
|
"github.example.com"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the GitHub App."
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"description": "The private key of the GitHub App.",
|
||||||
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
|
}
|
||||||
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"privateKeyPath"
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"env": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"env"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,34 @@
|
||||||
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
|
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
|
||||||
|
|
||||||
export type AppConfig = GithubAppConfig;
|
export type AppConfig = GitHubAppConfig;
|
||||||
export type GithubAppConfig = {
|
|
||||||
[k: string]: unknown;
|
export interface GitHubAppConfig {
|
||||||
};
|
/**
|
||||||
|
* GitHub App Configuration
|
||||||
|
*/
|
||||||
|
type: "githubApp";
|
||||||
|
/**
|
||||||
|
* The hostname of the GitHub App deployment.
|
||||||
|
*/
|
||||||
|
deploymentHostname?: string;
|
||||||
|
/**
|
||||||
|
* The ID of the GitHub App.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* The private key of the GitHub App.
|
||||||
|
*/
|
||||||
|
privateKey:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the secret that contains the token.
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token. Only supported in declarative connection configs.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
|
|
||||||
const schema = {
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
|
||||||
"title": "GithubAppConfig",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"const": "githubApp",
|
|
||||||
"description": "GitHub App Configuration"
|
|
||||||
},
|
|
||||||
"deploymentHostname": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "hostname",
|
|
||||||
"default": "github.com",
|
|
||||||
"description": "The hostname of the GitHub App deployment.",
|
|
||||||
"examples": [
|
|
||||||
"github.com",
|
|
||||||
"github.example.com"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the GitHub App."
|
|
||||||
},
|
|
||||||
"privateKey": {
|
|
||||||
"description": "The private key of the GitHub App.",
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"secret": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The name of the secret that contains the token."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"secret"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"env": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"env"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"type",
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"privateKey"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": [
|
|
||||||
"privateKeyPath"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
} as const;
|
|
||||||
export { schema as githubAppSchema };
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
|
|
||||||
|
|
||||||
export type GithubAppConfig = {
|
|
||||||
/**
|
|
||||||
* GitHub App Configuration
|
|
||||||
*/
|
|
||||||
type: "githubApp";
|
|
||||||
/**
|
|
||||||
* The hostname of the GitHub App deployment.
|
|
||||||
*/
|
|
||||||
deploymentHostname?: string;
|
|
||||||
/**
|
|
||||||
* The ID of the GitHub App.
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* The private key of the GitHub App.
|
|
||||||
*/
|
|
||||||
privateKey?:
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* The name of the secret that contains the token.
|
|
||||||
*/
|
|
||||||
secret: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* The name of the environment variable that contains the token. Only supported in declarative connection configs.
|
|
||||||
*/
|
|
||||||
env: string;
|
|
||||||
};
|
|
||||||
} & {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
@ -4279,11 +4279,9 @@ const schema = {
|
||||||
"items": {
|
"items": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"title": "AppConfig",
|
"title": "AppConfig",
|
||||||
"oneOf": [
|
"definitions": {
|
||||||
{
|
"GitHubAppConfig": {
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "GithubAppConfig",
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"const": "githubApp",
|
"const": "githubApp",
|
||||||
|
|
@ -4337,19 +4335,70 @@ const schema = {
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"type",
|
"type",
|
||||||
"id"
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"type": "object",
|
||||||
"privateKey"
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "githubApp",
|
||||||
|
"description": "GitHub App Configuration"
|
||||||
|
},
|
||||||
|
"deploymentHostname": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "hostname",
|
||||||
|
"default": "github.com",
|
||||||
|
"description": "The hostname of the GitHub App deployment.",
|
||||||
|
"examples": [
|
||||||
|
"github.com",
|
||||||
|
"github.example.com"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the GitHub App."
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"anyOf": [
|
||||||
{
|
{
|
||||||
"required": [
|
"type": "object",
|
||||||
"privateKeyPath"
|
"properties": {
|
||||||
]
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"env": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the environment variable that contains the token. Only supported in declarative connection configs."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"env"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "The private key of the GitHub App."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"privateKey"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,7 @@ export type LanguageModel =
|
||||||
| OpenAICompatibleLanguageModel
|
| OpenAICompatibleLanguageModel
|
||||||
| OpenRouterLanguageModel
|
| OpenRouterLanguageModel
|
||||||
| XaiLanguageModel;
|
| XaiLanguageModel;
|
||||||
export type AppConfig = GithubAppConfig;
|
export type AppConfig = GitHubAppConfig;
|
||||||
export type GithubAppConfig = {
|
|
||||||
[k: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SourcebotConfig {
|
export interface SourcebotConfig {
|
||||||
$schema?: string;
|
$schema?: string;
|
||||||
|
|
@ -1073,3 +1070,33 @@ export interface XaiLanguageModel {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
headers?: LanguageModelHeaders;
|
headers?: LanguageModelHeaders;
|
||||||
}
|
}
|
||||||
|
export interface GitHubAppConfig {
|
||||||
|
/**
|
||||||
|
* GitHub App Configuration
|
||||||
|
*/
|
||||||
|
type: "githubApp";
|
||||||
|
/**
|
||||||
|
* The hostname of the GitHub App deployment.
|
||||||
|
*/
|
||||||
|
deploymentHostname?: string;
|
||||||
|
/**
|
||||||
|
* The ID of the GitHub App.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* The private key of the GitHub App.
|
||||||
|
*/
|
||||||
|
privateKey:
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the secret that contains the token.
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the environment variable that contains the token. Only supported in declarative connection configs.
|
||||||
|
*/
|
||||||
|
env: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,4 @@ export {
|
||||||
isRemotePath,
|
isRemotePath,
|
||||||
getConfigSettings,
|
getConfigSettings,
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
export {
|
|
||||||
syncSearchContexts,
|
|
||||||
} from "./ee/syncSearchContexts.js";
|
|
||||||
export * from "./constants.js";
|
export * from "./constants.js";
|
||||||
|
|
@ -113,7 +113,6 @@
|
||||||
"ai": "^5.0.45",
|
"ai": "^5.0.45",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chokidar": "^4.0.3",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { prisma } from "@/prisma";
|
||||||
import { render } from "@react-email/components";
|
import { render } from "@react-email/components";
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
import { encrypt, generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/crypto";
|
||||||
import { ApiKey, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db";
|
import { ApiKey, ConnectionSyncJobStatus, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||||
|
|
@ -30,7 +30,7 @@ import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail";
|
||||||
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
|
||||||
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
|
||||||
import { ApiKeyPayload, TenancyMode } from "./lib/types";
|
import { ApiKeyPayload, TenancyMode } from "./lib/types";
|
||||||
import { withOptionalAuthV2 } from "./withAuthV2";
|
import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
|
||||||
|
|
||||||
const logger = createLogger('web-actions');
|
const logger = createLogger('web-actions');
|
||||||
const auditService = getAuditService();
|
const auditService = getAuditService();
|
||||||
|
|
@ -593,6 +593,7 @@ export const getReposStats = async () => sew(() =>
|
||||||
prisma.repo.count({
|
prisma.repo.count({
|
||||||
where: {
|
where: {
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
|
indexedAt: null,
|
||||||
jobs: {
|
jobs: {
|
||||||
some: {
|
some: {
|
||||||
type: RepoIndexingJobType.INDEX,
|
type: RepoIndexingJobType.INDEX,
|
||||||
|
|
@ -604,7 +605,6 @@ export const getReposStats = async () => sew(() =>
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
indexedAt: null,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.repo.count({
|
prisma.repo.count({
|
||||||
|
|
@ -625,6 +625,42 @@ export const getReposStats = async () => sew(() =>
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const getConnectionStats = async () => sew(() =>
|
||||||
|
withAuthV2(async ({ org, prisma }) => {
|
||||||
|
const [
|
||||||
|
numberOfConnections,
|
||||||
|
numberOfConnectionsWithFirstTimeSyncJobsInProgress,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.connection.count({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.connection.count({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
syncedAt: null,
|
||||||
|
syncJobs: {
|
||||||
|
some: {
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
ConnectionSyncJobStatus.PENDING,
|
||||||
|
ConnectionSyncJobStatus.IN_PROGRESS,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numberOfConnections,
|
||||||
|
numberOfConnectionsWithFirstTimeSyncJobsInProgress,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
export const getRepoInfoByName = async (repoName: string) => sew(() =>
|
||||||
withOptionalAuthV2(async ({ org, prisma }) => {
|
withOptionalAuthV2(async ({ org, prisma }) => {
|
||||||
// @note: repo names are represented by their remote url
|
// @note: repo names are represented by their remote url
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import { DemoExamples, DemoSearchExample, DemoSearchScope } from "@/types";
|
import { DemoExamples, DemoSearchExample, DemoSearchScope } from "@/types";
|
||||||
import { cn, getCodeHostIcon } from "@/lib/utils";
|
import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
import { SearchScopeInfoCard } from "@/features/chat/components/chatBox/searchScopeInfoCard";
|
||||||
|
|
||||||
|
|
@ -41,8 +41,7 @@ export const DemoCards = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchScope.codeHostType) {
|
if (searchScope.codeHostType) {
|
||||||
const codeHostIcon = getCodeHostIcon(searchScope.codeHostType);
|
const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType);
|
||||||
if (codeHostIcon) {
|
|
||||||
// When selected, icons need to match the inverted badge colors
|
// When selected, icons need to match the inverted badge colors
|
||||||
// In light mode selected: light icon on dark bg (invert)
|
// In light mode selected: light icon on dark bg (invert)
|
||||||
// In dark mode selected: dark icon on light bg (no invert, override dark:invert)
|
// In dark mode selected: dark icon on light bg (no invert, override dark:invert)
|
||||||
|
|
@ -60,7 +59,6 @@ export const DemoCards = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return <Code className={cn(sizeClass, colorClass)} />;
|
return <Code className={cn(sizeClass, colorClass)} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
packages/web/src/app/[domain]/components/backButton.tsx
Normal file
20
packages/web/src/app/[domain]/components/backButton.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
interface BackButtonProps {
|
||||||
|
href: string;
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackButton({ href, name, className }: BackButtonProps) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={cn("inline-flex items-center text-link transition-colors group", className)}>
|
||||||
|
<span className="inline-flex items-center gap-1.5 border-b border-transparent group-hover:border-link pb-0.5">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span>{name}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getRepos, getReposStats } from "@/actions";
|
import { getConnectionStats, getRepos, getReposStats } from "@/actions";
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -39,6 +39,11 @@ export const NavigationMenu = async ({
|
||||||
throw new ServiceErrorException(repoStats);
|
throw new ServiceErrorException(repoStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const connectionStats = isAuthenticated ? await getConnectionStats() : null;
|
||||||
|
if (isServiceError(connectionStats)) {
|
||||||
|
throw new ServiceErrorException(connectionStats);
|
||||||
|
}
|
||||||
|
|
||||||
const sampleRepos = await getRepos({
|
const sampleRepos = await getRepos({
|
||||||
where: {
|
where: {
|
||||||
jobs: {
|
jobs: {
|
||||||
|
|
@ -93,7 +98,12 @@ export const NavigationMenu = async ({
|
||||||
<NavigationItems
|
<NavigationItems
|
||||||
domain={domain}
|
domain={domain}
|
||||||
numberOfRepos={numberOfRepos}
|
numberOfRepos={numberOfRepos}
|
||||||
numberOfReposWithFirstTimeIndexingJobsInProgress={numberOfReposWithFirstTimeIndexingJobsInProgress}
|
isReposButtonNotificationDotVisible={numberOfReposWithFirstTimeIndexingJobsInProgress > 0}
|
||||||
|
isSettingsButtonNotificationDotVisible={
|
||||||
|
connectionStats ?
|
||||||
|
connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0 :
|
||||||
|
false
|
||||||
|
}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
/>
|
/>
|
||||||
</NavigationMenuBase>
|
</NavigationMenuBase>
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,23 @@
|
||||||
import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
|
import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn, getShortenedNumberDisplayString } from "@/lib/utils";
|
import { cn, getShortenedNumberDisplayString } from "@/lib/utils";
|
||||||
import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, CircleIcon } from "lucide-react";
|
import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { NotificationDot } from "../notificationDot";
|
||||||
|
|
||||||
interface NavigationItemsProps {
|
interface NavigationItemsProps {
|
||||||
domain: string;
|
domain: string;
|
||||||
numberOfRepos: number;
|
numberOfRepos: number;
|
||||||
numberOfReposWithFirstTimeIndexingJobsInProgress: number;
|
isReposButtonNotificationDotVisible: boolean;
|
||||||
|
isSettingsButtonNotificationDotVisible: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavigationItems = ({
|
export const NavigationItems = ({
|
||||||
domain,
|
domain,
|
||||||
numberOfRepos,
|
numberOfRepos,
|
||||||
numberOfReposWithFirstTimeIndexingJobsInProgress,
|
isReposButtonNotificationDotVisible,
|
||||||
|
isSettingsButtonNotificationDotVisible,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
}: NavigationItemsProps) => {
|
}: NavigationItemsProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
@ -59,9 +62,7 @@ export const NavigationItems = ({
|
||||||
<span className="mr-2">Repositories</span>
|
<span className="mr-2">Repositories</span>
|
||||||
<Badge variant="secondary" className="px-1.5 relative">
|
<Badge variant="secondary" className="px-1.5 relative">
|
||||||
{getShortenedNumberDisplayString(numberOfRepos)}
|
{getShortenedNumberDisplayString(numberOfRepos)}
|
||||||
{numberOfReposWithFirstTimeIndexingJobsInProgress > 0 && (
|
{isReposButtonNotificationDotVisible && <NotificationDot className="absolute -right-0.5 -top-0.5" />}
|
||||||
<CircleIcon className="absolute -right-0.5 -top-0.5 h-2 w-2 text-green-600" fill="currentColor" />
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
{isActive(`/${domain}/repos`) && <ActiveIndicator />}
|
{isActive(`/${domain}/repos`) && <ActiveIndicator />}
|
||||||
|
|
@ -74,6 +75,7 @@ export const NavigationItems = ({
|
||||||
>
|
>
|
||||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||||
Settings
|
Settings
|
||||||
|
{isSettingsButtonNotificationDotVisible && <NotificationDot className="absolute -right-0.5 -top-0.5" />}
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
{isActive(`/${domain}/settings`) && <ActiveIndicator />}
|
{isActive(`/${domain}/settings`) && <ActiveIndicator />}
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
import { RepositoryQuery } from "@/lib/types";
|
import { RepositoryQuery } from "@/lib/types";
|
||||||
import { getCodeHostInfoForRepo, getShortenedNumberDisplayString } from "@/lib/utils";
|
import { getCodeHostInfoForRepo, getShortenedNumberDisplayString } from "@/lib/utils";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
@ -110,13 +111,14 @@ const RepoItem = ({ repo }: { repo: RepositoryQuery }) => {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
className={clsx("flex flex-row items-center gap-2 border rounded-md p-2 text-clip")}
|
||||||
|
href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repo.repoId}`}
|
||||||
>
|
>
|
||||||
{repoIcon}
|
{repoIcon}
|
||||||
<span className="text-sm truncate">
|
<span className="text-sm truncate">
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface NotificationDotProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationDot = ({ className }: NotificationDotProps) => {
|
||||||
|
return <div className={cn("w-2 h-2 rounded-full bg-green-600", className)} />
|
||||||
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ export function RepositoryCarousel({
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
<>
|
<>
|
||||||
Create a{" "}
|
Create a{" "}
|
||||||
<Link href={`https://docs.sourcebot.dev/docs/connections/overview`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
<Link href={`/${domain}/settings/connections`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
||||||
connection
|
connection
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to start indexing repositories
|
to start indexing repositories
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,21 @@ import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { env } from "@/env.mjs"
|
||||||
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
||||||
import { ServiceErrorException } from "@/lib/serviceError"
|
import { ServiceErrorException } from "@/lib/serviceError"
|
||||||
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"
|
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils"
|
||||||
import { withOptionalAuthV2 } from "@/withAuthV2"
|
import { withOptionalAuthV2 } from "@/withAuthV2"
|
||||||
import { ChevronLeft, ExternalLink, Info } from "lucide-react"
|
import { getConfigSettings, repoMetadataSchema } from "@sourcebot/shared"
|
||||||
|
import { ExternalLink, Info } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { RepoJobsTable } from "../components/repoJobsTable"
|
import { BackButton } from "../../components/backButton"
|
||||||
import { getConfigSettings } from "@sourcebot/shared"
|
|
||||||
import { env } from "@/env.mjs"
|
|
||||||
import { DisplayDate } from "../../components/DisplayDate"
|
import { DisplayDate } from "../../components/DisplayDate"
|
||||||
import { RepoBranchesTable } from "../components/repoBranchesTable"
|
import { RepoBranchesTable } from "../components/repoBranchesTable"
|
||||||
import { repoMetadataSchema } from "@sourcebot/shared"
|
import { RepoJobsTable } from "../components/repoJobsTable"
|
||||||
|
|
||||||
export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function RepoDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
@ -52,14 +52,13 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
const repoMetadata = repoMetadataSchema.parse(repo.metadata);
|
const repoMetadata = repoMetadataSchema.parse(repo.metadata);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button variant="ghost" asChild className="mb-4">
|
<BackButton
|
||||||
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos`}>
|
href={`/${SINGLE_TENANT_ORG_DOMAIN}/repos`}
|
||||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
name="Back to repositories"
|
||||||
Back to repositories
|
className="mb-2"
|
||||||
</Link>
|
/>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -103,7 +102,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DisplayDate date={repo.createdAt} className="text-2xl font-semibold" />
|
<span className="text-2xl font-semibold"><DisplayDate date={repo.createdAt} /></span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -122,7 +121,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{repo.indexedAt ? <DisplayDate date={repo.indexedAt} className="text-2xl font-semibold" /> : "Never"}
|
<span className="text-2xl font-semibold">{repo.indexedAt ? <DisplayDate date={repo.indexedAt} /> : "Never"}</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -141,7 +140,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{nextIndexAttempt ? <DisplayDate date={nextIndexAttempt} className="text-2xl font-semibold" /> : "-"}
|
<span className="text-2xl font-semibold">{nextIndexAttempt ? <DisplayDate date={nextIndexAttempt} /> : "-"}</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -168,7 +167,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Indexing Jobs</CardTitle>
|
<CardTitle>Indexing History</CardTitle>
|
||||||
<CardDescription>History of all indexing and cleanup jobs for this repository.</CardDescription>
|
<CardDescription>History of all indexing and cleanup jobs for this repository.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -177,16 +176,17 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id:
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRepoWithJobs = async (repoId: number) => sew(() =>
|
const getRepoWithJobs = async (repoId: number) => sew(() =>
|
||||||
withOptionalAuthV2(async ({ prisma }) => {
|
withOptionalAuthV2(async ({ prisma, org }) => {
|
||||||
|
|
||||||
const repo = await prisma.repo.findUnique({
|
const repo = await prisma.repo.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: repoId,
|
id: repoId,
|
||||||
|
orgId: org.id,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
jobs: {
|
jobs: {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { useRouter } from "next/navigation"
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { DisplayDate } from "../../components/DisplayDate"
|
import { DisplayDate } from "../../components/DisplayDate"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { NotificationDot } from "../../components/notificationDot"
|
||||||
|
|
||||||
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
|
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ export type Repo = {
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
indexedCommitHash: string | null
|
indexedCommitHash: string | null
|
||||||
latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null
|
latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null
|
||||||
|
isFirstTimeIndex: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBadgeVariants = cva("", {
|
const statusBadgeVariants = cva("", {
|
||||||
|
|
@ -111,14 +113,32 @@ export const columns: ColumnDef<Repo>[] = [
|
||||||
{repo.displayName?.charAt(0) ?? repo.name.charAt(0)}
|
{repo.displayName?.charAt(0) ?? repo.name.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Link href={getBrowsePath({
|
|
||||||
|
{/* Link to the details page (instead of browse) when the repo is indexing
|
||||||
|
as the code will not be available yet */}
|
||||||
|
<Link
|
||||||
|
href={repo.isFirstTimeIndex ? `/${SINGLE_TENANT_ORG_DOMAIN}/repos/${repo.id}` : getBrowsePath({
|
||||||
repoName: repo.name,
|
repoName: repo.name,
|
||||||
path: '/',
|
path: '/',
|
||||||
pathType: 'tree',
|
pathType: 'tree',
|
||||||
domain: SINGLE_TENANT_ORG_DOMAIN,
|
domain: SINGLE_TENANT_ORG_DOMAIN,
|
||||||
})} className="font-medium hover:underline">
|
})}
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
>
|
||||||
{repo.displayName || repo.name}
|
{repo.displayName || repo.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
{repo.isFirstTimeIndex && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<NotificationDot className="ml-1.5" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<span>This is the first time Sourcebot is indexing this repository. It may take a few minutes to complete.</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -150,7 +170,7 @@ export const columns: ColumnDef<Repo>[] = [
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisplayDate date={indexedAt} className="ml-3"/>
|
<DisplayDate date={indexedAt} className="ml-3" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -331,7 +351,7 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table style={{ tableLayout: 'fixed', width: '100%' }}>
|
<Table style={{ width: '100%' }}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
import { NavigationMenu } from "../components/navigationMenu";
|
import { NavigationMenu } from "../components/navigationMenu";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getCurrentUserRole, getReposStats } from "@/actions";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
|
import { OrgRole } from "@sourcebot/db";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -12,11 +19,28 @@ export default async function Layout(
|
||||||
const { domain } = params;
|
const { domain } = params;
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
|
const repoStats = await getReposStats();
|
||||||
|
if (isServiceError(repoStats)) {
|
||||||
|
throw new ServiceErrorException(repoStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoleInOrg = await getCurrentUserRole(domain);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<NavigationMenu domain={domain} />
|
<NavigationMenu domain={domain} />
|
||||||
|
{(repoStats.numberOfRepos === 0 && userRoleInOrg === OrgRole.OWNER) && (
|
||||||
|
<div className="w-full flex flex-row justify-center items-center bg-accent py-0.5">
|
||||||
|
<InfoIcon className="w-4 h-4 mr-1" />
|
||||||
|
<span><span className="font-medium">No repositories configured.</span> Create a <Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections`} className="text-link hover:underline">connection</Link> to get started.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
|
<main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
|
||||||
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
|
<div className="w-full max-w-6xl rounded-lg p-6">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,33 @@ import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { withOptionalAuthV2 } from "@/withAuthV2";
|
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||||
import { ReposTable } from "./components/reposTable";
|
import { ReposTable } from "./components/reposTable";
|
||||||
|
import { RepoIndexingJobStatus } from "@sourcebot/db";
|
||||||
|
|
||||||
export default async function ReposPage() {
|
export default async function ReposPage() {
|
||||||
|
|
||||||
const repos = await getReposWithLatestJob();
|
const _repos = await getReposWithLatestJob();
|
||||||
if (isServiceError(repos)) {
|
if (isServiceError(_repos)) {
|
||||||
throw new ServiceErrorException(repos);
|
throw new ServiceErrorException(_repos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repos = _repos
|
||||||
|
.map((repo) => ({
|
||||||
|
...repo,
|
||||||
|
latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null,
|
||||||
|
isFirstTimeIndex: repo.indexedAt === null && repo.jobs.filter((job) => job.status === RepoIndexingJobStatus.PENDING || job.status === RepoIndexingJobStatus.IN_PROGRESS).length > 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isFirstTimeIndex && !b.isFirstTimeIndex) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!a.isFirstTimeIndex && b.isFirstTimeIndex) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto">
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-3xl font-semibold">Repositories</h1>
|
<h1 className="text-3xl font-semibold">Repositories</h1>
|
||||||
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
|
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
|
||||||
|
|
@ -27,16 +44,17 @@ export default async function ReposPage() {
|
||||||
createdAt: repo.createdAt,
|
createdAt: repo.createdAt,
|
||||||
webUrl: repo.webUrl,
|
webUrl: repo.webUrl,
|
||||||
imageUrl: repo.imageUrl,
|
imageUrl: repo.imageUrl,
|
||||||
latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null,
|
latestJobStatus: repo.latestJobStatus,
|
||||||
|
isFirstTimeIndex: repo.isFirstTimeIndex,
|
||||||
codeHostType: repo.external_codeHostType,
|
codeHostType: repo.external_codeHostType,
|
||||||
indexedCommitHash: repo.indexedCommitHash,
|
indexedCommitHash: repo.indexedCommitHash,
|
||||||
}))} />
|
}))} />
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposWithLatestJob = async () => sew(() =>
|
const getReposWithLatestJob = async () => sew(() =>
|
||||||
withOptionalAuthV2(async ({ prisma }) => {
|
withOptionalAuthV2(async ({ prisma, org }) => {
|
||||||
const repos = await prisma.repo.findMany({
|
const repos = await prisma.repo.findMany({
|
||||||
include: {
|
include: {
|
||||||
jobs: {
|
jobs: {
|
||||||
|
|
@ -48,6 +66,9 @@ const getReposWithLatestJob = async () => sew(() =>
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
name: 'asc'
|
name: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return repos;
|
return repos;
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import clsx from "clsx";
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
withTopMargin?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Header = ({
|
|
||||||
children,
|
|
||||||
withTopMargin = true,
|
|
||||||
className,
|
|
||||||
}: HeaderProps) => {
|
|
||||||
return (
|
|
||||||
<div className={cn("mb-16", className)}>
|
|
||||||
{children}
|
|
||||||
<Separator className={clsx("absolute left-0 right-0", { "mt-12": withTopMargin })} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React from "react"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { NotificationDot } from "@/app/[domain]/components/notificationDot"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils"
|
import React from "react"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
|
||||||
|
export type SidebarNavItem = {
|
||||||
|
href: string
|
||||||
|
hrefRegex?: string
|
||||||
|
title: React.ReactNode
|
||||||
|
isNotificationDotVisible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
items: {
|
items: SidebarNavItem[]
|
||||||
href: string
|
|
||||||
title: React.ReactNode
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||||
|
|
@ -24,21 +29,26 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
|
const isActive = item.hrefRegex ? new RegExp(item.hrefRegex).test(pathname) : pathname === item.href;
|
||||||
|
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
pathname === item.href
|
isActive
|
||||||
? "bg-muted hover:bg-muted"
|
? "bg-muted hover:bg-muted"
|
||||||
: "hover:bg-transparent hover:underline",
|
: "hover:bg-transparent hover:underline",
|
||||||
"justify-start"
|
"justify-start"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
|
{item.isNotificationDotVisible && <NotificationDot className="ml-1.5" />}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
204
packages/web/src/app/[domain]/settings/connections/[id]/page.tsx
Normal file
204
packages/web/src/app/[domain]/settings/connections/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { sew } from "@/actions";
|
||||||
|
import { BackButton } from "@/app/[domain]/components/backButton";
|
||||||
|
import { DisplayDate } from "@/app/[domain]/components/DisplayDate";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { env } from "@/env.mjs";
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
|
||||||
|
import { notFound, ServiceErrorException } from "@/lib/serviceError";
|
||||||
|
import { CodeHostType, isServiceError } from "@/lib/utils";
|
||||||
|
import { withAuthV2 } from "@/withAuthV2";
|
||||||
|
import { AzureDevOpsConnectionConfig, BitbucketConnectionConfig, GenericGitHostConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GithubConnectionConfig, GitlabConnectionConfig } from "@sourcebot/schemas/v3/index.type";
|
||||||
|
import { getConfigSettings } from "@sourcebot/shared";
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { ConnectionJobsTable } from "../components/connectionJobsTable";
|
||||||
|
|
||||||
|
interface ConnectionDetailPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default async function ConnectionDetailPage(props: ConnectionDetailPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const connection = await getConnectionWithJobs(Number.parseInt(id));
|
||||||
|
if (isServiceError(connection)) {
|
||||||
|
throw new ServiceErrorException(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configSettings = await getConfigSettings(env.CONFIG_PATH);
|
||||||
|
|
||||||
|
const nextSyncAttempt = (() => {
|
||||||
|
const latestJob = connection.syncJobs.length > 0 ? connection.syncJobs[0] : null;
|
||||||
|
if (!latestJob) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestJob.completedAt) {
|
||||||
|
return new Date(latestJob.completedAt.getTime() + configSettings.resyncConnectionIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const codeHostUrl = (() => {
|
||||||
|
const connectionType = connection.connectionType as CodeHostType;
|
||||||
|
switch (connectionType) {
|
||||||
|
case 'github': {
|
||||||
|
const config = connection.config as unknown as GithubConnectionConfig;
|
||||||
|
return config.url ?? 'https://github.com';
|
||||||
|
}
|
||||||
|
case 'gitlab': {
|
||||||
|
const config = connection.config as unknown as GitlabConnectionConfig;
|
||||||
|
return config.url ?? 'https://gitlab.com';
|
||||||
|
}
|
||||||
|
case 'gitea': {
|
||||||
|
const config = connection.config as unknown as GiteaConnectionConfig;
|
||||||
|
return config.url ?? 'https://gitea.com';
|
||||||
|
}
|
||||||
|
case 'gerrit': {
|
||||||
|
const config = connection.config as unknown as GerritConnectionConfig;
|
||||||
|
return config.url;
|
||||||
|
}
|
||||||
|
case 'bitbucket-server': {
|
||||||
|
const config = connection.config as unknown as BitbucketConnectionConfig;
|
||||||
|
return config.url!;
|
||||||
|
}
|
||||||
|
case 'bitbucket-cloud': {
|
||||||
|
const config = connection.config as unknown as BitbucketConnectionConfig;
|
||||||
|
return config.url ?? 'https://bitbucket.org';
|
||||||
|
}
|
||||||
|
case 'azuredevops': {
|
||||||
|
const config = connection.config as unknown as AzureDevOpsConnectionConfig;
|
||||||
|
return config.url ?? 'https://dev.azure.com';
|
||||||
|
}
|
||||||
|
case 'generic-git-host': {
|
||||||
|
const config = connection.config as unknown as GenericGitHostConnectionConfig;
|
||||||
|
return config.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<BackButton
|
||||||
|
href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections`}
|
||||||
|
name="Back to connections"
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
|
<h1 className="text-3xl font-semibold">{connection.name}</h1>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={codeHostUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline text-muted-foreground"
|
||||||
|
>
|
||||||
|
{codeHostUrl}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Created
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>When this connection was first added to Sourcebot</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="text-2xl font-semibold"><DisplayDate date={connection.createdAt} /></span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Last synced
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>The last time this connection was successfully synced</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="text-2xl font-semibold">{connection.syncedAt ? <DisplayDate date={connection.syncedAt} /> : "Never"}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Scheduled
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>When the connection will be resynced next. Modifying the config will also trigger a resync.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<span className="text-2xl font-semibold">{nextSyncAttempt ? <DisplayDate date={nextSyncAttempt} /> : "-"}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sync History</CardTitle>
|
||||||
|
<CardDescription>History of all sync jobs for this connection.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Suspense fallback={<Skeleton className="h-96 w-full" />}>
|
||||||
|
<ConnectionJobsTable data={connection.syncJobs} />
|
||||||
|
</Suspense>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConnectionWithJobs = async (id: number) => sew(() =>
|
||||||
|
withAuthV2(async ({ prisma, org }) => {
|
||||||
|
const connection = await prisma.connection.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
orgId: org.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
syncJobs: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { AlertCircle, AlertTriangle, ArrowUpDown, RefreshCwIcon } from "lucide-react"
|
||||||
|
import * as React from "react"
|
||||||
|
import { CopyIconButton } from "@/app/[domain]/components/copyIconButton"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { LightweightCodeHighlighter } from "@/app/[domain]/components/lightweightCodeHighlighter"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
import { DisplayDate } from "@/app/[domain]/components/DisplayDate"
|
||||||
|
|
||||||
|
|
||||||
|
export type ConnectionSyncJob = {
|
||||||
|
id: string
|
||||||
|
status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED"
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
completedAt: Date | null
|
||||||
|
errorMessage: string | null
|
||||||
|
warningMessages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadgeVariants = cva("", {
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
COMPLETED: "bg-green-600 text-white hover:bg-green-700",
|
||||||
|
FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusBadge = (status: ConnectionSyncJob["status"]) => {
|
||||||
|
const labels = {
|
||||||
|
PENDING: "Pending",
|
||||||
|
IN_PROGRESS: "In Progress",
|
||||||
|
COMPLETED: "Completed",
|
||||||
|
FAILED: "Failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDuration = (start: Date, end: Date | null) => {
|
||||||
|
if (!end) return "-"
|
||||||
|
const diff = end.getTime() - start.getTime()
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const seconds = Math.floor((diff % 60000) / 1000)
|
||||||
|
return `${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns: ColumnDef<ConnectionSyncJob>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const job = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge(row.getValue("status"))}
|
||||||
|
{job.errorMessage ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[750px] max-h-96 overflow-scroll p-4">
|
||||||
|
<LightweightCodeHighlighter
|
||||||
|
language="text"
|
||||||
|
lineNumbers={true}
|
||||||
|
renderWhitespace={false}
|
||||||
|
>
|
||||||
|
{job.errorMessage}
|
||||||
|
</LightweightCodeHighlighter>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : job.warningMessages.length > 0 ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-[750px] max-h-96 overflow-scroll p-4">
|
||||||
|
<p className="text-sm font-medium mb-2">{job.warningMessages.length} warning(s) while syncing:</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{job.warningMessages.map((warning, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="text-sm font-mono flex flex-row items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span>{index + 1}.</span>
|
||||||
|
<span className="text-warning">{warning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
filterFn: (row, id, value) => {
|
||||||
|
return value.includes(row.getValue(id))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
Started
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => <DisplayDate date={row.getValue("createdAt") as Date} className="ml-3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "completedAt",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
Completed
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const completedAt = row.getValue("completedAt") as Date | null;
|
||||||
|
if (!completedAt) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DisplayDate date={completedAt} className="ml-3" />
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "duration",
|
||||||
|
header: "Duration",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const job = row.original
|
||||||
|
return getDuration(job.createdAt, job.completedAt)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "Job ID",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const id = row.getValue("id") as string
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs text-muted-foreground">{id}</code>
|
||||||
|
<CopyIconButton onCopy={() => {
|
||||||
|
navigator.clipboard.writeText(id);
|
||||||
|
return true;
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ConnectionJobsTable = ({ data }: { data: ConnectionSyncJob[] }) => {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([{ id: "createdAt", desc: true }])
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
numCompleted,
|
||||||
|
numInProgress,
|
||||||
|
numPending,
|
||||||
|
numFailed,
|
||||||
|
} = useMemo(() => {
|
||||||
|
return {
|
||||||
|
numCompleted: data.filter((job) => job.status === "COMPLETED").length,
|
||||||
|
numInProgress: data.filter((job) => job.status === "IN_PROGRESS").length,
|
||||||
|
numPending: data.filter((job) => job.status === "PENDING").length,
|
||||||
|
numFailed: data.filter((job) => job.status === "FAILED").length,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-4 py-4">
|
||||||
|
<Select
|
||||||
|
value={(table.getColumn("status")?.getFilterValue() as string) ?? "all"}
|
||||||
|
onValueChange={(value) => table.getColumn("status")?.setFilterValue(value === "all" ? "" : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Filter by status</SelectItem>
|
||||||
|
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
|
||||||
|
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
|
||||||
|
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
|
||||||
|
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
router.refresh();
|
||||||
|
toast({
|
||||||
|
description: "Page refreshed",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="w-3 h-3" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No sync jobs found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredRowModel().rows.length} job(s) total
|
||||||
|
</div>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,295 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { DisplayDate } from "@/app/[domain]/components/DisplayDate"
|
||||||
|
import { NotificationDot } from "@/app/[domain]/components/notificationDot"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
||||||
|
import { CodeHostType, getCodeHostIcon } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ArrowUpDown, RefreshCwIcon } from "lucide-react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export type Connection = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
syncedAt: Date | null
|
||||||
|
codeHostType: CodeHostType
|
||||||
|
latestJobStatus: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | null
|
||||||
|
isFirstTimeSync: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadgeVariants = cva("", {
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
PENDING: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
IN_PROGRESS: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
COMPLETED: "bg-green-600 text-white hover:bg-green-700",
|
||||||
|
FAILED: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
NO_JOBS: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusBadge = (status: Connection["latestJobStatus"]) => {
|
||||||
|
if (!status) {
|
||||||
|
return <Badge className={statusBadgeVariants({ status: "NO_JOBS" })}>No Jobs</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
PENDING: "Pending",
|
||||||
|
IN_PROGRESS: "In Progress",
|
||||||
|
COMPLETED: "Completed",
|
||||||
|
FAILED: "Failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns: ColumnDef<Connection>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
size: 400,
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||||
|
Name
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const connection = row.original;
|
||||||
|
const codeHostIcon = getCodeHostIcon(connection.codeHostType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<Image
|
||||||
|
src={codeHostIcon.src}
|
||||||
|
alt={`${connection.codeHostType} logo`}
|
||||||
|
className={codeHostIcon.className}
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
/>
|
||||||
|
<Link href={`/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections/${connection.id}`} className="font-medium hover:underline">
|
||||||
|
{connection.name}
|
||||||
|
</Link>
|
||||||
|
{connection.isFirstTimeSync && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>
|
||||||
|
<NotificationDot className="ml-1.5" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<span>This is the first time Sourcebot is syncing this connection. It may take a few minutes to complete.</span>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "latestJobStatus",
|
||||||
|
size: 150,
|
||||||
|
header: "Lastest status",
|
||||||
|
cell: ({ row }) => getStatusBadge(row.getValue("latestJobStatus")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "syncedAt",
|
||||||
|
size: 200,
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Last synced
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const syncedAt = row.getValue("syncedAt") as Date | null;
|
||||||
|
if (!syncedAt) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisplayDate date={syncedAt} className="ml-3" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ConnectionsTable = ({ data }: { data: Connection[] }) => {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
numCompleted,
|
||||||
|
numInProgress,
|
||||||
|
numPending,
|
||||||
|
numFailed,
|
||||||
|
numNoJobs,
|
||||||
|
} = useMemo(() => {
|
||||||
|
return {
|
||||||
|
numCompleted: data.filter((connection) => connection.latestJobStatus === "COMPLETED").length,
|
||||||
|
numInProgress: data.filter((connection) => connection.latestJobStatus === "IN_PROGRESS").length,
|
||||||
|
numPending: data.filter((connection) => connection.latestJobStatus === "PENDING").length,
|
||||||
|
numFailed: data.filter((connection) => connection.latestJobStatus === "FAILED").length,
|
||||||
|
numNoJobs: data.filter((connection) => connection.latestJobStatus === null).length,
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
enableColumnResizing: false,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-4 py-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter connections..."
|
||||||
|
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={(table.getColumn("latestJobStatus")?.getFilterValue() as string) ?? "all"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.getColumn("latestJobStatus")?.setFilterValue(value === "all" ? "" : value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Filter by status</SelectItem>
|
||||||
|
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
|
||||||
|
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
|
||||||
|
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
|
||||||
|
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
|
||||||
|
<SelectItem value="null">No status ({numNoJobs})</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
router.refresh();
|
||||||
|
toast({
|
||||||
|
description: "Page refreshed",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="w-3 h-3" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table style={{ width: '100%' }}>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
style={{ width: `${header.getSize()}px` }}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{ width: `${cell.column.getSize()}px` }}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{table.getFilteredRowModel().rows.length} {data.length > 1 ? 'connections' : 'connection'} total
|
||||||
|
</div>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { getMe } from "@/actions";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { OrgRole } from "@sourcebot/db";
|
||||||
|
|
||||||
|
|
||||||
|
interface ConnectionsLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{
|
||||||
|
domain: string
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ConnectionsLayout({ children, params }: ConnectionsLayoutProps) {
|
||||||
|
const { domain } = await params;
|
||||||
|
|
||||||
|
const org = await getOrgFromDomain(domain);
|
||||||
|
if (!org) {
|
||||||
|
throw new Error("Organization not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = await getMe();
|
||||||
|
if (isServiceError(me)) {
|
||||||
|
throw new ServiceErrorException(me);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role;
|
||||||
|
if (!userRoleInOrg) {
|
||||||
|
throw new Error("User role not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRoleInOrg !== OrgRole.OWNER) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
77
packages/web/src/app/[domain]/settings/connections/page.tsx
Normal file
77
packages/web/src/app/[domain]/settings/connections/page.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { sew } from "@/actions";
|
||||||
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
|
import { CodeHostType, isServiceError } from "@/lib/utils";
|
||||||
|
import { withAuthV2 } from "@/withAuthV2";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ConnectionsTable } from "./components/connectionsTable";
|
||||||
|
import { ConnectionSyncJobStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const DOCS_URL = "https://docs.sourcebot.dev/docs/connections/overview";
|
||||||
|
|
||||||
|
export default async function ConnectionsPage() {
|
||||||
|
const _connections = await getConnectionsWithLatestJob();
|
||||||
|
if (isServiceError(_connections)) {
|
||||||
|
throw new ServiceErrorException(_connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort connections so that first time syncs are at the top.
|
||||||
|
const connections = _connections
|
||||||
|
.map((connection) => ({
|
||||||
|
...connection,
|
||||||
|
isFirstTimeSync: connection.syncedAt === null && connection.syncJobs.filter((job) => job.status === ConnectionSyncJobStatus.PENDING || job.status === ConnectionSyncJobStatus.IN_PROGRESS).length > 0,
|
||||||
|
latestJobStatus: connection.syncJobs.length > 0 ? connection.syncJobs[0].status : null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.isFirstTimeSync && !b.isFirstTimeSync) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!a.isFirstTimeSync && b.isFirstTimeSync) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Code Host Connections</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Manage your connections to external code hosts. <Link href={DOCS_URL} target="_blank" className="text-link hover:underline">Learn more</Link></p>
|
||||||
|
</div>
|
||||||
|
<ConnectionsTable data={connections.map((connection) => ({
|
||||||
|
id: connection.id,
|
||||||
|
name: connection.name,
|
||||||
|
codeHostType: connection.connectionType as CodeHostType,
|
||||||
|
syncedAt: connection.syncedAt,
|
||||||
|
latestJobStatus: connection.latestJobStatus,
|
||||||
|
isFirstTimeSync: connection.isFirstTimeSync,
|
||||||
|
}))} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConnectionsWithLatestJob = async () => sew(() =>
|
||||||
|
withAuthV2(async ({ prisma, org }) => {
|
||||||
|
const connections = await prisma.connection.findMany({
|
||||||
|
where: {
|
||||||
|
orgId: org.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
syncJobs: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
syncJobs: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
},
|
||||||
|
take: 1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: 'asc'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return connections;
|
||||||
|
}));
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Metadata } from "next"
|
import { Metadata } from "next"
|
||||||
import { SidebarNav } from "./components/sidebar-nav"
|
import { SidebarNav, SidebarNavItem } from "./components/sidebar-nav"
|
||||||
import { NavigationMenu } from "../components/navigationMenu"
|
import { NavigationMenu } from "../components/navigationMenu"
|
||||||
import { Header } from "./components/header";
|
|
||||||
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { getMe, getOrgAccountRequests } from "@/actions";
|
import { getConnectionStats, getMe, getOrgAccountRequests } from "@/actions";
|
||||||
import { ServiceErrorException } from "@/lib/serviceError";
|
import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
import { OrgRole } from "@prisma/client";
|
import { OrgRole } from "@prisma/client";
|
||||||
|
|
@ -64,7 +63,12 @@ export default async function SettingsLayout(
|
||||||
numJoinRequests = requests.length;
|
numJoinRequests = requests.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarNavItems = [
|
const connectionStats = await getConnectionStats();
|
||||||
|
if (isServiceError(connectionStats)) {
|
||||||
|
throw new ServiceErrorException(connectionStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarNavItems: SidebarNavItem[] = [
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: `/${domain}/settings`,
|
href: `/${domain}/settings`,
|
||||||
|
|
@ -94,6 +98,14 @@ export default async function SettingsLayout(
|
||||||
),
|
),
|
||||||
href: `/${domain}/settings/members`,
|
href: `/${domain}/settings/members`,
|
||||||
}] : []),
|
}] : []),
|
||||||
|
...(userRoleInOrg === OrgRole.OWNER ? [
|
||||||
|
{
|
||||||
|
title: "Connections",
|
||||||
|
href: `/${domain}/settings/connections`,
|
||||||
|
hrefRegex: `/${domain}/settings/connections(/[^/]+)?$`,
|
||||||
|
isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0,
|
||||||
|
}
|
||||||
|
] : []),
|
||||||
{
|
{
|
||||||
title: "Secrets",
|
title: "Secrets",
|
||||||
href: `/${domain}/settings/secrets`,
|
href: `/${domain}/settings/secrets`,
|
||||||
|
|
@ -115,14 +127,15 @@ export default async function SettingsLayout(
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-backgroundSecondary">
|
<div className="min-h-screen flex flex-col">
|
||||||
<NavigationMenu domain={domain} />
|
<NavigationMenu domain={domain} />
|
||||||
<div className="flex-grow flex justify-center p-4 relative">
|
<main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
|
||||||
<div className="w-full max-w-6xl p-6">
|
<div className="w-full max-w-6xl rounded-lg p-6">
|
||||||
<Header className="w-full">
|
<div className="container mx-auto">
|
||||||
<h1 className="text-3xl">Settings</h1>
|
<div className="mb-16">
|
||||||
</Header>
|
<h1 className="text-3xl font-semibold">Settings</h1>
|
||||||
<div className="flex flex-row gap-10 mt-20">
|
</div>
|
||||||
|
<div className="flex flex-row gap-10">
|
||||||
<aside className="lg:w-48">
|
<aside className="lg:w-48">
|
||||||
<SidebarNav items={sidebarNavItems} />
|
<SidebarNav items={sidebarNavItems} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -130,6 +143,8 @@ export default async function SettingsLayout(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
|
||||||
<CardContent className="flex flex-row gap-4 w-full justify-center">
|
<CardContent className="flex flex-row gap-4 w-full justify-center">
|
||||||
<CodeHostIconButton
|
<CodeHostIconButton
|
||||||
name="GitHub"
|
name="GitHub"
|
||||||
logo={getCodeHostIcon("github")!}
|
logo={getCodeHostIcon("github")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCodeHost("github");
|
setSelectedCodeHost("github");
|
||||||
setIsImportSecretDialogOpen(true);
|
setIsImportSecretDialogOpen(true);
|
||||||
|
|
@ -35,7 +35,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
|
||||||
/>
|
/>
|
||||||
<CodeHostIconButton
|
<CodeHostIconButton
|
||||||
name="GitLab"
|
name="GitLab"
|
||||||
logo={getCodeHostIcon("gitlab")!}
|
logo={getCodeHostIcon("gitlab")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCodeHost("gitlab");
|
setSelectedCodeHost("gitlab");
|
||||||
setIsImportSecretDialogOpen(true);
|
setIsImportSecretDialogOpen(true);
|
||||||
|
|
@ -43,7 +43,7 @@ export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
|
||||||
/>
|
/>
|
||||||
<CodeHostIconButton
|
<CodeHostIconButton
|
||||||
name="Gitea"
|
name="Gitea"
|
||||||
logo={getCodeHostIcon("gitea")!}
|
logo={getCodeHostIcon("gitea")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCodeHost("gitea");
|
setSelectedCodeHost("gitea");
|
||||||
setIsImportSecretDialogOpen(true);
|
setIsImportSecretDialogOpen(true);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { headers } from 'next/headers';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { prisma } from '@/prisma';
|
import { prisma } from '@/prisma';
|
||||||
import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db';
|
import { StripeSubscriptionStatus } from '@sourcebot/db';
|
||||||
import { stripeClient } from '@/ee/features/billing/stripe';
|
import { stripeClient } from '@/ee/features/billing/stripe';
|
||||||
import { env } from '@/env.mjs';
|
import { env } from '@/env.mjs';
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
|
@ -85,16 +85,6 @@ export async function POST(req: NextRequest) {
|
||||||
});
|
});
|
||||||
logger.info(`Org ${org.id} subscription status updated to ACTIVE`);
|
logger.info(`Org ${org.id} subscription status updated to ACTIVE`);
|
||||||
|
|
||||||
// mark all of this org's connections for sync, since their repos may have been previously garbage collected
|
|
||||||
await prisma.connection.updateMany({
|
|
||||||
where: {
|
|
||||||
orgId: org.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
syncStatus: ConnectionSyncStatus.SYNC_NEEDED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ received: true }), {
|
return new Response(JSON.stringify({ received: true }), {
|
||||||
status: 200
|
status: 200
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { cn, getCodeHostIcon } from "@/lib/utils";
|
import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils";
|
||||||
import { FolderIcon, LibraryBigIcon } from "lucide-react";
|
import { LibraryBigIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { SearchScope } from "../types";
|
import { SearchScope } from "../types";
|
||||||
|
|
||||||
|
|
@ -13,8 +13,7 @@ export const SearchScopeIcon = ({ searchScope, className = "h-4 w-4" }: SearchSc
|
||||||
return <LibraryBigIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
|
return <LibraryBigIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
|
||||||
} else {
|
} else {
|
||||||
// Render code host icon for repos
|
// Render code host icon for repos
|
||||||
const codeHostIcon = searchScope.codeHostType ? getCodeHostIcon(searchScope.codeHostType) : null;
|
const codeHostIcon = getCodeHostIcon(searchScope.codeHostType as CodeHostType);
|
||||||
if (codeHostIcon) {
|
|
||||||
const size = className.includes('h-3') ? 12 : 16;
|
const size = className.includes('h-3') ? 12 : 16;
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -25,8 +24,5 @@ export const SearchScopeIcon = ({ searchScope, className = "h-4 w-4" }: SearchSc
|
||||||
className={cn(className, "flex-shrink-0", codeHostIcon.className)}
|
className={cn(className, "flex-shrink-0", codeHostIcon.className)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return <FolderIcon className={cn(className, "text-muted-foreground flex-shrink-0")} />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1,129 +1,17 @@
|
||||||
import { ConnectionSyncStatus, OrgRole, Prisma } from '@sourcebot/db';
|
|
||||||
import { env } from './env.mjs';
|
|
||||||
import { prisma } from "@/prisma";
|
|
||||||
import { SINGLE_TENANT_ORG_ID, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SINGLE_TENANT_ORG_NAME } from './lib/constants';
|
|
||||||
import chokidar from 'chokidar';
|
|
||||||
import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
|
||||||
import { hasEntitlement, loadConfig, isRemotePath, syncSearchContexts } from '@sourcebot/shared';
|
|
||||||
import { isServiceError, getOrgMetadata } from './lib/utils';
|
|
||||||
import { ServiceErrorException } from './lib/serviceError';
|
|
||||||
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
|
|
||||||
import { createLogger } from "@sourcebot/logger";
|
|
||||||
import { createGuestUser } from '@/lib/authUtils';
|
import { createGuestUser } from '@/lib/authUtils';
|
||||||
|
import { SOURCEBOT_SUPPORT_EMAIL } from "@/lib/constants";
|
||||||
|
import { prisma } from "@/prisma";
|
||||||
|
import { OrgRole } from '@sourcebot/db';
|
||||||
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
import { hasEntitlement, loadConfig } from '@sourcebot/shared';
|
||||||
import { getOrgFromDomain } from './data/org';
|
import { getOrgFromDomain } from './data/org';
|
||||||
|
import { env } from './env.mjs';
|
||||||
|
import { SINGLE_TENANT_ORG_DOMAIN, SINGLE_TENANT_ORG_ID, SOURCEBOT_GUEST_USER_ID } from './lib/constants';
|
||||||
|
import { ServiceErrorException } from './lib/serviceError';
|
||||||
|
import { getOrgMetadata, isServiceError } from './lib/utils';
|
||||||
|
|
||||||
const logger = createLogger('web-initialize');
|
const logger = createLogger('web-initialize');
|
||||||
|
|
||||||
const syncConnections = async (connections?: { [key: string]: ConnectionConfig }) => {
|
|
||||||
if (connections) {
|
|
||||||
for (const [key, newConnectionConfig] of Object.entries(connections)) {
|
|
||||||
const currentConnection = await prisma.connection.findUnique({
|
|
||||||
where: {
|
|
||||||
name_orgId: {
|
|
||||||
name: key,
|
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
repos: {
|
|
||||||
include: {
|
|
||||||
repo: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentConnectionConfig = currentConnection ? currentConnection.config as unknown as ConnectionConfig : undefined;
|
|
||||||
const syncNeededOnUpdate =
|
|
||||||
(currentConnectionConfig && JSON.stringify(currentConnectionConfig) !== JSON.stringify(newConnectionConfig)) ||
|
|
||||||
(currentConnection?.syncStatus === ConnectionSyncStatus.FAILED);
|
|
||||||
|
|
||||||
const connectionDb = await prisma.connection.upsert({
|
|
||||||
where: {
|
|
||||||
name_orgId: {
|
|
||||||
name: key,
|
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
config: newConnectionConfig as unknown as Prisma.InputJsonValue,
|
|
||||||
syncStatus: syncNeededOnUpdate ? ConnectionSyncStatus.SYNC_NEEDED : undefined,
|
|
||||||
isDeclarative: true,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: key,
|
|
||||||
connectionType: newConnectionConfig.type,
|
|
||||||
config: newConnectionConfig as unknown as Prisma.InputJsonValue,
|
|
||||||
isDeclarative: true,
|
|
||||||
org: {
|
|
||||||
connect: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Upserted connection with name '${key}'. Connection ID: ${connectionDb.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete any connections that are no longer in the config.
|
|
||||||
const deletedConnections = await prisma.connection.findMany({
|
|
||||||
where: {
|
|
||||||
isDeclarative: true,
|
|
||||||
name: {
|
|
||||||
notIn: Object.keys(connections ?? {}),
|
|
||||||
},
|
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const connection of deletedConnections) {
|
|
||||||
logger.info(`Deleting connection with name '${connection.name}'. Connection ID: ${connection.id}`);
|
|
||||||
await prisma.connection.delete({
|
|
||||||
where: {
|
|
||||||
id: connection.id,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncDeclarativeConfig = async (configPath: string) => {
|
|
||||||
const config = await loadConfig(configPath);
|
|
||||||
|
|
||||||
const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
|
|
||||||
if (forceEnableAnonymousAccess) {
|
|
||||||
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
|
|
||||||
if (!hasAnonymousAccessEntitlement) {
|
|
||||||
logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`);
|
|
||||||
} else {
|
|
||||||
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
|
||||||
if (org) {
|
|
||||||
const currentMetadata = getOrgMetadata(org);
|
|
||||||
const mergedMetadata = {
|
|
||||||
...(currentMetadata ?? {}),
|
|
||||||
anonymousAccessEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
await prisma.org.update({
|
|
||||||
where: { id: org.id },
|
|
||||||
data: {
|
|
||||||
metadata: mergedMetadata,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncConnections(config.connections);
|
|
||||||
await syncSearchContexts({
|
|
||||||
contexts: config.contexts,
|
|
||||||
orgId: SINGLE_TENANT_ORG_ID,
|
|
||||||
db: prisma,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const pruneOldGuestUser = async () => {
|
const pruneOldGuestUser = async () => {
|
||||||
// The old guest user doesn't have the GUEST role
|
// The old guest user doesn't have the GUEST role
|
||||||
const guestUser = await prisma.userToOrg.findUnique({
|
const guestUser = await prisma.userToOrg.findUnique({
|
||||||
|
|
@ -150,35 +38,6 @@ const pruneOldGuestUser = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const initSingleTenancy = async () => {
|
const initSingleTenancy = async () => {
|
||||||
// Back fill the inviteId if the org has already been created to prevent needing to wipe the db
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
const org = await tx.org.findUnique({
|
|
||||||
where: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
await tx.org.create({
|
|
||||||
data: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
|
||||||
name: SINGLE_TENANT_ORG_NAME,
|
|
||||||
domain: SINGLE_TENANT_ORG_DOMAIN,
|
|
||||||
inviteLinkId: crypto.randomUUID(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (!org.inviteLinkId) {
|
|
||||||
await tx.org.update({
|
|
||||||
where: {
|
|
||||||
id: SINGLE_TENANT_ORG_ID,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
inviteLinkId: crypto.randomUUID(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// This is needed because v4 introduces the GUEST org role as well as making authentication required.
|
// This is needed because v4 introduces the GUEST org role as well as making authentication required.
|
||||||
// To keep things simple, we'll just delete the old guest user if it exists in the DB
|
// To keep things simple, we'll just delete the old guest user if it exists in the DB
|
||||||
await pruneOldGuestUser();
|
await pruneOldGuestUser();
|
||||||
|
|
@ -205,30 +64,32 @@ const initSingleTenancy = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load any connections defined declaratively in the config file.
|
// Sync anonymous access config from the config file
|
||||||
const configPath = env.CONFIG_PATH;
|
if (env.CONFIG_PATH) {
|
||||||
if (configPath) {
|
const config = await loadConfig(env.CONFIG_PATH);
|
||||||
await syncDeclarativeConfig(configPath);
|
const forceEnableAnonymousAccess = config.settings?.enablePublicAccess ?? env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
|
||||||
|
|
||||||
// watch for changes assuming it is a local file
|
if (forceEnableAnonymousAccess) {
|
||||||
if (!isRemotePath(configPath)) {
|
if (!hasAnonymousAccessEntitlement) {
|
||||||
const watcher = chokidar.watch(configPath, {
|
logger.warn(`FORCE_ENABLE_ANONYMOUS_ACCESS env var is set to true but anonymous access entitlement is not available. Setting will be ignored.`);
|
||||||
ignoreInitial: true, // Don't fire events for existing files
|
} else {
|
||||||
awaitWriteFinish: {
|
const org = await getOrgFromDomain(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
stabilityThreshold: 100, // File size stable for 100ms
|
if (org) {
|
||||||
pollInterval: 100 // Check every 100ms
|
const currentMetadata = getOrgMetadata(org);
|
||||||
|
const mergedMetadata = {
|
||||||
|
...(currentMetadata ?? {}),
|
||||||
|
anonymousAccessEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.org.update({
|
||||||
|
where: { id: org.id },
|
||||||
|
data: {
|
||||||
|
metadata: mergedMetadata,
|
||||||
},
|
},
|
||||||
atomic: true // Handle atomic writes (temp file + rename)
|
|
||||||
});
|
});
|
||||||
|
logger.info(`Anonymous access enabled via FORCE_ENABLE_ANONYMOUS_ACCESS environment variable`);
|
||||||
watcher.on('change', async () => {
|
}
|
||||||
logger.info(`Config file ${configPath} changed. Re-syncing...`);
|
|
||||||
try {
|
|
||||||
await syncDeclarativeConfig(configPath);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to sync config: ${error}`);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const NotFoundSchema = z.object({
|
|
||||||
users: z.array(z.string()),
|
|
||||||
orgs: z.array(z.string()),
|
|
||||||
repos: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SyncStatusMetadataSchema = z.object({
|
|
||||||
notFound: NotFoundSchema.optional(),
|
|
||||||
error: z.string().optional(),
|
|
||||||
secretKey: z.string().optional(),
|
|
||||||
status: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type NotFoundData = z.infer<typeof NotFoundSchema>;
|
|
||||||
export type SyncStatusMetadata = z.infer<typeof SyncStatusMetadataSchema>;
|
|
||||||
|
|
@ -283,7 +283,7 @@ export const getCodeHostInfoForRepo = (repo: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCodeHostIcon = (codeHostType: string): { src: string, className?: string } | null => {
|
export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } => {
|
||||||
switch (codeHostType) {
|
switch (codeHostType) {
|
||||||
case "github":
|
case "github":
|
||||||
return {
|
return {
|
||||||
|
|
@ -315,8 +315,6 @@ export const getCodeHostIcon = (codeHostType: string): { src: string, className?
|
||||||
return {
|
return {
|
||||||
src: gitLogo,
|
src: gitLogo,
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,44 @@
|
||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"title": "AppConfig",
|
"title": "AppConfig",
|
||||||
|
"definitions": {
|
||||||
|
"GitHubAppConfig": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"const": "githubApp",
|
||||||
|
"description": "GitHub App Configuration"
|
||||||
|
},
|
||||||
|
"deploymentHostname": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "hostname",
|
||||||
|
"default": "github.com",
|
||||||
|
"description": "The hostname of the GitHub App deployment.",
|
||||||
|
"examples": [
|
||||||
|
"github.com",
|
||||||
|
"github.example.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The ID of the GitHub App."
|
||||||
|
},
|
||||||
|
"privateKey": {
|
||||||
|
"$ref": "./shared.json#/definitions/Token",
|
||||||
|
"description": "The private key of the GitHub App."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"privateKey"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"$ref": "./githubApp.json"
|
"$ref": "#/definitions/GitHubAppConfig"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
|
||||||
"title": "GithubAppConfig",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"const": "githubApp",
|
|
||||||
"description": "GitHub App Configuration"
|
|
||||||
},
|
|
||||||
"deploymentHostname": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "hostname",
|
|
||||||
"default": "github.com",
|
|
||||||
"description": "The hostname of the GitHub App deployment.",
|
|
||||||
"examples": [
|
|
||||||
"github.com",
|
|
||||||
"github.example.com"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the GitHub App."
|
|
||||||
},
|
|
||||||
"privateKey": {
|
|
||||||
"$ref": "./shared.json#/definitions/Token",
|
|
||||||
"description": "The private key of the GitHub App."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"type",
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"required": ["privateKey"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"required": ["privateKeyPath"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
|
|
@ -7796,6 +7796,7 @@ __metadata:
|
||||||
argparse: "npm:^2.0.1"
|
argparse: "npm:^2.0.1"
|
||||||
azure-devops-node-api: "npm:^15.1.1"
|
azure-devops-node-api: "npm:^15.1.1"
|
||||||
bullmq: "npm:^5.34.10"
|
bullmq: "npm:^5.34.10"
|
||||||
|
chokidar: "npm:^4.0.3"
|
||||||
cross-env: "npm:^7.0.3"
|
cross-env: "npm:^7.0.3"
|
||||||
cross-fetch: "npm:^4.0.0"
|
cross-fetch: "npm:^4.0.0"
|
||||||
dotenv: "npm:^16.4.5"
|
dotenv: "npm:^16.4.5"
|
||||||
|
|
@ -8055,7 +8056,6 @@ __metadata:
|
||||||
ai: "npm:^5.0.45"
|
ai: "npm:^5.0.45"
|
||||||
ajv: "npm:^8.17.1"
|
ajv: "npm:^8.17.1"
|
||||||
bcryptjs: "npm:^3.0.2"
|
bcryptjs: "npm:^3.0.2"
|
||||||
chokidar: "npm:^4.0.3"
|
|
||||||
class-variance-authority: "npm:^0.7.0"
|
class-variance-authority: "npm:^0.7.0"
|
||||||
client-only: "npm:^0.0.1"
|
client-only: "npm:^0.0.1"
|
||||||
clsx: "npm:^2.1.1"
|
clsx: "npm:^2.1.1"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue