Merge pull request #20729 from awanlin/topic/use-azure-devops-credentials-provider

[Revisit] Use AzureDevOpsCredentialsProvider
This commit is contained in:
Fredrik Adelöw
2023-11-27 10:02:18 +01:00
committed by GitHub
10 changed files with 1497 additions and 616 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-azure-devops-backend': minor
---
**BREAKING** New `fromConfig` static method must be used now when creating an instance of the `AzureDevOpsApi`
Added support for using the `AzureDevOpsCredentialsProvider`
+8 -2
View File
@@ -20,11 +20,17 @@ 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);
// (undocumented)
static fromConfig(
config: Config,
options: {
logger: Logger;
urlReader: UrlReader;
},
): AzureDevOpsApi;
// (undocumented)
getAllTeams(): Promise<Team[]>;
// (undocumented)
+3 -1
View File
@@ -15,7 +15,9 @@
*/
export interface Config {
/** Configuration options for the azure-devops-backend plugin */
/**
* Configuration options for the azure-devops-backend plugin
*/
azureDevOps: {
/**
* The hostname of the given Azure instance
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -19,9 +19,7 @@ import {
BuildDefinitionReference,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
import {
BuildResult,
BuildRun,
BuildStatus,
DashboardPullRequest,
GitTag,
Policy,
@@ -49,23 +47,92 @@ 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,
getHandlerFromToken,
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 {
AzureDevOpsCredentialsProvider,
DefaultAzureDevOpsCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import {
mappedBuildRun,
mappedGitTag,
mappedPullRequest,
mappedRepoBuild,
} from './mappers';
/** @public */
export class AzureDevOpsApi {
public constructor(
private readonly logger: Logger,
private readonly webApi: WebApi,
private readonly urlReader: UrlReader,
) {}
private readonly logger: Logger;
private readonly urlReader: UrlReader;
private readonly config: Config;
private readonly credentialsProvider: AzureDevOpsCredentialsProvider;
private constructor(
logger: Logger,
urlReader: UrlReader,
config: Config,
credentialsProvider: AzureDevOpsCredentialsProvider,
) {
this.logger = logger;
this.urlReader = urlReader;
this.config = config;
this.credentialsProvider = credentialsProvider;
}
static fromConfig(
config: Config,
options: { logger: Logger; urlReader: UrlReader },
) {
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<WebApi> {
// 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}/${encodeURI(validOrg)}`;
const credentials = await this.credentialsProvider.getCredentials({
url,
});
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;
}
public async getProjects(): Promise<Project[]> {
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 +154,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 +168,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 +227,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 +239,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 +262,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 +275,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 +293,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 +326,7 @@ export class AzureDevOpsApi {
return convertDashboardPullRequest(
gitPullRequest,
this.webApi.serverUrl,
webApi.serverUrl,
policies,
);
}),
@@ -270,7 +342,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 +358,8 @@ export class AzureDevOpsApi {
public async getAllTeams(): Promise<Team[]> {
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 +381,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 +402,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 +417,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,
@@ -421,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',
};
}
@@ -0,0 +1,573 @@
/*
* Copyright 2021 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 {
Build,
DefinitionReference,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
import {
BuildResult,
BuildStatus,
GitTag,
PullRequest,
PullRequestStatus,
RepoBuild,
} from '@backstage/plugin-azure-devops-common';
import {
GitPullRequest,
GitRef,
GitRepository,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import {
mappedBuildRun,
mappedGitTag,
mappedPullRequest,
mappedRepoBuild,
} from './mappers';
import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces';
describe('mappers', () => {
describe('mappedRepoBuild', () => {
describe('mappedRepoBuild happy path', () => {
it('should return RepoBuild from Build', () => {
const inputBuildDefinition: DefinitionReference = {
name: 'My Build Definition',
};
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: 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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: inputBuildDefinition,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'My Build Definition - Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
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: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedRepoBuild with no Build definition name', () => {
it('should return RepoBuild with only Build Number for title', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: 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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
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: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedRepoBuild with undefined status', () => {
it('should return BuildStatus of None for status', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: undefined,
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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.None,
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: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedRepoBuild with undefined result', () => {
it('should return BuildResult of None for result', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.InProgress,
result: undefined,
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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: '2020-09-12T06:10:23.932Z',
startTime: '2020-09-12T06:15:23.932Z',
finishTime: '2020-09-12T06:20:23.932Z',
source: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedRepoBuild with undefined link', () => {
it('should return empty string for link', () => {
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.InProgress,
result: undefined,
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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: undefined,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: '',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: '2020-09-12T06:10:23.932Z',
startTime: '2020-09-12T06:15:23.932Z',
finishTime: '2020-09-12T06:20:23.932Z',
source: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedRepoBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
});
describe('mappedGitTag', () => {
describe('mappedGitTag happy path', () => {
it('should return GitTag from GitRef', () => {
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
};
const inputGitRef: GitRef = {
name: 'refs/tags/v1.1.2',
creator: inputIdentityRef,
objectId: '1111aaaa2222bbbb3333cccc4444dddd5555eeee',
peeledObjectId: '1234567890abcdef1234567890abcdef12345678',
};
const inputLinkBaseUrl =
'https://host.com/myOrg/_git/super-feature-repo?version=GT';
const inputCommitBaseUrl =
'https://host.com/myOrg/_git/super-feature-repo/commit';
const outputGitTag: GitTag = {
name: 'v1.1.2',
createdBy: 'Jane Doe',
commitLink:
'https://host.com/myOrg/_git/super-feature-repo/commit/1234567890abcdef1234567890abcdef12345678',
objectId: '1111aaaa2222bbbb3333cccc4444dddd5555eeee',
peeledObjectId: '1234567890abcdef1234567890abcdef12345678',
link: 'https://host.com/myOrg/_git/super-feature-repo?version=GTv1.1.2',
};
expect(
mappedGitTag(inputGitRef, inputLinkBaseUrl, inputCommitBaseUrl),
).toEqual(outputGitTag);
});
});
});
describe('mappedPullRequest', () => {
describe('mappedPullRequest happy path', () => {
it('should return PullRequest from GitPullRequest', () => {
const inputGitRepository: GitRepository = {
name: 'super-feature-repo',
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputPullRequest: GitPullRequest = {
pullRequestId: 7181,
repository: inputGitRepository,
title: 'My Awesome New Feature',
createdBy: inputIdentityRef,
creationDate: new Date('2020-09-12T06:10:23.932Z'),
sourceRefName: 'refs/heads/topic/super-awesome-feature',
targetRefName: 'refs/heads/main',
status: PullRequestStatus.Active,
isDraft: false,
};
const inputBaseUrl =
'https://host.com/myOrg/_git/super-feature-repo/pullrequest';
const outputPullRequest: PullRequest = {
pullRequestId: 7181,
repoName: 'super-feature-repo',
title: 'My Awesome New Feature',
uniqueName: 'DOMAIN\\jdoe',
createdBy: 'Jane Doe',
creationDate: '2020-09-12T06:10:23.932Z',
sourceRefName: 'refs/heads/topic/super-awesome-feature',
targetRefName: 'refs/heads/main',
status: PullRequestStatus.Active,
isDraft: false,
link: 'https://host.com/myOrg/_git/super-feature-repo/pullrequest/7181',
};
expect(mappedPullRequest(inputPullRequest, inputBaseUrl)).toEqual(
outputPullRequest,
);
});
});
});
describe('mappedBuildRun', () => {
describe('mappedBuildRun happy path', () => {
it('should return RepoBuild from Build', () => {
const inputBuildDefinition: DefinitionReference = {
name: 'My Build Definition',
};
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: 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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: inputBuildDefinition,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'My Build Definition - Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
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: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedBuildRun with no Build definition name', () => {
it('should return RepoBuild with only Build Number for title', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: 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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
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: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedBuildRun with undefined status', () => {
it('should return BuildStatus of None for status', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: undefined,
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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.None,
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: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedBuildRun with undefined result', () => {
it('should return BuildResult of None for result', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.InProgress,
result: undefined,
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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: '2020-09-12T06:10:23.932Z',
startTime: '2020-09-12T06:15:23.932Z',
finishTime: '2020-09-12T06:20:23.932Z',
source: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('mappedBuildRun with undefined link', () => {
it('should return empty string for link', () => {
const inputIdentityRef: IdentityRef = {
displayName: 'Jane Doe',
uniqueName: 'DOMAIN\\jdoe',
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.InProgress,
result: undefined,
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: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: undefined,
requestedFor: inputIdentityRef,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: '',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: '2020-09-12T06:10:23.932Z',
startTime: '2020-09-12T06:15:23.932Z',
finishTime: '2020-09-12T06:20:23.932Z',
source: 'refs/heads/develop (f4f78b31)',
uniqueName: 'DOMAIN\\jdoe',
};
expect(mappedBuildRun(inputBuild)).toEqual(outputRepoBuild);
});
});
});
});
@@ -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',
};
}
@@ -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,11 @@ export interface RouterOptions {
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
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 ||
AzureDevOpsApi.fromConfig(config, { logger, urlReader: reader });
const pullRequestsDashboardProvider =
await PullRequestsDashboardProvider.create(logger, azureDevOpsApi);
@@ -195,6 +187,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,
+1
View File
@@ -5051,6 +5051,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