feat: Adding github-pull-requests-board plugin

Signed-off-by: Talita Gregory Nunes Freire <talita.freire@dazn.com>
This commit is contained in:
Talita Gregory Nunes Freire
2022-04-22 13:35:13 +02:00
parent 7d96a85399
commit 36fb2461c5
39 changed files with 1134 additions and 0 deletions
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
@@ -0,0 +1,15 @@
# github-pull-requests-board
Welcome to the github-pull-requests-board plugin!
This plugin will help you and your team stay on top of open pull requests, hopefully reducing the time from open to merged. It's particularly useful when your team deals with many repositories.
## Getting started
The plugin exports the **TeamPullRequestsTable** component which should be added into the Team page level, so it can consume the backstage **"team"** entity.
```javascript
import { TeamPullRequestsTable } from '@backstage/plugin-github-pull-requests-board';
<TeamPullRequestsTable />;
```
@@ -0,0 +1,56 @@
{
"name": "@backstage/plugin-github-pull-requests-board",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^1.0.0",
"@backstage/core-components": "^0.9.2",
"@backstage/core-plugin-api": "^1.0.0",
"@backstage/plugin-catalog-react": "^1.0.0",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.6.7",
"moment": "^2.29.1",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.14.1",
"@backstage/core-app-api": "^0.5.2",
"@backstage/dev-utils": "^0.2.5",
"@backstage/test-utils": "^0.2.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,71 @@
import React from 'react';
import { GraphQlPullRequest, PullRequest } from '../utils/types';
import { useOctokitGraphQl } from './useOctokitGraphQl';
export const useGetPullRequestDetails = () => {
const graphql = useOctokitGraphQl<GraphQlPullRequest<PullRequest>>();
const fn = React.useRef(async (repo: string, number: number): Promise<PullRequest> => {
const [ organisation, repositoryName ] = repo.split('/');
const { repository } = await graphql(`
query($name: String!, $owner: String!, $pull_number: Int!) {
repository(name: $name, owner: $owner) {
pullRequest(number: $pull_number) {
id
repository {
name
}
title
url
createdAt
lastEditedAt
latestReviews(first: 10) {
nodes {
author {
login
avatarUrl
... on User {
id
email
name
login
}
}
state
}
}
mergeable
state
reviewDecision
isDraft
createdAt
author {
... on User {
id
email
avatarUrl
name
login
}
... on Bot {
id
avatarUrl
login
}
}
}
}
}
`, {
'name': repositoryName,
'owner': organisation,
'pull_number': number
});
return repository.pullRequest
});
return fn.current;
};
@@ -0,0 +1,33 @@
import React from 'react';
import { GraphQlPullRequests, PullRequestsNumber } from '../utils/types';
import { useOctokitGraphQl } from './useOctokitGraphQl';
export const useGetPullRequestsFromRepository = () => {
const graphql = useOctokitGraphQl<GraphQlPullRequests<PullRequestsNumber[]>>();
const fn = React.useRef(async (repo: string): Promise<PullRequestsNumber[]> => {
const [ organisation, repositoryName ] = repo.split('/');
const { repository } = await graphql(`
query($name: String!, $owner: String!) {
repository(name: $name, owner: $owner) {
pullRequests(states: OPEN, first: 10) {
edges {
node {
number
}
}
}
}
}
`, {
'name': repositoryName,
'owner': organisation,
});
return repository.pullRequests.edges
});
return fn.current;
};
@@ -0,0 +1,21 @@
import { Octokit } from '@octokit/rest';
import { useApi, githubAuthApiRef } from '@backstage/core-plugin-api';
let octokit: any;
export const useOctokitGraphQl = <T>() => {
const auth = useApi(githubAuthApiRef);
return (path: string, options?: any): Promise<T> =>
auth.getAccessToken(['repo'])
.then((token: string) => {
if(!octokit) {
octokit = new Octokit({ auth: token })
}
return octokit
})
.then(octokitInstance => {
return octokitInstance.graphql(path, options)
});
};
@@ -0,0 +1,48 @@
import React, { PropsWithChildren, FunctionComponent } from 'react';
import { Box, Paper, CardActionArea } from '@material-ui/core';
import CardHeader from './CardHeader';
type Props = {
title: string;
createdAt: string;
updatedAt?: string;
prUrl: string;
authorName: string;
authorAvatar?: string;
repositoryName: string;
}
const Card: FunctionComponent<Props> = (props: PropsWithChildren<Props>) => {
const {
title,
createdAt,
updatedAt,
prUrl,
authorName,
authorAvatar,
repositoryName,
children
} = props;
return (
<Box marginBottom={1}>
<Paper variant='outlined'>
<CardActionArea href={prUrl} target='_blank'>
<Box padding={1}>
<CardHeader
title={title}
createdAt={createdAt}
updatedAt={updatedAt}
authorName={authorName}
authorAvatar={authorAvatar}
repositoryName={repositoryName}
/>
{ children }
</Box>
</CardActionArea>
</Paper>
</Box>
);
};
export default Card;
@@ -0,0 +1,53 @@
import React from 'react';
import { Typography, Box } from '@material-ui/core';
import { getElapsedTime } from '../../utils/functions';
import { UserHeader } from '../UserHeader';
type Props = {
title: string;
createdAt: string;
updatedAt?: string;
authorName: string;
authorAvatar?: string;
repositoryName: string;
}
const CardHeader = (props: Props) => {
const {
title,
createdAt,
updatedAt,
authorName,
authorAvatar,
repositoryName,
} = props;
return (
<>
<Box display='flex' justifyContent='space-between'>
<Typography color='textSecondary' variant='body2' component='p'>
{repositoryName}
</Typography>
<UserHeader name={authorName} avatar={authorAvatar} />
</Box>
<Typography component='h3'>
<b>{title}</b>
</Typography>
<Box display='flex' justifyContent='space-between' marginY={1}>
<Typography variant='body2' component='p'>
Created at: <strong>{getElapsedTime(createdAt)}</strong>
</Typography>
{
updatedAt && (
<Typography variant='body2' component='p'>
Last update: <strong>{getElapsedTime(updatedAt)}</strong>
</Typography>
)
}
</Box>
</>
);
};
export default CardHeader;
@@ -0,0 +1 @@
export { default as Card } from './Card';
@@ -0,0 +1,25 @@
import React, { PropsWithChildren } from 'react';
import { Typography, Box, IconButton } from '@material-ui/core';
import RefreshIcon from '@material-ui/icons/Refresh';
type Props = {
onRefresh: () => void;
}
const InfoCardHeader = (props: PropsWithChildren<Props>) => {
const { children, onRefresh } = props;
return (
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box display="flex" alignItems="center">
<Typography variant="h5">Open pull requests</Typography>
<IconButton color="secondary" onClick={onRefresh}>
<RefreshIcon />
</IconButton>
</Box>
{children}
</Box>
);
};
export default InfoCardHeader;
@@ -0,0 +1 @@
export { default as InfoCardHeader } from './InfoCardHeader';
@@ -0,0 +1,43 @@
import React, { ReactNode } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab';
import { Tooltip, Box } from '@material-ui/core';
import { PRCardFormating } from '../../utils/types';
type Option = {
icon: ReactNode;
value: string;
ariaLabel: string;
}
type Props = {
value: string[];
onClickOption: (selectedOptions: PRCardFormating[]) => void;
options: Option[];
}
const PullRequestBoardOptions = (props: Props) => {
const { value, onClickOption, options } = props;
return (
<ToggleButtonGroup
size="small"
value={value}
onChange={(_event, selectedOptions) => onClickOption(selectedOptions)}
aria-label="Pull Request board settings"
>
{
options.map(({ icon, value: toggleValue, ariaLabel }, index) => (
<ToggleButton value={toggleValue} aria-label={ariaLabel} key={`${ariaLabel}-${index}`}>
<Tooltip title={ariaLabel}>
<Box display='flex' justifyContent='center' alignItems='center'>
{icon}
</Box>
</Tooltip>
</ToggleButton>
))
}
</ToggleButtonGroup>
);
};
export default PullRequestBoardOptions;
@@ -0,0 +1 @@
export { default as PullRequestBoardOptions } from './PullRequestBoardOptions';
@@ -0,0 +1,62 @@
import React, { FunctionComponent } from 'react';
import { getApprovedReviews, getChangeRequests, getCommentedReviews } from '../../utils/functions';
import { Reviews, Author } from '../../utils/types';
import { Card } from '../Card';
import { UserHeaderList } from '../UserHeaderList';
type Props = {
title: string;
createdAt: string;
updatedAt?: string;
author: Author;
url: string;
reviews: Reviews;
repositoryName: string;
isDraft: boolean;
}
const PullRequestCard: FunctionComponent<Props> = (props: Props) => {
const {
title,
createdAt,
updatedAt,
author,
url,
reviews,
repositoryName,
isDraft,
} = props;
const approvedReviews = getApprovedReviews(reviews);
const commentsReviews = getCommentedReviews(reviews);
const changeRequests = getChangeRequests(reviews);
const cardTitle = isDraft ? `🔧 DRAFT - ${title}` : title;
return (
<Card
title={cardTitle}
createdAt={createdAt}
updatedAt={updatedAt}
authorName={author.login}
authorAvatar={author.avatarUrl}
repositoryName={repositoryName}
prUrl={url}
>
{!!approvedReviews.length && (
<UserHeaderList label='👍' users={approvedReviews.map(({ author: reviewAuthor }) => reviewAuthor)}/>
)}
{!!commentsReviews.length && (
<UserHeaderList
label='💬'
users={commentsReviews.map(({ author: reviewAuthor }) => reviewAuthor)}
/>
)}
{!!changeRequests.length && (
<UserHeaderList label='🚧' users={changeRequests.map(({ author: reviewAuthor }) => reviewAuthor)}/>
)}
</Card>
);
};
export default PullRequestCard;
@@ -0,0 +1 @@
export { default as PullRequestCard } from './PullRequestCard';
@@ -0,0 +1,74 @@
import React, { FunctionComponent } from 'react';
import { Box, Chip } from '@material-ui/core';
import { getApprovedReviews, getCommentedReviews } from '../../utils/functions';
import { Card } from '../Card';
import { Reviews, Author } from '../../utils/types';
type Props = {
title: string;
createdAt: string;
updatedAt?: string;
author: Author;
url: string;
reviews: Reviews;
repositoryName: string;
isDraft: boolean;
}
const SmallPullRequestCard: FunctionComponent<Props> = (props: Props) => {
const {
title,
createdAt,
updatedAt,
author,
url,
reviews,
repositoryName,
isDraft,
} = props;
const approvedReviews = getApprovedReviews(reviews);
const commentsReviews = getCommentedReviews(reviews);
const containReviews = !!approvedReviews.length || !!commentsReviews.length;
const cardTitle = isDraft ? `🔧 DRAFT - ${title}` : title;
return (
<Card
title={cardTitle}
createdAt={createdAt}
updatedAt={updatedAt}
authorName={author.login}
authorAvatar={author.avatarUrl}
repositoryName={repositoryName}
prUrl={url}
>
{
containReviews && (
<Box display='flex' width='100%' marginY={1}>
{!!approvedReviews.length && (
<Box marginX={1}>
<Chip
color='secondary'
variant='outlined'
size='small'
label={`${approvedReviews.length} approvals 👍`}
/>
</Box>
)}
{!!commentsReviews.length && (
<Chip
color='primary'
variant='outlined'
size='small'
label={`${commentsReviews.length} comments 💬`}
/>
)}
</Box>
)
}
</Card>
);
};
export default SmallPullRequestCard;
@@ -0,0 +1 @@
export { default as SmallPullRequestCard } from './SmallPullRequestCard';
@@ -0,0 +1,109 @@
import React, { FunctionComponent, useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import ViewModuleIcon from '@material-ui/icons/ViewModule';
import { Progress, InfoCard } from '@backstage/core-components';
import { InfoCardHeader } from '../../components/InfoCardHeader';
import { PullRequestBoardOptions } from '../../components/PullRequestBoardOptions';
import { Wrapper } from '../../components/Wrapper';
import { SmallPullRequestCard } from '../../components/SmallPullRequestCard';
import { PullRequestCard } from '../../components/PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { DraftPrIcon } from '../../components/icons/DraftPr'
import { useUserRepositories } from '../../hooks/useUserRepositories';
const TeamPullRequestsPage: FunctionComponent = () => {
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(repositories);
const CardComponent = infoCardFormat.includes('compacted')
? SmallPullRequestCard
: PullRequestCard;
const header = (
<InfoCardHeader onRefresh={refreshPullRequests}>
<PullRequestBoardOptions
onClickOption={(newFormats) => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <ViewModuleIcon />,
value: 'compacted',
ariaLabel: 'Cards compacted'
},
{
icon: <DraftPrIcon />,
value: 'draft',
ariaLabel: 'Show draft PRs'
},
]}
/>
</InfoCardHeader>
);
const getContent = () => {
if (loading) {
return <Progress />;
}
return (
<Grid container spacing={2}>
{pullRequests.length ? (
pullRequests.map(({ title: columnTitle, content }) => (
<Wrapper
key={columnTitle}
fullscreen
>
<Typography variant="overline">
{columnTitle}
</Typography>
{content.map(({
id,
title,
createdAt,
lastEditedAt,
author,
url,
latestReviews,
repository,
isDraft
}, index) => (
isDraft ? (infoCardFormat.includes('draft') === isDraft) &&
<CardComponent
key={`pull-request-${id}-${index}`}
title={title}
createdAt={createdAt}
updatedAt={lastEditedAt}
author={author}
url={url}
reviews={latestReviews.nodes}
repositoryName={repository.name}
isDraft={isDraft}
/>
: <CardComponent
key={`pull-request-${id}-${index}`}
title={title}
createdAt={createdAt}
updatedAt={lastEditedAt}
author={author}
url={url}
reviews={latestReviews.nodes}
repositoryName={repository.name}
isDraft={isDraft}
/>
))}
</Wrapper>
))
) : (
<Typography variant="overline">No pull requests found</Typography>
)}
</Grid>
);
};
return <InfoCard title={header}>{getContent()}</InfoCard>;
};
export default TeamPullRequestsPage;
@@ -0,0 +1 @@
export { default as TeamPullRequestsPage } from './TeamPullRequestsPage';
@@ -0,0 +1,116 @@
import React, { FunctionComponent, useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import ViewModuleIcon from '@material-ui/icons/ViewModule';
import FullscreenIcon from '@material-ui/icons/Fullscreen';
import { Progress, InfoCard } from '@backstage/core-components';
import { InfoCardHeader } from '../../components/InfoCardHeader';
import { PullRequestBoardOptions } from '../../components/PullRequestBoardOptions';
import { Wrapper } from '../../components/Wrapper';
import { SmallPullRequestCard } from '../../components/SmallPullRequestCard';
import { PullRequestCard } from '../../components/PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { DraftPrIcon } from '../../components/icons/DraftPr'
import { useUserRepositories } from '../../hooks/useUserRepositories';
const TeamPullRequestsTable: FunctionComponent = () => {
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(repositories);
const CardComponent = infoCardFormat.includes('compacted')
? SmallPullRequestCard
: PullRequestCard;
const header = (
<InfoCardHeader onRefresh={refreshPullRequests}>
<PullRequestBoardOptions
onClickOption={(newFormats) => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <DraftPrIcon />,
value: 'draft',
ariaLabel: 'Show draft PRs'
},
{
icon: <ViewModuleIcon />,
value: 'compacted',
ariaLabel: 'Cards compacted'
},
{
icon: <FullscreenIcon />,
value: 'fullscreen',
ariaLabel: 'Info card is set to fullscreen'
}
]}
/>
</InfoCardHeader>
);
const getContent = () => {
if (loading) {
return <Progress />;
}
return (
<Grid container spacing={2}>
{pullRequests.length ? (
pullRequests.map(({ title: columnTitle, content }) => (
<Wrapper
key={columnTitle}
fullscreen={infoCardFormat.includes('fullscreen')}
>
<Typography variant="overline">
{columnTitle}
</Typography>
{content.map(({
id,
title,
createdAt,
lastEditedAt,
author,
url,
latestReviews,
repository,
isDraft
}, index) => (
isDraft ? (infoCardFormat.includes('draft') === isDraft) &&
<CardComponent
key={`pull-request-${id}-${index}`}
title={title}
createdAt={createdAt}
updatedAt={lastEditedAt}
author={author}
url={url}
reviews={latestReviews.nodes}
repositoryName={repository.name}
isDraft={isDraft}
/>
: <CardComponent
key={`pull-request-${id}-${index}`}
title={title}
createdAt={createdAt}
updatedAt={lastEditedAt}
author={author}
url={url}
reviews={latestReviews.nodes}
repositoryName={repository.name}
isDraft={isDraft}
/>
))}
</Wrapper>
))
) : (
<Typography variant="overline">No pull requests found</Typography>
)}
</Grid>
);
};
return <InfoCard title={header}>{getContent()}</InfoCard>;
};
export default TeamPullRequestsTable;
@@ -0,0 +1 @@
export { default as TeamPullRequestsTable } from './TeamPullRequestsTable';
@@ -0,0 +1,36 @@
import React from 'react';
import {
Typography,
Box,
Avatar,
makeStyles
} from '@material-ui/core';
type Props = {
name: string;
avatar?: string;
}
const useStyles = makeStyles((theme) => ({
small: {
width: theme.spacing(4),
height: theme.spacing(4),
marginLeft: theme.spacing(1)
}
}));
const UserHeader = (props: Props) => {
const { name, avatar } = props;
const classes = useStyles();
return (
<Box display='flex' alignItems='center' marginX={1}>
<Typography color='textSecondary' variant='body2' component='p'>
{name}
</Typography>
<Avatar alt={name} src={avatar} className={classes.small} />
</Box>
);
};
export default UserHeader;
@@ -0,0 +1 @@
export { default as UserHeader } from './UserHeader';
@@ -0,0 +1,24 @@
import React from 'react';
import { Typography, Box } from '@material-ui/core';
import { filterSameUser } from '../../utils/functions';
import { UserHeader } from '../UserHeader';
import { Author } from '../../utils/types';
type Props = {
label?: string;
users: Author[];
}
const UserHeaderList = (props: Props) => {
const { users, label } = props;
return (
<Box display='flex' width='100%' alignItems='center' marginY={2} flexWrap='wrap'>
{label && <Typography variant='subtitle2'>{label}</Typography>}
{filterSameUser(users).map(({ login, avatarUrl }) => <UserHeader name={login} avatar={avatarUrl} key={login} />)}
</Box>
);
};
export default UserHeaderList;
@@ -0,0 +1 @@
export { default as UserHeaderList } from './UserHeaderList';
@@ -0,0 +1,20 @@
import React, { PropsWithChildren } from 'react';
import { Grid, Box } from '@material-ui/core';
type Props = {
fullscreen: boolean;
}
const Wrapper = (props: PropsWithChildren<Props>) => {
const { children, fullscreen } = props;
return (
<Grid item xs>
<Box maxHeight={fullscreen ? '100vh' : '50vh'} overflow='auto'>
{children}
</Box>
</Grid>
)
};
export default Wrapper;
@@ -0,0 +1 @@
export { default as Wrapper } from './Wrapper';
@@ -0,0 +1,10 @@
import React from 'react';
const DraftPr = () => (
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true">
<path fillRule="evenodd" 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" />
</svg>
);
export default DraftPr;
@@ -0,0 +1 @@
export { default as DraftPrIcon } from './DraftPr';
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useState } from 'react';
import { formatPRsByReviewDecision } from '../utils/functions';
import { PullRequests, PullRequestsColumn } from '../utils/types';
import { useGetPullRequestsFromRepository } from '../api/useGetPullRequestsFromRepository';
import { useGetPullRequestDetails } from '../api/useGetPullRequestDetails';
export function usePullRequestsByTeam(repositories: string[]) {
const [pullRequests, setPullRequests] = useState<PullRequestsColumn[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const getPullRequests = useGetPullRequestsFromRepository();
const getPullRequestDetails = useGetPullRequestDetails();
const getPRsPerRepository = useCallback(async (repository: string): Promise<PullRequests> => {
const pullRequestsNumbers = await getPullRequests(repository)
const pullRequestsWithDetails = await Promise.all(
pullRequestsNumbers.map(async ({ node }) => {
const pullRequest = await getPullRequestDetails(
repository,
node.number,
);
return pullRequest;
}),
);
return pullRequestsWithDetails;
}, [getPullRequests, getPullRequestDetails]);
const getPRsFromTeam = useCallback(
async (teamRepositories: string[]): Promise<PullRequests> => {
const teamRepositoriesPromises = teamRepositories.map(repository =>
getPRsPerRepository(repository),
);
const teamPullRequests = await Promise.allSettled(teamRepositoriesPromises)
.then(promises => promises.reduce((acc, curr) => {
if (curr.status === 'fulfilled') {
return [...acc, ...curr.value];
}
return acc;
},[] as PullRequests)
);
return teamPullRequests;
},
[getPRsPerRepository],
);
const getAllPullRequests = useCallback(async () => {
setLoading(true);
const teamPullRequests = await getPRsFromTeam(repositories);
setPullRequests(formatPRsByReviewDecision(teamPullRequests));
setLoading(false);
}, [getPRsFromTeam, repositories]);
useEffect(() => {
getAllPullRequests()
}, [getAllPullRequests]);
return {
pullRequests,
loading,
refreshPullRequests: getAllPullRequests,
};
}
@@ -0,0 +1,34 @@
import { useApi } from '@backstage/core-plugin-api';
import { useEntity, catalogApiRef } 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: {
kind: 'Component',
'spec.type': 'service',
'spec.owner': teamEntity?.metadata?.name,
},
});
const entitiesNames: string[] = entitiesList.items.map(componentEntity =>
getProjectNameFromEntity(componentEntity)
);
setRepositories([...new Set(entitiesNames)]);
}, [catalogApi, teamEntity?.metadata?.name]);
useEffect(() => {
getRepositoriesNames()
}, [getRepositoriesNames]);
return {
repositories,
};
}
@@ -0,0 +1 @@
export { TeamPullRequestsTable, TeamPullRequestsPage } from './plugin';
@@ -0,0 +1,7 @@
import { TeamPullRequestsTable } from './plugin';
describe('github-pull-requests-board', () => {
it('should export TeamPullRequestsTable', () => {
expect(TeamPullRequestsTable).toBeDefined();
});
});
@@ -0,0 +1,34 @@
import {
createPlugin,
createComponentExtension,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
const githubPullRequestsBoardPlugin = createPlugin({
id: 'github-pull-requests-board',
routes: {
root: rootRouteRef,
},
});
export const TeamPullRequestsTable = githubPullRequestsBoardPlugin.provide(
createComponentExtension({
name: 'TeamPullRequestsTable',
component: {
lazy: () =>
import('./components/TeamPullRequestsTable').then(
m => m.TeamPullRequestsTable,
),
},
}),
);
export const TeamPullRequestsPage = githubPullRequestsBoardPlugin.provide(
createRoutableExtension({
name: 'PullRequestPage',
component: () =>
import('./components/TeamPullRequestsPage').then(m => m.TeamPullRequestsPage),
mountPoint: rootRouteRef,
}),
);
@@ -0,0 +1,5 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'github-pull-requests-board',
});
@@ -0,0 +1,2 @@
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
@@ -0,0 +1,5 @@
export const COLUMNS = Object.freeze({
REVIEW_REQUIRED: '🔍 Review required',
REVIEW_IN_PROGRESS: '📝 Review in progress',
APPROVED: '👍 Approved'
})
@@ -0,0 +1,77 @@
import { Entity } from '@backstage/catalog-model';
import moment from 'moment';
import { Reviews, PullRequests, ReviewDecision, PullRequestsColumn, Author } from './types';
import { COLUMNS } from './constants';
const GITHUB_PULL_REQUESTS_ANNOTATION = 'github.com/project-slug';
export const getProjectNameFromEntity = (entity: Entity): string => {
return entity?.metadata.annotations?.[GITHUB_PULL_REQUESTS_ANNOTATION] ?? '';
};
export const getApprovedReviews = (reviews: Reviews = []): Reviews => {
return reviews.filter(({ state }) => state === 'APPROVED');
};
export const getCommentedReviews = (reviews: Reviews = []): Reviews => {
return reviews.filter(({ state }) => state === 'COMMENTED');
};
export const getChangeRequests = (reviews: Reviews = []): Reviews => {
return reviews.filter(({ state }) => state === 'CHANGES_REQUESTED');
};
export const filterSameUser = (users: Author[]): Author[] => {
return users.reduce((acc, curr) => {
const contaisUser = acc.find(({ login }) => login === curr.login);
if(!contaisUser) {
return [ ...acc, curr ];
}
return acc;
}, [] as Author[]);
}
export const getElapsedTime = (start: string): string => {
return moment(start).fromNow();
};
export const formatPRsByReviewDecision = (prs: PullRequests): PullRequestsColumn[] => {
const reviewDecisions = prs.reduce((acc, curr) => {
const decision = curr.reviewDecision || 'REVIEW_REQUIRED';
if(decision !== 'APPROVED' && curr.latestReviews.nodes.length === 0) {
return {
...acc,
REVIEW_REQUIRED: [...acc.REVIEW_REQUIRED, curr]
}
}
if(decision !== 'APPROVED' && curr.latestReviews.nodes.length > 0) {
return {
...acc,
IN_PROGRESS: [...acc.IN_PROGRESS, curr]
}
}
if(decision === 'APPROVED') {
return {
...acc,
APPROVED: [...acc.APPROVED, curr]
}
}
return acc;
}, {
REVIEW_REQUIRED: [],
IN_PROGRESS: [],
APPROVED: []
} as Record<ReviewDecision, PullRequests>);
return [
{ title: COLUMNS.REVIEW_REQUIRED, content: reviewDecisions.REVIEW_REQUIRED },
{ title: COLUMNS.REVIEW_IN_PROGRESS, content: reviewDecisions.IN_PROGRESS },
{ title: COLUMNS.APPROVED, content: reviewDecisions.APPROVED },
];
};
@@ -0,0 +1,69 @@
export type GraphQlPullRequest<T> = {
repository: {
pullRequest: T
}
}
export type GraphQlPullRequests<T> = {
repository: {
pullRequests: {
edges: T
}
}
}
export type PullRequestsNumber = {
node: {
number: number;
}
}
export type Review = {
state:
| 'PENDING'
| 'COMMENTED'
| 'APPROVED'
| 'CHANGES_REQUESTED'
| 'DISMISSED';
author: Author;
};
export type Reviews = Review[];
export type Author = {
login: string;
avatarUrl: string;
id: string;
email: string;
name: string;
};
export type PullRequest = {
id: string;
repository: {
name: string;
};
title: string;
url: string;
lastEditedAt: string;
latestReviews: {
nodes: Reviews;
};
mergeable: boolean;
state: string;
reviewDecision: ReviewDecision | null;
isDraft: boolean;
createdAt: string;
author: Author
};
export type PullRequests = PullRequest[];
export type PullRequestsColumn = {
title: string;
content: PullRequests;
};
export type PRCardFormating = 'compacted' | 'fullscreen' | 'draft';
export type ReviewDecision = 'IN_PROGRESS' | 'APPROVED' | 'REVIEW_REQUIRED'