From a364fc0ef56ec33172d494f1fc1e52b10156872c Mon Sep 17 00:00:00 2001 From: msukkari Date: Sat, 1 Feb 2025 16:05:11 -0800 Subject: [PATCH 01/11] add non github config definitions --- packages/schemas/src/v3/connection.schema.ts | 365 +++++++++++++++++++ packages/schemas/src/v3/connection.type.ts | 181 ++++++++- packages/schemas/src/v3/gerrit.schema.ts | 59 +++ packages/schemas/src/v3/gerrit.type.ts | 22 ++ packages/schemas/src/v3/git.schema.ts | 64 ++++ packages/schemas/src/v3/git.type.ts | 26 ++ packages/schemas/src/v3/gitea.schema.ts | 166 +++++++++ packages/schemas/src/v3/gitea.type.ts | 69 ++++ packages/schemas/src/v3/gitlab.schema.ts | 208 +++++++++++ packages/schemas/src/v3/gitlab.type.ts | 83 +++++ packages/schemas/src/v3/local.schema.ts | 52 +++ packages/schemas/src/v3/local.type.ts | 22 ++ schemas/v3/connection.json | 15 + schemas/v3/gerrit.json | 57 +++ schemas/v3/git.json | 24 ++ schemas/v3/gitea.json | 96 +++++ schemas/v3/gitlab.json | 126 +++++++ schemas/v3/local.json | 50 +++ 18 files changed, 1684 insertions(+), 1 deletion(-) create mode 100644 packages/schemas/src/v3/gerrit.schema.ts create mode 100644 packages/schemas/src/v3/gerrit.type.ts create mode 100644 packages/schemas/src/v3/git.schema.ts create mode 100644 packages/schemas/src/v3/git.type.ts create mode 100644 packages/schemas/src/v3/gitea.schema.ts create mode 100644 packages/schemas/src/v3/gitea.type.ts create mode 100644 packages/schemas/src/v3/gitlab.schema.ts create mode 100644 packages/schemas/src/v3/gitlab.type.ts create mode 100644 packages/schemas/src/v3/local.schema.ts create mode 100644 packages/schemas/src/v3/local.type.ts create mode 100644 schemas/v3/gerrit.json create mode 100644 schemas/v3/git.json create mode 100644 schemas/v3/gitea.json create mode 100644 schemas/v3/gitlab.json create mode 100644 schemas/v3/local.json diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 77a85423..c396d368 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -214,6 +214,371 @@ 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": "A Personal Access Token (PAT).", + "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 + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GiteaConnectionConfig", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "$ref": "#/oneOf/0/properties/token", + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "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. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "#/oneOf/0/properties/revisions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "$ref": "#/oneOf/0/properties/revisions" + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false } ] } as const; diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index 3d07a7c2..c2ce45e4 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -1,6 +1,12 @@ // THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! -export type ConnectionConfig = GithubConnectionConfig; +export type ConnectionConfig = + | GithubConnectionConfig + | GitlabConnectionConfig + | GiteaConnectionConfig + | GerritConnectionConfig + | GitConnectionConfig + | GerritConnectionConfig1; export interface GithubConnectionConfig { /** @@ -92,3 +98,176 @@ export interface GitRevisions { */ tags?: string[]; } +export interface GitlabConnectionConfig { + /** + * GitLab Configuration + */ + type: "gitlab"; + /** + * A Personal Access Token (PAT). + */ + 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; +} +export interface GiteaConnectionConfig { + /** + * Gitea Configuration + */ + type: "gitea"; + /** + * A Personal Access Token (PAT). + */ + 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 Gitea host. Defaults to https://gitea.com + */ + url?: string; + /** + * 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. If a `token` is provided, it must have the read:organization scope. + */ + orgs?: string[]; + /** + * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'. + */ + repos?: string[]; + /** + * List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope. + */ + users?: string[]; + exclude?: { + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * List of individual repositories to exclude from syncing. Glob patterns are supported. + */ + repos?: string[]; + }; + revisions?: GitRevisions; +} +export interface GerritConnectionConfig { + /** + * Gerrit Configuration + */ + type: "gerrit"; + /** + * The URL of the Gerrit host. + */ + url: string; + /** + * List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported + */ + projects?: string[]; + exclude?: { + /** + * List of specific projects to exclude from syncing. + */ + projects?: string[]; + }; +} +export interface GitConnectionConfig { + /** + * Git Configuration + */ + type: "git"; + /** + * The URL to the git repository. + */ + url: string; + revisions?: GitRevisions; +} +export interface GerritConnectionConfig1 { + /** + * Local Configuration + */ + type: "local"; + /** + * Path to the local directory to sync with. Relative paths are relative to the configuration file's directory. + */ + path: string; + /** + * Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true. + */ + watch?: boolean; + exclude?: { + /** + * List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded. + */ + paths?: string[]; + }; +} diff --git a/packages/schemas/src/v3/gerrit.schema.ts b/packages/schemas/src/v3/gerrit.schema.ts new file mode 100644 index 00000000..9ecca34a --- /dev/null +++ b/packages/schemas/src/v3/gerrit.schema.ts @@ -0,0 +1,59 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false +} as const; +export { schema as gerritSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/gerrit.type.ts b/packages/schemas/src/v3/gerrit.type.ts new file mode 100644 index 00000000..735f87f6 --- /dev/null +++ b/packages/schemas/src/v3/gerrit.type.ts @@ -0,0 +1,22 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface GerritConnectionConfig { + /** + * Gerrit Configuration + */ + type: "gerrit"; + /** + * The URL of the Gerrit host. + */ + url: string; + /** + * List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported + */ + projects?: string[]; + exclude?: { + /** + * List of specific projects to exclude from syncing. + */ + projects?: string[]; + }; +} diff --git a/packages/schemas/src/v3/git.schema.ts b/packages/schemas/src/v3/git.schema.ts new file mode 100644 index 00000000..3d8e889f --- /dev/null +++ b/packages/schemas/src/v3/git.schema.ts @@ -0,0 +1,64 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "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.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "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.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false +} as const; +export { schema as gitSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/git.type.ts b/packages/schemas/src/v3/git.type.ts new file mode 100644 index 00000000..b60d5832 --- /dev/null +++ b/packages/schemas/src/v3/git.type.ts @@ -0,0 +1,26 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface GitConnectionConfig { + /** + * Git Configuration + */ + type: "git"; + /** + * The URL to the git repository. + */ + url: 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/schemas/src/v3/gitea.schema.ts b/packages/schemas/src/v3/gitea.schema.ts new file mode 100644 index 00000000..57341345 --- /dev/null +++ b/packages/schemas/src/v3/gitea.schema.ts @@ -0,0 +1,166 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GiteaConnectionConfig", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "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://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "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. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "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.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "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.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} as const; +export { schema as giteaSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/gitea.type.ts b/packages/schemas/src/v3/gitea.type.ts new file mode 100644 index 00000000..09e1df19 --- /dev/null +++ b/packages/schemas/src/v3/gitea.type.ts @@ -0,0 +1,69 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface GiteaConnectionConfig { + /** + * Gitea Configuration + */ + type: "gitea"; + /** + * A Personal Access Token (PAT). + */ + 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 Gitea host. Defaults to https://gitea.com + */ + url?: string; + /** + * 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. If a `token` is provided, it must have the read:organization scope. + */ + orgs?: string[]; + /** + * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'. + */ + repos?: string[]; + /** + * List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope. + */ + users?: string[]; + exclude?: { + /** + * Exclude forked repositories from syncing. + */ + forks?: boolean; + /** + * Exclude archived repositories from syncing. + */ + archived?: boolean; + /** + * List of individual repositories to exclude from syncing. Glob patterns are supported. + */ + repos?: 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/schemas/src/v3/gitlab.schema.ts b/packages/schemas/src/v3/gitlab.schema.ts new file mode 100644 index 00000000..e9571412 --- /dev/null +++ b/packages/schemas/src/v3/gitlab.schema.ts @@ -0,0 +1,208 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitlabConnectionConfig", + "properties": { + "type": { + "const": "gitlab", + "description": "GitLab Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ], + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token." + } + }, + "required": [ + "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://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": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed.", + "properties": { + "branches": { + "type": "array", + "description": "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.", + "items": { + "type": "string" + }, + "examples": [ + [ + "main", + "release/*" + ], + [ + "**" + ] + ], + "default": [] + }, + "tags": { + "type": "array", + "description": "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.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} as const; +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..0fb09179 --- /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"; + /** + * A Personal Access Token (PAT). + */ + 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/schemas/src/v3/local.schema.ts b/packages/schemas/src/v3/local.schema.ts new file mode 100644 index 00000000..fce2c51c --- /dev/null +++ b/packages/schemas/src/v3/local.schema.ts @@ -0,0 +1,52 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false +} as const; +export { schema as localSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/local.type.ts b/packages/schemas/src/v3/local.type.ts new file mode 100644 index 00000000..4551e3f9 --- /dev/null +++ b/packages/schemas/src/v3/local.type.ts @@ -0,0 +1,22 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface GerritConnectionConfig { + /** + * Local Configuration + */ + type: "local"; + /** + * Path to the local directory to sync with. Relative paths are relative to the configuration file's directory. + */ + path: string; + /** + * Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true. + */ + watch?: boolean; + exclude?: { + /** + * List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded. + */ + paths?: string[]; + }; +} diff --git a/schemas/v3/connection.json b/schemas/v3/connection.json index a40c76e1..67700fb6 100644 --- a/schemas/v3/connection.json +++ b/schemas/v3/connection.json @@ -4,6 +4,21 @@ "oneOf": [ { "$ref": "./github.json" + }, + { + "$ref": "./gitlab.json" + }, + { + "$ref": "./gitea.json" + }, + { + "$ref": "./gerrit.json" + }, + { + "$ref": "./git.json" + }, + { + "$ref": "./local.json" } ] } \ No newline at end of file diff --git a/schemas/v3/gerrit.json b/schemas/v3/gerrit.json new file mode 100644 index 00000000..fe18a97f --- /dev/null +++ b/schemas/v3/gerrit.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "gerrit", + "description": "Gerrit Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL of the Gerrit host.", + "examples": [ + "https://gerrit.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of specific projects to sync. If not specified, all projects will be synced. Glob patterns are supported", + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ] + }, + "exclude": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "project1/repo1", + "project2/**" + ] + ], + "description": "List of specific projects to exclude from syncing." + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v3/git.json b/schemas/v3/git.json new file mode 100644 index 00000000..f6c030ff --- /dev/null +++ b/schemas/v3/git.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GitConnectionConfig", + "properties": { + "type": { + "const": "git", + "description": "Git Configuration" + }, + "url": { + "type": "string", + "format": "url", + "description": "The URL to the git repository." + }, + "revisions": { + "$ref": "./shared.json#/definitions/GitRevisions" + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v3/gitea.json b/schemas/v3/gitea.json new file mode 100644 index 00000000..99fdeaf7 --- /dev/null +++ b/schemas/v3/gitea.json @@ -0,0 +1,96 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GiteaConnectionConfig", + "properties": { + "type": { + "const": "gitea", + "description": "Gitea Configuration" + }, + "token": { + "$ref": "./shared.json#/definitions/Token", + "description": "A Personal Access Token (PAT).", + "examples": [ + "secret-token", + { + "env": "ENV_VAR_CONTAINING_TOKEN" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://gitea.com", + "description": "The URL of the Gitea host. Defaults to https://gitea.com", + "examples": [ + "https://gitea.com", + "https://gitea.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "orgs": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "my-org-name" + ] + ], + "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. If a `token` is provided, it must have the read:organization scope." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'." + }, + "users": { + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "username-1", + "username-2" + ] + ], + "description": "List of users to sync with. All repositories that the user owns will be synced, unless explicitly defined in the `exclude` property. If a `token` is provided, it must have the read:user scope." + }, + "exclude": { + "type": "object", + "properties": { + "forks": { + "type": "boolean", + "default": false, + "description": "Exclude forked repositories from syncing." + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Exclude archived repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "./shared.json#/definitions/GitRevisions" + } + }, + "required": [ + "type" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v3/gitlab.json b/schemas/v3/gitlab.json new file mode 100644 index 00000000..3de53568 --- /dev/null +++ b/schemas/v3/gitlab.json @@ -0,0 +1,126 @@ +{ + "$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": "A Personal Access Token (PAT).", + "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 +} \ No newline at end of file diff --git a/schemas/v3/local.json b/schemas/v3/local.json new file mode 100644 index 00000000..5be97d31 --- /dev/null +++ b/schemas/v3/local.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "GerritConnectionConfig", + "properties": { + "type": { + "const": "local", + "description": "Local Configuration" + }, + "path": { + "type": "string", + "description": "Path to the local directory to sync with. Relative paths are relative to the configuration file's directory.", + "pattern": ".+" + }, + "watch": { + "type": "boolean", + "default": true, + "description": "Enables a file watcher that will automatically re-sync when changes are made within `path` (recursively). Defaults to true." + }, + "exclude": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string", + "pattern": ".+" + }, + "description": "List of paths relative to the provided `path` to exclude from the index. .git, .hg, and .svn are always exluded.", + "default": [], + "examples": [ + [ + "node_modules", + "bin", + "dist", + "build", + "out" + ] + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false +} \ No newline at end of file From a65e33619e9bbedbbc7554e18d4ff5df75c8a3b5 Mon Sep 17 00:00:00 2001 From: msukkari Date: Sat, 1 Feb 2025 16:39:17 -0800 Subject: [PATCH 02/11] refactor github config compilation to seperate file --- packages/backend/src/connectionManager.ts | 50 ++------------------ packages/backend/src/gitlab.ts | 6 +-- packages/backend/src/repoCompileUtils.ts | 57 +++++++++++++++++++++++ 3 files changed, 65 insertions(+), 48 deletions(-) create mode 100644 packages/backend/src/repoCompileUtils.ts diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 4fcc4113..5bb28ebf 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -5,8 +5,7 @@ import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { createLogger } from "./logger.js"; import os from 'os'; import { Redis } from 'ioredis'; -import { marshalBool } from "./utils.js"; -import { getGitHubReposFromConfig } from "./github.js"; +import { RepoData, compileGithubConfig } from "./repoCompileUtils.js"; interface IConnectionManager { scheduleConnectionSync: (connection: Connection) => Promise; @@ -79,52 +78,13 @@ export class ConnectionManager implements IConnectionManager { // @note: We aren't actually doing anything with this atm. 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, - }, - }, - 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 await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController); + } + default: { + return []; } } })(); diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index 73ed5e92..833ba8e7 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -1,14 +1,14 @@ import { Gitlab, ProjectSchema } from "@gitbeaker/rest"; import micromatch from "micromatch"; import { createLogger } from "./logger.js"; -import { GitLabConfig } from "@sourcebot/schemas/v2/index.type" -import { AppContext } from "./types.js"; +import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type" import { getTokenFromConfig, measure } from "./utils.js"; +import { PrismaClient } from "@sourcebot/db"; const logger = createLogger("GitLab"); export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; -export const getGitLabReposFromConfig = async (config: GitLabConfig, orgId: number, ctx: AppContext) => { +export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => { // TODO: pass in DB here to fetch secret properly const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined; const api = new Gitlab({ diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts new file mode 100644 index 00000000..8d8f1a27 --- /dev/null +++ b/packages/backend/src/repoCompileUtils.ts @@ -0,0 +1,57 @@ +import { GithubConnectionConfig } from '@sourcebot/schemas/v3/github.type'; +import { getGitHubReposFromConfig } from "./github.js"; +import { Prisma, PrismaClient } from '@sourcebot/db'; +import { WithRequired } from "./types.js" +import { marshalBool } from "./utils.js"; + +export type RepoData = WithRequired; + +export const compileGithubConfig = async ( + config: GithubConnectionConfig, + connectionId: number, + orgId: number, + db: PrismaClient, + abortController: AbortController): Promise => { + const gitHubRepos = await getGitHubReposFromConfig(config, orgId, 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, + }, + }, + connections: { + create: { + connectionId: 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; + }) +} \ No newline at end of file From 5eb8656ceb284a76c3f1a596c291b252a02725e9 Mon Sep 17 00:00:00 2001 From: msukkari Date: Sat, 8 Feb 2025 15:23:47 -0800 Subject: [PATCH 03/11] add gitlab config compilation --- packages/backend/src/connectionManager.ts | 5 ++- packages/backend/src/gitlab.ts | 5 +-- packages/backend/src/repoCompileUtils.ts | 53 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 5bb28ebf..1c3ea579 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -5,7 +5,7 @@ import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { createLogger } from "./logger.js"; import os from 'os'; import { Redis } from 'ioredis'; -import { RepoData, compileGithubConfig } from "./repoCompileUtils.js"; +import { RepoData, compileGithubConfig, compileGitlabConfig } from "./repoCompileUtils.js"; interface IConnectionManager { scheduleConnectionSync: (connection: Connection) => Promise; @@ -83,6 +83,9 @@ export class ConnectionManager implements IConnectionManager { case 'github': { return await compileGithubConfig(config, job.data.connectionId, orgId, this.db, abortController); } + case 'gitlab': { + return await compileGitlabConfig(config, job.data.connectionId, orgId, this.db); + } default: { return []; } diff --git a/packages/backend/src/gitlab.ts b/packages/backend/src/gitlab.ts index 833ba8e7..62fa6e6c 100644 --- a/packages/backend/src/gitlab.ts +++ b/packages/backend/src/gitlab.ts @@ -9,8 +9,7 @@ const logger = createLogger("GitLab"); export const GITLAB_CLOUD_HOSTNAME = "gitlab.com"; export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, orgId: number, db: PrismaClient) => { - // TODO: pass in DB here to fetch secret properly - const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined; + const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined; const api = new Gitlab({ ...(config.token ? { token, @@ -37,7 +36,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o logger.error(`Failed to fetch all projects visible in ${config.url}.`, e); } } else { - logger.warn(`Ignoring option all:true in ${ctx.configPath} : host is ${GITLAB_CLOUD_HOSTNAME}`); + logger.warn(`Ignoring option all:true in config : host is ${GITLAB_CLOUD_HOSTNAME}`); } } diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 8d8f1a27..02b5685f 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -1,8 +1,10 @@ import { GithubConnectionConfig } from '@sourcebot/schemas/v3/github.type'; import { getGitHubReposFromConfig } from "./github.js"; +import { getGitLabReposFromConfig } from "./gitlab.js"; import { Prisma, PrismaClient } from '@sourcebot/db'; import { WithRequired } from "./types.js" import { marshalBool } from "./utils.js"; +import { GitlabConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; export type RepoData = WithRequired; @@ -54,4 +56,55 @@ export const compileGithubConfig = async ( return record; }) +} + +export const compileGitlabConfig = async ( + config: GitlabConnectionConfig, + connectionId: number, + orgId: number, + db: PrismaClient) => { + + const gitlabRepos = await getGitLabReposFromConfig(config, orgId, db); + const hostUrl = config.url ?? 'https://gitlab.com'; + + return gitlabRepos.map((project) => { + const projectName = `${config.url}/${project.full_name}`; + const projectUrl = `${hostUrl}/${project.path_with_namespace}`; + const cloneUrl = new URL(project.clone_url!); + const isFork = project.forked_from_project !== undefined; + + const record: RepoData = { + external_id: project.id.toString(), + external_codeHostType: 'gitlab', + external_codeHostUrl: hostUrl, + cloneUrl: cloneUrl.toString(), + name: project.path_with_namespace, + isFork: isFork, + isArchived: !!project.archived, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + 'zoekt.web-url-type': 'github', + 'zoekt.web-url': projectUrl, + 'zoekt.name': projectName, + 'zoekt.github-stars': (project.stargazers_count ?? 0).toString(), + 'zoekt.github-watchers': (project.watchers_count ?? 0).toString(), + 'zoekt.github-subscribers': (project.subscribers_count ?? 0).toString(), + 'zoekt.github-forks': (project.forks_count ?? 0).toString(), + 'zoekt.archived': marshalBool(project.archived), + 'zoekt.fork': marshalBool(isFork), + 'zoekt.public': marshalBool(project.private === false) + }, + }; + + return record; + } } \ No newline at end of file From b5dc898bd5c380585664efd9303f7b3e45fa58f7 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 4 Feb 2025 15:04:05 -0500 Subject: [PATCH 04/11] Connection management (#183) --- packages/backend/src/connectionManager.ts | 8 + packages/backend/src/github.ts | 3 + .../migration.sql | 8 + .../migration.sql | 8 + .../migration.sql | 2 + packages/db/prisma/schema.prisma | 53 ++--- packages/web/next.config.mjs | 9 + packages/web/package.json | 6 +- packages/web/src/actions.ts | 152 +++++++++++-- .../web/src/app/components/navigationMenu.tsx | 7 + packages/web/src/app/components/notFound.tsx | 25 +++ .../web/src/app/components/pageNotFound.tsx | 13 +- .../searchBar/useSuggestionsData.ts | 30 +-- .../[id]/components/configSetting.tsx | 142 ++++++++++++ .../components/deleteConnectionSetting.tsx | 90 ++++++++ .../[id]/components/displayNameSetting.tsx | 96 ++++++++ .../[id]/components/repoListItem.tsx | 82 +++++++ .../web/src/app/connections/[id]/page.tsx | 170 +++++++++++++++ .../connections/components/configEditor.tsx | 149 +++++++++++++ .../connections/components/connectionIcon.tsx | 36 +++ .../connectionList/connectionListItem.tsx | 97 +++++++++ .../components/connectionList/index.tsx | 40 ++++ .../src/app/connections/components/header.tsx | 22 ++ .../components/newConnectionCard.tsx | 92 ++++++++ .../app/connections/components/statusIcon.tsx | 27 +++ packages/web/src/app/connections/layout.tsx | 4 +- .../components/connectionCreationForm.tsx | 134 ++++++++++++ .../src/app/connections/new/[type]/page.tsx | 64 ++++++ packages/web/src/app/connections/new/page.tsx | 171 --------------- packages/web/src/app/connections/page.tsx | 41 ++++ .../web/src/app/connections/quickActions.ts | 82 +++++++ packages/web/src/app/connections/utils.ts | 33 +++ packages/web/src/app/not-found.tsx | 2 +- .../web/src/components/ui/alert-dialog.tsx | 141 ++++++++++++ packages/web/src/components/ui/breadcrumb.tsx | 115 ++++++++++ .../web/src/components/ui/tab-switcher.tsx | 53 +++++ packages/web/src/components/ui/tabs.tsx | 55 +++++ packages/web/src/data/connection.ts | 29 +++ packages/web/src/lib/errorCodes.ts | 1 + packages/web/src/lib/utils.ts | 86 +++++++- packages/web/src/schemas/github.schema.ts | 205 ------------------ packages/web/tailwind.config.ts | 1 + yarn.lock | 109 +++++++++- 43 files changed, 2218 insertions(+), 475 deletions(-) create mode 100644 packages/db/prisma/migrations/20250124230248_add_name_to_connection/migration.sql create mode 100644 packages/db/prisma/migrations/20250124231224_add_connection_type/migration.sql create mode 100644 packages/db/prisma/migrations/20250203215003_add_repo_image/migration.sql create mode 100644 packages/web/src/app/components/notFound.tsx create mode 100644 packages/web/src/app/connections/[id]/components/configSetting.tsx create mode 100644 packages/web/src/app/connections/[id]/components/deleteConnectionSetting.tsx create mode 100644 packages/web/src/app/connections/[id]/components/displayNameSetting.tsx create mode 100644 packages/web/src/app/connections/[id]/components/repoListItem.tsx create mode 100644 packages/web/src/app/connections/[id]/page.tsx create mode 100644 packages/web/src/app/connections/components/configEditor.tsx create mode 100644 packages/web/src/app/connections/components/connectionIcon.tsx create mode 100644 packages/web/src/app/connections/components/connectionList/connectionListItem.tsx create mode 100644 packages/web/src/app/connections/components/connectionList/index.tsx create mode 100644 packages/web/src/app/connections/components/header.tsx create mode 100644 packages/web/src/app/connections/components/newConnectionCard.tsx create mode 100644 packages/web/src/app/connections/components/statusIcon.tsx create mode 100644 packages/web/src/app/connections/new/[type]/components/connectionCreationForm.tsx create mode 100644 packages/web/src/app/connections/new/[type]/page.tsx delete mode 100644 packages/web/src/app/connections/new/page.tsx create mode 100644 packages/web/src/app/connections/page.tsx create mode 100644 packages/web/src/app/connections/quickActions.ts create mode 100644 packages/web/src/app/connections/utils.ts create mode 100644 packages/web/src/components/ui/alert-dialog.tsx create mode 100644 packages/web/src/components/ui/breadcrumb.tsx create mode 100644 packages/web/src/components/ui/tab-switcher.tsx create mode 100644 packages/web/src/components/ui/tabs.tsx create mode 100644 packages/web/src/data/connection.ts delete mode 100644 packages/web/src/schemas/github.schema.ts diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index 1c3ea579..56a8ac25 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -92,6 +92,14 @@ export class ConnectionManager implements IConnectionManager { } })(); + // Filter out any duplicates by external_id and external_codeHostUrl. + repoData.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 // captured by the connection's config (e.g., it was deleted, marked archived, etc.), it won't 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/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) =>