mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 13:25:21 +00:00
Connection management (#183)
This commit is contained in:
parent
afff36f6c6
commit
846d73b0e6
48 changed files with 2757 additions and 370 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Repo" ADD COLUMN "imageUrl" TEXT;
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
83
packages/schemas/src/v3/gitlab.type.ts
Normal file
83
packages/schemas/src/v3/gitlab.type.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
25
packages/web/src/app/components/notFound.tsx
Normal file
25
packages/web/src/app/components/notFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
170
packages/web/src/app/connections/[id]/page.tsx
Normal file
170
packages/web/src/app/connections/[id]/page.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
||||
149
packages/web/src/app/connections/components/configEditor.tsx
Normal file
149
packages/web/src/app/connections/components/configEditor.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
22
packages/web/src/app/connections/components/header.tsx
Normal file
22
packages/web/src/app/connections/components/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
27
packages/web/src/app/connections/components/statusIcon.tsx
Normal file
27
packages/web/src/app/connections/components/statusIcon.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
64
packages/web/src/app/connections/new/[type]/page.tsx
Normal file
64
packages/web/src/app/connections/new/[type]/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
41
packages/web/src/app/connections/page.tsx
Normal file
41
packages/web/src/app/connections/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
packages/web/src/app/connections/quickActions.ts
Normal file
82
packages/web/src/app/connections/quickActions.ts
Normal 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",
|
||||
}
|
||||
]
|
||||
|
||||
33
packages/web/src/app/connections/utils.ts
Normal file
33
packages/web/src/app/connections/utils.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { PageNotFound } from "./components/pageNotFound";
|
||||
|
||||
export default function NotFound() {
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<PageNotFound />
|
||||
)
|
||||
|
|
|
|||
141
packages/web/src/components/ui/alert-dialog.tsx
Normal file
141
packages/web/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
115
packages/web/src/components/ui/breadcrumb.tsx
Normal file
115
packages/web/src/components/ui/breadcrumb.tsx
Normal 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,
|
||||
}
|
||||
53
packages/web/src/components/ui/tab-switcher.tsx
Normal file
53
packages/web/src/components/ui/tab-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
55
packages/web/src/components/ui/tabs.tsx
Normal file
55
packages/web/src/components/ui/tabs.tsx
Normal 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 }
|
||||
29
packages/web/src/data/connection.ts
Normal file
29
packages/web/src/data/connection.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
"oneOf": [
|
||||
{
|
||||
"$ref": "./github.json"
|
||||
},
|
||||
{
|
||||
"$ref": "./gitlab.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
138
schemas/v3/gitlab.json
Normal file
138
schemas/v3/gitlab.json
Normal 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
109
yarn.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue