diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 4fcc4113..3fe5c0a7 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -80,54 +80,68 @@ export class ConnectionManager implements IConnectionManager { const abortController = new AbortController(); type RepoData = WithRequired; - 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!); - - 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 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!); + + 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) - }, - }; - - return record; - }) + 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; + }) + } + 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 diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index bef5e6bc..10fe6f18 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -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) => { diff --git a/packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql b/packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql new file mode 100644 index 00000000..6ab30f01 --- /dev/null +++ b/packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql b/packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql new file mode 100644 index 00000000..9df4c179 --- /dev/null +++ b/packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql b/packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql new file mode 100644 index 00000000..c75047a4 --- /dev/null +++ b/packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Repo" ADD COLUMN "imageUrl" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 4f071a1b..156adccc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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) diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 77a85423..dbef6edf 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -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; diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 3d07a7c2..4ef6d4b7 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -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; +} diff --git a/packages/web/src/schemas/github.schema.ts b/packages/schemas/src/v3/gitlab.schema.ts similarity index 57% rename from packages/web/src/schemas/github.schema.ts rename to packages/schemas/src/v3/gitlab.schema.ts index 0c88f81b..8ed238ad 100644 --- a/packages/web/src/schemas/github.schema.ts +++ b/packages/schemas/src/v3/gitlab.schema.ts @@ -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 }; \ No newline at end of file +export { schema as gitlabSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/gitlab.type.ts b/packages/schemas/src/v3/gitlab.type.ts new file mode 100644 index 00000000..aa30c206 --- /dev/null +++ b/packages/schemas/src/v3/gitlab.type.ts @@ -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[]; +} diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index 585bb245..a560a21f 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -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 diff --git a/packages/web/package.json b/packages/web/package.json index d269aa94..106d29f7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index b0f5f468..bea36b00 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -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; } diff --git a/packages/web/src/app/components/navigationMenu.tsx b/packages/web/src/app/components/navigationMenu.tsx index aeefd81c..2f7ab7b6 100644 --- a/packages/web/src/app/components/navigationMenu.tsx +++ b/packages/web/src/app/components/navigationMenu.tsx @@ -63,6 +63,13 @@ export const NavigationMenu = async () => { + + + + Connections + + + diff --git a/packages/web/src/app/components/notFound.tsx b/packages/web/src/app/components/notFound.tsx new file mode 100644 index 00000000..6d9dc36f --- /dev/null +++ b/packages/web/src/app/components/notFound.tsx @@ -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 ( +
+
+

404

+ +

{message}

+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/components/pageNotFound.tsx b/packages/web/src/app/components/pageNotFound.tsx index 8878f85b..aa1e6e6d 100644 --- a/packages/web/src/app/components/pageNotFound.tsx +++ b/packages/web/src/app/components/pageNotFound.tsx @@ -1,18 +1,9 @@ -import { Separator } from "@/components/ui/separator" +import { NotFound } from "./notFound" export const PageNotFound = () => { return (
-
-
-

404

- -

Page not found

-
-
+
) } \ No newline at end of file diff --git a/packages/web/src/app/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/components/searchBar/useSuggestionsData.ts index 668207f7..3548fe4d 100644 --- a/packages/web/src/app/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/components/searchBar/useSuggestionsData.ts @@ -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'); - } -} diff --git a/packages/web/src/app/connections/[id]/components/configSetting.tsx b/packages/web/src/app/connections/[id]/components/configSetting.tsx new file mode 100644 index 00000000..7b245130 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/configSetting.tsx @@ -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 + {...props} + quickActions={githubQuickActions} + schema={githubSchema} + />; + } + + if (type === 'gitlab') { + return + {...props} + quickActions={gitlabQuickActions} + schema={gitlabSchema} + />; + } + + return null; +} + + +function ConfigSettingInternal({ + connectionId, + config, + quickActions, + schema, +}: ConfigSettingProps & { + quickActions?: QuickAction[], + schema: Schema, +}) { + const { toast } = useToast(); + const router = useRouter(); + const formSchema = useMemo(() => { + return z.object({ + config: createZodConnectionConfigValidator(schema), + }); + }, [schema]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + config, + }, + }); + + const [isLoading, setIsLoading] = useState(false); + const onSubmit = useCallback((data: z.infer) => { + 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 ( +
+
+ + ( + + + Configuration + {/* @todo : refactor this description into a shared file */} + Code hosts are configured via a....TODO + + + value={value} + onChange={onChange} + schema={schema} + actions={quickActions ?? []} + /> + + + + + + )} + /> +
+ +
+ + +
+ ); +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx b/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx new file mode 100644 index 00000000..cce8f399 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx @@ -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 ( +
+

Delete Connection

+

+ Permanently delete this connection from Sourcebot. All linked repositories that are not linked to any other connection will also be deleted. +

+
+ + + + + + + Are you sure? + + This action cannot be undone. + + + + Cancel + Yes, delete connection + + + +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx b/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx new file mode 100644 index 00000000..26918d42 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/displayNameSetting.tsx @@ -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>({ + resolver: zodResolver(formSchema), + defaultValues: { + name, + }, + }); + + const [isLoading, setIsLoading] = useState(false); + const onSubmit = useCallback((data: z.infer) => { + 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 ( +
+
+ + ( + + Display Name + {/* @todo : refactor this description into a shared file */} + This is the {`connection's`} display name within Sourcebot. Examples: public-github, self-hosted-gitlab, gerrit-other, etc. + + + + + + )} + /> +
+ +
+ + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/components/repoListItem.tsx b/packages/web/src/app/connections/[id]/components/repoListItem.tsx new file mode 100644 index 00000000..731454c7 --- /dev/null +++ b/packages/web/src/app/connections/[id]/components/repoListItem.tsx @@ -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 ( +
+
+ {name} +

{name}

+
+
+ +

+ {statusDisplayName} + { + ( + status === RepoIndexingStatus.INDEXED || + status === RepoIndexingStatus.FAILED + ) && indexedAt && ( + {` ${getDisplayTime(indexedAt)}`} + ) + } +

+
+
+ ) +} + +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'; + } +} \ No newline at end of file diff --git a/packages/web/src/app/connections/[id]/page.tsx b/packages/web/src/app/connections/[id]/page.tsx new file mode 100644 index 00000000..74213a51 --- /dev/null +++ b/packages/web/src/app/connections/[id]/page.tsx @@ -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 ( + + ) + } + + const connection = await getConnection(Number(params.id), orgId); + if (!connection) { + return ( + + ) + } + + const linkedRepos = await getLinkedRepos(connectionId, orgId); + + const currentTab = searchParams.tab || "overview"; + + return ( + +
+ + + + Connections + + + + {connection.name} + + + +
+ +

{connection.name}

+
+ +
+ +

Overview

+
+
+
+

Connection Type

+

{connection.connectionType}

+
+
+

Last Synced At

+

{connection.syncedAt ? new Date(connection.syncedAt).toLocaleDateString() : 'never'}

+
+
+

Linked Repositories

+

{linkedRepos.length}

+
+
+

Status

+

{connection.syncStatus}

+
+
+
+

Linked Repositories

+ +
+ {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 }) => ( + + ))} +
+
+
+ + + + + +
+ + ) +} diff --git a/packages/web/src/app/connections/components/configEditor.tsx b/packages/web/src/app/connections/components/configEditor.tsx new file mode 100644 index 00000000..28f47f58 --- /dev/null +++ b/packages/web/src/app/connections/components/configEditor.tsx @@ -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 = (previous: T) => T; +export type QuickAction = { + name: string; + fn: QuickActionFn; +}; + +interface ConfigEditorProps { + value: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange: (...event: any[]) => void; + actions: QuickAction[], + 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({ + value, + onChange, + actions, + schema, +}: ConfigEditorProps) { + const editorRef = useRef(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) => { + 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 ( + <> +
+ {actions.map(({ name, fn }, index) => ( +
+ + {index !== actions.length - 1 && ( + + )} +
+ ))} +
+ + + + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/connectionIcon.tsx b/packages/web/src/app/connections/components/connectionIcon.tsx new file mode 100644 index 00000000..01afb849 --- /dev/null +++ b/packages/web/src/app/connections/components/connectionIcon.tsx @@ -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 ( + {`${type} + ) + } + + return {''} + + }, [type]); + + return Icon; +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx b/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx new file mode 100644 index 00000000..0d4dfc22 --- /dev/null +++ b/packages/web/src/app/connections/components/connectionList/connectionListItem.tsx @@ -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 ( + +
+
+ +
+

{name}

+ {`Edited ${getDisplayTime(editedAt)}`} +
+
+
+ +

+ {statusDisplayName} + { + ( + status === ConnectionSyncStatus.SYNCED || + status === ConnectionSyncStatus.FAILED + ) && syncedAt && ( + {` ${getDisplayTime(syncedAt)}`} + ) + } +

+ +
+
+ + ) +} diff --git a/packages/web/src/app/connections/components/connectionList/index.tsx b/packages/web/src/app/connections/components/connectionList/index.tsx new file mode 100644 index 00000000..841c833d --- /dev/null +++ b/packages/web/src/app/connections/components/connectionList/index.tsx @@ -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 ( +
+ {connections.length > 0 ? connections + .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) + .map((connection) => ( + + )) + : ( +
+ +

No connections

+
+ )} +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/header.tsx b/packages/web/src/app/connections/components/header.tsx new file mode 100644 index 00000000..79a24ee4 --- /dev/null +++ b/packages/web/src/app/connections/components/header.tsx @@ -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 ( +
+ {children} + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/newConnectionCard.tsx b/packages/web/src/app/connections/components/newConnectionCard.tsx new file mode 100644 index 00000000..fd6351fd --- /dev/null +++ b/packages/web/src/app/connections/components/newConnectionCard.tsx @@ -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 ( +
+ +

Connect to a Code Host

+

Create a connection to import repos from a code host.

+
+ + + + +
+
+ ) +} + +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 ( + {`${type} + ) + } + + return {`${type} + + }, [type]); + + return ( + +
+ {Icon} +
+

{title}

+

{subtitle}

+
+
+ + ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/components/statusIcon.tsx b/packages/web/src/app/connections/components/statusIcon.tsx new file mode 100644 index 00000000..b7b6b2bf --- /dev/null +++ b/packages/web/src/app/connections/components/statusIcon.tsx @@ -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 ; + case 'succeeded': + return ; + case 'failed': + return ; + + } + }, [className, status]); + + return Icon; +} \ No newline at end of file diff --git a/packages/web/src/app/connections/layout.tsx b/packages/web/src/app/connections/layout.tsx index 3c9edb6a..2877c918 100644 --- a/packages/web/src/app/connections/layout.tsx +++ b/packages/web/src/app/connections/layout.tsx @@ -9,8 +9,8 @@ export default function Layout({ return (
-
-
{children}
+
+
{children}
) diff --git a/packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx new file mode 100644 index 00000000..9f9f2fe4 --- /dev/null +++ b/packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx @@ -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 { + type: 'github' | 'gitlab'; + defaultValues: { + name: string; + config: string; + }; + title: string; + schema: Schema; + quickActions?: { + name: string; + fn: QuickActionFn; + }[], +} + +export default function ConnectionCreationForm({ + type, + defaultValues, + title, + schema, + quickActions, +}: ConnectionCreationForm) { + + const { toast } = useToast(); + const router = useRouter(); + + const formSchema = useMemo(() => { + return z.object({ + name: z.string().min(1), + config: createZodConnectionConfigValidator(schema), + }); + }, [schema]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: defaultValues, + }); + + const onSubmit = useCallback((data: z.infer) => { + 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 ( +
+
+ +

{title}

+
+
+ +
+ ( + + Display Name + This is the {`connection's`} display name within Sourcebot. Examples: public-github, self-hosted-gitlab, gerrit-other, etc. + + + + + + )} + /> + { + return ( + + Configuration + {/* @todo : refactor this description into a shared file */} + Code hosts are configured via a....TODO + + + value={value} + onChange={onChange} + actions={quickActions ?? []} + schema={schema} + /> + + + + ) + }} + /> +
+ +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/[type]/page.tsx b/packages/web/src/app/connections/new/[type]/page.tsx new file mode 100644 index 00000000..6c16d113 --- /dev/null +++ b/packages/web/src/app/connections/new/[type]/page.tsx @@ -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 ; + } + + if (type === 'gitlab') { + return ; + } + + router.push('/connections'); +} + +const GitLabCreationForm = () => { + const defaultConfig: GitLabConnectionConfig = { + type: 'gitlab', + } + + return ( + + 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 ( + + type="github" + title="Create a GitHub connection" + defaultValues={{ + config: JSON.stringify(defaultConfig, null, 2), + name: 'my-github-connection', + }} + schema={githubSchema} + quickActions={githubQuickActions} + /> + ) +} \ No newline at end of file diff --git a/packages/web/src/app/connections/new/page.tsx b/packages/web/src/app/connections/new/page.tsx deleted file mode 100644 index e5239658..00000000 --- a/packages/web/src/app/connections/new/page.tsx +++ /dev/null @@ -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>({ - resolver: zodResolver(formSchema), - defaultValues: { - config: JSON.stringify({ type: "github" }, null, 2), - }, - }); - - const editorRef = useRef(null); - const keymapExtension = useKeymapExtension(editorRef.current?.view); - const { theme } = useThemeNormalized(); - const { toast } = useToast(); - const router = useRouter(); - - const onSubmit = useCallback((data: z.infer) => { - 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 ( -
-

Create a connection

-
- -
- ( - - Display Name - - - - - - )} - /> - ( - - Configuration - - - - - - - )} - /> -
- -
- -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/connections/page.tsx b/packages/web/src/app/connections/page.tsx new file mode 100644 index 00000000..f023c216 --- /dev/null +++ b/packages/web/src/app/connections/page.tsx @@ -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 ( +
+
+

Connections

+
+
+ + +
+
+ ); +} diff --git a/packages/web/src/app/connections/quickActions.ts b/packages/web/src/app/connections/quickActions.ts new file mode 100644 index 00000000..5051dd07 --- /dev/null +++ b/packages/web/src/app/connections/quickActions.ts @@ -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[] = [ + { + 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[] = [ + { + 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", + } +] + diff --git a/packages/web/src/app/connections/utils.ts b/packages/web/src/app/connections/utils.ts new file mode 100644 index 00000000..2fbe552d --- /dev/null +++ b/packages/web/src/app/connections/utils.ts @@ -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)); + } + }); +} \ No newline at end of file diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx index b777ee1b..4e9f5e34 100644 --- a/packages/web/src/app/not-found.tsx +++ b/packages/web/src/app/not-found.tsx @@ -1,6 +1,6 @@ import { PageNotFound } from "./components/pageNotFound"; -export default function NotFound() { +export default function NotFoundPage() { return ( ) diff --git a/packages/web/src/components/ui/alert-dialog.tsx b/packages/web/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..25e7b474 --- /dev/null +++ b/packages/web/src/components/ui/alert-dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/packages/web/src/components/ui/breadcrumb.tsx b/packages/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..60e6c96f --- /dev/null +++ b/packages/web/src/components/ui/breadcrumb.tsx @@ -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) =>