diff --git a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts index 23ea3311f0..53dc19c68d 100644 --- a/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts +++ b/plugins/azure-devops-backend/src/api/AzureDevOpsApi.ts @@ -17,19 +17,30 @@ import { BuildResult, BuildStatus, + DashboardPullRequest, + Policy, PullRequest, PullRequestOptions, RepoBuild, + Team, } from '@backstage/plugin-azure-devops-common'; import { GitPullRequest, GitPullRequestSearchCriteria, GitRepository, } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { + convertDashboardPullRequest, + convertPolicy, + getArtifactId, +} from '../utils'; import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces'; import { Logger } from 'winston'; +import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; +import { TeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; import { WebApi } from 'azure-devops-node-api'; +import { WebApiTeam } from 'azure-devops-node-api/interfaces/CoreInterfaces'; export class AzureDevOpsApi { public constructor( @@ -138,6 +149,115 @@ export class AzureDevOpsApi { return pullRequests; } + + public async getDashboardPullRequests( + projectName: string, + options: PullRequestOptions, + ): Promise { + this.logger?.debug( + `Getting dashboard pull requests for project '${projectName}'.`, + ); + + const client = await this.webApi.getGitApi(); + + const searchCriteria: GitPullRequestSearchCriteria = { + status: options.status, + }; + + const gitPullRequests: GitPullRequest[] = + await client.getPullRequestsByProject( + projectName, + searchCriteria, + undefined, + undefined, + options.top, + ); + + return Promise.all( + gitPullRequests.map(async gitPullRequest => { + const projectId = gitPullRequest.repository?.project?.id; + const prId = gitPullRequest.pullRequestId; + + let policies: Policy[] | undefined; + + if (projectId && prId) { + policies = await this.getPullRequestPolicies( + projectName, + projectId, + prId, + ); + } + + return convertDashboardPullRequest( + gitPullRequest, + this.webApi.serverUrl, + policies, + ); + }), + ); + } + + private async getPullRequestPolicies( + projectName: string, + projectId: string, + pullRequestId: number, + ): Promise { + this.logger?.debug( + `Getting pull request policies for pull request id '${pullRequestId}'.`, + ); + + const client = await this.webApi.getPolicyApi(); + + const artifactId = getArtifactId(projectId, pullRequestId); + + const policyEvaluationRecords: PolicyEvaluationRecord[] = + await client.getPolicyEvaluations(projectName, artifactId); + + return policyEvaluationRecords + .map(convertPolicy) + .filter((policy): policy is Policy => Boolean(policy)); + } + + public async getAllTeams(): Promise { + this.logger?.debug('Getting all teams.'); + + const client = await this.webApi.getCoreApi(); + const webApiTeams: WebApiTeam[] = await client.getAllTeams(); + + const teams: Team[] = await Promise.all( + webApiTeams.map(async team => ({ + id: team.id, + name: team.name, + memberIds: await this.getTeamMemberIds(team), + })), + ); + + return teams.sort((a, b) => + a.name && b.name ? a.name.localeCompare(b.name) : 0, + ); + } + + private async getTeamMemberIds( + team: WebApiTeam, + ): Promise { + this.logger?.debug(`Getting team member ids for team '${team.name}'.`); + + if (!team.projectId || !team.id) { + return undefined; + } + + const client = await this.webApi.getCoreApi(); + + const teamMembers: TeamMember[] = + await client.getTeamMembersWithExtendedProperties( + team.projectId, + team.id, + ); + + return teamMembers + .map(teamMember => teamMember.identity?.id) + .filter((id): id is string => Boolean(id)); + } } export function mappedRepoBuild(build: Build): RepoBuild { diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index d61148b4ff..131fb2ff5b 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -15,6 +15,7 @@ */ import { + DashboardPullRequest, PullRequestOptions, PullRequestStatus, } from '@backstage/plugin-azure-devops-common'; @@ -27,7 +28,7 @@ import Router from 'express-promise-router'; import { errorHandler } from '@backstage/backend-common'; import express from 'express'; -const DEFAULT_TOP: number = 10; +const DEFAULT_TOP = 10; export interface RouterOptions { azureDevOpsApi?: AzureDevOpsApi; @@ -80,33 +81,69 @@ export async function createRouter( router.get('/repo-builds/:projectName/:repoName', async (req, res) => { const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const gitRepository = await azureDevOpsApi.getRepoBuilds( projectName, repoName, top, ); + res.status(200).json(gitRepository); }); router.get('/pull-requests/:projectName/:repoName', async (req, res) => { const { projectName, repoName } = req.params; + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + const status = req.query.status ? Number(req.query.status) : PullRequestStatus.Active; + const pullRequestOptions: PullRequestOptions = { top: top, status: status, }; + const gitPullRequest = await azureDevOpsApi.getPullRequests( projectName, repoName, pullRequestOptions, ); + res.status(200).json(gitPullRequest); }); + router.get('/dashboard-pull-requests/:projectName', async (req, res) => { + const { projectName } = req.params; + + const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP; + + const status = req.query.status + ? Number(req.query.status) + : PullRequestStatus.Active; + + const pullRequestOptions: PullRequestOptions = { + top: top, + status: status, + }; + + const pullRequests: DashboardPullRequest[] = + await azureDevOpsApi.getDashboardPullRequests( + projectName, + pullRequestOptions, + ); + + res.status(200).json(pullRequests); + }); + + router.get('/all-teams', async (_req, res) => { + const allTeams = Object.values(await azureDevOpsApi.getAllTeams()); + res.status(200).json(allTeams); + }); + router.use(errorHandler()); return router; } diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts new file mode 100644 index 0000000000..8d82f84b60 --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts @@ -0,0 +1,264 @@ +/* + * 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 { + CreatedBy, + DashboardPullRequest, + Policy, + PolicyEvaluationStatus, + PolicyType, + PolicyTypeId, + PullRequestVoteStatus, + Repository, + Reviewer, +} from '@backstage/plugin-azure-devops-common'; +import { + GitPullRequest, + GitRepository, + IdentityRefWithVote, +} from 'azure-devops-node-api/interfaces/GitInterfaces'; + +import { IdentityRef } from 'azure-devops-node-api/interfaces/common/VSSInterfaces'; +import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces'; + +export function convertDashboardPullRequest( + pullRequest: GitPullRequest, + baseUrl: string, + policies: Policy[] | undefined, +): DashboardPullRequest { + return { + pullRequestId: pullRequest.pullRequestId, + title: pullRequest.title, + description: pullRequest.description, + repository: convertRepository(pullRequest.repository), + createdBy: convertCreatedBy(pullRequest.createdBy), + hasAutoComplete: hasAutoComplete(pullRequest), + policies, + reviewers: convertReviewers(pullRequest.reviewers), + creationDate: pullRequest.creationDate?.toISOString(), + status: pullRequest.status, + isDraft: pullRequest.isDraft, + link: getPullRequestLink(baseUrl, pullRequest), + }; +} + +export function getPullRequestLink( + baseUrl: string, + pullRequest: GitPullRequest, +): string | undefined { + const projectName = pullRequest.repository?.project?.name; + const repoName = pullRequest.repository?.name; + const pullRequestId = pullRequest.pullRequestId; + + if (!projectName || !repoName || !pullRequestId) { + return undefined; + } + + const encodedProjectName = encodeURIComponent(projectName); + const encodedRepoName = encodeURIComponent(repoName); + + return `${baseUrl}/${encodedProjectName}/_git/${encodedRepoName}/pullrequest/${pullRequestId}`; +} + +/** + * Tries to get the avatar from the new property if not then falls-back to deprecated `imageUrl`. + * https://docs.microsoft.com/en-us/rest/api/azure/devops/git/pull-requests/get-pull-requests-by-project?view=azure-devops-rest-6.0#identityref + */ +export function getAvatarUrl(identity: IdentityRef): string | undefined { + return identity._links?.avatar?.href ?? identity.imageUrl; +} + +export function getArtifactId( + projectId: string, + pullRequestId: number, +): string { + return `vstfs:///CodeReview/CodeReviewId/${projectId}/${pullRequestId}`; +} + +export function convertPolicy( + policyEvaluationRecord: PolicyEvaluationRecord, +): Policy | undefined { + const policyConfig = policyEvaluationRecord.configuration; + const policyStatus = policyEvaluationRecord.status; + + if (!policyConfig) { + return undefined; + } + + if ( + !( + policyConfig.isEnabled && + !policyConfig.isDeleted && + (policyConfig.isBlocking || + policyConfig.type?.id === PolicyType.Status) && // Optional "Status" policies are actually required for automatic completion. + policyStatus !== PolicyEvaluationStatus.Approved + ) + ) { + return undefined; + } + + const policyTypeId = policyConfig.type?.id; + + if (!policyTypeId) { + return undefined; + } + + const policyType: PolicyType | undefined = ( + { + [PolicyTypeId.Build]: PolicyType.Build, + [PolicyTypeId.Status]: PolicyType.Status, + [PolicyTypeId.MinimumReviewers]: PolicyType.MinimumReviewers, + [PolicyTypeId.Comments]: PolicyType.Comments, + [PolicyTypeId.RequiredReviewers]: PolicyType.RequiredReviewers, + [PolicyTypeId.MergeStrategy]: PolicyType.MergeStrategy, + } as Record + )[policyTypeId]; + + if (!policyType) { + return undefined; + } + + const policyConfigSettings = policyConfig.settings; + let policyText = policyConfig.type?.displayName; + let policyLink: string | undefined; + + switch (policyType) { + case PolicyType.Build: { + const buildDisplayName = policyConfigSettings.displayName; + + if (buildDisplayName) { + policyText += `: ${buildDisplayName}`; + } + + const buildId = policyEvaluationRecord.context?.buildId; + const policyConfigUrl = policyConfig.url; + + if (buildId && policyConfigUrl) { + policyLink = policyConfigUrl.replace( + `_apis/policy/configurations/${policyConfig.id}`, + `_build/results?buildId=${buildId}`, + ); + } + + if (!policyStatus) { + break; + } + + const buildExpired = Boolean(policyConfigSettings.isExpired); + const buildPolicyStatus = + ( + { + [PolicyEvaluationStatus.Queued]: buildExpired + ? 'expired' + : 'queued', + [PolicyEvaluationStatus.Rejected]: 'failed', + } as Record + )[policyStatus] ?? PolicyEvaluationStatus[policyStatus].toLowerCase(); + + policyText += ` (${buildPolicyStatus})`; + + break; + } + case PolicyType.Status: { + const statusGenre = policyConfigSettings.statusGenre; + const statusName = policyConfigSettings.statusGenre; + + if (statusName) { + policyText += `: ${statusGenre}/${statusName}`; + } + + break; + } + case PolicyType.MinimumReviewers: { + const minimumApproverCount = policyConfigSettings.minimumApproverCount; + policyText += ` (${minimumApproverCount})`; + break; + } + case PolicyType.Comments: + break; + case PolicyType.RequiredReviewers: + break; + case PolicyType.MergeStrategy: + default: + return undefined; + } + + return { + id: policyConfig.id, + type: policyType, + status: policyStatus, + text: policyText, + link: policyLink, + }; +} + +function convertReviewer( + identityRef?: IdentityRefWithVote, +): Reviewer | undefined { + if (!identityRef) { + return undefined; + } + + return { + id: identityRef.id, + displayName: identityRef.displayName, + imageUrl: getAvatarUrl(identityRef), + isRequired: identityRef.isRequired, + isContainer: identityRef.isContainer, + voteStatus: (identityRef.vote ?? 0) as PullRequestVoteStatus, + }; +} + +function convertReviewers( + identityRefs?: IdentityRefWithVote[], +): Reviewer[] | undefined { + if (!identityRefs) { + return undefined; + } + + return identityRefs + .map(convertReviewer) + .filter((reviewer): reviewer is Reviewer => Boolean(reviewer)); +} + +function convertRepository(repository?: GitRepository): Repository | undefined { + if (!repository) { + return undefined; + } + + return { + id: repository.id, + name: repository.name, + url: repository.url, + }; +} + +function convertCreatedBy(identityRef?: IdentityRef): CreatedBy | undefined { + if (!identityRef) { + return undefined; + } + + return { + id: identityRef.id, + displayName: identityRef.displayName, + uniqueName: identityRef.uniqueName, + imageUrl: getAvatarUrl(identityRef), + }; +} + +function hasAutoComplete(pullRequest: GitPullRequest): boolean { + return pullRequest.isDraft !== true && !!pullRequest.completionOptions; +} diff --git a/plugins/azure-devops-backend/src/utils/index.ts b/plugins/azure-devops-backend/src/utils/index.ts new file mode 100644 index 0000000000..415503091a --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './azure-devops-utils';