Merge pull request #8282 from marleypowell/marley/7678-pr-dashboard-backend
feat: Added new backend endpoints for PR dashboard and implemented long polling for PRs
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
---
|
||||
'@backstage/plugin-azure-devops': patch
|
||||
'@backstage/plugin-azure-devops-backend': patch
|
||||
---
|
||||
|
||||
**Backend**
|
||||
|
||||
- Created new `/dashboard-pull-requests/:projectName` endpoint
|
||||
- Created new `/all-teams` endpoint
|
||||
- Implemented pull request policy evaluation conversion
|
||||
|
||||
**Frontend**
|
||||
|
||||
- Refactored `PullRequestsPage` and added new properties for `projectName` and `pollingInterval`
|
||||
- Fixed spacing issue between repo link and creation date in `PullRequestCard`
|
||||
- Added missing condition to `PullRequestCardPolicy` for `RequiredReviewers`
|
||||
- Updated `useDashboardPullRequests` hook to implement long polling for pull requests
|
||||
@@ -5,12 +5,14 @@
|
||||
```ts
|
||||
import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces';
|
||||
import { Config } from '@backstage/config';
|
||||
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
|
||||
import express from 'express';
|
||||
import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { PullRequest } from '@backstage/plugin-azure-devops-common';
|
||||
import { PullRequestOptions } from '@backstage/plugin-azure-devops-common';
|
||||
import { RepoBuild } from '@backstage/plugin-azure-devops-common';
|
||||
import { Team } from '@backstage/plugin-azure-devops-common';
|
||||
import { WebApi } from 'azure-devops-node-api';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -19,12 +21,19 @@ import { WebApi } from 'azure-devops-node-api';
|
||||
export class AzureDevOpsApi {
|
||||
constructor(logger: Logger_2, webApi: WebApi);
|
||||
// (undocumented)
|
||||
getAllTeams(): Promise<Team[]>;
|
||||
// (undocumented)
|
||||
getBuildList(
|
||||
projectName: string,
|
||||
repoId: string,
|
||||
top: number,
|
||||
): Promise<Build[]>;
|
||||
// (undocumented)
|
||||
getDashboardPullRequests(
|
||||
projectName: string,
|
||||
options: PullRequestOptions,
|
||||
): Promise<DashboardPullRequest[]>;
|
||||
// (undocumented)
|
||||
getGitRepository(
|
||||
projectName: string,
|
||||
repoName: string,
|
||||
|
||||
@@ -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 = await azureDevOpsApi.getAllTeams();
|
||||
res.status(200).json(allTeams);
|
||||
});
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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 {
|
||||
DashboardPullRequest,
|
||||
PullRequestStatus,
|
||||
PullRequestVoteStatus,
|
||||
} from '@backstage/plugin-azure-devops-common';
|
||||
import {
|
||||
convertDashboardPullRequest,
|
||||
getArtifactId,
|
||||
getAvatarUrl,
|
||||
getPullRequestLink,
|
||||
} from './azure-devops-utils';
|
||||
|
||||
import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces';
|
||||
|
||||
describe('convertDashboardPullRequest', () => {
|
||||
it('should return DashboardPullRequest', () => {
|
||||
const baseUrl = 'https://dev.azure.com';
|
||||
|
||||
const pullRequest: GitPullRequest = {
|
||||
pullRequestId: 1,
|
||||
title: 'Pull Request 1',
|
||||
description: 'Test description',
|
||||
repository: {
|
||||
id: 'repo1',
|
||||
name: 'azure-devops',
|
||||
url: 'https://dev.azure.com/backstage/backstage/_apis/git/repositories/azure-devops',
|
||||
project: {
|
||||
name: 'backstage',
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
id: 'user1',
|
||||
displayName: 'User 1',
|
||||
uniqueName: 'user1@backstage.io',
|
||||
_links: {
|
||||
avatar: {
|
||||
href: 'avatar-href',
|
||||
},
|
||||
},
|
||||
imageUrl: 'avatar-url',
|
||||
},
|
||||
reviewers: [
|
||||
{
|
||||
id: 'user2',
|
||||
displayName: 'User 2',
|
||||
_links: {
|
||||
avatar: {
|
||||
href: 'avatar-href',
|
||||
},
|
||||
},
|
||||
isRequired: true,
|
||||
isContainer: false,
|
||||
vote: 10,
|
||||
},
|
||||
],
|
||||
creationDate: new Date('2021-10-15T09:30:00.0000000Z'),
|
||||
status: PullRequestStatus.Active,
|
||||
isDraft: false,
|
||||
completionOptions: {},
|
||||
};
|
||||
|
||||
const expectedPullRequest: DashboardPullRequest = {
|
||||
pullRequestId: 1,
|
||||
title: 'Pull Request 1',
|
||||
description: 'Test description',
|
||||
repository: {
|
||||
id: 'repo1',
|
||||
name: 'azure-devops',
|
||||
url: 'https://dev.azure.com/backstage/backstage/_git/azure-devops',
|
||||
},
|
||||
createdBy: {
|
||||
id: 'user1',
|
||||
displayName: 'User 1',
|
||||
uniqueName: 'user1@backstage.io',
|
||||
imageUrl: 'avatar-href',
|
||||
},
|
||||
hasAutoComplete: true,
|
||||
policies: [],
|
||||
reviewers: [
|
||||
{
|
||||
id: 'user2',
|
||||
displayName: 'User 2',
|
||||
imageUrl: 'avatar-href',
|
||||
isRequired: true,
|
||||
isContainer: false,
|
||||
voteStatus: PullRequestVoteStatus.Approved,
|
||||
},
|
||||
],
|
||||
creationDate: '2021-10-15T09:30:00.000Z',
|
||||
status: PullRequestStatus.Active,
|
||||
isDraft: false,
|
||||
link: 'https://dev.azure.com/backstage/_git/azure-devops/pullrequest/1',
|
||||
};
|
||||
|
||||
const result = convertDashboardPullRequest(pullRequest, baseUrl, []);
|
||||
expect(result).toEqual(expectedPullRequest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPullRequestLink', () => {
|
||||
it('should return pull request link', () => {
|
||||
const baseUrl = 'dev.azure.com';
|
||||
const pullRequest = {
|
||||
pullRequestId: 1,
|
||||
repository: {
|
||||
name: 'azure-devops',
|
||||
project: {
|
||||
name: 'backstage',
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = getPullRequestLink(baseUrl, pullRequest);
|
||||
expect(result).toBe(`${baseUrl}/backstage/_git/azure-devops/pullrequest/1`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvatarUrl', () => {
|
||||
it('should return avatar href', () => {
|
||||
const identity = {
|
||||
_links: {
|
||||
avatar: {
|
||||
href: 'avatar-href',
|
||||
},
|
||||
},
|
||||
imageUrl: 'avatar-url',
|
||||
};
|
||||
const result = getAvatarUrl(identity);
|
||||
expect(result).toBe('avatar-href');
|
||||
});
|
||||
|
||||
it('should return avatar image url', () => {
|
||||
const identity = {
|
||||
imageUrl: 'avatar-url',
|
||||
};
|
||||
const result = getAvatarUrl(identity);
|
||||
expect(result).toBe('avatar-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArtifactId', () => {
|
||||
it('should return artifact id', () => {
|
||||
const result = getArtifactId('project1', 1);
|
||||
expect(result).toBe('vstfs:///CodeReview/CodeReviewId/project1/1');
|
||||
});
|
||||
});
|
||||
@@ -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?.replace('_apis/git/repositories', '_git'),
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -120,14 +120,14 @@ export enum PolicyType {
|
||||
// Warning: (ae-missing-release-tag) "PolicyTypeId" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const PolicyTypeId: {
|
||||
Build: string;
|
||||
Status: string;
|
||||
MinimumReviewers: string;
|
||||
Comments: string;
|
||||
RequiredReviewers: string;
|
||||
MergeStrategy: string;
|
||||
};
|
||||
export enum PolicyTypeId {
|
||||
Build = '0609b952-1397-4640-95ec-e00a01b2c241',
|
||||
Comments = 'c6a1889d-b943-4856-b76f-9e46bb6b0df2',
|
||||
MergeStrategy = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab',
|
||||
MinimumReviewers = 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd',
|
||||
RequiredReviewers = 'fd2167ab-b0be-447a-8ec8-39368250530e',
|
||||
Status = 'cbdc66da-9728-4af8-aada-9a5a32e4a226',
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "PullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
|
||||
@@ -217,32 +217,32 @@ export enum PolicyType {
|
||||
MergeStrategy = 'MergeStrategy',
|
||||
}
|
||||
|
||||
export const PolicyTypeId = {
|
||||
export enum PolicyTypeId {
|
||||
/**
|
||||
* This policy will require a successful build has been performed before updating protected refs.
|
||||
*/
|
||||
Build: '0609b952-1397-4640-95ec-e00a01b2c241',
|
||||
Build = '0609b952-1397-4640-95ec-e00a01b2c241',
|
||||
/**
|
||||
* This policy will require a successful status to be posted before updating protected refs.
|
||||
*/
|
||||
Status: 'cbdc66da-9728-4af8-aada-9a5a32e4a226',
|
||||
Status = 'cbdc66da-9728-4af8-aada-9a5a32e4a226',
|
||||
/**
|
||||
* This policy will ensure that a minimum number of reviewers have approved a pull request before completion.
|
||||
*/
|
||||
MinimumReviewers: 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd',
|
||||
MinimumReviewers = 'fa4e907d-c16b-4a4c-9dfa-4906e5d171dd',
|
||||
/**
|
||||
* Check if the pull request has any active comments.
|
||||
*/
|
||||
Comments: 'c6a1889d-b943-4856-b76f-9e46bb6b0df2',
|
||||
Comments = 'c6a1889d-b943-4856-b76f-9e46bb6b0df2',
|
||||
/**
|
||||
* This policy will ensure that required reviewers are added for modified files matching specified patterns.
|
||||
*/
|
||||
RequiredReviewers: 'fd2167ab-b0be-447a-8ec8-39368250530e',
|
||||
RequiredReviewers = 'fd2167ab-b0be-447a-8ec8-39368250530e',
|
||||
/**
|
||||
* This policy ensures that pull requests use a consistent merge strategy.
|
||||
*/
|
||||
MergeStrategy: 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab',
|
||||
};
|
||||
MergeStrategy = 'fa4e907d-c16b-4a4c-9dfa-4916e5d171ab',
|
||||
}
|
||||
|
||||
export enum PullRequestVoteStatus {
|
||||
Approved = 10,
|
||||
|
||||
@@ -22,7 +22,13 @@ export const AzurePullRequestsIcon: (props: SvgIconProps) => JSX.Element;
|
||||
// Warning: (ae-missing-release-tag) "AzurePullRequestsPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const AzurePullRequestsPage: () => JSX.Element;
|
||||
export const AzurePullRequestsPage: ({
|
||||
projectName,
|
||||
pollingInterval,
|
||||
}: {
|
||||
projectName?: string | undefined;
|
||||
pollingInterval?: number | undefined;
|
||||
}) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntityAzurePipelinesContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Content,
|
||||
Header,
|
||||
Page,
|
||||
Progress,
|
||||
ResponseErrorPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { PullRequestGroup, PullRequestGroupConfig } from './lib/types';
|
||||
@@ -28,11 +29,6 @@ import { useDashboardPullRequests, useUserEmail } from '../../hooks';
|
||||
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
|
||||
import { PullRequestGrid } from './lib/PullRequestGrid';
|
||||
|
||||
/**
|
||||
* @deprecated TEMPORARY - This will be configurable in a follow up PR.
|
||||
*/
|
||||
const PROJECT_NAME = 'projectName';
|
||||
|
||||
function usePullRequestGroupConfigs(
|
||||
userEmail: string | undefined,
|
||||
): PullRequestGroupConfig[] {
|
||||
@@ -73,8 +69,41 @@ function usePullRequestGroups(
|
||||
return pullRequestGroups;
|
||||
}
|
||||
|
||||
export const PullRequestsPage = () => {
|
||||
const { pullRequests, error } = useDashboardPullRequests(PROJECT_NAME);
|
||||
type PullRequestsPageContentProps = {
|
||||
pullRequestGroups: PullRequestGroup[];
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
const PullRequestsPageContent = ({
|
||||
pullRequestGroups,
|
||||
loading,
|
||||
error,
|
||||
}: PullRequestsPageContentProps) => {
|
||||
if (loading && pullRequestGroups.length <= 0) {
|
||||
return <Progress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
return <PullRequestGrid pullRequestGroups={pullRequestGroups} />;
|
||||
};
|
||||
|
||||
type PullRequestsPageProps = {
|
||||
projectName?: string;
|
||||
pollingInterval?: number;
|
||||
};
|
||||
|
||||
export const PullRequestsPage = ({
|
||||
projectName,
|
||||
pollingInterval,
|
||||
}: PullRequestsPageProps) => {
|
||||
const { pullRequests, loading, error } = useDashboardPullRequests(
|
||||
projectName,
|
||||
pollingInterval,
|
||||
);
|
||||
const userEmail = useUserEmail();
|
||||
const pullRequestGroupConfigs = usePullRequestGroupConfigs(userEmail);
|
||||
const pullRequestGroups = usePullRequestGroups(
|
||||
@@ -82,16 +111,16 @@ export const PullRequestsPage = () => {
|
||||
pullRequestGroupConfigs,
|
||||
);
|
||||
|
||||
const pullRequestsContent = error ? (
|
||||
<ResponseErrorPanel error={error} />
|
||||
) : (
|
||||
<PullRequestGrid pullRequestGroups={pullRequestGroups} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header title="Azure Pull Requests" />
|
||||
<Content>{pullRequestsContent}</Content>
|
||||
<Content>
|
||||
<PullRequestsPageContent
|
||||
pullRequestGroups={pullRequestGroups}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ export const PullRequestCard = ({
|
||||
|
||||
const subheader = (
|
||||
<span>
|
||||
{repoLink}·{creationDate}
|
||||
{repoLink} · {creationDate}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
+1
@@ -67,6 +67,7 @@ function getPolicyIcon(policy: Policy): JSX.Element | null {
|
||||
return null;
|
||||
}
|
||||
case PolicyType.MinimumReviewers:
|
||||
case PolicyType.RequiredReviewers:
|
||||
return <PolicyRequiredIcon />;
|
||||
case PolicyType.Status:
|
||||
case PolicyType.Comments:
|
||||
|
||||
@@ -14,25 +14,55 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { useAsyncRetry, useInterval } from 'react-use';
|
||||
|
||||
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
|
||||
import { azureDevOpsApiRef } from '../api';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { useAsync } from 'react-use';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export function useDashboardPullRequests(project: string): {
|
||||
const POLLING_INTERVAL = 10000;
|
||||
|
||||
export function useDashboardPullRequests(
|
||||
project?: string,
|
||||
pollingInterval: number = POLLING_INTERVAL,
|
||||
): {
|
||||
pullRequests?: DashboardPullRequest[];
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
} {
|
||||
const api = useApi(azureDevOpsApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const getDashboardPullRequests = useCallback(async (): Promise<
|
||||
DashboardPullRequest[]
|
||||
> => {
|
||||
if (!project) {
|
||||
return Promise.reject(new Error('Missing project name'));
|
||||
}
|
||||
|
||||
try {
|
||||
return await api.getDashboardPullRequests(project);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}, [project, api, errorApi]);
|
||||
|
||||
const {
|
||||
value: pullRequests,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(() => {
|
||||
return api.getDashboardPullRequests(project);
|
||||
}, [api, project]);
|
||||
retry,
|
||||
} = useAsyncRetry(
|
||||
() => getDashboardPullRequests(),
|
||||
[getDashboardPullRequests],
|
||||
);
|
||||
|
||||
useInterval(() => retry(), pollingInterval);
|
||||
|
||||
return {
|
||||
pullRequests,
|
||||
|
||||
@@ -18,7 +18,6 @@ import { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const azurePullRequestDashboardRouteRef = createRouteRef({
|
||||
id: 'azure-pull-request-dashboard',
|
||||
path: '',
|
||||
});
|
||||
|
||||
export const azurePipelinesEntityContentRouteRef = createRouteRef({
|
||||
|
||||
Reference in New Issue
Block a user