From 844969cd979edccbd0fa0f1b58dcc43ee53d66e8 Mon Sep 17 00:00:00 2001 From: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Date: Fri, 25 Aug 2023 15:17:03 -0500 Subject: [PATCH 1/9] Use AzureDevOpsCredentialsProvider Signed-off-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> --- .changeset/unlucky-clouds-build.md | 5 ++ plugins/azure-devops-backend/api-report.md | 3 +- plugins/azure-devops-backend/config.d.ts | 2 + plugins/azure-devops-backend/package.json | 1 + .../src/api/AzureDevOpsApi.ts | 72 ++++++++++++++----- .../src/service/router.ts | 15 ++-- yarn.lock | 1 + 7 files changed, 69 insertions(+), 30 deletions(-) create mode 100644 .changeset/unlucky-clouds-build.md diff --git a/.changeset/unlucky-clouds-build.md b/.changeset/unlucky-clouds-build.md new file mode 100644 index 0000000000..7bd1cb0644 --- /dev/null +++ b/.changeset/unlucky-clouds-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-azure-devops-backend': patch +--- + +Added support for using `AzureDevOpsCredentialsProvider` and deprecated `azureDevOps.token` configuration value diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 8c916a1495..4cdccc6ffc 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -20,11 +20,10 @@ import { RepoBuild } from '@backstage/plugin-azure-devops-common'; import { Team } from '@backstage/plugin-azure-devops-common'; import { TeamMember } from '@backstage/plugin-azure-devops-common'; import { UrlReader } from '@backstage/backend-common'; -import { WebApi } from 'azure-devops-node-api'; // @public (undocumented) export class AzureDevOpsApi { - constructor(logger: Logger, webApi: WebApi, urlReader: UrlReader); + constructor(logger: Logger, urlReader: UrlReader, config: Config); // (undocumented) getAllTeams(): Promise; // (undocumented) diff --git a/plugins/azure-devops-backend/config.d.ts b/plugins/azure-devops-backend/config.d.ts index 433e4df22f..39523a7a05 100644 --- a/plugins/azure-devops-backend/config.d.ts +++ b/plugins/azure-devops-backend/config.d.ts @@ -24,6 +24,8 @@ export interface Config { /** * Token used to authenticate requests. * @visibility secret + * @deprecated Use `integrations.azure` instead. + * @see https://backstage.io/docs/integrations/azure/locations */ token: string; /** diff --git a/plugins/azure-devops-backend/package.json b/plugins/azure-devops-backend/package.json index 030ac45088..69c278f491 100644 --- a/plugins/azure-devops-backend/package.json +++ b/plugins/azure-devops-backend/package.json @@ -31,6 +31,7 @@ "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/integration": "workspace:^", "@backstage/plugin-azure-devops-common": "workspace:^", "@types/express": "^4.17.6", "azure-devops-node-api": "^11.0.1", diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 87ded2f0e9..ecd0e4704a 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -49,23 +49,51 @@ import { import { TeamMember as AdoTeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { Logger } from 'winston'; import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; -import { WebApi } from 'azure-devops-node-api'; +import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; import { TeamProjectReference, WebApiTeam, } from 'azure-devops-node-api/interfaces/CoreInterfaces'; import { UrlReader } from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { + DefaultAzureDevOpsCredentialsProvider, + ScmIntegrations, +} from '@backstage/integration'; /** @public */ export class AzureDevOpsApi { public constructor( private readonly logger: Logger, - private readonly webApi: WebApi, private readonly urlReader: UrlReader, + private readonly config: Config, ) {} + private async getWebApi(host?: string, org?: string): Promise { + const validHost = host ?? this.config.getString('azureDevOps.host'); + const validOrg = org ?? this.config.getString('azureDevOps.organization'); + const scmIntegrations = ScmIntegrations.fromConfig(this.config); + const credentialsProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations); + const credentials = await credentialsProvider.getCredentials({ + url: `https://${validHost}/${validOrg}`, + }); + + let validToken: string; + if (credentials && credentials.token) { + validToken = credentials.token; + } else { + validToken = this.config.getString('azureDevOps.token'); + } + + const authHandler = getPersonalAccessTokenHandler(validToken); + const webApi = new WebApi(`https://${validHost}/${validOrg}`, authHandler); + return webApi; + } + public async getProjects(): Promise { - const client = await this.webApi.getCoreApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getCoreApi(); const projectList: TeamProjectReference[] = await client.getProjects(); const projects: Project[] = projectList.map(project => ({ @@ -87,7 +115,8 @@ export class AzureDevOpsApi { `Calling Azure DevOps REST API, getting Repository ${repoName} for Project ${projectName}`, ); - const client = await this.webApi.getGitApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getGitApi(); return client.getRepository(repoName, projectName); } @@ -100,7 +129,8 @@ export class AzureDevOpsApi { `Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`, ); - const client = await this.webApi.getBuildApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getBuildApi(); return client.getBuilds( projectName, undefined, @@ -158,7 +188,8 @@ export class AzureDevOpsApi { ); const gitRepository = await this.getGitRepository(projectName, repoName); - const client = await this.webApi.getGitApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getGitApi(); const tagRefs: GitRef[] = await client.getRefs( gitRepository.id as string, projectName, @@ -169,10 +200,10 @@ export class AzureDevOpsApi { false, true, ); - const linkBaseUrl = `${this.webApi.serverUrl}/${encodeURIComponent( + const linkBaseUrl = `${webApi.serverUrl}/${encodeURIComponent( projectName, )}/_git/${encodeURIComponent(repoName)}?version=GT`; - const commitBaseUrl = `${this.webApi.serverUrl}/${encodeURIComponent( + const commitBaseUrl = `${webApi.serverUrl}/${encodeURIComponent( projectName, )}/_git/${encodeURIComponent(repoName)}/commit`; const gitTags: GitTag[] = tagRefs.map(tagRef => { @@ -192,7 +223,8 @@ export class AzureDevOpsApi { ); const gitRepository = await this.getGitRepository(projectName, repoName); - const client = await this.webApi.getGitApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getGitApi(); const searchCriteria: GitPullRequestSearchCriteria = { status: options.status, }; @@ -204,7 +236,7 @@ export class AzureDevOpsApi { undefined, options.top, ); - const linkBaseUrl = `${this.webApi.serverUrl}/${encodeURIComponent( + const linkBaseUrl = `${webApi.serverUrl}/${encodeURIComponent( projectName, )}/_git/${encodeURIComponent(repoName)}/pullrequest`; const pullRequests: PullRequest[] = gitPullRequests.map(gitPullRequest => { @@ -222,7 +254,8 @@ export class AzureDevOpsApi { `Getting dashboard pull requests for project '${projectName}'.`, ); - const client = await this.webApi.getGitApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getGitApi(); const searchCriteria: GitPullRequestSearchCriteria = { status: options.status, @@ -254,7 +287,7 @@ export class AzureDevOpsApi { return convertDashboardPullRequest( gitPullRequest, - this.webApi.serverUrl, + webApi.serverUrl, policies, ); }), @@ -270,7 +303,8 @@ export class AzureDevOpsApi { `Getting pull request policies for pull request id '${pullRequestId}'.`, ); - const client = await this.webApi.getPolicyApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getPolicyApi(); const artifactId = getArtifactId(projectId, pullRequestId); @@ -285,7 +319,8 @@ export class AzureDevOpsApi { public async getAllTeams(): Promise { this.logger?.debug('Getting all teams.'); - const client = await this.webApi.getCoreApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getCoreApi(); const webApiTeams: WebApiTeam[] = await client.getAllTeams(); const teams: Team[] = webApiTeams.map(team => ({ @@ -307,7 +342,8 @@ export class AzureDevOpsApi { const { projectId, teamId } = options; this.logger?.debug(`Getting team member ids for team '${teamId}'.`); - const client = await this.webApi.getCoreApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getCoreApi(); const teamMembers: AdoTeamMember[] = await client.getTeamMembersWithExtendedProperties(projectId, teamId); @@ -327,7 +363,8 @@ export class AzureDevOpsApi { `Calling Azure DevOps REST API, getting Build Definitions for ${definitionName} in Project ${projectName}`, ); - const client = await this.webApi.getBuildApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getBuildApi(); return client.getDefinitions(projectName, definitionName); } @@ -341,7 +378,8 @@ export class AzureDevOpsApi { `Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`, ); - const client = await this.webApi.getBuildApi(); + const webApi = await this.getWebApi(); + const client = await webApi.getBuildApi(); return client.getBuilds( projectName, definitions, diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index b6b8a6fdea..13d8a78d96 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -19,7 +19,6 @@ import { PullRequestOptions, PullRequestStatus, } from '@backstage/plugin-azure-devops-common'; -import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; import { AzureDevOpsApi } from '../api'; import { Config } from '@backstage/config'; @@ -43,18 +42,10 @@ export interface RouterOptions { export async function createRouter( options: RouterOptions, ): Promise { - const { logger, reader } = options; - const config = options.config.getConfig('azureDevOps'); - - const token = config.getString('token'); - const host = config.getString('host'); - const organization = config.getString('organization'); - - const authHandler = getPersonalAccessTokenHandler(token); - const webApi = new WebApi(`https://${host}/${organization}`, authHandler); + const { logger, reader, config } = options; const azureDevOpsApi = - options.azureDevOpsApi || new AzureDevOpsApi(logger, webApi, reader); + options.azureDevOpsApi || new AzureDevOpsApi(logger, reader, config); const pullRequestsDashboardProvider = await PullRequestsDashboardProvider.create(logger, azureDevOpsApi); @@ -195,6 +186,8 @@ export async function createRouter( }); router.get('/readme/:projectName/:repoName', async (req, res) => { + const host = config.getString('azureDevOps.host'); + const organization = config.getString('azureDevOps.organization'); const { projectName, repoName } = req.params; const readme = await azureDevOpsApi.getReadme( host, diff --git a/yarn.lock b/yarn.lock index 0a5ad1d720..89923ecd97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5077,6 +5077,7 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/integration": "workspace:^" "@backstage/plugin-azure-devops-common": "workspace:^" "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 From 38a8135171385e5f2c60859743aa86dac3db0a31 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Sun, 22 Oct 2023 11:42:01 -0500 Subject: [PATCH 2/9] Use correct authHandler Signed-off-by: Andre Wanlin --- .../src/api/AzureDevOpsApi.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index ecd0e4704a..451d5a9a71 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -49,7 +49,11 @@ import { import { TeamMember as AdoTeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { Logger } from 'winston'; import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; -import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; +import { + WebApi, + getBearerHandler, + getPersonalAccessTokenHandler, +} from 'azure-devops-node-api'; import { TeamProjectReference, WebApiTeam, @@ -72,22 +76,22 @@ export class AzureDevOpsApi { private async getWebApi(host?: string, org?: string): Promise { const validHost = host ?? this.config.getString('azureDevOps.host'); const validOrg = org ?? this.config.getString('azureDevOps.organization'); + const url = `https://${validHost}/${validOrg}`; + const scmIntegrations = ScmIntegrations.fromConfig(this.config); const credentialsProvider = DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations); const credentials = await credentialsProvider.getCredentials({ - url: `https://${validHost}/${validOrg}`, + url, }); - let validToken: string; - if (credentials && credentials.token) { - validToken = credentials.token; - } else { - validToken = this.config.getString('azureDevOps.token'); - } - - const authHandler = getPersonalAccessTokenHandler(validToken); - const webApi = new WebApi(`https://${validHost}/${validOrg}`, authHandler); + const authHandler = + this.config.getString('azureDevOps.token') || credentials?.type === 'pat' + ? getPersonalAccessTokenHandler( + credentials!.token ?? this.config.getString('azureDevOps.token'), + ) + : getBearerHandler(credentials!.token); + const webApi = new WebApi(url, authHandler); return webApi; } From cec7146643aa6f77a9bd09e895b22b9caafb3c6f Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Sun, 22 Oct 2023 12:14:01 -0500 Subject: [PATCH 3/9] Added factory Signed-off-by: Andre Wanlin --- plugins/azure-devops-backend/api-report.md | 9 +++++++- .../src/api/AzureDevOpsApi.ts | 21 ++++++++++++++----- .../src/service/router.ts | 3 ++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 4cdccc6ffc..35faecd827 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -23,7 +23,14 @@ import { UrlReader } from '@backstage/backend-common'; // @public (undocumented) export class AzureDevOpsApi { - constructor(logger: Logger, urlReader: UrlReader, config: Config); + // (undocumented) + static fromConfig( + config: Config, + options: { + logger: Logger; + urlReader: UrlReader; + }, + ): AzureDevOpsApi; // (undocumented) getAllTeams(): Promise; // (undocumented) diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 451d5a9a71..ffc0fd24d1 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -67,11 +67,22 @@ import { /** @public */ export class AzureDevOpsApi { - public constructor( - private readonly logger: Logger, - private readonly urlReader: UrlReader, - private readonly config: Config, - ) {} + private readonly logger: Logger; + private readonly urlReader: UrlReader; + private readonly config: Config; + + private constructor(logger: Logger, urlReader: UrlReader, config: Config) { + this.logger = logger; + this.urlReader = urlReader; + this.config = config; + } + + static fromConfig( + config: Config, + options: { logger: Logger; urlReader: UrlReader }, + ) { + return new AzureDevOpsApi(options.logger, options.urlReader, config); + } private async getWebApi(host?: string, org?: string): Promise { const validHost = host ?? this.config.getString('azureDevOps.host'); diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index 13d8a78d96..9fc762f1a7 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -45,7 +45,8 @@ export async function createRouter( const { logger, reader, config } = options; const azureDevOpsApi = - options.azureDevOpsApi || new AzureDevOpsApi(logger, reader, config); + options.azureDevOpsApi || + AzureDevOpsApi.fromConfig(config, { logger, urlReader: reader }); const pullRequestsDashboardProvider = await PullRequestsDashboardProvider.create(logger, azureDevOpsApi); From 9dbebe8f84cd4504695d40029c5004e91fcf9a49 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Mon, 30 Oct 2023 10:10:16 -0500 Subject: [PATCH 4/9] Improved getWebApi based on feedback Signed-off-by: Andre Wanlin --- .../azure-devops-backend/src/api/AzureDevOpsApi.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index ffc0fd24d1..bdfd7b1b82 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -70,11 +70,16 @@ export class AzureDevOpsApi { private readonly logger: Logger; private readonly urlReader: UrlReader; private readonly config: Config; + private readonly credentialsProvider: DefaultAzureDevOpsCredentialsProvider; private constructor(logger: Logger, urlReader: UrlReader, config: Config) { this.logger = logger; this.urlReader = urlReader; this.config = config; + + const scmIntegrations = ScmIntegrations.fromConfig(this.config); + this.credentialsProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations); } static fromConfig( @@ -89,15 +94,14 @@ export class AzureDevOpsApi { const validOrg = org ?? this.config.getString('azureDevOps.organization'); const url = `https://${validHost}/${validOrg}`; - const scmIntegrations = ScmIntegrations.fromConfig(this.config); - const credentialsProvider = - DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations); - const credentials = await credentialsProvider.getCredentials({ + const credentials = await this.credentialsProvider.getCredentials({ url, }); + // TODO:(awanlin) use `getHandlerFromToken` once we no longer + // need to support the 'azureDevOps.token' fallback config value const authHandler = - this.config.getString('azureDevOps.token') || credentials?.type === 'pat' + credentials?.type === 'pat' ? getPersonalAccessTokenHandler( credentials!.token ?? this.config.getString('azureDevOps.token'), ) From c8f2b807c0573996548e6b9b7edf60f688437673 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Thu, 16 Nov 2023 11:24:11 -0600 Subject: [PATCH 5/9] Changes based on latest feedback Signed-off-by: Andre Wanlin --- .changeset/unlucky-clouds-build.md | 6 ++- plugins/azure-devops-backend/config.d.ts | 9 +++- .../src/api/AzureDevOpsApi.ts | 50 ++++++++++++------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/.changeset/unlucky-clouds-build.md b/.changeset/unlucky-clouds-build.md index 7bd1cb0644..6f7813a8e6 100644 --- a/.changeset/unlucky-clouds-build.md +++ b/.changeset/unlucky-clouds-build.md @@ -1,5 +1,7 @@ --- -'@backstage/plugin-azure-devops-backend': patch +'@backstage/plugin-azure-devops-backend': minor --- -Added support for using `AzureDevOpsCredentialsProvider` and deprecated `azureDevOps.token` configuration value +**BREAKING** New `fromConfig` static method must be used now when created an instance of the `AzureDevOpsApi` + +Added support for using the `AzureDevOpsCredentialsProvider` and deprecated the entire `azureDevOps` configuration section diff --git a/plugins/azure-devops-backend/config.d.ts b/plugins/azure-devops-backend/config.d.ts index 39523a7a05..c973a0595f 100644 --- a/plugins/azure-devops-backend/config.d.ts +++ b/plugins/azure-devops-backend/config.d.ts @@ -15,10 +15,15 @@ */ export interface Config { - /** Configuration options for the azure-devops-backend plugin */ + /** Configuration options for the azure-devops-backend plugin + * @deprecated Use `integrations.azure` instead. + * @see https://backstage.io/docs/integrations/azure/locations + */ azureDevOps: { /** * The hostname of the given Azure instance + * @deprecated Use `integrations.azure` instead. + * @see https://backstage.io/docs/integrations/azure/locations */ host: string; /** @@ -30,6 +35,8 @@ export interface Config { token: string; /** * The organization of the given Azure instance + * @deprecated Use `integrations.azure` instead. + * @see https://backstage.io/docs/integrations/azure/locations */ organization: string; }; diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index bdfd7b1b82..f02d681f56 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -51,7 +51,7 @@ import { Logger } from 'winston'; import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; import { WebApi, - getBearerHandler, + getHandlerFromToken, getPersonalAccessTokenHandler, } from 'azure-devops-node-api'; import { @@ -61,6 +61,7 @@ import { import { UrlReader } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { + AzureDevOpsCredentialsProvider, DefaultAzureDevOpsCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; @@ -70,42 +71,57 @@ export class AzureDevOpsApi { private readonly logger: Logger; private readonly urlReader: UrlReader; private readonly config: Config; - private readonly credentialsProvider: DefaultAzureDevOpsCredentialsProvider; + private readonly credentialsProvider: AzureDevOpsCredentialsProvider; - private constructor(logger: Logger, urlReader: UrlReader, config: Config) { + private constructor( + logger: Logger, + urlReader: UrlReader, + config: Config, + credentialsProvider: AzureDevOpsCredentialsProvider, + ) { this.logger = logger; this.urlReader = urlReader; this.config = config; - - const scmIntegrations = ScmIntegrations.fromConfig(this.config); - this.credentialsProvider = - DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations); + this.credentialsProvider = credentialsProvider; } static fromConfig( config: Config, options: { logger: Logger; urlReader: UrlReader }, ) { - return new AzureDevOpsApi(options.logger, options.urlReader, config); + const scmIntegrations = ScmIntegrations.fromConfig(config); + const credentialsProvider = + DefaultAzureDevOpsCredentialsProvider.fromIntegrations(scmIntegrations); + return new AzureDevOpsApi( + options.logger, + options.urlReader, + config, + credentialsProvider, + ); } private async getWebApi(host?: string, org?: string): Promise { + // If no host or org is provided we fall back to the values from the `azureDevOps` config section + // these may have been setup in the `integrations.azure` config section + // which is why use them here and not just falling back on them entirely const validHost = host ?? this.config.getString('azureDevOps.host'); const validOrg = org ?? this.config.getString('azureDevOps.organization'); - const url = `https://${validHost}/${validOrg}`; + const url = `https://${validHost}/${encodeURI(validOrg)}`; const credentials = await this.credentialsProvider.getCredentials({ url, }); - // TODO:(awanlin) use `getHandlerFromToken` once we no longer - // need to support the 'azureDevOps.token' fallback config value - const authHandler = - credentials?.type === 'pat' - ? getPersonalAccessTokenHandler( - credentials!.token ?? this.config.getString('azureDevOps.token'), - ) - : getBearerHandler(credentials!.token); + let authHandler; + if (!credentials) { + // No credentials found for the provided host and org in the `integrations.azure` config section + // use the fall back personal access token from `azureDevOps.token` + const token = this.config.getString('azureDevOps.token'); + authHandler = getPersonalAccessTokenHandler(token); + } else { + authHandler = getHandlerFromToken(credentials.token); + } + const webApi = new WebApi(url, authHandler); return webApi; } From 6c178e1bf28a11232c8d2170b8fef6fafe201d0f Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Thu, 16 Nov 2023 11:45:20 -0600 Subject: [PATCH 6/9] Refactored mappers and their tests Signed-off-by: Andre Wanlin --- .../src/api/AzureDevOpsApi.ts | 80 ++------------ ...AzureDevOpsApi.test.ts => mappers.test.ts} | 2 +- .../azure-devops-backend/src/api/mappers.ts | 101 ++++++++++++++++++ 3 files changed, 108 insertions(+), 75 deletions(-) rename plugins/azure-devops-backend/src/api/{AzureDevOpsApi.test.ts => mappers.test.ts} (99%) create mode 100644 plugins/azure-devops-backend/src/api/mappers.ts diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index f02d681f56..6765b54c05 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -19,9 +19,7 @@ import { BuildDefinitionReference, } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { - BuildResult, BuildRun, - BuildStatus, DashboardPullRequest, GitTag, Policy, @@ -65,6 +63,12 @@ import { DefaultAzureDevOpsCredentialsProvider, ScmIntegrations, } from '@backstage/integration'; +import { + mappedBuildRun, + mappedGitTag, + mappedPullRequest, + mappedRepoBuild, +} from './mappers'; /** @public */ export class AzureDevOpsApi { @@ -494,75 +498,3 @@ export class AzureDevOpsApi { return { url, content }; } } - -export function mappedRepoBuild(build: Build): RepoBuild { - return { - id: build.id, - title: [build.definition?.name, build.buildNumber] - .filter(Boolean) - .join(' - '), - link: build._links?.web.href ?? '', - status: build.status ?? BuildStatus.None, - result: build.result ?? BuildResult.None, - queueTime: build.queueTime?.toISOString(), - startTime: build.startTime?.toISOString(), - finishTime: build.finishTime?.toISOString(), - source: `${build.sourceBranch} (${build.sourceVersion?.slice(0, 8)})`, - uniqueName: build.requestedFor?.uniqueName ?? 'N/A', - }; -} - -export function mappedGitTag( - gitRef: GitRef, - linkBaseUrl: string, - commitBaseUrl: string, -): GitTag { - return { - objectId: gitRef.objectId, - peeledObjectId: gitRef.peeledObjectId, - name: gitRef.name?.replace('refs/tags/', ''), - createdBy: gitRef.creator?.displayName ?? 'N/A', - link: `${linkBaseUrl}${encodeURIComponent( - gitRef.name?.replace('refs/tags/', '') ?? '', - )}`, - commitLink: `${commitBaseUrl}/${encodeURIComponent( - gitRef.peeledObjectId ?? '', - )}`, - }; -} - -export function mappedPullRequest( - pullRequest: GitPullRequest, - linkBaseUrl: string, -): PullRequest { - return { - pullRequestId: pullRequest.pullRequestId, - repoName: pullRequest.repository?.name, - title: pullRequest.title, - uniqueName: pullRequest.createdBy?.uniqueName ?? 'N/A', - createdBy: pullRequest.createdBy?.displayName ?? 'N/A', - creationDate: pullRequest.creationDate?.toISOString(), - sourceRefName: pullRequest.sourceRefName, - targetRefName: pullRequest.targetRefName, - status: pullRequest.status, - isDraft: pullRequest.isDraft, - link: `${linkBaseUrl}/${pullRequest.pullRequestId}`, - }; -} - -export function mappedBuildRun(build: Build): BuildRun { - return { - id: build.id, - title: [build.definition?.name, build.buildNumber] - .filter(Boolean) - .join(' - '), - link: build._links?.web.href ?? '', - status: build.status ?? BuildStatus.None, - result: build.result ?? BuildResult.None, - queueTime: build.queueTime?.toISOString(), - startTime: build.startTime?.toISOString(), - finishTime: build.finishTime?.toISOString(), - source: `${build.sourceBranch} (${build.sourceVersion?.slice(0, 8)})`, - uniqueName: build.requestedFor?.uniqueName ?? 'N/A', - }; -} diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/mappers.test.ts similarity index 99% rename from plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts rename to plugins/azure-devops-backend/src/api/mappers.test.ts index dade60f1ed..85c82a2a14 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts +++ b/plugins/azure-devops-backend/src/api/mappers.test.ts @@ -36,7 +36,7 @@ import { mappedGitTag, mappedPullRequest, mappedRepoBuild, -} from './AzureDevOpsApi'; +} from './mappers'; import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; diff --git a/plugins/azure-devops-backend/src/api/mappers.ts b/plugins/azure-devops-backend/src/api/mappers.ts new file mode 100644 index 0000000000..72097a25b8 --- /dev/null +++ b/plugins/azure-devops-backend/src/api/mappers.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + RepoBuild, + BuildStatus, + BuildResult, + GitTag, + PullRequest, + BuildRun, +} from '@backstage/plugin-azure-devops-common'; +import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { + GitRef, + GitPullRequest, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; + +export function mappedRepoBuild(build: Build): RepoBuild { + return { + id: build.id, + title: [build.definition?.name, build.buildNumber] + .filter(Boolean) + .join(' - '), + link: build._links?.web.href ?? '', + status: build.status ?? BuildStatus.None, + result: build.result ?? BuildResult.None, + queueTime: build.queueTime?.toISOString(), + startTime: build.startTime?.toISOString(), + finishTime: build.finishTime?.toISOString(), + source: `${build.sourceBranch} (${build.sourceVersion?.slice(0, 8)})`, + uniqueName: build.requestedFor?.uniqueName ?? 'N/A', + }; +} + +export function mappedGitTag( + gitRef: GitRef, + linkBaseUrl: string, + commitBaseUrl: string, +): GitTag { + return { + objectId: gitRef.objectId, + peeledObjectId: gitRef.peeledObjectId, + name: gitRef.name?.replace('refs/tags/', ''), + createdBy: gitRef.creator?.displayName ?? 'N/A', + link: `${linkBaseUrl}${encodeURIComponent( + gitRef.name?.replace('refs/tags/', '') ?? '', + )}`, + commitLink: `${commitBaseUrl}/${encodeURIComponent( + gitRef.peeledObjectId ?? '', + )}`, + }; +} + +export function mappedPullRequest( + pullRequest: GitPullRequest, + linkBaseUrl: string, +): PullRequest { + return { + pullRequestId: pullRequest.pullRequestId, + repoName: pullRequest.repository?.name, + title: pullRequest.title, + uniqueName: pullRequest.createdBy?.uniqueName ?? 'N/A', + createdBy: pullRequest.createdBy?.displayName ?? 'N/A', + creationDate: pullRequest.creationDate?.toISOString(), + sourceRefName: pullRequest.sourceRefName, + targetRefName: pullRequest.targetRefName, + status: pullRequest.status, + isDraft: pullRequest.isDraft, + link: `${linkBaseUrl}/${pullRequest.pullRequestId}`, + }; +} + +export function mappedBuildRun(build: Build): BuildRun { + return { + id: build.id, + title: [build.definition?.name, build.buildNumber] + .filter(Boolean) + .join(' - '), + link: build._links?.web.href ?? '', + status: build.status ?? BuildStatus.None, + result: build.result ?? BuildResult.None, + queueTime: build.queueTime?.toISOString(), + startTime: build.startTime?.toISOString(), + finishTime: build.finishTime?.toISOString(), + source: `${build.sourceBranch} (${build.sourceVersion?.slice(0, 8)})`, + uniqueName: build.requestedFor?.uniqueName ?? 'N/A', + }; +} From 64b2f1e9bf04bc83ec1caa0f4582e04407c3e284 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Fri, 17 Nov 2023 11:45:56 -0600 Subject: [PATCH 7/9] Removed deprecated for now Signed-off-by: Andre Wanlin --- .changeset/unlucky-clouds-build.md | 4 ++-- plugins/azure-devops-backend/config.d.ts | 11 ++--------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.changeset/unlucky-clouds-build.md b/.changeset/unlucky-clouds-build.md index 6f7813a8e6..35aa277653 100644 --- a/.changeset/unlucky-clouds-build.md +++ b/.changeset/unlucky-clouds-build.md @@ -2,6 +2,6 @@ '@backstage/plugin-azure-devops-backend': minor --- -**BREAKING** New `fromConfig` static method must be used now when created an instance of the `AzureDevOpsApi` +**BREAKING** New `fromConfig` static method must be used now when creating an instance of the `AzureDevOpsApi` -Added support for using the `AzureDevOpsCredentialsProvider` and deprecated the entire `azureDevOps` configuration section +Added support for using the `AzureDevOpsCredentialsProvider` diff --git a/plugins/azure-devops-backend/config.d.ts b/plugins/azure-devops-backend/config.d.ts index c973a0595f..86fefeeef9 100644 --- a/plugins/azure-devops-backend/config.d.ts +++ b/plugins/azure-devops-backend/config.d.ts @@ -15,28 +15,21 @@ */ export interface Config { - /** Configuration options for the azure-devops-backend plugin - * @deprecated Use `integrations.azure` instead. - * @see https://backstage.io/docs/integrations/azure/locations + /** + * Configuration options for the azure-devops-backend plugin */ azureDevOps: { /** * The hostname of the given Azure instance - * @deprecated Use `integrations.azure` instead. - * @see https://backstage.io/docs/integrations/azure/locations */ host: string; /** * Token used to authenticate requests. * @visibility secret - * @deprecated Use `integrations.azure` instead. - * @see https://backstage.io/docs/integrations/azure/locations */ token: string; /** * The organization of the given Azure instance - * @deprecated Use `integrations.azure` instead. - * @see https://backstage.io/docs/integrations/azure/locations */ organization: string; }; From 8cd4422216125928dc42e437e1cb4fd155cf77c2 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Sat, 25 Nov 2023 16:09:49 -0600 Subject: [PATCH 8/9] Signed-off-by: Andre Wanlin --- plugins/azure-devops-backend/src/api/mappers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/azure-devops-backend/src/api/mappers.test.ts b/plugins/azure-devops-backend/src/api/mappers.test.ts index 85c82a2a14..5f7fe5eb28 100644 --- a/plugins/azure-devops-backend/src/api/mappers.test.ts +++ b/plugins/azure-devops-backend/src/api/mappers.test.ts @@ -40,7 +40,7 @@ import { import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; -describe('AzureDevOpsApi', () => { +describe('mappers', () => { describe('mappedRepoBuild', () => { describe('mappedRepoBuild happy path', () => { it('should return RepoBuild from Build', () => { From 1953874ee548606149e8b9aab72265f03c68ddd9 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Sat, 25 Nov 2023 18:40:57 -0600 Subject: [PATCH 9/9] Signed-off-by: Andre Wanlin --- .../src/api/AzureDevOpsApi.test.ts | 764 ++++++++++++++++++ 1 file changed, 764 insertions(+) create mode 100644 plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts new file mode 100644 index 0000000000..174c5ff6cd --- /dev/null +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.test.ts @@ -0,0 +1,764 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jest.mock('azure-devops-node-api', () => ({ + WebApi: jest.fn(), + getHandlerFromToken: jest.fn().mockReturnValue(() => {}), +})); + +import { UrlReader, getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { AzureDevOpsApi } from './AzureDevOpsApi'; +import { WebApi } from 'azure-devops-node-api'; +import { TeamProjectReference } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { GitRepository } from 'azure-devops-node-api/interfaces/TfvcInterfaces'; +import { + Build, + BuildResult, + BuildStatus, +} from 'azure-devops-node-api/interfaces/BuildInterfaces'; +import { + GitPullRequest, + GitRef, + PullRequestStatus, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { PullRequestOptions } from '@backstage/plugin-azure-devops-common'; + +describe('AzureDevOpsApi', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockConfig = new ConfigReader({ + azureDevOps: { + host: 'dev.azure.com', + token: 'token', + organization: 'org', + }, + integrations: { + azure: [ + { + host: 'dev.azure.com', + credentials: [ + { + personalAccessToken: 'pat', + }, + ], + }, + ], + }, + }); + + const mockLogger = getVoidLogger(); + + const mockUrlReader: UrlReader = { + readUrl: url => + Promise.resolve({ + buffer: async () => Buffer.from(url), + etag: 'buffer', + stream: jest.fn(), + }), + readTree: jest.fn(), + search: jest.fn(), + }; + + it('should get projects', async () => { + const mockProjects: TeamProjectReference[] = [ + { + id: 'one', + name: 'one', + description: 'one', + }, + { + id: 'two', + name: 'two', + description: 'two', + }, + { + id: 'three', + name: 'three', + description: 'three', + }, + ]; + + const mockCoreApiClient = { + getProjects: jest.fn().mockResolvedValue(mockProjects), + }; + + const mockCoreApi = { + getCoreApi: jest.fn().mockResolvedValue(mockCoreApiClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockCoreApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getProjects(); + + expect(result).toEqual([ + { + id: 'one', + name: 'one', + description: 'one', + }, + { + id: 'three', + name: 'three', + description: 'three', + }, + { + id: 'two', + name: 'two', + description: 'two', + }, + ]); + }); + + it('should get git repository', async () => { + const mockGitRepository: GitRepository = { + id: 'repo', + }; + + const mockGitClient = { + getRepository: jest.fn().mockResolvedValue(mockGitRepository), + }; + const mockGitApi = { + getGitApi: jest.fn().mockReturnValue(mockGitClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockGitApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getGitRepository('project', 'repo'); + + expect(result).toEqual({ id: 'repo' }); + }); + + it('should get build list', async () => { + const mockBuilds: Build[] = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + ]; + + const mockBuildClient = { + getBuilds: jest.fn().mockResolvedValue(mockBuilds), + }; + const mockBuildApi = { + getBuildApi: jest.fn().mockReturnValue(mockBuildClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockBuildApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getBuildList('project', 'repo', 10); + + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + it('should get repo builds', async () => { + const mockGitRepository: GitRepository = { + id: 'repo', + }; + + const mockBuilds: Build[] = [ + { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + { + id: 2, + buildNumber: 'Build-2', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + { + id: 3, + buildNumber: 'Build-3', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + ]; + + const mockBuildClient = { + getBuilds: jest.fn().mockResolvedValue(mockBuilds), + }; + const mockGitClient = { + getRepository: jest.fn().mockResolvedValue(mockGitRepository), + }; + const mockApi = { + getGitApi: jest.fn().mockReturnValue(mockGitClient), + getBuildApi: jest.fn().mockReturnValue(mockBuildClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getRepoBuilds('project', 'repo', 10); + + expect(result).toEqual([ + { + id: 1, + title: 'Build-1', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + { + id: 2, + title: 'Build-2', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + { + id: 3, + title: 'Build-3', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + ]); + }); + + it('should get git tags', async () => { + const mockGitRepository: GitRepository = { + id: 'repo', + }; + + const mockGitRefs: GitRef[] = [ + { + objectId: '1.0', + peeledObjectId: '1.0', + name: 'v1.0', + }, + { + objectId: '2.0', + peeledObjectId: '2.0', + name: 'v2.0', + }, + { + objectId: '3.0', + peeledObjectId: '3.0', + name: 'v3.0', + }, + ]; + + const mockGitClient = { + getRepository: jest.fn().mockResolvedValue(mockGitRepository), + getRefs: jest.fn().mockResolvedValue(mockGitRefs), + }; + const mockApi = { + getGitApi: jest.fn().mockReturnValue(mockGitClient), + serverUrl: 'serverUrl', + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getGitTags('project', 'repo'); + + expect(result).toEqual([ + { + objectId: '1.0', + peeledObjectId: '1.0', + name: 'v1.0', + commitLink: 'serverUrl/project/_git/repo/commit/1.0', + createdBy: 'N/A', + link: 'serverUrl/project/_git/repo?version=GTv1.0', + }, + { + objectId: '2.0', + peeledObjectId: '2.0', + name: 'v2.0', + commitLink: 'serverUrl/project/_git/repo/commit/2.0', + createdBy: 'N/A', + link: 'serverUrl/project/_git/repo?version=GTv2.0', + }, + { + objectId: '3.0', + peeledObjectId: '3.0', + name: 'v3.0', + commitLink: 'serverUrl/project/_git/repo/commit/3.0', + createdBy: 'N/A', + link: 'serverUrl/project/_git/repo?version=GTv3.0', + }, + ]); + }); + + it('should get pull requests', async () => { + const mockGitRepository: GitRepository = { + id: 'repo', + name: 'repo', + }; + + const mockGitPullRequests: GitPullRequest[] = [ + { + pullRequestId: 1, + repository: mockGitRepository, + title: 'PR1', + creationDate: new Date('2020-09-12T06:10:23.932Z'), + sourceRefName: 'refs/heads/topic/pr1', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + }, + { + pullRequestId: 2, + repository: mockGitRepository, + title: 'PR2', + creationDate: new Date('2020-09-12T06:10:23.932Z'), + sourceRefName: 'refs/heads/topic/pr2', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + }, + { + pullRequestId: 3, + repository: mockGitRepository, + title: 'PR3', + creationDate: new Date('2020-09-12T06:10:23.932Z'), + sourceRefName: 'refs/heads/topic/pr3', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + }, + ]; + + const mockGitClient = { + getRepository: jest.fn().mockResolvedValue(mockGitRepository), + getPullRequests: jest.fn().mockResolvedValue(mockGitPullRequests), + }; + const mockApi = { + getGitApi: jest.fn().mockReturnValue(mockGitClient), + serverUrl: 'serverUrl', + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const pullRequestOptions: PullRequestOptions = { + top: 10, + status: PullRequestStatus.Active, + }; + + const result = await api.getPullRequests( + 'project', + 'repo', + pullRequestOptions, + ); + + expect(result).toEqual([ + { + pullRequestId: 1, + repoName: 'repo', + title: 'PR1', + uniqueName: 'N/A', + createdBy: 'N/A', + creationDate: '2020-09-12T06:10:23.932Z', + sourceRefName: 'refs/heads/topic/pr1', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'serverUrl/project/_git/repo/pullrequest/1', + }, + { + pullRequestId: 2, + repoName: 'repo', + title: 'PR2', + uniqueName: 'N/A', + createdBy: 'N/A', + creationDate: '2020-09-12T06:10:23.932Z', + sourceRefName: 'refs/heads/topic/pr2', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'serverUrl/project/_git/repo/pullrequest/2', + }, + { + pullRequestId: 3, + repoName: 'repo', + title: 'PR3', + uniqueName: 'N/A', + createdBy: 'N/A', + creationDate: '2020-09-12T06:10:23.932Z', + sourceRefName: 'refs/heads/topic/pr3', + targetRefName: 'refs/heads/main', + status: PullRequestStatus.Active, + isDraft: false, + link: 'serverUrl/project/_git/repo/pullrequest/3', + }, + ]); + }); + + it('should get build definitions', async () => { + const mockBuilds: Build[] = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + ]; + + const mockBuildClient = { + getDefinitions: jest.fn().mockResolvedValue(mockBuilds), + }; + const mockBuildApi = { + getBuildApi: jest.fn().mockReturnValue(mockBuildClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockBuildApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getBuildDefinitions('project', 'definition'); + + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + it('should get build builds with repoId', async () => { + const mockBuilds: Build[] = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + ]; + + const mockBuildClient = { + getBuilds: jest.fn().mockResolvedValue(mockBuilds), + }; + const mockBuildApi = { + getBuildApi: jest.fn().mockReturnValue(mockBuildClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockBuildApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getBuilds('project', 10, 'repo', undefined); + + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + it('should get build builds with definitions', async () => { + const mockBuilds: Build[] = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + ]; + + const mockBuildClient = { + getBuilds: jest.fn().mockResolvedValue(mockBuilds), + }; + const mockBuildApi = { + getBuildApi: jest.fn().mockReturnValue(mockBuildClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockBuildApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getBuilds('project', 10, undefined, [1, 2, 3]); + + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + it('should get build runs with repoName', async () => { + const mockGitRepository: GitRepository = { + id: 'repo', + }; + + const mockBuilds: Build[] = [ + { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + { + id: 2, + buildNumber: 'Build-2', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + { + id: 3, + buildNumber: 'Build-3', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + ]; + + const mockBuildClient = { + getBuilds: jest.fn().mockResolvedValue(mockBuilds), + }; + const mockGitClient = { + getRepository: jest.fn().mockResolvedValue(mockGitRepository), + }; + const mockApi = { + getGitApi: jest.fn().mockReturnValue(mockGitClient), + getBuildApi: jest.fn().mockReturnValue(mockBuildClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getBuildRuns('project', 10, 'repo', undefined); + + expect(result).toEqual([ + { + id: 1, + title: 'Build-1', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + { + id: 2, + title: 'Build-2', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + { + id: 3, + title: 'Build-3', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + ]); + }); + + it('should get build runs with definitionName', async () => { + const mockBuilds: Build[] = [ + { + id: 1, + buildNumber: 'Build-1', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + { + id: 2, + buildNumber: 'Build-2', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + { + id: 3, + buildNumber: 'Build-3', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: new Date('2020-09-12T06:10:23.932Z'), + startTime: new Date('2020-09-12T06:15:23.932Z'), + finishTime: new Date('2020-09-12T06:20:23.932Z'), + sourceBranch: 'main', + sourceVersion: 'abcd', + }, + ]; + + const mockBuildClient = { + getBuilds: jest.fn().mockResolvedValue(mockBuilds), + getDefinitions: jest.fn().mockResolvedValue(mockBuilds), + }; + + const mockApi = { + getBuildApi: jest.fn().mockReturnValue(mockBuildClient), + }; + + (WebApi as unknown as jest.Mock).mockImplementation(() => mockApi); + + const api = AzureDevOpsApi.fromConfig(mockConfig, { + logger: mockLogger, + urlReader: mockUrlReader, + }); + + const result = await api.getBuildRuns( + 'project', + 10, + undefined, + 'definition', + ); + + expect(result).toEqual([ + { + id: 1, + title: 'Build-1', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + { + id: 2, + title: 'Build-2', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + { + id: 3, + title: 'Build-3', + link: '', + status: BuildStatus.Completed, + result: BuildResult.Succeeded, + queueTime: '2020-09-12T06:10:23.932Z', + startTime: '2020-09-12T06:15:23.932Z', + finishTime: '2020-09-12T06:20:23.932Z', + source: 'main (abcd)', + uniqueName: 'N/A', + }, + ]); + }); +});