initial ado pol

This commit is contained in:
msukkari 2025-09-16 19:42:11 -07:00
parent 83a8d306db
commit 84eda76bd9
19 changed files with 2207 additions and 17 deletions

View file

@ -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
}
```

View file

@ -869,6 +869,220 @@
}, },
"additionalProperties": false "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#", "$schema": "http://json-schema.org/draft-07/schema#",
"type": "object", "type": "object",

View file

@ -1132,6 +1132,220 @@
}, },
"additionalProperties": false "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#", "$schema": "http://json-schema.org/draft-07/schema#",
"type": "object", "type": "object",

View file

@ -37,6 +37,7 @@
"@t3-oss/env-core": "^0.12.0", "@t3-oss/env-core": "^0.12.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"argparse": "^2.0.1", "argparse": "^2.0.1",
"azure-devops-node-api": "^15.1.1",
"bullmq": "^5.34.10", "bullmq": "^5.34.10",
"cross-fetch": "^4.0.0", "cross-fetch": "^4.0.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",

View file

@ -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<GitRepository>(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<GitRepository>(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<GitRepository>(results);
return {
validRepos,
notFoundRepos,
};
}

View file

@ -4,7 +4,7 @@ import { Settings } from "./types.js";
import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { Redis } from 'ioredis'; 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 { BackendError, BackendException } from "@sourcebot/error";
import { captureEvent } from "./posthog.js"; import { captureEvent } from "./posthog.js";
import { env } from "./env.js"; import { env } from "./env.js";
@ -177,6 +177,9 @@ export class ConnectionManager implements IConnectionManager {
case 'bitbucket': { case 'bitbucket': {
return await compileBitbucketConfig(config, job.data.connectionId, orgId, this.db); 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': { case 'git': {
return await compileGenericGitHostConfig(config, job.data.connectionId, orgId); return await compileGenericGitHostConfig(config, job.data.connectionId, orgId);
} }

View file

@ -4,13 +4,15 @@ import { getGitLabReposFromConfig } from "./gitlab.js";
import { getGiteaReposFromConfig } from "./gitea.js"; import { getGiteaReposFromConfig } from "./gitea.js";
import { getGerritReposFromConfig } from "./gerrit.js"; import { getGerritReposFromConfig } from "./gerrit.js";
import { BitbucketRepository, getBitbucketReposFromConfig } from "./bitbucket.js"; import { BitbucketRepository, getBitbucketReposFromConfig } from "./bitbucket.js";
import { getAzureDevOpsReposFromConfig } from "./azuredevops.js";
import { SchemaRestRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { SchemaRestRepository as BitbucketServerRepository } from "@coderabbitai/bitbucket/server/openapi";
import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitbucket/cloud/openapi"; import { SchemaRepository as BitbucketCloudRepository } from "@coderabbitai/bitbucket/cloud/openapi";
import { Prisma, PrismaClient } from '@sourcebot/db'; import { Prisma, PrismaClient } from '@sourcebot/db';
import { WithRequired } from "./types.js" import { WithRequired } from "./types.js"
import { marshalBool } from "./utils.js"; import { marshalBool } from "./utils.js";
import { createLogger } from '@sourcebot/logger'; 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 { RepoMetadata } from './types.js';
import path from 'path'; import path from 'path';
import { glob } from 'glob'; 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 ( export const compileGenericGitHostConfig_url = async (
config: GenericGitHostConnectionConfig, config: GenericGitHostConnectionConfig,
orgId: number, orgId: number,

View file

@ -2,7 +2,7 @@ import { Job, Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { Connection, PrismaClient, Repo, RepoToConnection, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db"; 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 { AppContext, Settings, repoMetadataSchema } from "./types.js";
import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js"; import { getRepoPath, getTokenFromConfig, measure, getShardPrefix } from "./utils.js";
import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js"; import { cloneRepository, fetchRepository, unsetGitConfig, upsertGitConfig } from "./git.js";
@ -186,9 +186,7 @@ export class RepoManager implements IRepoManager {
password: token, password: token,
} }
} }
} } else if (connection.connectionType === 'gitlab') {
else if (connection.connectionType === 'gitlab') {
const config = connection.config as unknown as GitlabConnectionConfig; const config = connection.config as unknown as GitlabConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
@ -197,9 +195,7 @@ export class RepoManager implements IRepoManager {
password: token, password: token,
} }
} }
} } else if (connection.connectionType === 'gitea') {
else if (connection.connectionType === 'gitea') {
const config = connection.config as unknown as GiteaConnectionConfig; const config = connection.config as unknown as GiteaConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
@ -207,9 +203,7 @@ export class RepoManager implements IRepoManager {
password: token, password: token,
} }
} }
} } else if (connection.connectionType === 'bitbucket') {
else if (connection.connectionType === 'bitbucket') {
const config = connection.config as unknown as BitbucketConnectionConfig; const config = connection.config as unknown as BitbucketConnectionConfig;
if (config.token) { if (config.token) {
const token = await getTokenFromConfig(config.token, connection.orgId, db, logger); const token = await getTokenFromConfig(config.token, connection.orgId, db, logger);
@ -219,6 +213,14 @@ export class RepoManager implements IRepoManager {
password: token, 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,
}
}
} }
} }

View file

@ -42,10 +42,11 @@ enum ChatVisibility {
model Repo { model Repo {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String // Full repo name, including the vcs hostname (ex. github.com/sourcebot-dev/sourcebot)
displayName String? displayName String? // Display name of the repo for UI (ex. sourcebot-dev/sourcebot)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
/// When the repo was last indexed successfully. /// When the repo was last indexed successfully.
indexedAt DateTime? indexedAt DateTime?
isFork Boolean isFork Boolean

View file

@ -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 };

View file

@ -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[];
}

View file

@ -868,6 +868,220 @@ const schema = {
}, },
"additionalProperties": false "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#", "$schema": "http://json-schema.org/draft-07/schema#",
"type": "object", "type": "object",

View file

@ -6,6 +6,7 @@ export type ConnectionConfig =
| GiteaConnectionConfig | GiteaConnectionConfig
| GerritConnectionConfig | GerritConnectionConfig
| BitbucketConnectionConfig | BitbucketConnectionConfig
| AzureDevOpsConnectionConfig
| GenericGitHostConnectionConfig; | GenericGitHostConnectionConfig;
export interface GithubConnectionConfig { export interface GithubConnectionConfig {
@ -311,6 +312,84 @@ export interface BitbucketConnectionConfig {
}; };
revisions?: GitRevisions; 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 { export interface GenericGitHostConnectionConfig {
/** /**
* Generic Git host configuration * Generic Git host configuration

View file

@ -1131,6 +1131,220 @@ const schema = {
}, },
"additionalProperties": false "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#", "$schema": "http://json-schema.org/draft-07/schema#",
"type": "object", "type": "object",

View file

@ -10,6 +10,7 @@ export type ConnectionConfig =
| GiteaConnectionConfig | GiteaConnectionConfig
| GerritConnectionConfig | GerritConnectionConfig
| BitbucketConnectionConfig | BitbucketConnectionConfig
| AzureDevOpsConnectionConfig
| GenericGitHostConnectionConfig; | GenericGitHostConnectionConfig;
export type LanguageModel = export type LanguageModel =
| AmazonBedrockLanguageModel | AmazonBedrockLanguageModel
@ -436,6 +437,84 @@ export interface BitbucketConnectionConfig {
}; };
revisions?: GitRevisions; 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 { export interface GenericGitHostConnectionConfig {
/** /**
* Generic Git host configuration * Generic Git host configuration

View file

@ -2221,7 +2221,8 @@ const parseConnectionConfig = (config: string) => {
switch (connectionType) { switch (connectionType) {
case "gitea": case "gitea":
case "github": case "github":
case "bitbucket": { case "bitbucket":
case "azuredevops": {
return { return {
numRepos: parsedConfig.repos?.length, numRepos: parsedConfig.repos?.length,
hasToken: !!parsedConfig.token, hasToken: !!parsedConfig.token,

146
schemas/v3/azuredevops.json Normal file
View file

@ -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
}

View file

@ -17,6 +17,9 @@
{ {
"$ref": "./bitbucket.json" "$ref": "./bitbucket.json"
}, },
{
"$ref": "./azuredevops.json"
},
{ {
"$ref": "./genericGitHost.json" "$ref": "./genericGitHost.json"
} }

View file

@ -6597,6 +6597,7 @@ __metadata:
"@types/micromatch": "npm:^4.0.9" "@types/micromatch": "npm:^4.0.9"
"@types/node": "npm:^22.7.5" "@types/node": "npm:^22.7.5"
argparse: "npm:^2.0.1" argparse: "npm:^2.0.1"
azure-devops-node-api: "npm:^15.1.1"
bullmq: "npm:^5.34.10" bullmq: "npm:^5.34.10"
cross-env: "npm:^7.0.3" cross-env: "npm:^7.0.3"
cross-fetch: "npm:^4.0.0" cross-fetch: "npm:^4.0.0"
@ -8578,6 +8579,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "bail@npm:^2.0.0":
version: 2.0.2 version: 2.0.2
resolution: "bail@npm:2.0.2" resolution: "bail@npm:2.0.2"
@ -9925,6 +9936,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "destroy@npm:1.2.0":
version: 1.2.0 version: 1.2.0
resolution: "destroy@npm:1.2.0" resolution: "destroy@npm:1.2.0"
@ -12454,7 +12475,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 2.0.4
resolution: "inherits@npm:2.0.4" resolution: "inherits@npm:2.0.4"
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
@ -13006,6 +13027,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0 version: 4.0.0
resolution: "js-tokens@npm:4.0.0" resolution: "js-tokens@npm:4.0.0"
@ -14273,6 +14301,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "minimatch@npm:^10.0.0":
version: 10.0.1 version: 10.0.1
resolution: "minimatch@npm:10.0.1" resolution: "minimatch@npm:10.0.1"
@ -15948,7 +15983,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 6.14.0
resolution: "qs@npm:6.14.0" resolution: "qs@npm:6.14.0"
dependencies: dependencies:
@ -18438,6 +18473,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "type-check@npm:^0.4.0, type-check@npm:~0.4.0":
version: 0.4.0 version: 0.4.0
resolution: "type-check@npm:0.4.0" resolution: "type-check@npm:0.4.0"
@ -18535,6 +18577,19 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "typescript@npm:^5, typescript@npm:^5.6.2, typescript@npm:^5.7.3":
version: 5.8.2 version: 5.8.2
resolution: "typescript@npm:5.8.2" resolution: "typescript@npm:5.8.2"
@ -18610,6 +18665,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "undici-types@npm:~5.26.4":
version: 5.26.5 version: 5.26.5
resolution: "undici-types@npm:5.26.5" resolution: "undici-types@npm:5.26.5"