2025-09-18 05:18:56 +00:00
|
|
|
import { AzureDevOpsConnectionConfig } from "@sourcebot/schemas/v3/azuredevops.type";
|
2025-11-05 05:22:31 +00:00
|
|
|
import { createLogger } from "@sourcebot/shared";
|
2025-10-29 04:31:28 +00:00
|
|
|
import { measure, fetchWithRetry } from "./utils.js";
|
2025-09-18 05:18:56 +00:00
|
|
|
import micromatch from "micromatch";
|
|
|
|
|
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";
|
2025-11-05 05:22:31 +00:00
|
|
|
import { getTokenFromConfig } from "@sourcebot/shared";
|
2025-09-18 05:18:56 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
) => {
|
|
|
|
|
const baseUrl = config.url || `https://${AZUREDEVOPS_CLOUD_HOSTNAME}`;
|
|
|
|
|
|
|
|
|
|
const token = config.token ?
|
2025-10-31 21:33:28 +00:00
|
|
|
await getTokenFromConfig(config.token) :
|
2025-09-18 05:18:56 +00:00
|
|
|
undefined;
|
|
|
|
|
|
|
|
|
|
if (!token) {
|
2025-11-05 05:22:31 +00:00
|
|
|
const e = new Error('Azure DevOps requires a Personal Access Token');
|
2025-09-18 05:18:56 +00:00
|
|
|
Sentry.captureException(e);
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const useTfsPath = config.useTfsPath || false;
|
|
|
|
|
let allRepos: GitRepository[] = [];
|
2025-10-29 04:31:28 +00:00
|
|
|
let allWarnings: string[] = [];
|
2025-09-18 05:18:56 +00:00
|
|
|
|
|
|
|
|
if (config.orgs) {
|
2025-10-29 04:31:28 +00:00
|
|
|
const { repos, warnings } = await getReposForOrganizations(
|
2025-09-18 05:18:56 +00:00
|
|
|
config.orgs,
|
|
|
|
|
baseUrl,
|
|
|
|
|
token,
|
|
|
|
|
useTfsPath
|
|
|
|
|
);
|
2025-10-29 04:31:28 +00:00
|
|
|
allRepos = allRepos.concat(repos);
|
|
|
|
|
allWarnings = allWarnings.concat(warnings);
|
2025-09-18 05:18:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (config.projects) {
|
2025-10-29 04:31:28 +00:00
|
|
|
const { repos, warnings } = await getReposForProjects(
|
2025-09-18 05:18:56 +00:00
|
|
|
config.projects,
|
|
|
|
|
baseUrl,
|
|
|
|
|
token,
|
|
|
|
|
useTfsPath
|
|
|
|
|
);
|
2025-10-29 04:31:28 +00:00
|
|
|
allRepos = allRepos.concat(repos);
|
|
|
|
|
allWarnings = allWarnings.concat(warnings);
|
2025-09-18 05:18:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (config.repos) {
|
2025-10-29 04:31:28 +00:00
|
|
|
const { repos, warnings } = await getRepos(
|
2025-09-18 05:18:56 +00:00
|
|
|
config.repos,
|
|
|
|
|
baseUrl,
|
|
|
|
|
token,
|
|
|
|
|
useTfsPath
|
|
|
|
|
);
|
2025-10-29 04:31:28 +00:00
|
|
|
allRepos = allRepos.concat(repos);
|
|
|
|
|
allWarnings = allWarnings.concat(warnings);
|
2025-09-18 05:18:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let repos = allRepos
|
|
|
|
|
.filter((repo) => {
|
|
|
|
|
const isExcluded = shouldExcludeRepo({
|
|
|
|
|
repo,
|
|
|
|
|
exclude: config.exclude,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return !isExcluded;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
logger.debug(`Found ${repos.length} total repositories.`);
|
|
|
|
|
|
|
|
|
|
return {
|
2025-10-29 04:31:28 +00:00
|
|
|
repos,
|
|
|
|
|
warnings: allWarnings,
|
2025-09-18 05:18:56 +00:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-10-29 04:31:28 +00:00
|
|
|
const warning = `Organization ${org} not found or no access`;
|
|
|
|
|
logger.warn(warning);
|
2025-09-18 05:18:56 +00:00
|
|
|
return {
|
2025-10-29 04:31:28 +00:00
|
|
|
type: 'warning' as const,
|
|
|
|
|
warning
|
2025-09-18 05:18:56 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
throwIfAnyFailed(results);
|
2025-10-29 04:31:28 +00:00
|
|
|
const { validItems: repos, warnings } = processPromiseResults<GitRepository>(results);
|
2025-09-18 05:18:56 +00:00
|
|
|
|
|
|
|
|
return {
|
2025-10-29 04:31:28 +00:00
|
|
|
repos,
|
|
|
|
|
warnings,
|
2025-09-18 05:18:56 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-10-29 04:31:28 +00:00
|
|
|
const warning = `Project ${project} not found or no access`;
|
|
|
|
|
logger.warn(warning);
|
2025-09-18 05:18:56 +00:00
|
|
|
return {
|
2025-10-29 04:31:28 +00:00
|
|
|
type: 'warning' as const,
|
|
|
|
|
warning
|
2025-09-18 05:18:56 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
throwIfAnyFailed(results);
|
2025-10-29 04:31:28 +00:00
|
|
|
const { validItems: repos, warnings } = processPromiseResults<GitRepository>(results);
|
2025-09-18 05:18:56 +00:00
|
|
|
|
|
|
|
|
return {
|
2025-10-29 04:31:28 +00:00
|
|
|
repos,
|
|
|
|
|
warnings,
|
2025-09-18 05:18:56 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-10-29 04:31:28 +00:00
|
|
|
const warning = `Repository ${repo} not found or no access`;
|
|
|
|
|
logger.warn(warning);
|
2025-09-18 05:18:56 +00:00
|
|
|
return {
|
2025-10-29 04:31:28 +00:00
|
|
|
type: 'warning' as const,
|
|
|
|
|
warning
|
2025-09-18 05:18:56 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
throwIfAnyFailed(results);
|
2025-10-29 04:31:28 +00:00
|
|
|
const { validItems: repos, warnings } = processPromiseResults<GitRepository>(results);
|
2025-09-18 05:18:56 +00:00
|
|
|
|
|
|
|
|
return {
|
2025-10-29 04:31:28 +00:00
|
|
|
repos,
|
|
|
|
|
warnings,
|
2025-09-18 05:18:56 +00:00
|
|
|
};
|
|
|
|
|
}
|