Merge pull request #8517 from marleypowell/marley/7678-pull-request-custom-filters-2

feat: Added the ability to resolve team and team member relations for PR column filters
This commit is contained in:
Ben Lambert
2022-01-12 11:38:03 +01:00
committed by GitHub
14 changed files with 292 additions and 30 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-azure-devops-backend': minor
'@backstage/plugin-azure-devops': patch
'@backstage/plugin-azure-devops-common': minor
---
- feat: Created PullRequestsDashboardProvider for resolving team and team member relations
- feat: Created useUserTeamIds hook.
- feat: Updated useFilterProcessor to provide teamIds for `AssignedToCurrentUsersTeams` and `CreatedByCurrentUsersTeams` filters.
@@ -15,6 +15,7 @@ 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 { TeamMember } 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)
@@ -71,6 +72,14 @@ export class AzureDevOpsApi {
repoName: string,
top: number,
): Promise<RepoBuild[]>;
// (undocumented)
getTeamMembers({
projectId,
teamId,
}: {
projectId: string;
teamId: string;
}): Promise<TeamMember[] | undefined>;
}
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -27,6 +27,7 @@
"azure-devops-node-api": "^11.0.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"p-limit": "^3.1.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
@@ -28,6 +28,7 @@ import {
PullRequestOptions,
RepoBuild,
Team,
TeamMember,
} from '@backstage/plugin-azure-devops-common';
import {
GitPullRequest,
@@ -40,9 +41,9 @@ import {
getArtifactId,
} from '../utils';
import { TeamMember as AdoTeamMember } from 'azure-devops-node-api/interfaces/common/VSSInterfaces';
import { Logger } from 'winston';
import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces';
import { 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';
@@ -228,39 +229,37 @@ export class AzureDevOpsApi {
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),
})),
);
const teams: Team[] = webApiTeams.map(team => ({
id: team.id,
name: team.name,
projectId: team.projectId,
projectName: team.projectName,
}));
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;
}
public async getTeamMembers({
projectId,
teamId,
}: {
projectId: string;
teamId: string;
}): Promise<TeamMember[] | undefined> {
this.logger?.debug(`Getting team member ids for team '${teamId}'.`);
const client = await this.webApi.getCoreApi();
const teamMembers: TeamMember[] =
await client.getTeamMembersWithExtendedProperties(
team.projectId,
team.id,
);
const teamMembers: AdoTeamMember[] =
await client.getTeamMembersWithExtendedProperties(projectId, teamId);
return teamMembers
.map(teamMember => teamMember.identity?.id)
.filter((id): id is string => Boolean(id));
return teamMembers.map(teamMember => ({
id: teamMember.identity?.id,
displayName: teamMember.identity?.displayName,
uniqueName: teamMember.identity?.uniqueName,
}));
}
public async getBuildDefinitions(
@@ -0,0 +1,145 @@
/*
* 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,
PullRequestOptions,
Team,
TeamMember,
} from '@backstage/plugin-azure-devops-common';
import { AzureDevOpsApi } from './AzureDevOpsApi';
import { Logger } from 'winston';
import limiterFactory from 'p-limit';
export class PullRequestsDashboardProvider {
private teams = new Map<string, Team>();
private teamMembers = new Map<string, TeamMember>();
private constructor(
private readonly logger: Logger,
private readonly azureDevOpsApi: AzureDevOpsApi,
) {}
public static async create(
logger: Logger,
azureDevOpsApi: AzureDevOpsApi,
): Promise<PullRequestsDashboardProvider> {
const provider = new PullRequestsDashboardProvider(logger, azureDevOpsApi);
await provider.readTeams();
return provider;
}
public async readTeams(): Promise<void> {
this.logger.info('Reading teams.');
let teams = await this.azureDevOpsApi.getAllTeams();
// This is used to filter out the default Azure Devops project teams.
teams = teams.filter(team =>
team.name && team.projectName
? team.name !== `${team.projectName} Team`
: true,
);
this.teams = new Map<string, Team>();
this.teamMembers = new Map<string, TeamMember>();
const limiter = limiterFactory(5);
await Promise.all(
teams.map(team =>
limiter(async () => {
const teamId = team.id;
const projectId = team.projectId;
if (teamId) {
let teamMembers: TeamMember[] | undefined;
if (projectId) {
teamMembers = await this.azureDevOpsApi.getTeamMembers({
projectId,
teamId,
});
}
if (teamMembers) {
team.members = teamMembers.reduce((arr, teamMember) => {
const teamMemberId = teamMember.id;
if (teamMemberId) {
arr.push(teamMemberId);
const memberOf = [
...(this.teamMembers.get(teamMemberId)?.memberOf ?? []),
teamId,
];
this.teamMembers.set(teamMemberId, {
...teamMember,
memberOf,
});
}
return arr;
}, [] as string[]);
this.teams.set(teamId, team);
}
}
}),
),
);
}
public async getDashboardPullRequests(
projectName: string,
options: PullRequestOptions,
): Promise<DashboardPullRequest[]> {
const dashboardPullRequests =
await this.azureDevOpsApi.getDashboardPullRequests(projectName, options);
return dashboardPullRequests.map(pr => {
if (pr.createdBy?.id) {
const teamIds = this.teamMembers.get(pr.createdBy.id)?.memberOf;
pr.createdBy.teamIds = teamIds;
pr.createdBy.teamNames = teamIds?.map(
teamId => this.teams.get(teamId)?.name ?? '',
);
}
return pr;
});
}
public getUserTeamIds(email: string): string[] {
return (
this.getTeamMembers().find(teamMember => teamMember.uniqueName === email)
?.memberOf ?? []
);
}
public async getAllTeams(): Promise<Team[]> {
if (!this.teams.size) {
await this.readTeams();
}
return Array.from(this.teams.values());
}
public getTeamMembers(): TeamMember[] {
return Array.from(this.teamMembers.values());
}
}
@@ -49,6 +49,8 @@ describe('createRouter', () => {
getPullRequests: jest.fn(),
getBuilds: jest.fn(),
getBuildRuns: jest.fn(),
getAllTeams: jest.fn().mockReturnValue([]),
getTeamMembers: jest.fn(),
} as any;
const router = await createRouter({
azureDevOpsApi,
@@ -406,4 +408,11 @@ describe('createRouter', () => {
});
});
});
describe('GET /users/:userId/team-ids', () => {
it('fetches a a list of teams', async () => {
const response = await request(app).get('/users/user1/team-ids');
expect(response.status).toEqual(200);
});
});
});
@@ -24,6 +24,7 @@ import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api';
import { AzureDevOpsApi } from '../api';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import { PullRequestsDashboardProvider } from '../api/PullRequestsDashboardProvider';
import Router from 'express-promise-router';
import { errorHandler } from '@backstage/backend-common';
import express from 'express';
@@ -52,6 +53,9 @@ export async function createRouter(
const azureDevOpsApi =
options.azureDevOpsApi || new AzureDevOpsApi(logger, webApi);
const pullRequestsDashboardProvider =
await PullRequestsDashboardProvider.create(logger, azureDevOpsApi);
const router = Router();
router.use(express.json());
@@ -131,7 +135,7 @@ export async function createRouter(
};
const pullRequests: DashboardPullRequest[] =
await azureDevOpsApi.getDashboardPullRequests(
await pullRequestsDashboardProvider.getDashboardPullRequests(
projectName,
pullRequestOptions,
);
@@ -140,7 +144,7 @@ export async function createRouter(
});
router.get('/all-teams', async (_req, res) => {
const allTeams = await azureDevOpsApi.getAllTeams();
const allTeams = await pullRequestsDashboardProvider.getAllTeams();
res.status(200).json(allTeams);
});
@@ -170,6 +174,12 @@ export async function createRouter(
res.status(200).json(builds);
});
router.get('/users/:userId/team-ids', async (req, res) => {
const { userId } = req.params;
const teamIds = pullRequestsDashboardProvider.getUserTeamIds(userId);
res.status(200).json(teamIds);
});
router.use(errorHandler());
return router;
}
+19 -1
View File
@@ -270,9 +270,27 @@ export interface Team {
// (undocumented)
id?: string;
// (undocumented)
memberIds?: string[];
members?: string[];
// (undocumented)
name?: string;
// (undocumented)
projectId?: string;
// (undocumented)
projectName?: string;
}
// Warning: (ae-missing-release-tag) "TeamMember" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface TeamMember {
// (undocumented)
displayName?: string;
// (undocumented)
id?: string;
// (undocumented)
memberOf?: string[];
// (undocumented)
uniqueName?: string;
}
// (No @packageDocumentation comment for this package)
+10 -1
View File
@@ -178,7 +178,16 @@ export interface Repository {
export interface Team {
id?: string;
name?: string;
memberIds?: string[];
projectId?: string;
projectName?: string;
members?: string[];
}
export interface TeamMember {
id?: string;
displayName?: string;
uniqueName?: string;
memberOf?: string[];
}
/**
@@ -47,4 +47,6 @@ export interface AzureDevOpsApi {
): Promise<DashboardPullRequest[]>;
getAllTeams(): Promise<Team[]>;
getUserTeamIds(userId: string): Promise<string[]>;
}
@@ -88,6 +88,10 @@ export class AzureDevOpsClient implements AzureDevOpsApi {
return this.get<Team[]>('all-teams');
}
public getUserTeamIds(userId: string): Promise<string[]> {
return this.get<string[]>(`users/${userId}/team-ids`);
}
private async get<T>(path: string): Promise<T> {
const baseUrl = `${await this.discoveryApi.getBaseUrl('azure-devops')}/`;
const url = new URL(path, baseUrl);
@@ -15,11 +15,11 @@
*/
import { Filter, FilterType } from '../filters';
import { useUserEmail } from '../../../../hooks';
import { useUserEmail, useUserTeamIds } from '../../../../hooks';
export function useFilterProcessor(): (filters: Filter[]) => Filter[] {
const userEmail = useUserEmail();
const { teamIds } = useUserTeamIds(userEmail);
return (filters: Filter[]): Filter[] => {
for (const filter of filters) {
@@ -29,6 +29,11 @@ export function useFilterProcessor(): (filters: Filter[]) => Filter[] {
filter.email = userEmail;
break;
case FilterType.AssignedToCurrentUsersTeams:
case FilterType.CreatedByCurrentUsersTeams:
filter.teamIds = teamIds;
break;
default:
break;
}
+1
View File
@@ -20,3 +20,4 @@ export * from './useProjectRepoFromEntity';
export * from './usePullRequests';
export * from './useRepoBuilds';
export * from './useUserEmail';
export * from './useUserTeamIds';
@@ -0,0 +1,41 @@
/*
* 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 { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
export function useUserTeamIds(userId: string | undefined): {
teamIds?: string[];
loading: boolean;
error?: Error;
} {
const api = useApi(azureDevOpsApiRef);
const {
value: teamIds,
loading,
error,
} = useAsync(() => {
return userId ? api.getUserTeamIds(userId) : Promise.resolve(undefined);
}, [api]);
return {
teamIds,
loading,
error,
};
}