feat: Created new endpoints for getting dashboard PRs and all teams.
Signed-off-by: Marley Powell <Marley.Powell@exclaimer.com>
This commit is contained in:
@@ -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<DashboardPullRequest[]> {
|
||||
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<Policy[]> {
|
||||
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<Team[]> {
|
||||
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<string[] | undefined> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, PolicyType | undefined>
|
||||
)[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<PolicyEvaluationStatus, string | undefined>
|
||||
)[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;
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user