From 84eda76bd9078309c7302b953c44e52bdcd7e32e Mon Sep 17 00:00:00 2001 From: msukkari Date: Tue, 16 Sep 2025 19:42:11 -0700 Subject: [PATCH] initial ado pol --- .../schemas/v3/azuredevops.schema.mdx | 217 +++++++++++ .../snippets/schemas/v3/connection.schema.mdx | 214 +++++++++++ docs/snippets/schemas/v3/index.schema.mdx | 214 +++++++++++ packages/backend/package.json | 1 + packages/backend/src/azuredevops.ts | 348 ++++++++++++++++++ packages/backend/src/connectionManager.ts | 5 +- packages/backend/src/repoCompileUtils.ts | 85 ++++- packages/backend/src/repoManager.ts | 22 +- packages/db/prisma/schema.prisma | 5 +- packages/schemas/src/v3/azuredevops.schema.ts | 216 +++++++++++ packages/schemas/src/v3/azuredevops.type.ts | 93 +++++ packages/schemas/src/v3/connection.schema.ts | 214 +++++++++++ packages/schemas/src/v3/connection.type.ts | 79 ++++ packages/schemas/src/v3/index.schema.ts | 214 +++++++++++ packages/schemas/src/v3/index.type.ts | 79 ++++ packages/web/src/actions.ts | 3 +- schemas/v3/azuredevops.json | 146 ++++++++ schemas/v3/connection.json | 3 + yarn.lock | 66 +++- 19 files changed, 2207 insertions(+), 17 deletions(-) create mode 100644 docs/snippets/schemas/v3/azuredevops.schema.mdx create mode 100644 packages/backend/src/azuredevops.ts create mode 100644 packages/schemas/src/v3/azuredevops.schema.ts create mode 100644 packages/schemas/src/v3/azuredevops.type.ts create mode 100644 schemas/v3/azuredevops.json diff --git a/docs/snippets/schemas/v3/azuredevops.schema.mdx b/docs/snippets/schemas/v3/azuredevops.schema.mdx new file mode 100644 index 00000000..5f802485 --- /dev/null +++ b/docs/snippets/schemas/v3/azuredevops.schema.mdx @@ -0,0 +1,217 @@ +{/* THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! */} +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "AzureDevOpsConnectionConfig", + "properties": { + "type": { + "const": "azuredevops", + "description": "Azure DevOps Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://dev.azure.com", + "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.", + "examples": [ + "https://dev.azure.com", + "https://azuredevops.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Azure DevOps deployment" + }, + "apiVersion": { + "type": "string", + "default": "7.1", + "description": "The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1.", + "examples": [ + "7.1", + "7.0", + "6.0", + "5.1" + ] + }, + "useTfsPath": { + "type": "boolean", + "default": false, + "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)." + }, + "organizations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org" + ] + ], + "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property." + }, + "projects": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project", + "my-collection/my-project" + ] + ], + "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project/my-repo" + ] + ], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'." + }, + "exclude": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "default": false, + "description": "Exclude disabled repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of projects to exclude from syncing. Glob patterns are supported." + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their size.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "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. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "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. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false +} +``` diff --git a/docs/snippets/schemas/v3/connection.schema.mdx b/docs/snippets/schemas/v3/connection.schema.mdx index 631fc17f..6845a626 100644 --- a/docs/snippets/schemas/v3/connection.schema.mdx +++ b/docs/snippets/schemas/v3/connection.schema.mdx @@ -869,6 +869,220 @@ }, "additionalProperties": false }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "AzureDevOpsConnectionConfig", + "properties": { + "type": { + "const": "azuredevops", + "description": "Azure DevOps Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://dev.azure.com", + "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.", + "examples": [ + "https://dev.azure.com", + "https://azuredevops.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Azure DevOps deployment" + }, + "apiVersion": { + "type": "string", + "default": "7.1", + "description": "The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1.", + "examples": [ + "7.1", + "7.0", + "6.0", + "5.1" + ] + }, + "useTfsPath": { + "type": "boolean", + "default": false, + "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)." + }, + "organizations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org" + ] + ], + "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property." + }, + "projects": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project", + "my-collection/my-project" + ] + ], + "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project/my-repo" + ] + ], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'." + }, + "exclude": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "default": false, + "description": "Exclude disabled repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of projects to exclude from syncing. Glob patterns are supported." + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their size.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "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. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "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. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false + }, { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 1a513eba..0d740128 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -1132,6 +1132,220 @@ }, "additionalProperties": false }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "AzureDevOpsConnectionConfig", + "properties": { + "type": { + "const": "azuredevops", + "description": "Azure DevOps Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://dev.azure.com", + "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.", + "examples": [ + "https://dev.azure.com", + "https://azuredevops.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Azure DevOps deployment" + }, + "apiVersion": { + "type": "string", + "default": "7.1", + "description": "The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1.", + "examples": [ + "7.1", + "7.0", + "6.0", + "5.1" + ] + }, + "useTfsPath": { + "type": "boolean", + "default": false, + "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)." + }, + "organizations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org" + ] + ], + "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property." + }, + "projects": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project", + "my-collection/my-project" + ] + ], + "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project/my-repo" + ] + ], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'." + }, + "exclude": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "default": false, + "description": "Exclude disabled repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of projects to exclude from syncing. Glob patterns are supported." + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their size.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "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. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "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. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false + }, { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", diff --git a/packages/backend/package.json b/packages/backend/package.json index 17527800..dade7893 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,6 +37,7 @@ "@t3-oss/env-core": "^0.12.0", "@types/express": "^5.0.0", "argparse": "^2.0.1", + "azure-devops-node-api": "^15.1.1", "bullmq": "^5.34.10", "cross-fetch": "^4.0.0", "dotenv": "^16.4.5", diff --git a/packages/backend/src/azuredevops.ts b/packages/backend/src/azuredevops.ts new file mode 100644 index 00000000..41f5355a --- /dev/null +++ b/packages/backend/src/azuredevops.ts @@ -0,0 +1,348 @@ +import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type"; +import { createLogger } from "@sourcebot/logger"; +import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js"; +import micromatch from "micromatch"; +import { PrismaClient } from "@sourcebot/db"; +import { BackendException, BackendError } from "@sourcebot/error"; +import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; +import * as Sentry from "@sentry/node"; +import * as azdev from "azure-devops-node-api"; +import { GitRepository } from "azure-devops-node-api/interfaces/GitInterfaces.js"; + +const logger = createLogger('azuredevops'); +const AZUREDEVOPS_CLOUD_HOSTNAME = "dev.azure.com"; + + +function buildOrgUrl(baseUrl: string, org: string, useTfsPath: boolean): string { + const tfsSegment = useTfsPath ? '/tfs' : ''; + return `${baseUrl}${tfsSegment}/${org}`; +} + +function createAzureDevOpsConnection( + orgUrl: string, + token: string, +): azdev.WebApi { + const authHandler = azdev.getPersonalAccessTokenHandler(token); + return new azdev.WebApi(orgUrl, authHandler); +} + +export const getAzureDevOpsReposFromConfig = async ( + config: AzureDevOpsConnectionConfig, + orgId: number, + db: PrismaClient +) => { + const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`; + + const token = config.token ? + await getTokenFromConfig(config.token, orgId, db, logger) : + undefined; + + if (!token) { + const e = new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, { + message: 'Azure DevOps requires a Personal Access Token', + }); + Sentry.captureException(e); + throw e; + } + + const useTfsPath = config.useTfsPath || false; + let allRepos: GitRepository[] = []; + let notFound: { + users: string[], + orgs: string[], + repos: string[], + } = { + users: [], + orgs: [], + repos: [], + }; + + if (config.organizations) { + const { validRepos, notFoundOrgs } = await getReposForOrganizations( + config.organizations, + baseUrl, + token, + useTfsPath + ); + allRepos = allRepos.concat(validRepos); + notFound.orgs = notFoundOrgs; + } + + if (config.projects) { + const { validRepos, notFoundProjects } = await getReposForProjects( + config.projects, + baseUrl, + token, + useTfsPath + ); + allRepos = allRepos.concat(validRepos); + notFound.repos = notFound.repos.concat(notFoundProjects); + } + + if (config.repos) { + const { validRepos, notFoundRepos } = await getRepos( + config.repos, + baseUrl, + token, + useTfsPath + ); + allRepos = allRepos.concat(validRepos); + notFound.repos = notFound.repos.concat(notFoundRepos); + } + + let repos = allRepos + .filter((repo) => { + const isExcluded = shouldExcludeRepo({ + repo, + exclude: config.exclude, + }); + + return !isExcluded; + }); + + logger.debug(`Found ${repos.length} total repositories.`); + + return { + validRepos: repos, + notFound, + }; +}; + +export const shouldExcludeRepo = ({ + repo, + exclude +}: { + repo: GitRepository, + exclude?: AzureDevOpsConnectionConfig['exclude'] +}) => { + let reason = ''; + const repoName = `${repo.project!.name}/${repo.name}`; + + const shouldExclude = (() => { + if (!repo.remoteUrl) { + reason = 'remoteUrl is undefined'; + return true; + } + + if (!!exclude?.disabled && repo.isDisabled) { + reason = `\`exclude.disabled\` is true`; + return true; + } + + if (exclude?.repos) { + if (micromatch.isMatch(repoName, exclude.repos)) { + reason = `\`exclude.repos\` contains ${repoName}`; + return true; + } + } + + if (exclude?.projects) { + if (micromatch.isMatch(repo.project!.name!, exclude.projects)) { + reason = `\`exclude.projects\` contains ${repo.project!.name}`; + return true; + } + } + + const repoSizeInBytes = repo.size || 0; + if (exclude?.size && repoSizeInBytes) { + const min = exclude.size.min; + const max = exclude.size.max; + + if (min && repoSizeInBytes < min) { + reason = `repo is less than \`exclude.size.min\`=${min} bytes.`; + return true; + } + + if (max && repoSizeInBytes > max) { + reason = `repo is greater than \`exclude.size.max\`=${max} bytes.`; + return true; + } + } + + return false; + })(); + + if (shouldExclude) { + logger.debug(`Excluding repo ${repoName}. Reason: ${reason}`); + return true; + } + + return false; +}; + +async function getReposForOrganizations( + organizations: string[], + baseUrl: string, + token: string, + useTfsPath: boolean +) { + const results = await Promise.allSettled(organizations.map(async (org) => { + try { + logger.debug(`Fetching repositories for organization ${org}...`); + + const { durationMs, data } = await measure(async () => { + const fetchFn = async () => { + const orgUrl = buildOrgUrl(baseUrl, org, useTfsPath); + const connection = createAzureDevOpsConnection(orgUrl, token); // useTfsPath already handled in orgUrl + + const coreApi = await connection.getCoreApi(); + const gitApi = await connection.getGitApi(); + + const projects = await coreApi.getProjects(); + const allRepos: GitRepository[] = []; + for (const project of projects) { + if (!project.id) { + logger.warn(`Encountered project in org ${org} with no id: ${project.name}`); + continue; + } + + try { + const repos = await gitApi.getRepositories(project.id); + allRepos.push(...repos); + } catch (error) { + logger.warn(`Failed to fetch repositories for project ${project.name}: ${error}`); + } + } + + return allRepos; + }; + + return fetchWithRetry(fetchFn, `organization ${org}`, logger); + }); + + logger.debug(`Found ${data.length} repositories in organization ${org} in ${durationMs}ms.`); + return { + type: 'valid' as const, + data + }; + } catch (error) { + Sentry.captureException(error); + logger.error(`Failed to fetch repositories for organization ${org}.`, error); + + // Check if it's a 404-like error (organization not found) + if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { + logger.error(`Organization ${org} not found or no access`); + return { + type: 'notFound' as const, + value: org + }; + } + throw error; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundOrgs } = processPromiseResults(results); + + return { + validRepos, + notFoundOrgs, + }; +} + +async function getReposForProjects( + projects: string[], + baseUrl: string, + token: string, + useTfsPath: boolean +) { + const results = await Promise.allSettled(projects.map(async (project) => { + try { + const [org, projectName] = project.split('/'); + logger.debug(`Fetching repositories for project ${project}...`); + + const { durationMs, data } = await measure(async () => { + const fetchFn = async () => { + const orgUrl = buildOrgUrl(baseUrl, org, useTfsPath); + const connection = createAzureDevOpsConnection(orgUrl, token); + const gitApi = await connection.getGitApi(); + + const repos = await gitApi.getRepositories(projectName); + return repos; + }; + + return fetchWithRetry(fetchFn, `project ${project}`, logger); + }); + + logger.debug(`Found ${data.length} repositories in project ${project} in ${durationMs}ms.`); + return { + type: 'valid' as const, + data + }; + } catch (error) { + Sentry.captureException(error); + logger.error(`Failed to fetch repositories for project ${project}.`, error); + + if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { + logger.error(`Project ${project} not found or no access`); + return { + type: 'notFound' as const, + value: project + }; + } + throw error; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundProjects } = processPromiseResults(results); + + return { + validRepos, + notFoundProjects, + }; +} + +async function getRepos( + repoList: string[], + baseUrl: string, + token: string, + useTfsPath: boolean +) { + const results = await Promise.allSettled(repoList.map(async (repo) => { + try { + const [org, projectName, repoName] = repo.split('/'); + logger.info(`Fetching repository info for ${repo}...`); + + const { durationMs, data: result } = await measure(async () => { + const fetchFn = async () => { + const orgUrl = buildOrgUrl(baseUrl, org, useTfsPath); + const connection = createAzureDevOpsConnection(orgUrl, token); + const gitApi = await connection.getGitApi(); + + const repo = await gitApi.getRepository(repoName, projectName); + return repo; + }; + + return fetchWithRetry(fetchFn, repo, logger); + }); + + logger.info(`Found info for repository ${repo} in ${durationMs}ms`); + return { + type: 'valid' as const, + data: [result] + }; + + } catch (error) { + Sentry.captureException(error); + logger.error(`Failed to fetch repository ${repo}.`, error); + + if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { + logger.error(`Repository ${repo} not found or no access`); + return { + type: 'notFound' as const, + value: repo + }; + } + throw error; + } + })); + + throwIfAnyFailed(results); + const { validItems: validRepos, notFoundItems: notFoundRepos } = processPromiseResults(results); + + return { + validRepos, + notFoundRepos, + }; +} \ No newline at end of file diff --git a/packages/backend/src/connectionManager.ts b/packages/backend/src/connectionManager.ts index f025bdf7..5cf119b6 100644 --- a/packages/backend/src/connectionManager.ts +++ b/packages/backend/src/connectionManager.ts @@ -4,7 +4,7 @@ import { Settings } from "./types.js"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { createLogger } from "@sourcebot/logger"; import { Redis } from 'ioredis'; -import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig, compileBitbucketConfig, compileGenericGitHostConfig } from "./repoCompileUtils.js"; +import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig, compileBitbucketConfig, compileAzureDevOpsConfig, compileGenericGitHostConfig } from "./repoCompileUtils.js"; import { BackendError, BackendException } from "@sourcebot/error"; import { captureEvent } from "./posthog.js"; import { env } from "./env.js"; @@ -177,6 +177,9 @@ export class ConnectionManager implements IConnectionManager { case 'bitbucket': { return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db); } + case 'azuredevops': { + return await compileAzureDevOpsConfig(config, job.data.connectionId, orgId, this.db, abortController); + } case 'git': { return await compileGenericGitHostConfig(config, job.data.connectionId, orgId); } diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index ad26ed58..ab162ffe 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -4,13 +4,15 @@ import { getGitLabReposFromConfig } from "./gitlab.js"; import { getGiteaReposFromConfig } from "./gitea.js"; import { getGerritReposFromConfig } from "./gerrit.js"; import { BitbucketRepository, getBitbucketReposFromConfig } from "./bitbucket.js"; +import { getAzureDevOpsReposFromConfig } from "./azuredevops.js"; import { SchemaRestRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitbucket/cloud/openapi"; import { Prisma, PrismaClient } from '@sourcebot/db'; import { WithRequired } from "./types.js" import { marshalBool } from "./utils.js"; import { createLogger } from '@sourcebot/logger'; -import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js"; import { RepoMetadata } from './types.js'; import path from 'path'; import { glob } from 'glob'; @@ -544,6 +546,87 @@ export const compileGenericGitHostConfig_file = async ( } } +export const compileAzureDevOpsConfig = async ( + config: AzureDevOpsConnectionConfig, + connectionId: number, + orgId: number, + db: PrismaClient, + abortController: AbortController) => { + + const azureDevOpsReposResult = await getAzureDevOpsReposFromConfig(config, orgId, db); + const azureDevOpsRepos = azureDevOpsReposResult.validRepos; + const notFound = azureDevOpsReposResult.notFound; + + const hostUrl = config.url ?? 'https://dev.azure.com'; + const repoNameRoot = new URL(hostUrl) + .toString() + .replace(/^https?:\/\//, ''); + + const repos = azureDevOpsRepos.map((repo) => { + if (!repo.project) { + throw new Error(`No project found for repository ${repo.name}`); + } + + const repoDisplayName = `${repo.project.name}/${repo.name}`; + const repoName = path.join(repoNameRoot, repoDisplayName); + + if (!repo.remoteUrl) { + throw new Error(`No remoteUrl found for repository ${repoDisplayName}`); + } + if (!repo.id) { + throw new Error(`No id found for repository ${repoDisplayName}`); + } + + // Construct web URL for the repository + const webUrl = repo.webUrl || `${hostUrl}/${repo.project.name}/_git/${repo.name}`; + + logger.debug(`Found Azure DevOps repo ${repoDisplayName} with webUrl: ${webUrl}`); + + const record: RepoData = { + external_id: repo.id.toString(), + external_codeHostType: 'azuredevops', + external_codeHostUrl: hostUrl, + cloneUrl: webUrl, + webUrl: webUrl, + name: repoName, + displayName: repoDisplayName, + imageUrl: null, + isFork: !!repo.isFork, + isArchived: false, + org: { + connect: { + id: orgId, + }, + }, + connections: { + create: { + connectionId: connectionId, + } + }, + metadata: { + gitConfig: { + 'zoekt.web-url-type': 'azuredevops', + 'zoekt.web-url': webUrl, + 'zoekt.name': repoName, + 'zoekt.archived': marshalBool(false), + 'zoekt.fork': marshalBool(!!repo.isFork), + 'zoekt.public': marshalBool(repo.project.visibility === ProjectVisibility.Public), + 'zoekt.display-name': repoDisplayName, + }, + branches: config.revisions?.branches ?? undefined, + tags: config.revisions?.tags ?? undefined, + } satisfies RepoMetadata, + }; + + return record; + }) + + return { + repoData: repos, + notFound, + }; +} + export const compileGenericGitHostConfig_url = async ( config: GenericGitHostConnectionConfig, orgId: number, diff --git a/packages/backend/src/repoManager.ts b/packages/backend/src/repoManager.ts index d8e091ec..0a58c2fe 100644 --- a/packages/backend/src/repoManager.ts +++ b/packages/backend/src/repoManager.ts @@ -2,7 +2,7 @@ import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createLogger } from "@sourcebot/logger"; import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; -import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; +import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { AppContext, Settings, repoMetadataSchema } from "./types.js"; import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js"; import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js"; @@ -186,9 +186,7 @@ export class RepoManager implements IRepoManager { password: token, } } - } - - else if (connection.connectionType === 'gitlab') { + } else if (connection.connectionType === 'gitlab') { const config = connection.config as unknown as GitlabConnectionConfig; if (config.token) { const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); @@ -197,9 +195,7 @@ export class RepoManager implements IRepoManager { password: token, } } - } - - else if (connection.connectionType === 'gitea') { + } else if (connection.connectionType === 'gitea') { const config = connection.config as unknown as GiteaConnectionConfig; if (config.token) { const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); @@ -207,9 +203,7 @@ export class RepoManager implements IRepoManager { password: token, } } - } - - else if (connection.connectionType === 'bitbucket') { + } else if (connection.connectionType === 'bitbucket') { const config = connection.config as unknown as BitbucketConnectionConfig; if (config.token) { const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); @@ -219,6 +213,14 @@ export class RepoManager implements IRepoManager { password: token, } } + } else if (connection.connectionType === 'azuredevops') { + const config = connection.config as unknown as AzureDevOpsConnectionConfig; + if (config.token) { + const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); + return { + password: token, + } + } } } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 20454ddf..4b75430c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -42,10 +42,11 @@ enum ChatVisibility { model Repo { id Int @id @default(autoincrement()) - name String - displayName String? + name String // Full repo name, including the vcs hostname (ex. github.com/sourcebot-dev/sourcebot) + displayName String? // Display name of the repo for UI (ex. sourcebot-dev/sourcebot) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// When the repo was last indexed successfully. indexedAt DateTime? isFork Boolean diff --git a/packages/schemas/src/v3/azuredevops.schema.ts b/packages/schemas/src/v3/azuredevops.schema.ts new file mode 100644 index 00000000..be9af667 --- /dev/null +++ b/packages/schemas/src/v3/azuredevops.schema.ts @@ -0,0 +1,216 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! +const schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "AzureDevOpsConnectionConfig", + "properties": { + "type": { + "const": "azuredevops", + "description": "Azure DevOps Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://dev.azure.com", + "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.", + "examples": [ + "https://dev.azure.com", + "https://azuredevops.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Azure DevOps deployment" + }, + "apiVersion": { + "type": "string", + "default": "7.1", + "description": "The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1.", + "examples": [ + "7.1", + "7.0", + "6.0", + "5.1" + ] + }, + "useTfsPath": { + "type": "boolean", + "default": false, + "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)." + }, + "organizations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org" + ] + ], + "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property." + }, + "projects": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project", + "my-collection/my-project" + ] + ], + "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project/my-repo" + ] + ], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'." + }, + "exclude": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "default": false, + "description": "Exclude disabled repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of projects to exclude from syncing. Glob patterns are supported." + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their size.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "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. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "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. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false +} as const; +export { schema as azuredevopsSchema }; \ No newline at end of file diff --git a/packages/schemas/src/v3/azuredevops.type.ts b/packages/schemas/src/v3/azuredevops.type.ts new file mode 100644 index 00000000..cd5a5589 --- /dev/null +++ b/packages/schemas/src/v3/azuredevops.type.ts @@ -0,0 +1,93 @@ +// THIS IS A AUTO-GENERATED FILE. DO NOT MODIFY MANUALLY! + +export interface AzureDevOpsConnectionConfig { + /** + * Azure DevOps Configuration + */ + type: "azuredevops"; + /** + * A Personal Access Token (PAT). + */ + token: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL. + */ + url?: string; + /** + * The type of Azure DevOps deployment + */ + deploymentType?: "cloud" | "server"; + /** + * The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1. + */ + apiVersion?: string; + /** + * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...). + */ + useTfsPath?: boolean; + /** + * List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property. + */ + organizations?: string[]; + /** + * List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server. + */ + projects?: string[]; + /** + * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'. + */ + repos?: string[]; + exclude?: { + /** + * Exclude disabled repositories from syncing. + */ + disabled?: boolean; + /** + * List of individual repositories to exclude from syncing. Glob patterns are supported. + */ + repos?: string[]; + /** + * List of projects to exclude from syncing. Glob patterns are supported. + */ + projects?: string[]; + /** + * Exclude repositories based on their size. + */ + size?: { + /** + * Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing. + */ + min?: number; + /** + * Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing. + */ + max?: number; + }; + }; + revisions?: GitRevisions; +} +/** + * The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored. + */ +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. A maximum of 64 branches can be indexed, with any additional branches being ignored. + */ + 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. A maximum of 64 tags can be indexed, with any additional tags being ignored. + */ + tags?: string[]; +} diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 63357752..87658cbd 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -868,6 +868,220 @@ const schema = { }, "additionalProperties": false }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "AzureDevOpsConnectionConfig", + "properties": { + "type": { + "const": "azuredevops", + "description": "Azure DevOps Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://dev.azure.com", + "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.", + "examples": [ + "https://dev.azure.com", + "https://azuredevops.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Azure DevOps deployment" + }, + "apiVersion": { + "type": "string", + "default": "7.1", + "description": "The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1.", + "examples": [ + "7.1", + "7.0", + "6.0", + "5.1" + ] + }, + "useTfsPath": { + "type": "boolean", + "default": false, + "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)." + }, + "organizations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org" + ] + ], + "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property." + }, + "projects": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project", + "my-collection/my-project" + ] + ], + "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project/my-repo" + ] + ], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'." + }, + "exclude": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "default": false, + "description": "Exclude disabled repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of projects to exclude from syncing. Glob patterns are supported." + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their size.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "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. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "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. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false + }, { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", diff --git a/packages/schemas/src/v3/connection.type.ts b/packages/schemas/src/v3/connection.type.ts index cba5800e..eaba169a 100644 --- a/packages/schemas/src/v3/connection.type.ts +++ b/packages/schemas/src/v3/connection.type.ts @@ -6,6 +6,7 @@ export type ConnectionConfig = | GiteaConnectionConfig | GerritConnectionConfig | BitbucketConnectionConfig + | AzureDevOpsConnectionConfig | GenericGitHostConnectionConfig; export interface GithubConnectionConfig { @@ -311,6 +312,84 @@ export interface BitbucketConnectionConfig { }; revisions?: GitRevisions; } +export interface AzureDevOpsConnectionConfig { + /** + * Azure DevOps Configuration + */ + type: "azuredevops"; + /** + * A Personal Access Token (PAT). + */ + token: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL. + */ + url?: string; + /** + * The type of Azure DevOps deployment + */ + deploymentType?: "cloud" | "server"; + /** + * The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1. + */ + apiVersion?: string; + /** + * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...). + */ + useTfsPath?: boolean; + /** + * List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property. + */ + organizations?: string[]; + /** + * List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server. + */ + projects?: string[]; + /** + * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'. + */ + repos?: string[]; + exclude?: { + /** + * Exclude disabled repositories from syncing. + */ + disabled?: boolean; + /** + * List of individual repositories to exclude from syncing. Glob patterns are supported. + */ + repos?: string[]; + /** + * List of projects to exclude from syncing. Glob patterns are supported. + */ + projects?: string[]; + /** + * Exclude repositories based on their size. + */ + size?: { + /** + * Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing. + */ + min?: number; + /** + * Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing. + */ + max?: number; + }; + }; + revisions?: GitRevisions; +} export interface GenericGitHostConnectionConfig { /** * Generic Git host configuration diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index eb97e78d..7e60afa6 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -1131,6 +1131,220 @@ const schema = { }, "additionalProperties": false }, + { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "AzureDevOpsConnectionConfig", + "properties": { + "type": { + "const": "azuredevops", + "description": "Azure DevOps Configuration" + }, + "token": { + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ], + "anyOf": [ + { + "type": "object", + "properties": { + "secret": { + "type": "string", + "description": "The name of the secret that contains the token." + } + }, + "required": [ + "secret" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "env": { + "type": "string", + "description": "The name of the environment variable that contains the token. Only supported in declarative connection configs." + } + }, + "required": [ + "env" + ], + "additionalProperties": false + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://dev.azure.com", + "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.", + "examples": [ + "https://dev.azure.com", + "https://azuredevops.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": [ + "cloud", + "server" + ], + "default": "cloud", + "description": "The type of Azure DevOps deployment" + }, + "apiVersion": { + "type": "string", + "default": "7.1", + "description": "The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1.", + "examples": [ + "7.1", + "7.0", + "6.0", + "5.1" + ] + }, + "useTfsPath": { + "type": "boolean", + "default": false, + "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)." + }, + "organizations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org" + ] + ], + "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property." + }, + "projects": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project", + "my-collection/my-project" + ] + ], + "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project/my-repo" + ] + ], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'." + }, + "exclude": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "default": false, + "description": "Exclude disabled repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of projects to exclude from syncing. Glob patterns are supported." + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their size.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "type": "object", + "description": "The revisions (branches, tags) that should be included when indexing. The default branch (HEAD) is always indexed. A maximum of 64 revisions can be indexed, with any additional revisions being ignored.", + "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. A maximum of 64 branches can be indexed, with any additional branches being ignored.", + "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. A maximum of 64 tags can be indexed, with any additional tags being ignored.", + "items": { + "type": "string" + }, + "examples": [ + [ + "latest", + "v2.*.*" + ], + [ + "**" + ] + ], + "default": [] + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false + }, { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index 8f346bfa..1c4dd89e 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -10,6 +10,7 @@ export type ConnectionConfig = | GiteaConnectionConfig | GerritConnectionConfig | BitbucketConnectionConfig + | AzureDevOpsConnectionConfig | GenericGitHostConnectionConfig; export type LanguageModel = | AmazonBedrockLanguageModel @@ -436,6 +437,84 @@ export interface BitbucketConnectionConfig { }; revisions?: GitRevisions; } +export interface AzureDevOpsConnectionConfig { + /** + * Azure DevOps Configuration + */ + type: "azuredevops"; + /** + * A Personal Access Token (PAT). + */ + token: + | { + /** + * The name of the secret that contains the token. + */ + secret: string; + } + | { + /** + * The name of the environment variable that contains the token. Only supported in declarative connection configs. + */ + env: string; + }; + /** + * The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL. + */ + url?: string; + /** + * The type of Azure DevOps deployment + */ + deploymentType?: "cloud" | "server"; + /** + * The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1. + */ + apiVersion?: string; + /** + * Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...). + */ + useTfsPath?: boolean; + /** + * List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property. + */ + organizations?: string[]; + /** + * List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server. + */ + projects?: string[]; + /** + * List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'. + */ + repos?: string[]; + exclude?: { + /** + * Exclude disabled repositories from syncing. + */ + disabled?: boolean; + /** + * List of individual repositories to exclude from syncing. Glob patterns are supported. + */ + repos?: string[]; + /** + * List of projects to exclude from syncing. Glob patterns are supported. + */ + projects?: string[]; + /** + * Exclude repositories based on their size. + */ + size?: { + /** + * Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing. + */ + min?: number; + /** + * Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing. + */ + max?: number; + }; + }; + revisions?: GitRevisions; +} export interface GenericGitHostConnectionConfig { /** * Generic Git host configuration diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 80094989..f710d238 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -2221,7 +2221,8 @@ const parseConnectionConfig = (config: string) => { switch (connectionType) { case "gitea": case "github": - case "bitbucket": { + case "bitbucket": + case "azuredevops": { return { numRepos: parsedConfig.repos?.length, hasToken: !!parsedConfig.token, diff --git a/schemas/v3/azuredevops.json b/schemas/v3/azuredevops.json new file mode 100644 index 00000000..3486f835 --- /dev/null +++ b/schemas/v3/azuredevops.json @@ -0,0 +1,146 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "AzureDevOpsConnectionConfig", + "properties": { + "type": { + "const": "azuredevops", + "description": "Azure DevOps Configuration" + }, + "token": { + "$ref": "./shared.json#/definitions/Token", + "description": "A Personal Access Token (PAT).", + "examples": [ + { + "secret": "SECRET_KEY" + } + ] + }, + "url": { + "type": "string", + "format": "url", + "default": "https://dev.azure.com", + "description": "The URL of the Azure DevOps host. For Azure DevOps Cloud, use https://dev.azure.com. For Azure DevOps Server, use your server URL.", + "examples": [ + "https://dev.azure.com", + "https://azuredevops.example.com" + ], + "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "deploymentType": { + "type": "string", + "enum": ["cloud", "server"], + "default": "cloud", + "description": "The type of Azure DevOps deployment" + }, + "apiVersion": { + "type": "string", + "default": "7.1", + "description": "The Azure DevOps API version to use. For Cloud, use 7.1 or later. For Server: 2022 uses 7.1, 2020 uses 6.0, 2019 uses 5.1.", + "examples": [ + "7.1", + "7.0", + "6.0", + "5.1" + ] + }, + "useTfsPath": { + "type": "boolean", + "default": false, + "description": "Use legacy TFS path format (/tfs) in API URLs. Required for older TFS installations (TFS 2018 and earlier). When true, API URLs will include /tfs in the path (e.g., https://server/tfs/collection/_apis/...)." + }, + "organizations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org" + ] + ], + "description": "List of organizations to sync with. For Cloud, this is the organization name. For Server, this is the collection name. All projects and repositories visible to the provided `token` will be synced, unless explicitly defined in the `exclude` property." + }, + "projects": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project", + "my-collection/my-project" + ] + ], + "description": "List of specific projects to sync with. Expected to be formatted as '{orgName}/{projectName}' for Cloud or '{collectionName}/{projectName}' for Server." + }, + "repos": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[\\w.-]+\\/[\\w.-]+\\/[\\w.-]+$" + }, + "default": [], + "examples": [ + [ + "my-org/my-project/my-repo" + ] + ], + "description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{projectName}/{repoName}'." + }, + "exclude": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "default": false, + "description": "Exclude disabled repositories from syncing." + }, + "repos": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of individual repositories to exclude from syncing. Glob patterns are supported." + }, + "projects": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "List of projects to exclude from syncing. Glob patterns are supported." + }, + "size": { + "type": "object", + "description": "Exclude repositories based on their size.", + "properties": { + "min": { + "type": "integer", + "description": "Minimum repository size (in bytes) to sync (inclusive). Repositories less than this size will be excluded from syncing." + }, + "max": { + "type": "integer", + "description": "Maximum repository size (in bytes) to sync (inclusive). Repositories greater than this size will be excluded from syncing." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "revisions": { + "$ref": "./shared.json#/definitions/GitRevisions" + } + }, + "required": [ + "type", + "token" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/v3/connection.json b/schemas/v3/connection.json index c7c93c75..6cd316a5 100644 --- a/schemas/v3/connection.json +++ b/schemas/v3/connection.json @@ -17,6 +17,9 @@ { "$ref": "./bitbucket.json" }, + { + "$ref": "./azuredevops.json" + }, { "$ref": "./genericGitHost.json" } diff --git a/yarn.lock b/yarn.lock index 88b65afa..858f025c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6597,6 +6597,7 @@ __metadata: "@types/micromatch": "npm:^4.0.9" "@types/node": "npm:^22.7.5" argparse: "npm:^2.0.1" + azure-devops-node-api: "npm:^15.1.1" bullmq: "npm:^5.34.10" cross-env: "npm:^7.0.3" cross-fetch: "npm:^4.0.0" @@ -8578,6 +8579,16 @@ __metadata: languageName: node linkType: hard +"azure-devops-node-api@npm:^15.1.1": + version: 15.1.1 + resolution: "azure-devops-node-api@npm:15.1.1" + dependencies: + tunnel: "npm:0.0.6" + typed-rest-client: "npm:2.1.0" + checksum: 10c0/e57de52745b322523c2f0220770fcbcb692032610d240644c96914ba0f08613416b701e56dc996b52c9bf6761270148a931f694630ed171b238555e237ee002c + languageName: node + linkType: hard + "bail@npm:^2.0.0": version: 2.0.2 resolution: "bail@npm:2.0.2" @@ -9925,6 +9936,16 @@ __metadata: languageName: node linkType: hard +"des.js@npm:^1.1.0": + version: 1.1.0 + resolution: "des.js@npm:1.1.0" + dependencies: + inherits: "npm:^2.0.1" + minimalistic-assert: "npm:^1.0.0" + checksum: 10c0/671354943ad67493e49eb4c555480ab153edd7cee3a51c658082fcde539d2690ed2a4a0b5d1f401f9cde822edf3939a6afb2585f32c091f2d3a1b1665cd45236 + languageName: node + linkType: hard + "destroy@npm:1.2.0": version: 1.2.0 resolution: "destroy@npm:1.2.0" @@ -12454,7 +12475,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -13006,6 +13027,13 @@ __metadata: languageName: node linkType: hard +"js-md4@npm:^0.3.2": + version: 0.3.2 + resolution: "js-md4@npm:0.3.2" + checksum: 10c0/8313e00c45f710a53bdadc199c095b48ebaf54ea7b8cdb67a3f1863c270a5e9d0f89f204436b73866002af8c7ac4cacc872fdf271fc70e26614e424c7685b577 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -14273,6 +14301,13 @@ __metadata: languageName: node linkType: hard +"minimalistic-assert@npm:^1.0.0": + version: 1.0.1 + resolution: "minimalistic-assert@npm:1.0.1" + checksum: 10c0/96730e5601cd31457f81a296f521eb56036e6f69133c0b18c13fe941109d53ad23a4204d946a0d638d7f3099482a0cec8c9bb6d642604612ce43ee536be3dddd + languageName: node + linkType: hard + "minimatch@npm:^10.0.0": version: 10.0.1 resolution: "minimatch@npm:10.0.1" @@ -15948,7 +15983,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.11.0, qs@npm:^6.12.2, qs@npm:^6.14.0": +"qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.12.2, qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" dependencies: @@ -18438,6 +18473,13 @@ __metadata: languageName: node linkType: hard +"tunnel@npm:0.0.6": + version: 0.0.6 + resolution: "tunnel@npm:0.0.6" + checksum: 10c0/e27e7e896f2426c1c747325b5f54efebc1a004647d853fad892b46d64e37591ccd0b97439470795e5262b5c0748d22beb4489a04a0a448029636670bfd801b75 + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -18535,6 +18577,19 @@ __metadata: languageName: node linkType: hard +"typed-rest-client@npm:2.1.0": + version: 2.1.0 + resolution: "typed-rest-client@npm:2.1.0" + dependencies: + des.js: "npm:^1.1.0" + js-md4: "npm:^0.3.2" + qs: "npm:^6.10.3" + tunnel: "npm:0.0.6" + underscore: "npm:^1.12.1" + checksum: 10c0/b9d29db5217b6d3d0ae9aa68e87e84be8c2d885e7a932f4df3eca070bb615ded5f390035f26857996911803830d28ba2296d6cb748072dbc6d8657916107132d + languageName: node + linkType: hard + "typescript@npm:^5, typescript@npm:^5.6.2, typescript@npm:^5.7.3": version: 5.8.2 resolution: "typescript@npm:5.8.2" @@ -18610,6 +18665,13 @@ __metadata: languageName: node linkType: hard +"underscore@npm:^1.12.1": + version: 1.13.7 + resolution: "underscore@npm:1.13.7" + checksum: 10c0/fad2b4aac48847674aaf3c30558f383399d4fdafad6dd02dd60e4e1b8103b52c5a9e5937e0cc05dacfd26d6a0132ed0410ab4258241240757e4a4424507471cd + languageName: node + linkType: hard + "undici-types@npm:~5.26.4": version: 5.26.5 resolution: "undici-types@npm:5.26.5"