Connection management (#183)

This commit is contained in:
Brendan Kellam 2025-02-04 15:04:05 -05:00 committed by GitHub
parent afff36f6c6
commit 846d73b0e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2757 additions and 370 deletions

View file

@ -80,54 +80,68 @@ export class ConnectionManager implements IConnectionManager {
const abortController = new AbortController();
type RepoData = WithRequired<Prisma.RepoCreateInput, 'connections'>;
const repoData: RepoData[] = await (async () => {
switch (config.type) {
case 'github': {
const gitHubRepos = await getGitHubReposFromConfig(config, orgId, this.db, abortController.signal);
const hostUrl = config.url ?? 'https://github.com';
const hostname = config.url ? new URL(config.url).hostname : 'github.com';
const repoData: RepoData[] = (
await (async () => {
switch (config.type) {
case 'github': {
const gitHubRepos = await getGitHubReposFromConfig(config, orgId, this.db, abortController.signal);
const hostUrl = config.url ?? 'https://github.com';
const hostname = config.url ? new URL(config.url).hostname : 'github.com';
return gitHubRepos.map((repo) => {
const repoName = `${hostname}/${repo.full_name}`;
const cloneUrl = new URL(repo.clone_url!);
return gitHubRepos.map((repo) => {
const repoName = `${hostname}/${repo.full_name}`;
const cloneUrl = new URL(repo.clone_url!);
const record: RepoData = {
external_id: repo.id.toString(),
external_codeHostType: 'github',
external_codeHostUrl: hostUrl,
cloneUrl: cloneUrl.toString(),
name: repoName,
isFork: repo.fork,
isArchived: !!repo.archived,
org: {
connect: {
id: orgId,
const record: RepoData = {
external_id: repo.id.toString(),
external_codeHostType: 'github',
external_codeHostUrl: hostUrl,
cloneUrl: cloneUrl.toString(),
imageUrl: repo.owner.avatar_url,
name: repoName,
isFork: repo.fork,
isArchived: !!repo.archived,
org: {
connect: {
id: orgId,
},
},
},
connections: {
create: {
connectionId: job.data.connectionId,
}
},
metadata: {
'zoekt.web-url-type': 'github',
'zoekt.web-url': repo.html_url,
'zoekt.name': repoName,
'zoekt.github-stars': (repo.stargazers_count ?? 0).toString(),
'zoekt.github-watchers': (repo.watchers_count ?? 0).toString(),
'zoekt.github-subscribers': (repo.subscribers_count ?? 0).toString(),
'zoekt.github-forks': (repo.forks_count ?? 0).toString(),
'zoekt.archived': marshalBool(repo.archived),
'zoekt.fork': marshalBool(repo.fork),
'zoekt.public': marshalBool(repo.private === false)
},
};
connections: {
create: {
connectionId: job.data.connectionId,
}
},
metadata: {
'zoekt.web-url-type': 'github',
'zoekt.web-url': repo.html_url,
'zoekt.name': repoName,
'zoekt.github-stars': (repo.stargazers_count ?? 0).toString(),
'zoekt.github-watchers': (repo.watchers_count ?? 0).toString(),
'zoekt.github-subscribers': (repo.subscribers_count ?? 0).toString(),
'zoekt.github-forks': (repo.forks_count ?? 0).toString(),
'zoekt.archived': marshalBool(repo.archived),
'zoekt.fork': marshalBool(repo.fork),
'zoekt.public': marshalBool(repo.private === false)
},
};
return record;
})
return record;
})
}
case 'gitlab': {
// @todo
return [];
}
}
}
})();
})()
)
// Filter out any duplicates by external_id and external_codeHostUrl.
.filter((repo, index, self) => {
return index === self.findIndex(r =>
r.external_id === repo.external_id &&
r.external_codeHostUrl === repo.external_codeHostUrl
);
})
// @note: to handle orphaned Repos we delete all RepoToConnection records for this connection,
// and then recreate them when we upsert the repos. For example, if a repo is no-longer

View file

@ -24,6 +24,9 @@ export type OctokitRepository = {
topics?: string[],
// @note: this is expressed in kilobytes.
size?: number,
owner: {
avatar_url: string,
}
}
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {

View file

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `name` to the `Connection` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Connection" ADD COLUMN "name" TEXT NOT NULL;

View file

@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `connectionType` to the `Connection` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Connection" ADD COLUMN "connectionType" TEXT NOT NULL;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Repo" ADD COLUMN "imageUrl" TEXT;

View file

@ -27,17 +27,17 @@ enum ConnectionSyncStatus {
}
model Repo {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
indexedAt DateTime?
isFork Boolean
isArchived Boolean
metadata Json
cloneUrl String
connections RepoToConnection[]
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
indexedAt DateTime?
isFork Boolean
isArchived Boolean
metadata Json
cloneUrl String
connections RepoToConnection[]
imageUrl String?
repoIndexingStatus RepoIndexingStatus @default(NEW)
// The id of the repo in the external service
@ -54,15 +54,18 @@ model Repo {
}
model Connection {
id Int @id @default(autoincrement())
config Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncedAt DateTime?
repos RepoToConnection[]
id Int @id @default(autoincrement())
name String
config Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncedAt DateTime?
repos RepoToConnection[]
syncStatus ConnectionSyncStatus @default(SYNC_NEEDED)
// The type of connection (e.g., github, gitlab, etc.)
connectionType String
// The organization that owns this connection
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int
@ -71,10 +74,10 @@ model Connection {
model RepoToConnection {
addedAt DateTime @default(now())
connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
connectionId Int
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
repoId Int
@@id([connectionId, repoId])
@ -113,12 +116,12 @@ model UserToOrg {
}
model Secret {
orgId Int
key String
encryptedValue String
iv String
orgId Int
key String
encryptedValue String
iv String
createdAt DateTime @default(now())
createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)

View file

@ -214,6 +214,144 @@ const schema = {
"type"
],
"additionalProperties": false
},
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "GitLabConnectionConfig",
"properties": {
"type": {
"const": "gitlab",
"description": "GitLab Configuration"
},
"token": {
"$ref": "#/oneOf/0/properties/token",
"description": "An authentication token.",
"examples": [
"secret-token",
{
"env": "ENV_VAR_CONTAINING_TOKEN"
}
]
},
"url": {
"type": "string",
"format": "url",
"default": "https://gitlab.com",
"description": "The URL of the GitLab host. Defaults to https://gitlab.com",
"examples": [
"https://gitlab.com",
"https://gitlab.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"all": {
"type": "boolean",
"default": false,
"description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ."
},
"users": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property."
},
"groups": {
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"my-group"
],
[
"my-group/sub-group-a",
"my-group/sub-group-b"
]
],
"description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)."
},
"projects": {
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"my-group/my-project"
],
[
"my-group/my-sub-group/my-project"
]
],
"description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/"
},
"topics": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.",
"examples": [
[
"docs",
"core"
]
]
},
"exclude": {
"type": "object",
"properties": {
"forks": {
"type": "boolean",
"default": false,
"description": "Exclude forked projects from syncing."
},
"archived": {
"type": "boolean",
"default": false,
"description": "Exclude archived projects from syncing."
},
"projects": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"examples": [
[
"my-group/my-project"
]
],
"description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/"
},
"topics": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.",
"examples": [
[
"tests",
"ci"
]
]
}
},
"additionalProperties": false
},
"revisions": {
"$ref": "#/oneOf/0/properties/revisions"
}
},
"required": [
"type"
],
"additionalProperties": false
}
]
} as const;

View file

@ -1,6 +1,6 @@
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
export type ConnectionConfig = GithubConnectionConfig;
export type ConnectionConfig = GithubConnectionConfig | GitLabConnectionConfig;
export interface GithubConnectionConfig {
/**
@ -92,3 +92,71 @@ export interface GitRevisions {
*/
tags?: string[];
}
export interface GitLabConnectionConfig {
/**
* GitLab Configuration
*/
type: "gitlab";
/**
* An authentication token.
*/
token?:
| string
| {
/**
* The name of the environment variable that contains the token.
*/
env: string;
}
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
};
/**
* The URL of the GitLab host. Defaults to https://gitlab.com
*/
url?: string;
/**
* Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com .
*/
all?: boolean;
/**
* List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property.
*/
users?: string[];
/**
* List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`).
*/
groups?: string[];
/**
* List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/
*/
projects?: string[];
/**
* List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.
*
* @minItems 1
*/
topics?: string[];
exclude?: {
/**
* Exclude forked projects from syncing.
*/
forks?: boolean;
/**
* Exclude archived projects from syncing.
*/
archived?: boolean;
/**
* List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/
*/
projects?: string[];
/**
* List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.
*/
topics?: string[];
};
revisions?: GitRevisions;
}

View file

@ -2,14 +2,14 @@
const schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "GitHubConfig",
"title": "GitLabConnectionConfig",
"properties": {
"type": {
"const": "github",
"description": "GitHub Configuration"
"const": "gitlab",
"description": "GitLab Configuration"
},
"token": {
"description": "A Personal Access Token (PAT).",
"description": "An authentication token.",
"examples": [
"secret-token",
{
@ -32,58 +32,75 @@ const schema = {
"env"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "The name of the secret that contains the token."
}
},
"required": [
"secret"
],
"additionalProperties": false
}
]
},
"url": {
"type": "string",
"format": "url",
"default": "https://github.com",
"description": "The URL of the GitHub host. Defaults to https://github.com",
"default": "https://gitlab.com",
"description": "The URL of the GitLab host. Defaults to https://gitlab.com",
"examples": [
"https://github.com",
"https://github.example.com"
"https://gitlab.com",
"https://gitlab.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"all": {
"type": "boolean",
"default": false,
"description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ."
},
"users": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[\\w.-]+$"
"type": "string"
},
"examples": [
[
"torvalds",
"DHH"
]
],
"description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property."
"description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property."
},
"orgs": {
"groups": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[\\w.-]+$"
"type": "string"
},
"examples": [
[
"my-org-name"
"my-group"
],
[
"sourcebot-dev",
"commaai"
"my-group/sub-group-a",
"my-group/sub-group-b"
]
],
"description": "List of organizations to sync with. All repositories in the organization visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property."
"description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)."
},
"repos": {
"projects": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
"type": "string"
},
"description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'."
"examples": [
[
"my-group/my-project"
],
[
"my-group/my-sub-group/my-project"
]
],
"description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/"
},
"topics": {
"type": "array",
@ -91,7 +108,7 @@ const schema = {
"type": "string"
},
"minItems": 1,
"description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.",
"description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.",
"examples": [
[
"docs",
@ -99,58 +116,44 @@ const schema = {
]
]
},
"tenantId": {
"type": "number",
"description": "@nocheckin"
},
"exclude": {
"type": "object",
"properties": {
"forks": {
"type": "boolean",
"default": false,
"description": "Exclude forked repositories from syncing."
"description": "Exclude forked projects from syncing."
},
"archived": {
"type": "boolean",
"default": false,
"description": "Exclude archived repositories from syncing."
"description": "Exclude archived projects from syncing."
},
"repos": {
"projects": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "List of individual repositories to exclude from syncing. Glob patterns are supported."
"examples": [
[
"my-group/my-project"
]
],
"description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/"
},
"topics": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.",
"description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.",
"examples": [
[
"tests",
"ci"
]
]
},
"size": {
"type": "object",
"description": "Exclude repositories based on their disk usage. Note: the disk usage is calculated by GitHub and may not reflect the actual disk usage when cloned.",
"properties": {
"min": {
"type": "integer",
"description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing."
},
"max": {
"type": "integer",
"description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing."
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@ -202,4 +205,4 @@ const schema = {
],
"additionalProperties": false
} as const;
export { schema as githubSchema };
export { schema as gitlabSchema };

View file

@ -0,0 +1,83 @@
// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY!
export interface GitLabConnectionConfig {
/**
* GitLab Configuration
*/
type: "gitlab";
/**
* An authentication token.
*/
token?:
| string
| {
/**
* The name of the environment variable that contains the token.
*/
env: string;
}
| {
/**
* The name of the secret that contains the token.
*/
secret: string;
};
/**
* The URL of the GitLab host. Defaults to https://gitlab.com
*/
url?: string;
/**
* Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com .
*/
all?: boolean;
/**
* List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property.
*/
users?: string[];
/**
* List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`).
*/
groups?: string[];
/**
* List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/
*/
projects?: string[];
/**
* List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.
*
* @minItems 1
*/
topics?: string[];
exclude?: {
/**
* Exclude forked projects from syncing.
*/
forks?: boolean;
/**
* Exclude archived projects from syncing.
*/
archived?: boolean;
/**
* List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/
*/
projects?: string[];
/**
* List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.
*/
topics?: string[];
};
revisions?: GitRevisions;
}
/**
* The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.
*/
export interface GitRevisions {
/**
* List of branches to include when indexing. For a given repo, only the branches that exist on the repo's remote *and* match at least one of the provided `branches` will be indexed. The default branch (HEAD) is always indexed. Glob patterns are supported.
*/
branches?: string[];
/**
* List of tags to include when indexing. For a given repo, only the tags that exist on the repo's remote *and* match at least one of the provided `tags` will be indexed. Glob patterns are supported.
*/
tags?: string[];
}

View file

@ -22,6 +22,15 @@ const nextConfig = {
// This is required to support PostHog trailing slash API requests
skipTrailingSlashRedirect: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
}
]
}
// @nocheckin: This was interfering with the the `matcher` regex in middleware.ts,
// causing regular expressions parsing errors when making a request. It's unclear
// why exactly this was happening, but it's likely due to a bad replacement happening

View file

@ -40,6 +40,7 @@
"@hookform/resolvers": "^3.9.0",
"@iconify/react": "^5.1.0",
"@iizukak/codemirror-lang-wgsl": "^0.3.0",
"@radix-ui/react-alert-dialog": "^1.1.5",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.1",
@ -48,7 +49,8 @@
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
@ -59,8 +61,8 @@
"@replit/codemirror-vim": "^6.2.1",
"@shopify/lang-jsonc": "^1.0.0",
"@sourcebot/crypto": "^0.1.0",
"@sourcebot/schemas": "^0.1.0",
"@sourcebot/db": "^0.1.0",
"@sourcebot/schemas": "^0.1.0",
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
"@tanstack/react-query": "^5.53.3",
"@tanstack/react-table": "^8.20.5",

View file

@ -8,7 +8,11 @@ import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
import { isServiceError } from "@/lib/utils";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto"
import { getConnection } from "./data/connection";
import { Prisma } from "@sourcebot/db";
const ajv = new Ajv({
validateFormats: false,
@ -141,16 +145,127 @@ export const switchActiveOrg = async (orgId: number): Promise<{ id: number } | S
}
}
export const createConnection = async (config: string): Promise<{ id: number } | ServiceError> => {
export const createConnection = async (name: string, type: string, connectionConfig: string): Promise<{ id: number } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
let parsedConfig;
const parsedConfig = parseConnectionConfig(type, connectionConfig);
if (isServiceError(parsedConfig)) {
return parsedConfig;
}
const connection = await prisma.connection.create({
data: {
orgId,
name,
config: parsedConfig as unknown as Prisma.InputJsonValue,
connectionType: type,
}
});
return {
id: connection.id,
}
}
export const updateConnectionDisplayName = async (connectionId: number, name: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
await prisma.connection.update({
where: {
id: connectionId,
orgId,
},
data: {
name,
}
});
return {
success: true,
}
}
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
const parsedConfig = parseConnectionConfig(connection.connectionType, config);
if (isServiceError(parsedConfig)) {
return parsedConfig;
}
if (connection.syncStatus === "SYNC_NEEDED" ||
connection.syncStatus === "IN_SYNC_QUEUE" ||
connection.syncStatus === "SYNCING") {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.CONNECTION_SYNC_ALREADY_SCHEDULED,
message: "Connection is already syncing. Please wait for the sync to complete before updating the connection.",
} satisfies ServiceError;
}
await prisma.connection.update({
where: {
id: connectionId,
orgId,
},
data: {
config: parsedConfig as unknown as Prisma.InputJsonValue,
syncStatus: "SYNC_NEEDED",
}
});
return {
success: true,
}
}
export const deleteConnection = async (connectionId: number): Promise<{ success: boolean } | ServiceError> => {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return orgId;
}
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
}
await prisma.connection.delete({
where: {
id: connectionId,
orgId,
}
});
return {
success: true,
}
}
const parseConnectionConfig = (connectionType: string, config: string) => {
let parsedConfig: ConnectionConfig;
try {
parsedConfig = JSON.parse(config);
} catch {
} catch (_e) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
@ -158,8 +273,24 @@ export const createConnection = async (config: string): Promise<{ id: number } |
} satisfies ServiceError;
}
// @todo: we will need to validate the config against different schemas based on the type of connection.
const isValidConfig = ajv.validate(githubSchema, parsedConfig);
const schema = (() => {
switch (connectionType) {
case "github":
return githubSchema;
case "gitlab":
return gitlabSchema;
}
})();
if (!schema) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "invalid connection type",
} satisfies ServiceError;
}
const isValidConfig = ajv.validate(schema, parsedConfig);
if (!isValidConfig) {
return {
statusCode: StatusCodes.BAD_REQUEST,
@ -168,14 +299,5 @@ export const createConnection = async (config: string): Promise<{ id: number } |
} satisfies ServiceError;
}
const connection = await prisma.connection.create({
data: {
orgId: orgId,
config: parsedConfig,
}
});
return {
id: connection.id,
}
return parsedConfig;
}

View file

@ -63,6 +63,13 @@ export const NavigationMenu = async () => {
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link href="/connections" legacyBehavior passHref>
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
Connections
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenuBase>
</div>

View file

@ -0,0 +1,25 @@
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface NotFoundProps {
message: string;
className?: string;
}
export const NotFound = ({
message,
className,
}: NotFoundProps) => {
return (
<div className={cn("m-auto", className)}>
<div className="flex flex-row items-center gap-2">
<h1 className="text-xl">404</h1>
<Separator
orientation="vertical"
className="h-5"
/>
<p className="text-sm">{message}</p>
</div>
</div>
)
}

View file

@ -1,18 +1,9 @@
import { Separator } from "@/components/ui/separator"
import { NotFound } from "./notFound"
export const PageNotFound = () => {
return (
<div className="flex h-screen">
<div className="m-auto">
<div className="flex flex-row items-center gap-2">
<h1 className="text-xl">404</h1>
<Separator
orientation="vertical"
className="h-5"
/>
<p className="text-sm">Page not found</p>
</div>
</div>
<NotFound message="Page not found" />
</div>
)
}

View file

@ -18,6 +18,7 @@ import {
VscSymbolVariable
} from "react-icons/vsc";
import { useSearchHistory } from "@/hooks/useSearchHistory";
import { getDisplayTime } from "@/lib/utils";
interface Props {
@ -155,32 +156,3 @@ const getSymbolIcon = (symbol: Symbol) => {
return VscSymbolEnum;
}
}
const getDisplayTime = (createdAt: Date) => {
const now = new Date();
const minutes = (now.getTime() - createdAt.getTime()) / (1000 * 60);
const hours = minutes / 60;
const days = hours / 24;
const months = days / 30;
const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => {
const roundedValue = Math.floor(value);
if (roundedValue < 2) {
return `${roundedValue} ${unit} ago`;
} else {
return `${roundedValue} ${unit}s ago`;
}
}
if (minutes < 1) {
return 'just now';
} else if (minutes < 60) {
return formatTime(minutes, 'minute');
} else if (hours < 24) {
return formatTime(hours, 'hour');
} else if (days < 30) {
return formatTime(days, 'day');
} else {
return formatTime(months, 'month');
}
}

View file

@ -0,0 +1,142 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { Loader2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ConfigEditor, QuickAction } from "../../components/configEditor";
import { createZodConnectionConfigValidator } from "../../utils";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { githubQuickActions, gitlabQuickActions } from "../../quickActions";
import { Schema } from "ajv";
import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { updateConnectionConfigAndScheduleSync } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils";
import { useRouter } from "next/navigation";
interface ConfigSettingProps {
connectionId: number;
config: string;
type: string;
}
export const ConfigSetting = (props: ConfigSettingProps) => {
const { type } = props;
if (type === 'github') {
return <ConfigSettingInternal<GithubConnectionConfig>
{...props}
quickActions={githubQuickActions}
schema={githubSchema}
/>;
}
if (type === 'gitlab') {
return <ConfigSettingInternal<GitLabConnectionConfig>
{...props}
quickActions={gitlabQuickActions}
schema={gitlabSchema}
/>;
}
return null;
}
function ConfigSettingInternal<T>({
connectionId,
config,
quickActions,
schema,
}: ConfigSettingProps & {
quickActions?: QuickAction<T>[],
schema: Schema,
}) {
const { toast } = useToast();
const router = useRouter();
const formSchema = useMemo(() => {
return z.object({
config: createZodConnectionConfigValidator(schema),
});
}, [schema]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
config,
},
});
const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true);
updateConnectionConfigAndScheduleSync(connectionId, data.config)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to update connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection config updated successfully.`
});
router.push(`?tab=overview`);
router.refresh();
}
})
.finally(() => {
setIsLoading(false);
})
}, [connectionId, router, toast]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<Form
{...form}
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="config"
render={({ field: { value, onChange } }) => (
<FormItem>
<FormItem>
<FormLabel className="text-lg font-semibold">Configuration</FormLabel>
{/* @todo : refactor this description into a shared file */}
<FormDescription>Code hosts are configured via a....TODO</FormDescription>
<FormControl>
<ConfigEditor<T>
value={value}
onChange={onChange}
schema={schema}
actions={quickActions ?? []}
/>
</FormControl>
<FormMessage />
</FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 flex justify-end">
<Button
size="sm"
type="submit"
disabled={isLoading}
>
{isLoading && <Loader2 className="animate-spin mr-2" />}
{isLoading ? 'Syncing...' : 'Save'}
</Button>
</div>
</form>
</Form>
</div>
);
}

View file

@ -0,0 +1,90 @@
'use client';
import { Button } from "@/components/ui/button";
import { useCallback, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { deleteConnection } from "@/actions";
import { Loader2 } from "lucide-react";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
interface DeleteConnectionSettingProps {
connectionId: number;
}
export const DeleteConnectionSetting = ({
connectionId,
}: DeleteConnectionSettingProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const handleDelete = useCallback(() => {
setIsDialogOpen(false);
setIsLoading(true);
deleteConnection(connectionId)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to delete connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection deleted successfully.`
});
router.replace("/connections");
router.refresh();
}
})
.finally(() => {
setIsLoading(false);
});
}, [connectionId]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<h2 className="text-lg font-semibold">Delete Connection</h2>
<p className="text-sm text-muted-foreground mt-2">
Permanently delete this connection from Sourcebot. All linked repositories that are not linked to any other connection will also be deleted.
</p>
<div className="flex flex-row justify-end">
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
className="mt-4"
disabled={isLoading}
>
{isLoading && <Loader2 className="animate-spin mr-2" />}
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Yes, delete connection</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}

View file

@ -0,0 +1,96 @@
'use client';
import { updateConnectionDisplayName } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(1),
});
interface DisplayNameSettingProps {
connectionId: number;
name: string;
}
export const DisplayNameSetting = ({
connectionId,
name,
}: DisplayNameSettingProps) => {
const { toast } = useToast();
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name,
},
});
const [isLoading, setIsLoading] = useState(false);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true);
updateConnectionDisplayName(connectionId, data.name)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to rename connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection renamed successfully.`
});
router.refresh();
}
}).finally(() => {
setIsLoading(false);
});
}, [connectionId, router, toast]);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<Form
{...form}
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-lg font-semibold">Display Name</FormLabel>
{/* @todo : refactor this description into a shared file */}
<FormDescription>This is the {`connection's`} display name within Sourcebot. Examples: <b>public-github</b>, <b>self-hosted-gitlab</b>, <b>gerrit-other</b>, etc.</FormDescription>
<FormControl className="max-w-lg">
<Input
{...field}
spellCheck={false}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={isLoading}
>
{isLoading && <Loader2 className="animate-spin mr-2" />}
Save
</Button>
</div>
</form>
</Form>
</div>
)
}

View file

@ -0,0 +1,82 @@
import { getDisplayTime } from "@/lib/utils";
import Image from "next/image";
import { StatusIcon } from "../../components/statusIcon";
import { RepoIndexingStatus } from "@sourcebot/db";
import { useMemo } from "react";
interface RepoListItemProps {
name: string;
status: RepoIndexingStatus;
imageUrl?: string;
indexedAt?: Date;
}
export const RepoListItem = ({
imageUrl,
name,
indexedAt,
status,
}: RepoListItemProps) => {
const statusDisplayName = useMemo(() => {
switch (status) {
case RepoIndexingStatus.NEW:
return 'Waiting...';
case RepoIndexingStatus.IN_INDEX_QUEUE:
case RepoIndexingStatus.INDEXING:
return 'Indexing...';
case RepoIndexingStatus.INDEXED:
return 'Indexed';
case RepoIndexingStatus.FAILED:
return 'Index failed';
}
}, [status]);
return (
<div
className="flex flex-row items-center p-4 border rounded-lg bg-background justify-between"
>
<div className="flex flex-row items-center gap-2">
<Image
src={imageUrl ?? ""}
alt={name}
width={40}
height={40}
className="rounded-full"
/>
<p className="font-medium">{name}</p>
</div>
<div className="flex flex-row items-center">
<StatusIcon
status={convertIndexingStatus(status)}
className="w-4 h-4 mr-1"
/>
<p className="text-sm">
<span>{statusDisplayName}</span>
{
(
status === RepoIndexingStatus.INDEXED ||
status === RepoIndexingStatus.FAILED
) && indexedAt && (
<span>{` ${getDisplayTime(indexedAt)}`}</span>
)
}
</p>
</div>
</div>
)
}
const convertIndexingStatus = (status: RepoIndexingStatus) => {
switch (status) {
case RepoIndexingStatus.NEW:
return 'waiting';
case RepoIndexingStatus.IN_INDEX_QUEUE:
case RepoIndexingStatus.INDEXING:
return 'running';
case RepoIndexingStatus.INDEXED:
return 'succeeded';
case RepoIndexingStatus.FAILED:
return 'failed';
}
}

View file

@ -0,0 +1,170 @@
import { NotFound } from "@/app/components/notFound";
import { getCurrentUserOrg } from "@/auth";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { ScrollArea } from "@/components/ui/scroll-area";
import { TabSwitcher } from "@/components/ui/tab-switcher";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { getConnection, getLinkedRepos } from "@/data/connection";
import { isServiceError } from "@/lib/utils";
import { ConnectionIcon } from "../components/connectionIcon";
import { Header } from "../components/header";
import { ConfigSetting } from "./components/configSetting";
import { DeleteConnectionSetting } from "./components/deleteConnectionSetting";
import { DisplayNameSetting } from "./components/displayNameSetting";
import { RepoListItem } from "./components/repoListItem";
interface ConnectionManagementPageProps {
params: {
id: string;
},
searchParams: {
tab?: string;
}
}
export default async function ConnectionManagementPage({
params,
searchParams,
}: ConnectionManagementPageProps) {
const orgId = await getCurrentUserOrg();
if (isServiceError(orgId)) {
return (
<>
Error: {orgId.message}
</>
)
}
const connectionId = Number(params.id);
if (isNaN(connectionId)) {
return (
<NotFound
className="flex w-full h-full items-center justify-center"
message="Connection not found"
/>
)
}
const connection = await getConnection(Number(params.id), orgId);
if (!connection) {
return (
<NotFound
className="flex w-full h-full items-center justify-center"
message="Connection not found"
/>
)
}
const linkedRepos = await getLinkedRepos(connectionId, orgId);
const currentTab = searchParams.tab || "overview";
return (
<Tabs
value={currentTab}
className="w-full"
>
<Header
className="mb-6"
withTopMargin={false}
>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/connections">Connections</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{connection.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="mt-6 flex flex-row items-center gap-4">
<ConnectionIcon
type={connection.connectionType}
/>
<h1 className="text-lg font-semibold">{connection.name}</h1>
</div>
<TabSwitcher
className="h-auto p-0 bg-transparent border-b border-border mt-6"
tabs={[
{ label: "Overview", value: "overview" },
{ label: "Settings", value: "settings" },
]}
currentTab={currentTab}
/>
</Header>
<TabsContent value="overview">
<h1 className="font-semibold text-lg">Overview</h1>
<div className="mt-4 flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Connection Type</h2>
<p className="mt-2 text-sm">{connection.connectionType}</p>
</div>
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Last Synced At</h2>
<p className="mt-2 text-sm">{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : 'never'}</p>
</div>
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Linked Repositories</h2>
<p className="mt-2 text-sm">{linkedRepos.length}</p>
</div>
<div className="rounded-lg border border-border p-4 bg-background">
<h2 className="text-sm font-medium text-muted-foreground">Status</h2>
<p className="mt-2 text-sm">{connection.syncStatus}</p>
</div>
</div>
</div>
<h1 className="font-semibold text-lg mt-8">Linked Repositories</h1>
<ScrollArea
className="mt-4 max-h-96 overflow-scroll"
>
<div className="flex flex-col gap-4">
{linkedRepos
.sort((a, b) => {
const aIndexedAt = a.repo.indexedAt ?? new Date();
const bIndexedAt = b.repo.indexedAt ?? new Date();
return bIndexedAt.getTime() - aIndexedAt.getTime();
})
.map(({ repo }) => (
<RepoListItem
key={repo.id}
imageUrl={repo.imageUrl ?? undefined}
name={repo.name}
indexedAt={repo.indexedAt ?? undefined}
status={repo.repoIndexingStatus}
/>
))}
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="settings"
className="flex flex-col gap-6"
>
<DisplayNameSetting
connectionId={connection.id}
name={connection.name}
/>
<ConfigSetting
connectionId={connection.id}
type={connection.connectionType}
config={JSON.stringify(connection.config, null, 2)}
/>
<DeleteConnectionSetting
connectionId={connection.id}
/>
</TabsContent>
</Tabs>
)
}

View file

@ -0,0 +1,149 @@
'use client';
import { ScrollArea } from "@/components/ui/scroll-area";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorView, hoverTooltip } from "@codemirror/view";
import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import {
handleRefresh,
jsonCompletion,
jsonSchemaHover,
jsonSchemaLinter,
stateExtensions
} from "codemirror-json-schema";
import { useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Schema } from "ajv";
export type QuickActionFn<T> = (previous: T) => T;
export type QuickAction<T> = {
name: string;
fn: QuickActionFn<T>;
};
interface ConfigEditorProps<T> {
value: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange: (...event: any[]) => void;
actions: QuickAction<T>[],
schema: Schema;
}
const customAutocompleteStyle = EditorView.baseTheme({
".cm-tooltip.cm-completionInfo": {
padding: "8px",
fontSize: "12px",
fontFamily: "monospace",
},
".cm-tooltip-hover.cm-tooltip": {
padding: "8px",
fontSize: "12px",
fontFamily: "monospace",
}
});
export function ConfigEditor<T>({
value,
onChange,
actions,
schema,
}: ConfigEditorProps<T>) {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const keymapExtension = useKeymapExtension(editorRef.current?.view);
const { theme } = useThemeNormalized();
const isQuickActionsDisabled = useMemo(() => {
try {
JSON.parse(value);
return false;
} catch {
return true;
}
}, [value]);
const onQuickAction = (action: QuickActionFn<T>) => {
let previousConfig: T;
try {
previousConfig = JSON.parse(value) as T;
} catch {
return;
}
const nextConfig = action(previousConfig);
const next = JSON.stringify(nextConfig, null, 2);
const cursorPos = next.lastIndexOf(`""`) + 1;
editorRef.current?.view?.focus();
editorRef.current?.view?.dispatch({
changes: {
from: 0,
to: value.length,
insert: next,
}
});
editorRef.current?.view?.dispatch({
selection: { anchor: cursorPos, head: cursorPos }
});
}
return (
<>
<div className="flex flex-row items-center flex-wrap w-full">
{actions.map(({ name, fn }, index) => (
<div
key={index}
className="flex flex-row items-center"
>
<Button
variant="ghost"
className="disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed"
disabled={isQuickActionsDisabled}
onClick={(e) => {
e.preventDefault();
onQuickAction(fn);
}}
>
{name}
</Button>
{index !== actions.length - 1 && (
<Separator
orientation="vertical" className="h-4 mx-1"
/>
)}
</div>
))}
</div>
<ScrollArea className="rounded-md border p-1 overflow-auto flex-1 h-64">
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
extensions={[
keymapExtension,
json(),
linter(jsonParseLinter(), {
delay: 300,
}),
linter(jsonSchemaLinter(), {
needsRefresh: handleRefresh,
}),
jsonLanguage.data.of({
autocomplete: jsonCompletion(),
}),
hoverTooltip(jsonSchemaHover()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stateExtensions(schema as any),
customAutocompleteStyle,
]}
theme={theme === "dark" ? "dark" : "light"}
/>
</ScrollArea>
</>
)
}

View file

@ -0,0 +1,36 @@
import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils";
import { useMemo } from "react";
import Image from "next/image";
import placeholderLogo from "@/public/placeholder_avatar.png";
interface ConnectionIconProps {
type: string;
className?: string;
}
export const ConnectionIcon = ({
type,
className,
}: ConnectionIconProps) => {
const Icon = useMemo(() => {
const iconInfo = getCodeHostIcon(type as CodeHostType);
if (iconInfo) {
return (
<Image
src={iconInfo.src}
className={cn(cn("rounded-full w-8 h-8", iconInfo.className), className)}
alt={`${type} logo`}
/>
)
}
return <Image
src={placeholderLogo}
alt={''}
className={cn("rounded-full w-8 h-8", className)}
/>
}, [type]);
return Icon;
}

View file

@ -0,0 +1,97 @@
import { Button } from "@/components/ui/button";
import { getDisplayTime } from "@/lib/utils";
import Link from "next/link";
import { useMemo } from "react";
import { ConnectionIcon } from "../connectionIcon";
import { ConnectionSyncStatus } from "@sourcebot/db";
import { StatusIcon } from "../statusIcon";
const convertSyncStatus = (status: ConnectionSyncStatus) => {
switch (status) {
case ConnectionSyncStatus.SYNC_NEEDED:
return 'waiting';
case ConnectionSyncStatus.IN_SYNC_QUEUE:
case ConnectionSyncStatus.SYNCING:
return 'running';
case ConnectionSyncStatus.SYNCED:
return 'succeeded';
case ConnectionSyncStatus.FAILED:
return 'failed';
}
}
interface ConnectionListItemProps {
id: string;
name: string;
type: string;
status: ConnectionSyncStatus;
editedAt: Date;
syncedAt?: Date;
}
export const ConnectionListItem = ({
id,
name,
type,
status,
editedAt,
syncedAt,
}: ConnectionListItemProps) => {
const statusDisplayName = useMemo(() => {
switch (status) {
case ConnectionSyncStatus.SYNC_NEEDED:
return 'Waiting...';
case ConnectionSyncStatus.IN_SYNC_QUEUE:
case ConnectionSyncStatus.SYNCING:
return 'Syncing...';
case ConnectionSyncStatus.SYNCED:
return 'Synced';
case ConnectionSyncStatus.FAILED:
return 'Sync failed';
}
}, [status]);
return (
<Link href={`/connections/${id}`}>
<div
className="flex flex-row justify-between items-center border p-4 rounded-lg cursor-pointer bg-background"
>
<div className="flex flex-row items-center gap-3">
<ConnectionIcon
type={type}
className="w-8 h-8"
/>
<div className="flex flex-col">
<p className="font-medium">{name}</p>
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
</div>
</div>
<div className="flex flex-row items-center">
<StatusIcon
status={convertSyncStatus(status)}
className="w-4 h-4 mr-1"
/>
<p className="text-sm">
<span>{statusDisplayName}</span>
{
(
status === ConnectionSyncStatus.SYNCED ||
status === ConnectionSyncStatus.FAILED
) && syncedAt && (
<span>{` ${getDisplayTime(syncedAt)}`}</span>
)
}
</p>
<Button
variant="outline"
size={"sm"}
className="ml-4"
>
Manage
</Button>
</div>
</div>
</Link>
)
}

View file

@ -0,0 +1,40 @@
import { Connection } from "@sourcebot/db"
import { ConnectionListItem } from "./connectionListItem";
import { cn } from "@/lib/utils";
import { InfoCircledIcon } from "@radix-ui/react-icons";
interface ConnectionListProps {
connections: Connection[];
className?: string;
}
export const ConnectionList = ({
connections,
className,
}: ConnectionListProps) => {
return (
<div className={cn("flex flex-col gap-4", className)}>
{connections.length > 0 ? connections
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
.map((connection) => (
<ConnectionListItem
key={connection.id}
id={connection.id.toString()}
name={connection.name}
type={connection.connectionType}
status={connection.syncStatus}
editedAt={connection.updatedAt}
syncedAt={connection.syncedAt ?? undefined}
/>
))
: (
<div className="flex flex-col items-center justify-center border rounded-md p-4 h-full">
<InfoCircledIcon className="w-7 h-7" />
<h2 className="mt-2 font-medium">No connections</h2>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,22 @@
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>
)
}

View file

@ -0,0 +1,92 @@
import { cn, CodeHostType, getCodeHostIcon } from "@/lib/utils";
import placeholderLogo from "@/public/placeholder_avatar.png";
import { BlocksIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";
interface NewConnectionCardProps {
className?: string;
}
export const NewConnectionCard = ({
className,
}: NewConnectionCardProps) => {
return (
<div className={cn("flex flex-col border rounded-lg p-4 h-fit", className)}>
<BlocksIcon className="mx-auto w-7 h-7" />
<h2 className="mx-auto mt-4 font-medium text-lg">Connect to a Code Host</h2>
<p className="mx-auto text-center text-sm text-muted-foreground font-light">Create a connection to import repos from a code host.</p>
<div className="flex flex-col gap-2 mt-4">
<Card
type="github"
title="GitHub"
subtitle="Cloud or Enterprise supported."
/>
<Card
type="gitlab"
title="GitLab"
subtitle="Cloud and Self-Hosted supported."
/>
<Card
type="gitea"
title="Gitea"
subtitle="Cloud and Self-Hosted supported."
/>
<Card
type="gerrit"
title="Gerrit"
subtitle="Cloud and Self-Hosted supported."
/>
</div>
</div>
)
}
interface CardProps {
type: string;
title: string;
subtitle: string;
}
const Card = ({
type,
title,
subtitle,
}: CardProps) => {
const Icon = useMemo(() => {
const iconInfo = getCodeHostIcon(type as CodeHostType);
if (iconInfo) {
const { src, className } = iconInfo;
return (
<Image
src={src}
className={cn("rounded-full w-7 h-7 mb-1", className)}
alt={`${type} logo`}
/>
)
}
return <Image
src={placeholderLogo}
alt={`${type} logo`}
className="rounded-full w-7 h-7 mb-1"
/>
}, [type]);
return (
<Link
className="flex flex-row justify-between items-center cursor-pointer p-2"
href={`/connections/new/${type}`}
>
<div className="flex flex-row items-center gap-2">
{Icon}
<div>
<p className="font-medium">{title}</p>
<p className="text-sm text-muted-foreground font-light">{subtitle}</p>
</div>
</div>
</Link>
)
}

View file

@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
import { Cross2Icon } from "@radix-ui/react-icons";
import { CircleCheckIcon } from "lucide-react";
import { useMemo } from "react";
import { FiLoader } from "react-icons/fi";
export type Status = 'waiting' | 'running' | 'succeeded' | 'failed';
export const StatusIcon = ({
status,
className,
}: { status: Status, className?: string }) => {
const Icon = useMemo(() => {
switch (status) {
case 'waiting':
case 'running':
return <FiLoader className={cn('animate-spin-slow', className)} />;
case 'succeeded':
return <CircleCheckIcon className={cn('text-green-600', className)} />;
case 'failed':
return <Cross2Icon className={cn(className)} />;
}
}, [className, status]);
return Icon;
}

View file

@ -9,8 +9,8 @@ export default function Layout({
return (
<div className="min-h-screen flex flex-col">
<NavigationMenu />
<main className="flex-grow flex justify-center p-4">
<div className="w-full max-w-5xl rounded-lg border p-6">{children}</div>
<main className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div>
</main>
</div>
)

View file

@ -0,0 +1,134 @@
'use client';
import { createConnection } from "@/actions";
import { ConnectionIcon } from "@/app/connections/components/connectionIcon";
import { createZodConnectionConfigValidator } from "@/app/connections/utils";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Schema } from "ajv";
import { useRouter } from "next/navigation";
import { useCallback, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ConfigEditor, QuickActionFn } from "../../../components/configEditor";
interface ConnectionCreationForm<T> {
type: 'github' | 'gitlab';
defaultValues: {
name: string;
config: string;
};
title: string;
schema: Schema;
quickActions?: {
name: string;
fn: QuickActionFn<T>;
}[],
}
export default function ConnectionCreationForm<T>({
type,
defaultValues,
title,
schema,
quickActions,
}: ConnectionCreationForm<T>) {
const { toast } = useToast();
const router = useRouter();
const formSchema = useMemo(() => {
return z.object({
name: z.string().min(1),
config: createZodConnectionConfigValidator(schema),
});
}, [schema]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: defaultValues,
});
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
createConnection(data.name, type, data.config)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to create connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection created successfully.`
});
router.push('/connections');
router.refresh();
}
});
}, [router, toast, type]);
return (
<div className="flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6">
<div className="flex flex-row items-center gap-3 mb-6">
<ConnectionIcon
type={type}
className="w-7 h-7"
/>
<h1 className="text-3xl">{title}</h1>
</div>
<Form
{...form}
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormDescription>This is the {`connection's`} display name within Sourcebot. Examples: <b>public-github</b>, <b>self-hosted-gitlab</b>, <b>gerrit-other</b>, etc.</FormDescription>
<FormControl>
<Input
{...field}
spellCheck={false}
autoFocus={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config"
render={({ field: { value, onChange } }) => {
return (
<FormItem>
<FormLabel>Configuration</FormLabel>
{/* @todo : refactor this description into a shared file */}
<FormDescription>Code hosts are configured via a....TODO</FormDescription>
<FormControl>
<ConfigEditor<T>
value={value}
onChange={onChange}
actions={quickActions ?? []}
schema={schema}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
</div>
<Button className="mt-5" type="submit">Submit</Button>
</form>
</Form>
</div>
)
}

View file

@ -0,0 +1,64 @@
'use client';
import { githubQuickActions, gitlabQuickActions } from "../../quickActions";
import ConnectionCreationForm from "./components/connectionCreationForm";
import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { useRouter } from "next/navigation";
export default function NewConnectionPage({
params
}: { params: { type: string } }) {
const { type } = params;
const router = useRouter();
if (type === 'github') {
return <GitHubCreationForm />;
}
if (type === 'gitlab') {
return <GitLabCreationForm />;
}
router.push('/connections');
}
const GitLabCreationForm = () => {
const defaultConfig: GitLabConnectionConfig = {
type: 'gitlab',
}
return (
<ConnectionCreationForm<GitLabConnectionConfig>
type="gitlab"
title="Create a GitLab connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
name: 'my-gitlab-connection',
}}
schema={gitlabSchema}
quickActions={gitlabQuickActions}
/>
)
}
const GitHubCreationForm = () => {
const defaultConfig: GithubConnectionConfig = {
type: 'github',
}
return (
<ConnectionCreationForm<GithubConnectionConfig>
type="github"
title="Create a GitHub connection"
defaultValues={{
config: JSON.stringify(defaultConfig, null, 2),
name: 'my-github-connection',
}}
schema={githubSchema}
quickActions={githubQuickActions}
/>
)
}

View file

@ -1,171 +0,0 @@
'use client';
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { useKeymapExtension } from "@/hooks/useKeymapExtension";
import { useThemeNormalized } from "@/hooks/useThemeNormalized";
import { json, jsonLanguage, jsonParseLinter } from "@codemirror/lang-json";
import { linter } from "@codemirror/lint";
import { EditorView, hoverTooltip } from "@codemirror/view";
import { zodResolver } from "@hookform/resolvers/zod";
import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import Ajv from "ajv";
import {
handleRefresh,
jsonCompletion,
jsonSchemaHover,
jsonSchemaLinter,
stateExtensions
} from "codemirror-json-schema";
import { useCallback, useRef } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Input } from "@/components/ui/input";
import { createConnection } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
const ajv = new Ajv({
validateFormats: false,
});
// @todo: we will need to validate the config against different schemas based on the type of connection.
const validate = ajv.compile(githubSchema);
const formSchema = z.object({
name: z.string().min(1),
config: z
.string()
.superRefine((data, ctx) => {
const addIssue = (message: string) => {
return ctx.addIssue({
code: "custom",
message: `Schema validation error: ${message}`
});
}
let parsed;
try {
parsed = JSON.parse(data);
} catch {
addIssue("Invalid JSON");
return;
}
const valid = validate(parsed);
if (!valid) {
addIssue(ajv.errorsText(validate.errors));
}
}),
});
// Add this theme extension to your extensions array
const customAutocompleteStyle = EditorView.baseTheme({
".cm-tooltip.cm-completionInfo": {
padding: "8px",
fontSize: "12px",
fontFamily: "monospace",
},
".cm-tooltip-hover.cm-tooltip": {
padding: "8px",
fontSize: "12px",
fontFamily: "monospace",
}
})
export default function NewConnectionPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
config: JSON.stringify({ type: "github" }, null, 2),
},
});
const editorRef = useRef<ReactCodeMirrorRef>(null);
const keymapExtension = useKeymapExtension(editorRef.current?.view);
const { theme } = useThemeNormalized();
const { toast } = useToast();
const router = useRouter();
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
createConnection(data.config)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to create connection. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Connection created successfully!`
});
router.push('/');
}
});
}, [router, toast]);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Create a connection</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="config"
render={({ field: { value, onChange } }) => (
<FormItem>
<FormLabel>Configuration</FormLabel>
<FormControl>
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
extensions={[
keymapExtension,
json(),
linter(jsonParseLinter(), {
delay: 300,
}),
linter(jsonSchemaLinter(), {
needsRefresh: handleRefresh,
}),
jsonLanguage.data.of({
autocomplete: jsonCompletion(),
}),
hoverTooltip(jsonSchemaHover()),
// @todo: we will need to validate the config against different schemas based on the type of connection.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stateExtensions(githubSchema as any),
customAutocompleteStyle,
]}
theme={theme === "dark" ? "dark" : "light"}
>
</CodeMirror>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button className="mt-5" type="submit">Submit</Button>
</form>
</Form>
</div>
)
}

View file

@ -0,0 +1,41 @@
import { auth } from "@/auth";
import { getUser } from "@/data/user";
import { prisma } from "@/prisma";
import { ConnectionList } from "./components/connectionList";
import { Header } from "./components/header";
import { NewConnectionCard } from "./components/newConnectionCard";
export default async function ConnectionsPage() {
const session = await auth();
if (!session) {
return null;
}
const user = await getUser(session.user.id);
if (!user || !user.activeOrgId) {
return null;
}
const connections = await prisma.connection.findMany({
where: {
orgId: user.activeOrgId,
}
});
return (
<div>
<Header>
<h1 className="text-3xl">Connections</h1>
</Header>
<div className="flex flex-col md:flex-row gap-4">
<ConnectionList
connections={connections}
className="md:w-3/4"
/>
<NewConnectionCard
className="md:w-1/4"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"
import { GitLabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { QuickAction } from "./components/configEditor";
export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
orgs: [
...(previous.orgs ?? []),
""
]
}),
name: "Add an organization",
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
url: previous.url ?? "",
}),
name: "Set a custom url",
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
repos: [
...(previous.repos ?? []),
""
]
}),
name: "Add a repo",
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
token: previous.token ?? {
secret: "",
},
}),
name: "Add a secret",
}
];
export const gitlabQuickActions: QuickAction<GitLabConnectionConfig>[] = [
{
fn: (previous: GitLabConnectionConfig) => ({
...previous,
groups: [
...previous.groups ?? [],
""
]
}),
name: "Add a group",
},
{
fn: (previous: GitLabConnectionConfig) => ({
...previous,
url: previous.url ?? "",
}),
name: "Set a custom url",
},
{
fn: (previous: GitLabConnectionConfig) => ({
...previous,
token: previous.token ?? {
secret: "",
},
}),
name: "Add a secret",
},
{
fn: (previous: GitLabConnectionConfig) => ({
...previous,
projects: [
...previous.projects ?? [],
""
]
}),
name: "Add a project",
}
]

View file

@ -0,0 +1,33 @@
import Ajv, { Schema } from "ajv";
import { z } from "zod";
export const createZodConnectionConfigValidator = (jsonSchema: Schema) => {
const ajv = new Ajv({
validateFormats: false,
});
const validate = ajv.compile(jsonSchema);
return z
.string()
.superRefine((data, ctx) => {
const addIssue = (message: string) => {
return ctx.addIssue({
code: "custom",
message: `Schema validation error: ${message}`
});
}
let parsed;
try {
parsed = JSON.parse(data);
} catch {
addIssue("Invalid JSON");
return;
}
const valid = validate(parsed);
if (!valid) {
addIssue(ajv.errorsText(validate.errors));
}
});
}

View file

@ -1,6 +1,6 @@
import { PageNotFound } from "./components/pageNotFound";
export default function NotFound() {
export default function NotFoundPage() {
return (
<PageNotFound />
)

View file

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View file

@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View file

@ -0,0 +1,53 @@
"use client"
import { useRouter } from "next/navigation"
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
interface TabSwitcherProps {
tabs: { value: string; label: string }[]
currentTab: string
className?: string
}
export function TabSwitcher({ tabs, currentTab, className }: TabSwitcherProps) {
const router = useRouter()
const handleTabChange = (value: string) => {
router.push(`?tab=${value}`, { scroll: false })
}
return (
<TabsList className={className}>
{tabs.map((tab) => (
<LowProfileTabsTrigger
key={tab.value}
value={tab.value}
onClick={() => handleTabChange(tab.value)}
data-state={currentTab === tab.value ? "active" : ""}
>
{tab.label}
</LowProfileTabsTrigger>
))}
</TabsList>
)
}
interface LowProfileTabsTrigger {
value: string
children: React.ReactNode
onClick?: () => void
}
export function LowProfileTabsTrigger({ value, children, onClick }: LowProfileTabsTrigger) {
return (
<TabsTrigger
value={value}
onClick={onClick}
className="relative h-9 rounded-none border-b-2 border-transparent px-4 pb-3 pt-2 font-normal text-muted-foreground transition-none data-[state=active]:border-primary data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:bg-transparent"
>
{children}
</TabsTrigger>
)
}

View file

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View file

@ -0,0 +1,29 @@
import { prisma } from '@/prisma';
import 'server-only';
export const getConnection = async (connectionId: number, orgId: number) => {
const connection = await prisma.connection.findUnique({
where: {
id: connectionId,
orgId: orgId,
},
});
return connection;
}
export const getLinkedRepos = async (connectionId: number, orgId: number) => {
const linkedRepos = await prisma.repoToConnection.findMany({
where: {
connection: {
id: connectionId,
orgId: orgId,
}
},
include: {
repo: true,
}
});
return linkedRepos;
}

View file

@ -7,4 +7,5 @@ export enum ErrorCode {
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
NOT_AUTHENTICATED = 'NOT_AUTHENTICATED',
NOT_FOUND = 'NOT_FOUND',
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
}

View file

@ -31,8 +31,10 @@ export const createPathWithQueryParams = (path: string, ...queryParams: [string,
return `${path}?${queryString}`;
}
export type CodeHostType = "github" | "gitlab" | "gitea" | "gerrit";
type CodeHostInfo = {
type: "github" | "gitlab" | "gitea" | "gerrit";
type: CodeHostType;
displayName: string;
costHostName: string;
repoLink: string;
@ -53,39 +55,74 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined
const url = new URL(repo.URL);
const displayName = url.pathname.slice(1);
switch (webUrlType) {
case 'github':
case 'github': {
const { src, className } = getCodeHostIcon('github')!;
return {
type: "github",
displayName: displayName,
costHostName: "GitHub",
repoLink: repo.URL,
icon: githubLogo,
iconClassName: "dark:invert",
icon: src,
iconClassName: className,
}
case 'gitlab':
}
case 'gitlab': {
const { src, className } = getCodeHostIcon('gitlab')!;
return {
type: "gitlab",
displayName: displayName,
costHostName: "GitLab",
repoLink: repo.URL,
icon: gitlabLogo,
icon: src,
iconClassName: className,
}
case 'gitea':
}
case 'gitea': {
const { src, className } = getCodeHostIcon('gitea')!;
return {
type: "gitea",
displayName: displayName,
costHostName: "Gitea",
repoLink: repo.URL,
icon: giteaLogo,
icon: src,
iconClassName: className,
}
case 'gitiles':
}
case 'gitiles': {
const { src, className } = getCodeHostIcon('gerrit')!;
return {
type: "gerrit",
displayName: displayName,
costHostName: "Gerrit",
repoLink: repo.URL,
icon: gerritLogo,
icon: src,
iconClassName: className,
}
}
}
}
export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } | null => {
switch (codeHostType) {
case "github":
return {
src: githubLogo,
className: "dark:invert",
};
case "gitlab":
return {
src: gitlabLogo,
};
case "gitea":
return {
src: giteaLogo,
}
case "gerrit":
return {
src: gerritLogo,
}
default:
return null;
}
}
@ -122,3 +159,32 @@ export const base64Decode = (base64: string): string => {
export const isDefined = <T>(arg: T | null | undefined): arg is T extends null | undefined ? never : T => {
return arg !== null && arg !== undefined;
}
export const getDisplayTime = (date: Date) => {
const now = new Date();
const minutes = (now.getTime() - date.getTime()) / (1000 * 60);
const hours = minutes / 60;
const days = hours / 24;
const months = days / 30;
const formatTime = (value: number, unit: 'minute' | 'hour' | 'day' | 'month') => {
const roundedValue = Math.floor(value);
if (roundedValue < 2) {
return `${roundedValue} ${unit} ago`;
} else {
return `${roundedValue} ${unit}s ago`;
}
}
if (minutes < 1) {
return 'just now';
} else if (minutes < 60) {
return formatTime(minutes, 'minute');
} else if (hours < 24) {
return formatTime(hours, 'hour');
} else if (days < 30) {
return formatTime(days, 'day');
} else {
return formatTime(months, 'month');
}
}

View file

@ -72,6 +72,7 @@ const config = {
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"spin-slow": "spin 1.5s linear infinite",
},
},
},

View file

@ -4,6 +4,9 @@
"oneOf": [
{
"$ref": "./github.json"
},
{
"$ref": "./gitlab.json"
}
]
}

138
schemas/v3/gitlab.json Normal file
View file

@ -0,0 +1,138 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "GitLabConnectionConfig",
"properties": {
"type": {
"const": "gitlab",
"description": "GitLab Configuration"
},
"token": {
"$ref": "./shared.json#/definitions/Token",
"description": "An authentication token.",
"examples": [
"secret-token",
{
"env": "ENV_VAR_CONTAINING_TOKEN"
}
]
},
"url": {
"type": "string",
"format": "url",
"default": "https://gitlab.com",
"description": "The URL of the GitLab host. Defaults to https://gitlab.com",
"examples": [
"https://gitlab.com",
"https://gitlab.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"all": {
"type": "boolean",
"default": false,
"description": "Sync all projects visible to the provided `token` (if any) in the GitLab instance. This option is ignored if `url` is either unset or set to https://gitlab.com ."
},
"users": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of users to sync with. All projects owned by the user and visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property."
},
"groups": {
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"my-group"
],
[
"my-group/sub-group-a",
"my-group/sub-group-b"
]
],
"description": "List of groups to sync with. All projects in the group (and recursive subgroups) visible to the provided `token` (if any) will be synced, unless explicitly defined in the `exclude` property. Subgroups can be specified by providing the path to the subgroup (e.g. `my-group/sub-group-a`)."
},
"projects": {
"type": "array",
"items": {
"type": "string"
},
"examples": [
[
"my-group/my-project"
],
[
"my-group/my-sub-group/my-project"
]
],
"description": "List of individual projects to sync with. The project's namespace must be specified. See: https://docs.gitlab.com/ee/user/namespace/"
},
"topics": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"description": "List of project topics to include when syncing. Only projects that match at least one of the provided `topics` will be synced. If not specified, all projects will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.",
"examples": [
[
"docs",
"core"
]
]
},
"exclude": {
"type": "object",
"properties": {
"forks": {
"type": "boolean",
"default": false,
"description": "Exclude forked projects from syncing."
},
"archived": {
"type": "boolean",
"default": false,
"description": "Exclude archived projects from syncing."
},
"projects": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"examples": [
[
"my-group/my-project"
]
],
"description": "List of projects to exclude from syncing. Glob patterns are supported. The project's namespace must be specified, see: https://docs.gitlab.com/ee/user/namespace/"
},
"topics": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of project topics to exclude when syncing. Projects that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.",
"examples": [
[
"tests",
"ci"
]
]
}
},
"additionalProperties": false
},
"revisions": {
"$ref": "./shared.json#/definitions/GitRevisions"
}
},
"required": [
"type"
],
"additionalProperties": false
}

109
yarn.lock
View file

@ -1400,6 +1400,18 @@
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3"
integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==
"@radix-ui/react-alert-dialog@^1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.5.tgz#d937512a727d8b7afa8959d43dbd7e557d52a1eb"
integrity sha512-1Y2sI17QzSZP58RjGtrklfSGIf3AF7U/HkD3aAcAnhOUJrm7+7GG1wRDFaUlSe0nW5B/t4mYd/+7RNbP2Wexug==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-dialog" "1.1.5"
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-slot" "1.1.1"
"@radix-ui/react-arrow@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz#744f388182d360b86285217e43b6c63633f39e7a"
@ -1427,6 +1439,16 @@
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-slot" "1.1.0"
"@radix-ui/react-collection@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.1.tgz#be2c7e01d3508e6d4b6d838f492e7d182f17d3b0"
integrity sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-slot" "1.1.1"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
@ -1482,6 +1504,26 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-dialog@1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.5.tgz#1bb2880e6b0ef9d9d0d9f440e1414c94bbacb55b"
integrity sha512-LaO3e5h/NOEL4OfXjxD43k9Dx+vn+8n+PCFt6uhX/BADFflllyv3WJG6rgvvSVBxpTch938Qq/LGc2MMxipXPw==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-dismissable-layer" "1.1.4"
"@radix-ui/react-focus-guards" "1.1.1"
"@radix-ui/react-focus-scope" "1.1.1"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-portal" "1.1.3"
"@radix-ui/react-presence" "1.1.2"
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-slot" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.1.0"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.2"
"@radix-ui/react-dialog@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz#d68e977acfcc0d044b9dab47b6dd2c179d2b3191"
@ -1541,6 +1583,17 @@
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-escape-keydown" "1.1.0"
"@radix-ui/react-dismissable-layer@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.4.tgz#6e31ad92e7d9e77548001fd8c04f8561300c02a9"
integrity sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-escape-keydown" "1.1.0"
"@radix-ui/react-dropdown-menu@^2.1.1":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz#acc49577130e3c875ef0133bd1e271ea3392d924"
@ -1767,6 +1820,21 @@
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-roving-focus@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz#3b3abb1e03646937f28d9ab25e96343667ca6520"
integrity sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-collection" "1.1.1"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-scroll-area@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz#d09fd693728b09c50145935bec6f91efc2661729"
@ -1797,20 +1865,34 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-slot@1.1.0", "@radix-ui/react-slot@^1.1.0":
"@radix-ui/react-slot@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.0.tgz#7c5e48c36ef5496d97b08f1357bb26ed7c714b84"
integrity sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==
dependencies:
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-slot@1.1.1":
"@radix-ui/react-slot@1.1.1", "@radix-ui/react-slot@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.1.tgz#ab9a0ffae4027db7dc2af503c223c978706affc3"
integrity sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-tabs@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz#a72da059593cba30fccb30a226d63af686b32854"
integrity sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-presence" "1.1.2"
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-roving-focus" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-toast@^1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-toast/-/react-toast-1.2.2.tgz#fdd8ed0b80f47d6631dfd90278fee6debc06bf33"
@ -2718,7 +2800,7 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.1.1:
aria-hidden@^1.1.1, aria-hidden@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522"
integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==
@ -6101,6 +6183,17 @@ react-remove-scroll@^2.6.1:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.2"
react-remove-scroll@^2.6.2:
version "2.6.3"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz#df02cde56d5f2731e058531f8ffd7f9adec91ac2"
integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==
dependencies:
react-remove-scroll-bar "^2.3.7"
react-style-singleton "^2.2.3"
tslib "^2.1.0"
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-resizable-panels@^2.1.1:
version "2.1.4"
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.1.4.tgz#ae1803a916ba759e483336c7bd49830f1b0cd16f"
@ -6115,7 +6208,7 @@ react-style-singleton@^2.2.1:
invariant "^2.2.4"
tslib "^2.0.0"
react-style-singleton@^2.2.2:
react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388"
integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==
@ -7194,6 +7287,14 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sidecar@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb"
integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==
dependencies:
detect-node-es "^1.1.0"
tslib "^2.0.0"
usehooks-ts@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.0.tgz#156119f36efc85f1b1952616c02580f140950eca"