plugin-github-pull-requests-board: support team members pull requests

Signed-off-by: Simon Ninon <sninon@pagerduty.com>
This commit is contained in:
Simon Ninon
2022-10-09 20:08:14 -07:00
committed by Fredrik Adelöw
parent c517bef2d1
commit cc8bfc56c3
14 changed files with 426 additions and 99 deletions
@@ -45,6 +45,7 @@
"@material-ui/lab": "4.0.0-alpha.57",
"@octokit/rest": "^19.0.3",
"luxon": "^3.0.0",
"p-limit": "^4.0.0",
"react-use": "^17.2.4"
},
"devDependencies": {
@@ -33,6 +33,9 @@ export const useGetPullRequestDetails = () => {
id
repository {
name
owner {
login
}
}
title
url
@@ -33,7 +33,7 @@ export const useGetPullRequestsFromRepository = () => {
const limit = pullRequestLimit ?? PULL_REQUEST_LIMIT;
const [organisation, repositoryName] = repo.split('/');
return await getPullRequestEdges(
return await getPullRequestNodes(
graphql,
repositoryName,
organisation,
@@ -45,7 +45,7 @@ export const useGetPullRequestsFromRepository = () => {
return fn.current;
};
async function getPullRequestEdges(
async function getPullRequestNodes(
graphql: (
path: string,
options?: any,
@@ -54,7 +54,7 @@ async function getPullRequestEdges(
organisation: string,
pullRequestLimit: number,
): Promise<PullRequestsNumber[]> {
const pullRequestEdges: PullRequestsNumber[] = [];
const pullRequestNodes: PullRequestsNumber[] = [];
let result: GraphQlPullRequests<PullRequestsNumber[]> | undefined = undefined;
do {
@@ -68,10 +68,8 @@ async function getPullRequestEdges(
) {
repository(name: $name, owner: $owner) {
pullRequests(states: OPEN, first: $first, after: $endCursor) {
edges {
node {
number
}
nodes {
number
}
pageInfo {
hasNextPage
@@ -94,10 +92,10 @@ async function getPullRequestEdges(
},
);
pullRequestEdges.push(...result.repository.pullRequests.edges);
pullRequestNodes.push(...result.repository.pullRequests.nodes);
if (pullRequestEdges.length >= pullRequestLimit) return pullRequestEdges;
if (pullRequestNodes.length >= pullRequestLimit) return pullRequestNodes;
} while (result.repository.pullRequests.pageInfo.hasNextPage);
return pullRequestEdges;
return pullRequestNodes;
}
@@ -0,0 +1,108 @@
/*
* Copyright 2022 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 React from 'react';
import {
GraphQlUserPullRequests,
PullRequestsNumberAndOwner,
} from '../utils/types';
import { useOctokitGraphQl } from './useOctokitGraphQl';
const PULL_REQUEST_LIMIT = 10;
const GITHUB_GRAPHQL_MAX_ITEMS = 100;
export const useGetPullRequestsFromUser = () => {
const graphql =
useOctokitGraphQl<GraphQlUserPullRequests<PullRequestsNumberAndOwner[]>>();
const fn = React.useRef(
async (
userLogin: string,
organization?: string,
pullRequestLimit?: number,
): Promise<PullRequestsNumberAndOwner[]> => {
const limit = pullRequestLimit ?? PULL_REQUEST_LIMIT;
return await getPullRequestNodes(graphql, userLogin, limit, organization);
},
);
return fn.current;
};
async function getPullRequestNodes(
graphql: (
path: string,
options?: any,
) => Promise<GraphQlUserPullRequests<PullRequestsNumberAndOwner[]>>,
userLogin: string,
pullRequestLimit: number,
organization?: string,
): Promise<PullRequestsNumberAndOwner[]> {
const pullRequestNodes: PullRequestsNumberAndOwner[] = [];
let result:
| GraphQlUserPullRequests<PullRequestsNumberAndOwner[]>
| undefined = undefined;
do {
result = await graphql(
`
query ($login: String!, $first: Int, $endCursor: String) {
user(login: $login) {
pullRequests(states: OPEN, first: $first, after: $endCursor) {
nodes {
number
repository {
name
owner {
login
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{
login: userLogin,
first:
pullRequestLimit > GITHUB_GRAPHQL_MAX_ITEMS
? GITHUB_GRAPHQL_MAX_ITEMS
: pullRequestLimit,
endCursor: result
? result.user.pullRequests.pageInfo.endCursor
: undefined,
},
);
pullRequestNodes.push(
...result.user.pullRequests.nodes.filter(
edge =>
!organization ||
edge?.repository?.owner?.login?.toLocaleLowerCase('en-US') ===
organization.toLocaleLowerCase('en-US'),
),
);
if (pullRequestNodes.length >= pullRequestLimit) return pullRequestNodes;
} while (result.user.pullRequests.pageInfo.hasNextPage);
return pullRequestNodes;
}
@@ -16,6 +16,7 @@
import React, { useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import FullscreenIcon from '@material-ui/icons/Fullscreen';
import PeopleIcon from '@material-ui/icons/People';
import { Progress, InfoCard } from '@backstage/core-components';
@@ -25,8 +26,9 @@ import { Wrapper } from '../Wrapper';
import { PullRequestCard } from '../PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { shouldDisplayCard } from '../../utils/functions';
import { DraftPrIcon } from '../icons/DraftPr';
import { useUserRepositories } from '../../hooks/useUserRepositories';
import { useUserRepositoriesAndTeam } from '../../hooks/useUserRepositoriesAndTeam';
/** @public */
export interface EntityTeamPullRequestsCardProps {
@@ -36,9 +38,20 @@ export interface EntityTeamPullRequestsCardProps {
const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
const { pullRequestLimit } = props;
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(
const {
loading: loadingReposAndTeam,
repositories,
teamMembers,
teamMembersOrganization,
} = useUserRepositoriesAndTeam();
const {
loading: loadingPRs,
pullRequests,
refreshPullRequests,
} = usePullRequestsByTeam(
repositories,
teamMembers,
teamMembersOrganization,
pullRequestLimit,
);
@@ -48,6 +61,11 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <PeopleIcon />,
value: 'team',
ariaLabel: 'Show PRs from your team',
},
{
icon: <DraftPrIcon />,
value: 'draft',
@@ -56,7 +74,7 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
{
icon: <FullscreenIcon />,
value: 'fullscreen',
ariaLabel: 'Info card is set to fullscreen',
ariaLabel: 'Set card to fullscreen',
},
]}
/>
@@ -64,7 +82,7 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
);
const getContent = () => {
if (loading) {
if (loadingReposAndTeam || loadingPRs) {
return <Progress />;
}
@@ -92,7 +110,14 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
},
index,
) =>
infoCardFormat.includes('draft') === isDraft && (
shouldDisplayCard(
repository,
author,
repositories,
teamMembers,
infoCardFormat,
isDraft,
) && (
<PullRequestCard
key={`pull-request-${id}-${index}`}
title={title}
@@ -15,6 +15,7 @@
*/
import React, { useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import PeopleIcon from '@material-ui/icons/People';
import { Progress, InfoCard } from '@backstage/core-components';
import { InfoCardHeader } from '../InfoCardHeader';
@@ -23,8 +24,9 @@ import { Wrapper } from '../Wrapper';
import { PullRequestCard } from '../PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { shouldDisplayCard } from '../../utils/functions';
import { DraftPrIcon } from '../icons/DraftPr';
import { useUserRepositories } from '../../hooks/useUserRepositories';
import { useUserRepositoriesAndTeam } from '../../hooks/useUserRepositoriesAndTeam';
/** @public */
export interface EntityTeamPullRequestsContentProps {
@@ -36,9 +38,20 @@ const EntityTeamPullRequestsContent = (
) => {
const { pullRequestLimit } = props;
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(
const {
loading: loadingReposAndTeam,
repositories,
teamMembers,
teamMembersOrganization,
} = useUserRepositoriesAndTeam();
const {
loading: loadingPRs,
pullRequests,
refreshPullRequests,
} = usePullRequestsByTeam(
repositories,
teamMembers,
teamMembersOrganization,
pullRequestLimit,
);
@@ -48,6 +61,11 @@ const EntityTeamPullRequestsContent = (
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <PeopleIcon />,
value: 'team',
ariaLabel: 'Show PRs from your team',
},
{
icon: <DraftPrIcon />,
value: 'draft',
@@ -59,7 +77,7 @@ const EntityTeamPullRequestsContent = (
);
const getContent = () => {
if (loading) {
if (loadingReposAndTeam || loadingPRs) {
return <Progress />;
}
@@ -84,7 +102,14 @@ const EntityTeamPullRequestsContent = (
},
index,
) =>
infoCardFormat.includes('draft') === isDraft && (
shouldDisplayCard(
repository,
author,
repositories,
teamMembers,
infoCardFormat,
isDraft,
) && (
<PullRequestCard
key={`pull-request-${id}-${index}`}
title={title}
@@ -24,8 +24,16 @@ const DraftPr: FunctionComponent = () => (
width="16"
data-view-component="true"
>
<path d="M2.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.25 1a2.25 2.25 0 00-.75 4.372v5.256a2.251 2.251 0 101.5 0V5.372A2.25 2.25 0 003.25 1zm0 11a.75.75 0 100 1.5.75.75 0 000-1.5zm9.5 3a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5zm0-3a.75.75 0 100 1.5.75.75 0 000-1.5z" />
<path d="M14 7.5a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0zm0-4.25a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0z" />
<g color="rgba(0, 0, 0, 0.38)">
<path
fill="currentcolor"
d="M2.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.25 1a2.25 2.25 0 00-.75 4.372v5.256a2.251 2.251 0 101.5 0V5.372A2.25 2.25 0 003.25 1zm0 11a.75.75 0 100 1.5.75.75 0 000-1.5zm9.5 3a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5zm0-3a.75.75 0 100 1.5.75.75 0 000-1.5z"
/>
<path
fill="currentcolor"
d="M14 7.5a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0zm0-4.25a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0z"
/>
</g>
</svg>
);
@@ -13,48 +13,93 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import pLimit from 'p-limit';
import { useCallback, useEffect, useState } from 'react';
import { formatPRsByReviewDecision } from '../utils/functions';
import { PullRequests, PullRequestsColumn } from '../utils/types';
import { useGetPullRequestsFromRepository } from '../api/useGetPullRequestsFromRepository';
import { useGetPullRequestsFromUser } from '../api/useGetPullRequestsFromUser';
import { useGetPullRequestDetails } from '../api/useGetPullRequestDetails';
export function usePullRequestsByTeam(
repositories: string[],
members: string[],
organization?: string,
pullRequestLimit?: number,
) {
const [pullRequests, setPullRequests] = useState<PullRequestsColumn[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const getPullRequests = useGetPullRequestsFromRepository();
const getPullRequestsFromRepository = useGetPullRequestsFromRepository();
const getPullRequestsFromUser = useGetPullRequestsFromUser();
const getPullRequestDetails = useGetPullRequestDetails();
const getPRsPerRepository = useCallback(
async (repository: string): Promise<PullRequests> => {
const pullRequestsNumbers = await getPullRequests(
const concurrencyLimit = pLimit(5);
const pullRequestsNumbers = await getPullRequestsFromRepository(
repository,
pullRequestLimit,
);
const pullRequestsWithDetails = await Promise.all(
pullRequestsNumbers.map(({ node }) =>
getPullRequestDetails(repository, node.number),
pullRequestsNumbers.map(node =>
concurrencyLimit(() =>
getPullRequestDetails(repository, node.number),
),
),
);
return pullRequestsWithDetails;
},
[getPullRequests, getPullRequestDetails, pullRequestLimit],
[getPullRequestsFromRepository, getPullRequestDetails, pullRequestLimit],
);
const getPRsPerTeamMember = useCallback(
async (
teamMember: string,
teamOrganization?: string,
): Promise<PullRequests> => {
const concurrencyLimit = pLimit(3);
const pullRequestsNumbers = await getPullRequestsFromUser(
teamMember,
teamOrganization,
pullRequestLimit,
);
const pullRequestsWithDetails = await Promise.all(
pullRequestsNumbers.map(node =>
concurrencyLimit(() =>
getPullRequestDetails(
`${node.repository.owner.login}/${node.repository.name}`,
node.number,
),
),
),
);
return pullRequestsWithDetails;
},
[getPullRequestsFromUser, getPullRequestDetails, pullRequestLimit],
);
const getPRsFromTeam = useCallback(
async (teamRepositories: string[]): Promise<PullRequests> => {
async (
teamRepositories: string[],
teamMembers: string[],
teamOrganization?: string,
): Promise<PullRequests> => {
const teamRepositoriesPromises = teamRepositories.map(repository =>
getPRsPerRepository(repository),
);
const teamPullRequests = await Promise.allSettled(
teamRepositoriesPromises,
).then(promises =>
const teamMembersPromises = teamMembers.map(teamMember =>
getPRsPerTeamMember(teamMember, teamOrganization),
);
const teamPullRequests = await Promise.allSettled([
...teamRepositoriesPromises,
...teamMembersPromises,
]).then(promises =>
promises.reduce((acc, curr) => {
if (curr.status === 'fulfilled') {
return [...acc, ...curr.value];
@@ -63,18 +108,26 @@ export function usePullRequestsByTeam(
}, [] as PullRequests),
);
return teamPullRequests;
const uniqueTeamPullRequests = teamPullRequests.filter(
(lhs, i) => teamPullRequests.findIndex(rhs => lhs.id === rhs.id) === i,
);
return uniqueTeamPullRequests;
},
[getPRsPerRepository],
[getPRsPerRepository, getPRsPerTeamMember],
);
const getAllPullRequests = useCallback(async () => {
setLoading(true);
const teamPullRequests = await getPRsFromTeam(repositories);
const teamPullRequests = await getPRsFromTeam(
repositories,
members,
organization,
);
setPullRequests(formatPRsByReviewDecision(teamPullRequests));
setLoading(false);
}, [getPRsFromTeam, repositories]);
}, [getPRsFromTeam, repositories, members, organization]);
useEffect(() => {
getAllPullRequests();
@@ -1,47 +0,0 @@
/*
* Copyright 2022 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 { stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef, useEntity } from '@backstage/plugin-catalog-react';
import { useCallback, useEffect, useState } from 'react';
import { getProjectNameFromEntity } from '../utils/functions';
export function useUserRepositories() {
const { entity: teamEntity } = useEntity();
const catalogApi = useApi(catalogApiRef);
const [repositories, setRepositories] = useState<string[]>([]);
const getRepositoriesNames = useCallback(async () => {
const entitiesList = await catalogApi.getEntities({
filter: { 'relations.ownedBy': stringifyEntityRef(teamEntity) },
});
const entitiesNames: string[] = entitiesList.items.map(componentEntity =>
getProjectNameFromEntity(componentEntity),
);
setRepositories([...new Set(entitiesNames)]);
}, [catalogApi, teamEntity]);
useEffect(() => {
getRepositoriesNames();
}, [getRepositoriesNames]);
return {
repositories,
};
}
@@ -0,0 +1,81 @@
/*
* Copyright 2022 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 { stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef, useEntity } from '@backstage/plugin-catalog-react';
import { useCallback, useEffect, useState } from 'react';
import {
getProjectNameFromEntity,
getGithubOrganizationFromEntity,
getUserNameFromEntity,
} from '../utils/functions';
export function useUserRepositoriesAndTeam() {
const { entity: teamEntity } = useEntity();
const catalogApi = useApi(catalogApiRef);
const [loading, setLoading] = useState<boolean>(true);
const [teamData, setTeamData] = useState<{
repositories: string[];
teamMembers: string[];
}>({
repositories: [],
teamMembers: [],
});
const getTeamData = useCallback(async () => {
setLoading(true);
// get team repositories and members
const entitiesList = await catalogApi.getEntities({
filter: [
{ 'relations.ownedBy': stringifyEntityRef(teamEntity) },
{ 'relations.memberOf': stringifyEntityRef(teamEntity) },
],
});
const repositories = entitiesList.items.filter(
entity => entity.kind === 'Component',
);
const repositoriesNames: string[] = repositories
.map(componentEntity => getProjectNameFromEntity(componentEntity) ?? '')
.filter(projectName => !!projectName);
const teamMembers = entitiesList.items.filter(
entity => entity.kind === 'User',
);
const teamMembersNames: string[] = teamMembers
.map(componentEntity => getUserNameFromEntity(componentEntity) ?? '')
.filter(userName => !!userName);
setTeamData({
repositories: [...new Set(repositoriesNames)],
teamMembers: [...new Set(teamMembersNames)],
});
setLoading(false);
}, [catalogApi, teamEntity]);
useEffect(() => {
getTeamData();
}, [getTeamData]);
return {
loading,
repositories: teamData.repositories,
teamMembers: teamData.teamMembers,
teamMembersOrganization: getGithubOrganizationFromEntity(teamEntity),
};
}
@@ -21,13 +21,29 @@ import {
ReviewDecision,
PullRequestsColumn,
Author,
PRCardFormating,
Repository,
} from './types';
import { COLUMNS } from './constants';
const GITHUB_PULL_REQUESTS_ANNOTATION = 'github.com/project-slug';
const GITHUB_USER_LOGIN_ANNOTATION = 'github.com/user-login';
export const getProjectNameFromEntity = (entity: Entity): string => {
return entity?.metadata.annotations?.[GITHUB_PULL_REQUESTS_ANNOTATION] ?? '';
export const getProjectNameFromEntity = (
entity: Entity,
): string | undefined => {
return entity?.metadata.annotations?.[GITHUB_PULL_REQUESTS_ANNOTATION];
};
export const getUserNameFromEntity = (entity: Entity): string | undefined => {
return entity?.metadata.annotations?.[GITHUB_USER_LOGIN_ANNOTATION];
};
export const getGithubOrganizationFromEntity = (entity: Entity): string => {
return (
entity?.metadata?.annotations?.['github.com/team-slug']?.split('/')?.[0] ??
''
);
};
export const getApprovedReviews = (reviews: Reviews = []): Reviews => {
@@ -107,3 +123,32 @@ export const formatPRsByReviewDecision = (
{ title: COLUMNS.APPROVED, content: reviewDecisions.APPROVED },
];
};
export const shouldDisplayCard = (
repository: Repository,
author: Author,
teamRepositories: string[],
teamMembers: string[],
infoCardFormat: PRCardFormating[],
isDraft: boolean,
) => {
// hide draft PRs unless "draft" filter is toggled
if (infoCardFormat.includes('draft') !== isDraft) {
return false;
}
// when "team" filter is toggled on, only shows PR from team members
if (infoCardFormat.includes('team')) {
return teamMembers.includes(author.login);
}
const fullRepoName =
`${repository.owner.login}/${repository.name}`.toLocaleLowerCase('en-US');
const repositories = teamRepositories.map(repo =>
repo.toLocaleLowerCase('en-US'),
);
// when "team" filter is toggled off, only shows PR on team repos
return repositories.includes(fullRepoName);
};
@@ -19,22 +19,33 @@ export type GraphQlPullRequest<T> = {
};
};
export type Connection<T> = {
nodes: T;
pageInfo: {
hasNextPage: boolean;
endCursor?: string;
};
};
export type GraphQlPullRequests<T> = {
repository: {
pullRequests: {
edges: T;
pageInfo: {
hasNextPage: boolean;
endCursor?: string;
};
};
pullRequests: Connection<T>;
};
};
export type GraphQlUserPullRequests<T> = {
user: {
pullRequests: Connection<T>;
};
};
export type PullRequestsNumber = {
node: {
number: number;
};
number: number;
};
export type PullRequestsNumberAndOwner = {
number: number;
repository: Repository;
};
export type Review = {
@@ -57,11 +68,16 @@ export type Author = {
name: string;
};
export type Repository = {
name: string;
owner: {
login: string;
};
};
export type PullRequest = {
id: string;
repository: {
name: string;
};
repository: Repository;
title: string;
url: string;
lastEditedAt: string;
@@ -83,6 +99,8 @@ export type PullRequestsColumn = {
content: PullRequests;
};
export type PRCardFormating = 'compacted' | 'fullscreen' | 'draft';
export type PRCardFormating = 'compacted' | 'fullscreen' | 'draft' | 'team';
export type ReviewDecision = 'IN_PROGRESS' | 'APPROVED' | 'REVIEW_REQUIRED';
export type PullRequestsSourceType = 'TeamRepositories' | 'TeamMembers';