From 5c3941970e64676f41f6690b1ce8f820a5ffa88e Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Fri, 26 Nov 2021 15:32:37 +0000 Subject: [PATCH 1/9] feat: Created new endpoints for getting dashboard PRs and all teams. Signed-off-by: Marley Powell --- .../src/api/AzureDevOpsApi.ts | 120 ++++++++ .../src/service/router.ts | 39 ++- .../src/utils/azure-devops-utils.ts | 264 ++++++++++++++++++ .../azure-devops-backend/src/utils/index.ts | 17 ++ 4 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 plugins/azure-devops-backend/src/utils/azure-devops-utils.ts create mode 100644 plugins/azure-devops-backend/src/utils/index.ts 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'; From 5d0c42a44751c04fd3a81adeac0fc04fd858428d Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Fri, 26 Nov 2021 15:39:05 +0000 Subject: [PATCH 2/9] chore: Updated api report. Signed-off-by: Marley Powell --- plugins/azure-devops-backend/api-report.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/azure-devops-backend/api-report.md b/plugins/azure-devops-backend/api-report.md index 1eabccc720..19d96dc728 100644 --- a/plugins/azure-devops-backend/api-report.md +++ b/plugins/azure-devops-backend/api-report.md @@ -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; + // (undocumented) getBuildList( projectName: string, repoId: string, top: number, ): Promise; // (undocumented) + getDashboardPullRequests( + projectName: string, + options: PullRequestOptions, + ): Promise; + // (undocumented) getGitRepository( projectName: string, repoName: string, From aa2a3759cc37ac52532bceabfd48276541e2887d Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Mon, 29 Nov 2021 10:07:22 +0000 Subject: [PATCH 3/9] test: Added some unit tests for `azure-devops-utils`. Signed-off-by: Marley Powell --- .../src/utils/azure-devops-utils.test.ts | 161 ++++++++++++++++++ .../src/utils/azure-devops-utils.ts | 2 +- 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts new file mode 100644 index 0000000000..8aed436710 --- /dev/null +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.test.ts @@ -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'); + }); +}); diff --git a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts index 8d82f84b60..aa843e9c17 100644 --- a/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts +++ b/plugins/azure-devops-backend/src/utils/azure-devops-utils.ts @@ -242,7 +242,7 @@ function convertRepository(repository?: GitRepository): Repository | undefined { return { id: repository.id, name: repository.name, - url: repository.url, + url: repository.url?.replace('_apis/git/repositories', '_git'), }; } From 947acdf8b3346eaa9e99a35c5383e46e004174dd Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Tue, 30 Nov 2021 08:58:32 +0000 Subject: [PATCH 4/9] feat: Added polling for pull requests and other small improvements. Signed-off-by: Marley Powell --- .../PullRequestsPage/PullRequestsPage.tsx | 57 ++++++++++++++----- .../lib/PullRequestCard/PullRequestCard.tsx | 2 +- .../PullRequestCard/PullRequestCardPolicy.tsx | 1 + .../src/hooks/useDashboardPullRequests.ts | 42 ++++++++++++-- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx index c3f0536749..9f21c4b81b 100644 --- a/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx +++ b/plugins/azure-devops/src/components/PullRequestsPage/PullRequestsPage.tsx @@ -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 ; + } + + if (error) { + return ; + } + + return ; +}; + +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 ? ( - - ) : ( - - ); - return (
- {pullRequestsContent} + + + ); }; diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx index b8b052425c..4b2823814e 100644 --- a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCard.tsx @@ -79,7 +79,7 @@ export const PullRequestCard = ({ const subheader = ( - {repoLink}·{creationDate} + {repoLink} · {creationDate} ); diff --git a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx index 42343a8b27..580f8c571f 100644 --- a/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx +++ b/plugins/azure-devops/src/components/PullRequestsPage/lib/PullRequestCard/PullRequestCardPolicy.tsx @@ -67,6 +67,7 @@ function getPolicyIcon(policy: Policy): JSX.Element | null { return null; } case PolicyType.MinimumReviewers: + case PolicyType.RequiredReviewers: return ; case PolicyType.Status: case PolicyType.Comments: diff --git a/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts b/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts index 977f3d4c0c..1b2333ba1c 100644 --- a/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts +++ b/plugins/azure-devops/src/hooks/useDashboardPullRequests.ts @@ -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, From 77f36290e5a40b030d6f5e69e491303d15eab764 Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Tue, 30 Nov 2021 09:37:08 +0000 Subject: [PATCH 5/9] fix: Removed unnecessary `Object.values` for `/all-teams` endpoint. Signed-off-by: Marley Powell --- plugins/azure-devops-backend/src/service/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/azure-devops-backend/src/service/router.ts b/plugins/azure-devops-backend/src/service/router.ts index 131fb2ff5b..57d54df18f 100644 --- a/plugins/azure-devops-backend/src/service/router.ts +++ b/plugins/azure-devops-backend/src/service/router.ts @@ -140,7 +140,7 @@ export async function createRouter( }); router.get('/all-teams', async (_req, res) => { - const allTeams = Object.values(await azureDevOpsApi.getAllTeams()); + const allTeams = await azureDevOpsApi.getAllTeams(); res.status(200).json(allTeams); }); From ce734226d24e6edbb6f8bfb2027e718319f7f9c9 Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Tue, 30 Nov 2021 09:46:46 +0000 Subject: [PATCH 6/9] chore: Updated API report. Signed-off-by: Marley Powell --- plugins/azure-devops/api-report.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/azure-devops/api-report.md b/plugins/azure-devops/api-report.md index cebe6595f7..0f3e3310ef 100644 --- a/plugins/azure-devops/api-report.md +++ b/plugins/azure-devops/api-report.md @@ -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) // From 82cd709fdba9791807eeb72649cd2eab41fea772 Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Tue, 30 Nov 2021 09:47:03 +0000 Subject: [PATCH 7/9] chore: Generated new changeset. Signed-off-by: Marley Powell --- .changeset/two-lions-cross.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/two-lions-cross.md diff --git a/.changeset/two-lions-cross.md b/.changeset/two-lions-cross.md new file mode 100644 index 0000000000..fffa4c28ee --- /dev/null +++ b/.changeset/two-lions-cross.md @@ -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 From b2d87cc0a00bdc004a694cf9df2cda652f398303 Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Wed, 1 Dec 2021 08:07:26 +0000 Subject: [PATCH 8/9] refactor: Removed deprecated use of `path` for `createRouteRef`. Signed-off-by: Marley Powell --- plugins/azure-devops/src/routes.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/azure-devops/src/routes.ts b/plugins/azure-devops/src/routes.ts index 881ed8cc30..0d20c12beb 100644 --- a/plugins/azure-devops/src/routes.ts +++ b/plugins/azure-devops/src/routes.ts @@ -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({ From a89ada59cf0573bfef4cb6e74c7e840df9e4a417 Mon Sep 17 00:00:00 2001 From: Marley Powell Date: Wed, 1 Dec 2021 08:28:53 +0000 Subject: [PATCH 9/9] refactor: Converted `PolicyTypeId` from `const` to `enum`. Signed-off-by: Marley Powell --- plugins/azure-devops-common/api-report.md | 16 ++++++++-------- plugins/azure-devops-common/src/types.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/plugins/azure-devops-common/api-report.md b/plugins/azure-devops-common/api-report.md index 61361495b3..17cccb05cb 100644 --- a/plugins/azure-devops-common/api-report.md +++ b/plugins/azure-devops-common/api-report.md @@ -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) // diff --git a/plugins/azure-devops-common/src/types.ts b/plugins/azure-devops-common/src/types.ts index 844001771f..da41e08d1f 100644 --- a/plugins/azure-devops-common/src/types.ts +++ b/plugins/azure-devops-common/src/types.ts @@ -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,