Merge pull request #11043 from getndazn/feat/github-pull-requests-board

Add GitHub Pull Requests Board Plugin
This commit is contained in:
Patrik Oldsberg
2022-05-27 12:49:39 +02:00
committed by GitHub
42 changed files with 1742 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-github-pull-requests-board': minor
---
Add Github Pull Requests board plugin
@@ -0,0 +1,9 @@
---
title: Github Pull Requests Board
author: DAZN
authorUrl: https://engineering.dazn.com
category: Source Control Mgmt
description: View all open GitHub pull requests owned by your team in Backstage.
documentation: https://github.com/backstage/backstage/tree/master/plugins/github-pull-requests-board
iconUrl: img/github-pull-requests-board-logo.svg
npmPackageName: '@backstage/plugin-github-pull-requests-board'
@@ -0,0 +1,30 @@
<svg width="522" height="704" viewBox="0 0 522 704" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_421_4547)">
<path d="M311.25 0H62.25C28.0125 0 0.311257 30.9503 0.311257 68.7783L0 619.005C0 656.833 27.7012 687.783 61.9387 687.783H435.75C469.987 687.783 498 656.833 498 619.005V206.335L311.25 0ZM280.125 206.335V51.5838L420.187 206.335H280.125Z" fill="#F7FF1A"/>
</g>
<g filter="url(#filter1_d_421_4547)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M280.027 69.0546H56.0273C25.2273 69.0546 0.30735 96.8974 0.30735 130.927L0.0273438 625.911C0.0273438 659.941 24.9473 687.784 55.7473 687.784H392.027C422.827 687.784 448.027 659.941 448.027 625.911V254.673L280.027 69.0546ZM252.027 254.673V115.459L378.027 254.673H252.027ZM132.909 567.758C122.874 567.758 114.739 575.893 114.739 585.928C114.739 595.962 122.874 604.097 132.909 604.097C142.943 604.097 151.078 595.962 151.078 585.928C151.078 575.893 142.943 567.758 132.909 567.758ZM132.886 376.3C122.862 376.288 114.739 368.158 114.739 358.131C114.739 348.096 122.874 339.961 132.909 339.961C142.943 339.961 151.078 348.096 151.078 358.131C151.078 368.158 142.955 376.288 132.931 376.3L132.909 376.3L132.886 376.3ZM116.909 405.695C97.046 399.016 82.7391 380.244 82.7391 358.131C82.7391 330.423 105.201 307.961 132.909 307.961C160.616 307.961 183.078 330.423 183.078 358.131C183.078 380.244 168.771 399.016 148.909 405.695V538.363C168.771 545.042 183.078 563.814 183.078 585.928C183.078 613.635 160.616 636.097 132.909 636.097C105.201 636.097 82.7391 613.635 82.7391 585.928C82.7391 563.814 97.046 545.042 116.909 538.363V405.695ZM258.121 301.258C264.369 307.506 264.369 317.637 258.121 323.885L239.875 342.131H272.434C288.005 342.131 302.939 348.317 313.949 359.327C324.96 370.338 331.146 385.271 331.146 400.843V538.363C351.008 545.042 365.315 563.814 365.315 585.928C365.315 613.635 342.854 636.097 315.146 636.097C287.438 636.097 264.976 613.635 264.976 585.928C264.976 563.814 279.283 545.042 299.146 538.363V400.843C299.146 393.758 296.332 386.964 291.322 381.955C286.313 376.945 279.518 374.131 272.434 374.131H239.875L258.121 392.377C264.369 398.625 264.369 408.756 258.121 415.004C251.872 421.252 241.742 421.252 235.493 415.004L189.934 369.445C183.685 363.196 183.685 353.066 189.934 346.817L235.493 301.258C241.742 295.009 251.872 295.009 258.121 301.258ZM315.146 567.758C305.111 567.758 296.976 575.893 296.976 585.928C296.976 595.962 305.111 604.097 315.146 604.097C325.181 604.097 333.315 595.962 333.315 585.928C333.315 575.893 325.181 567.758 315.146 567.758Z" fill="black"/>
</g>
<defs>
<filter id="filter0_d_421_4547" x="0" y="0" width="522" height="703.783" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="16" dy="8"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.968627 0 0 0 0 1 0 0 0 0 0.101961 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_421_4547"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_421_4547" result="shape"/>
</filter>
<filter id="filter1_d_421_4547" x="0.0273438" y="69.0546" width="472" height="634.729" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="16" dy="8"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_421_4547"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_421_4547" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
@@ -0,0 +1,79 @@
# GitHub Pull Requests Board Plugin
The GitHub Pull Requests Board Plugin helps to visualise all **Open Pull Requests** related to the owned team repository.
![github-pull-requests-board](./docs/pull-requests-board.png)
It 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.
## Prerequisites
- [GitHub Authentication Provider](https://backstage.io/docs/auth/github/provider)
## Getting started
Install the plugin by running the following command **from your Backstage root directory**
`yarn --cwd packages/app add @backstage/plugin-github-pull-requests-board`
The plugin exports the **EntityTeamPullRequestsCard** component which can be added to the Overview page of the team at `backstage/packages/app/src/components/catalog/EntityPage.tsx`
```javascript
import { EntityTeamPullRequestsCard } from '@backstage/plugin-github-pull-requests-board';
const groupPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
</Grid>
<Grid item xs={12}>
<EntityMembersListCard />
</Grid>
<Grid item xs={12}>
<EntityTeamPullRequestsCard />
</Grid>
</Grid>
</EntityLayout.Route>
</EntityLayoutWrapper>
);
```
Or you can also import the **EntityTeamPullRequestsContent** component which can be used to add a new tab under the group page at `backstage/packages/app/src/components/catalog/EntityPage.tsx`
```javascript
import { EntityTeamPullRequestsContent } from '@backstage/plugin-github-pull-requests-board';
const groupPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
</Grid>
<Grid item xs={12}>
<EntityMembersListCard />
</Grid>
</Grid>
</EntityLayout.Route>
<EntityLayout.Route path="/pull-requests" title="Pull Requests">
<EntityTeamPullRequestsContent />
</EntityLayout.Route>
</EntityLayoutWrapper>;
)
```
@@ -0,0 +1,21 @@
## API Report File for "@backstage/plugin-github-pull-requests-board"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { FunctionComponent } from 'react';
// Warning: (ae-missing-release-tag) "EntityTeamPullRequestsCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityTeamPullRequestsCard: FunctionComponent<{}>;
// Warning: (ae-missing-release-tag) "EntityTeamPullRequestsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityTeamPullRequestsContent: FunctionComponent<{}>;
// (No @packageDocumentation comment for this package)
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

@@ -0,0 +1,71 @@
{
"name": "@backstage/plugin-github-pull-requests-board",
"description": "A Backstage plugin that allows you to see all open Pull Requests for all the repositories owned by your team",
"version": "0.0.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"
},
"backstage": {
"role": "frontend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/github-pull-requests-board"
},
"keywords": [
"backstage",
"github",
"pull requests"
],
"scripts": {
"build": "backstage-cli package build",
"start": "backstage-cli package start",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/catalog-model": "^1.0.2-next.0",
"@backstage/core-components": "^0.9.5-next.0",
"@backstage/core-plugin-api": "^1.0.2",
"@backstage/integration": "^1.2.1-next.0",
"@backstage/plugin-catalog-react": "^1.1.1-next.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.57",
"@octokit/rest": "^18.12.0",
"moment": "^2.29.1",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.17.2-next.0",
"@backstage/dev-utils": "^1.0.3-next.0",
"@backstage/test-utils": "^1.1.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/jest": "^26.0.7",
"@types/node": "^16.11.26",
"cross-fetch": "^3.1.5",
"msw": "^0.35.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,91 @@
/*
* 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 { 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,54 @@
/*
* 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 { 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,47 @@
/*
* 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 { Octokit } from '@octokit/rest';
import {
useApi,
githubAuthApiRef,
configApiRef,
} from '@backstage/core-plugin-api';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
let octokit: any;
export const useOctokitGraphQl = <T>() => {
const auth = useApi(githubAuthApiRef);
const config = useApi(configApiRef);
const baseUrl = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
)[0].apiBaseUrl;
return (path: string, options?: any): Promise<T> =>
auth
.getAccessToken(['repo'])
.then((token: string) => {
if (!octokit) {
octokit = new Octokit({ auth: token, ...(baseUrl && { baseUrl }) });
}
return octokit;
})
.then(octokitInstance => {
return octokitInstance.graphql(path, options);
});
};
@@ -0,0 +1,63 @@
/*
* 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, { 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,65 @@
/*
* 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, { FunctionComponent } 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: FunctionComponent<Props> = (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,16 @@
/*
* 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.
*/
export { default as Card } from './Card';
@@ -0,0 +1,113 @@
/*
* 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, { FunctionComponent, useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import FullscreenIcon from '@material-ui/icons/Fullscreen';
import { Progress, InfoCard } from '@backstage/core-components';
import { InfoCardHeader } from '../InfoCardHeader';
import { PullRequestBoardOptions } from '../PullRequestBoardOptions';
import { Wrapper } from '../Wrapper';
import { PullRequestCard } from '../PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { DraftPrIcon } from '../icons/DraftPr';
import { useUserRepositories } from '../../hooks/useUserRepositories';
const EntityTeamPullRequestsCard: FunctionComponent = () => {
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } =
usePullRequestsByTeam(repositories);
const header = (
<InfoCardHeader onRefresh={refreshPullRequests}>
<PullRequestBoardOptions
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <DraftPrIcon />,
value: 'draft',
ariaLabel: 'Show draft PRs',
},
{
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,
) =>
infoCardFormat.includes('draft') === isDraft && (
<PullRequestCard
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 EntityTeamPullRequestsCard;
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { default as EntityTeamPullRequestsCard } from './EntityTeamPullRequestsCard';
@@ -0,0 +1,103 @@
/*
* 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, { FunctionComponent, useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import { Progress, InfoCard } from '@backstage/core-components';
import { InfoCardHeader } from '../InfoCardHeader';
import { PullRequestBoardOptions } from '../PullRequestBoardOptions';
import { Wrapper } from '../Wrapper';
import { PullRequestCard } from '../PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { DraftPrIcon } from '../icons/DraftPr';
import { useUserRepositories } from '../../hooks/useUserRepositories';
const EntityTeamPullRequestsContent: FunctionComponent = () => {
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } =
usePullRequestsByTeam(repositories);
const header = (
<InfoCardHeader onRefresh={refreshPullRequests}>
<PullRequestBoardOptions
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
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,
) =>
infoCardFormat.includes('draft') === isDraft && (
<PullRequestCard
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 EntityTeamPullRequestsContent;
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { default as EntityTeamPullRequestsContent } from './EntityTeamPullRequestsContent';
@@ -0,0 +1,42 @@
/*
* 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, { PropsWithChildren, FunctionComponent } from 'react';
import { Typography, Box, IconButton } from '@material-ui/core';
import RefreshIcon from '@material-ui/icons/Refresh';
type Props = {
onRefresh: () => void;
};
const InfoCardHeader: FunctionComponent<Props> = (
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,16 @@
/*
* 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.
*/
export { default as InfoCardHeader } from './InfoCardHeader';
@@ -0,0 +1,59 @@
/*
* 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, { ReactNode, FunctionComponent } 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: FunctionComponent<Props> = (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,16 @@
/*
* 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.
*/
export { default as PullRequestBoardOptions } from './PullRequestBoardOptions';
@@ -0,0 +1,91 @@
/*
* 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, { 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,16 @@
/*
* 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.
*/
export { default as PullRequestCard } from './PullRequestCard';
@@ -0,0 +1,46 @@
/*
* 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, { FunctionComponent } 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: FunctionComponent<Props> = (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,16 @@
/*
* 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.
*/
export { default as UserHeader } from './UserHeader';
@@ -0,0 +1,47 @@
/*
* 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, { FunctionComponent } 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: FunctionComponent<Props> = (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,16 @@
/*
* 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.
*/
export { default as UserHeaderList } from './UserHeaderList';
@@ -0,0 +1,35 @@
/*
* 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, { PropsWithChildren, FunctionComponent } from 'react';
import { Grid, Box } from '@material-ui/core';
type Props = {
fullscreen: boolean;
};
const Wrapper: FunctionComponent<Props> = (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,16 @@
/*
* 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.
*/
export { default as Wrapper } from './Wrapper';
@@ -0,0 +1,32 @@
/*
* 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, { FunctionComponent } from 'react';
const DraftPr: FunctionComponent = () => (
<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
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" />
</svg>
);
export default DraftPr;
@@ -0,0 +1,16 @@
/*
* 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.
*/
export { default as DraftPrIcon } from './DraftPr';
@@ -0,0 +1,82 @@
/*
* 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 { 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(({ node }) =>
getPullRequestDetails(repository, node.number),
),
);
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,49 @@
/*
* 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 { 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,19 @@
/*
* 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.
*/
export {
EntityTeamPullRequestsCard,
EntityTeamPullRequestsContent,
} from './plugin';
@@ -0,0 +1,28 @@
/*
* 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 {
EntityTeamPullRequestsCard,
EntityTeamPullRequestsContent,
} from './plugin';
describe('github-pull-requests-board', () => {
it('should export EntityTeamPullRequestsCard', () => {
expect(EntityTeamPullRequestsCard).toBeDefined();
});
it('should export EntityTeamPullRequestsContent', () => {
expect(EntityTeamPullRequestsContent).toBeDefined();
});
});
@@ -0,0 +1,52 @@
/*
* 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 {
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 EntityTeamPullRequestsCard = githubPullRequestsBoardPlugin.provide(
createComponentExtension({
name: 'EntityTeamPullRequestsCard',
component: {
lazy: () =>
import('./components/EntityTeamPullRequestsCard').then(
m => m.EntityTeamPullRequestsCard,
),
},
}),
);
export const EntityTeamPullRequestsContent =
githubPullRequestsBoardPlugin.provide(
createRoutableExtension({
name: 'PullRequestPage',
component: () =>
import('./components/EntityTeamPullRequestsContent').then(
m => m.EntityTeamPullRequestsContent,
),
mountPoint: rootRouteRef,
}),
);
@@ -0,0 +1,20 @@
/*
* 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 { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'github-pull-requests-board',
});
@@ -0,0 +1,17 @@
/*
* 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
@@ -0,0 +1,20 @@
/*
* 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.
*/
export const COLUMNS = Object.freeze({
REVIEW_REQUIRED: '🔍 Review required',
REVIEW_IN_PROGRESS: '📝 Review in progress',
APPROVED: '👍 Approved',
});
@@ -0,0 +1,105 @@
/*
* 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 { 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 containsUser = acc.find(({ login }) => login === curr.login);
if (!containsUser) {
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,84 @@
/*
* 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.
*/
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';