From 36fb2461c5902708dcf8168e6bb16633135231ae Mon Sep 17 00:00:00 2001 From: Talita Gregory Nunes Freire Date: Fri, 22 Apr 2022 13:35:13 +0200 Subject: [PATCH] feat: Adding github-pull-requests-board plugin Signed-off-by: Talita Gregory Nunes Freire --- .../github-pull-requests-board/.eslintrc.js | 3 + plugins/github-pull-requests-board/README.md | 15 +++ .../github-pull-requests-board/package.json | 56 +++++++++ .../src/api/useGetPullRequestDetails.ts | 71 +++++++++++ .../api/useGetPullRequestsFromRepository.ts | 33 +++++ .../src/api/useOctokitGraphQl.ts | 21 ++++ .../src/components/Card/Card.tsx | 48 ++++++++ .../src/components/Card/CardHeader.tsx | 53 ++++++++ .../src/components/Card/index.ts | 1 + .../InfoCardHeader/InfoCardHeader.tsx | 25 ++++ .../src/components/InfoCardHeader/index.ts | 1 + .../PullRequestBoardOptions.tsx | 43 +++++++ .../PullRequestBoardOptions/index.ts | 1 + .../PullRequestCard/PullRequestCard.tsx | 62 ++++++++++ .../src/components/PullRequestCard/index.ts | 1 + .../SmallPullRequestCard.tsx | 74 +++++++++++ .../components/SmallPullRequestCard/index.ts | 1 + .../TeamPullRequestsPage.tsx | 109 ++++++++++++++++ .../components/TeamPullRequestsPage/index.ts | 1 + .../TeamPullRequestsTable.tsx | 116 ++++++++++++++++++ .../components/TeamPullRequestsTable/index.ts | 1 + .../src/components/UserHeader/UserHeader.tsx | 36 ++++++ .../src/components/UserHeader/index.ts | 1 + .../UserHeaderList/UserHeaderList.tsx | 24 ++++ .../src/components/UserHeaderList/index.ts | 1 + .../src/components/Wrapper/Wrapper.tsx | 20 +++ .../src/components/Wrapper/index.ts | 1 + .../src/components/icons/DraftPr/DraftPr.tsx | 10 ++ .../src/components/icons/DraftPr/index.ts | 1 + .../src/hooks/usePullRequestsByTeam.tsx | 70 +++++++++++ .../src/hooks/useUserRepositories.tsx | 34 +++++ .../github-pull-requests-board/src/index.ts | 1 + .../src/plugin.test.ts | 7 ++ .../github-pull-requests-board/src/plugin.ts | 34 +++++ .../github-pull-requests-board/src/routes.ts | 5 + .../src/setupTests.ts | 2 + .../src/utils/constants.ts | 5 + .../src/utils/functions.ts | 77 ++++++++++++ .../src/utils/types.tsx | 69 +++++++++++ 39 files changed, 1134 insertions(+) create mode 100644 plugins/github-pull-requests-board/.eslintrc.js create mode 100644 plugins/github-pull-requests-board/README.md create mode 100644 plugins/github-pull-requests-board/package.json create mode 100644 plugins/github-pull-requests-board/src/api/useGetPullRequestDetails.ts create mode 100644 plugins/github-pull-requests-board/src/api/useGetPullRequestsFromRepository.ts create mode 100644 plugins/github-pull-requests-board/src/api/useOctokitGraphQl.ts create mode 100644 plugins/github-pull-requests-board/src/components/Card/Card.tsx create mode 100644 plugins/github-pull-requests-board/src/components/Card/CardHeader.tsx create mode 100644 plugins/github-pull-requests-board/src/components/Card/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/InfoCardHeader/InfoCardHeader.tsx create mode 100644 plugins/github-pull-requests-board/src/components/InfoCardHeader/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/PullRequestBoardOptions.tsx create mode 100644 plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/PullRequestCard/PullRequestCard.tsx create mode 100644 plugins/github-pull-requests-board/src/components/PullRequestCard/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/SmallPullRequestCard/SmallPullRequestCard.tsx create mode 100644 plugins/github-pull-requests-board/src/components/SmallPullRequestCard/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/TeamPullRequestsPage.tsx create mode 100644 plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/TeamPullRequestsTable.tsx create mode 100644 plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/UserHeader/UserHeader.tsx create mode 100644 plugins/github-pull-requests-board/src/components/UserHeader/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/UserHeaderList/UserHeaderList.tsx create mode 100644 plugins/github-pull-requests-board/src/components/UserHeaderList/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/Wrapper/Wrapper.tsx create mode 100644 plugins/github-pull-requests-board/src/components/Wrapper/index.ts create mode 100644 plugins/github-pull-requests-board/src/components/icons/DraftPr/DraftPr.tsx create mode 100644 plugins/github-pull-requests-board/src/components/icons/DraftPr/index.ts create mode 100644 plugins/github-pull-requests-board/src/hooks/usePullRequestsByTeam.tsx create mode 100644 plugins/github-pull-requests-board/src/hooks/useUserRepositories.tsx create mode 100644 plugins/github-pull-requests-board/src/index.ts create mode 100644 plugins/github-pull-requests-board/src/plugin.test.ts create mode 100644 plugins/github-pull-requests-board/src/plugin.ts create mode 100644 plugins/github-pull-requests-board/src/routes.ts create mode 100644 plugins/github-pull-requests-board/src/setupTests.ts create mode 100644 plugins/github-pull-requests-board/src/utils/constants.ts create mode 100644 plugins/github-pull-requests-board/src/utils/functions.ts create mode 100644 plugins/github-pull-requests-board/src/utils/types.tsx diff --git a/plugins/github-pull-requests-board/.eslintrc.js b/plugins/github-pull-requests-board/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/github-pull-requests-board/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/github-pull-requests-board/README.md b/plugins/github-pull-requests-board/README.md new file mode 100644 index 0000000000..2beff349a8 --- /dev/null +++ b/plugins/github-pull-requests-board/README.md @@ -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'; + +; +``` diff --git a/plugins/github-pull-requests-board/package.json b/plugins/github-pull-requests-board/package.json new file mode 100644 index 0000000000..dc61fc7cde --- /dev/null +++ b/plugins/github-pull-requests-board/package.json @@ -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" + ] +} diff --git a/plugins/github-pull-requests-board/src/api/useGetPullRequestDetails.ts b/plugins/github-pull-requests-board/src/api/useGetPullRequestDetails.ts new file mode 100644 index 0000000000..0732f86683 --- /dev/null +++ b/plugins/github-pull-requests-board/src/api/useGetPullRequestDetails.ts @@ -0,0 +1,71 @@ +import React from 'react'; + +import { GraphQlPullRequest, PullRequest } from '../utils/types'; +import { useOctokitGraphQl } from './useOctokitGraphQl'; + +export const useGetPullRequestDetails = () => { + const graphql = useOctokitGraphQl>(); + + const fn = React.useRef(async (repo: string, number: number): Promise => { + 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; +}; diff --git a/plugins/github-pull-requests-board/src/api/useGetPullRequestsFromRepository.ts b/plugins/github-pull-requests-board/src/api/useGetPullRequestsFromRepository.ts new file mode 100644 index 0000000000..d03cca43d3 --- /dev/null +++ b/plugins/github-pull-requests-board/src/api/useGetPullRequestsFromRepository.ts @@ -0,0 +1,33 @@ +import React from 'react'; + +import { GraphQlPullRequests, PullRequestsNumber } from '../utils/types'; +import { useOctokitGraphQl } from './useOctokitGraphQl'; + +export const useGetPullRequestsFromRepository = () => { + const graphql = useOctokitGraphQl>(); + + const fn = React.useRef(async (repo: string): Promise => { + 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; +}; diff --git a/plugins/github-pull-requests-board/src/api/useOctokitGraphQl.ts b/plugins/github-pull-requests-board/src/api/useOctokitGraphQl.ts new file mode 100644 index 0000000000..36e53bcfb9 --- /dev/null +++ b/plugins/github-pull-requests-board/src/api/useOctokitGraphQl.ts @@ -0,0 +1,21 @@ +import { Octokit } from '@octokit/rest'; +import { useApi, githubAuthApiRef } from '@backstage/core-plugin-api'; + +let octokit: any; + +export const useOctokitGraphQl = () => { + const auth = useApi(githubAuthApiRef); + + return (path: string, options?: any): Promise => + auth.getAccessToken(['repo']) + .then((token: string) => { + if(!octokit) { + octokit = new Octokit({ auth: token }) + } + + return octokit + }) + .then(octokitInstance => { + return octokitInstance.graphql(path, options) + }); +}; diff --git a/plugins/github-pull-requests-board/src/components/Card/Card.tsx b/plugins/github-pull-requests-board/src/components/Card/Card.tsx new file mode 100644 index 0000000000..8e1067aa1d --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/Card/Card.tsx @@ -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: PropsWithChildren) => { + const { + title, + createdAt, + updatedAt, + prUrl, + authorName, + authorAvatar, + repositoryName, + children + } = props; + + return ( + + + + + + { children } + + + + + ); +}; + +export default Card; diff --git a/plugins/github-pull-requests-board/src/components/Card/CardHeader.tsx b/plugins/github-pull-requests-board/src/components/Card/CardHeader.tsx new file mode 100644 index 0000000000..79e936f37d --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/Card/CardHeader.tsx @@ -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 ( + <> + + + {repositoryName} + + + + + {title} + + + + Created at: {getElapsedTime(createdAt)} + + { + updatedAt && ( + + Last update: {getElapsedTime(updatedAt)} + + ) + } + + + + ); +}; + +export default CardHeader; diff --git a/plugins/github-pull-requests-board/src/components/Card/index.ts b/plugins/github-pull-requests-board/src/components/Card/index.ts new file mode 100644 index 0000000000..06c3388d6c --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/Card/index.ts @@ -0,0 +1 @@ +export { default as Card } from './Card'; diff --git a/plugins/github-pull-requests-board/src/components/InfoCardHeader/InfoCardHeader.tsx b/plugins/github-pull-requests-board/src/components/InfoCardHeader/InfoCardHeader.tsx new file mode 100644 index 0000000000..24c1393dd2 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/InfoCardHeader/InfoCardHeader.tsx @@ -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) => { + const { children, onRefresh } = props; + + return ( + + + Open pull requests + + + + + {children} + + ); +}; + +export default InfoCardHeader; diff --git a/plugins/github-pull-requests-board/src/components/InfoCardHeader/index.ts b/plugins/github-pull-requests-board/src/components/InfoCardHeader/index.ts new file mode 100644 index 0000000000..ad044f42a8 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/InfoCardHeader/index.ts @@ -0,0 +1 @@ +export { default as InfoCardHeader } from './InfoCardHeader'; diff --git a/plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/PullRequestBoardOptions.tsx b/plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/PullRequestBoardOptions.tsx new file mode 100644 index 0000000000..98f52c4970 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/PullRequestBoardOptions.tsx @@ -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 ( + onClickOption(selectedOptions)} + aria-label="Pull Request board settings" + > + { + options.map(({ icon, value: toggleValue, ariaLabel }, index) => ( + + + + {icon} + + + + )) + } + + + ); +}; + +export default PullRequestBoardOptions; diff --git a/plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/index.ts b/plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/index.ts new file mode 100644 index 0000000000..31b4198239 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/PullRequestBoardOptions/index.ts @@ -0,0 +1 @@ +export { default as PullRequestBoardOptions } from './PullRequestBoardOptions'; diff --git a/plugins/github-pull-requests-board/src/components/PullRequestCard/PullRequestCard.tsx b/plugins/github-pull-requests-board/src/components/PullRequestCard/PullRequestCard.tsx new file mode 100644 index 0000000000..66d4fbcf29 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/PullRequestCard/PullRequestCard.tsx @@ -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) => { + 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 ( + + {!!approvedReviews.length && ( + reviewAuthor)}/> + )} + {!!commentsReviews.length && ( + reviewAuthor)} + /> + )} + {!!changeRequests.length && ( + reviewAuthor)}/> + )} + + ); +}; + +export default PullRequestCard; diff --git a/plugins/github-pull-requests-board/src/components/PullRequestCard/index.ts b/plugins/github-pull-requests-board/src/components/PullRequestCard/index.ts new file mode 100644 index 0000000000..ed77163680 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/PullRequestCard/index.ts @@ -0,0 +1 @@ +export { default as PullRequestCard } from './PullRequestCard'; diff --git a/plugins/github-pull-requests-board/src/components/SmallPullRequestCard/SmallPullRequestCard.tsx b/plugins/github-pull-requests-board/src/components/SmallPullRequestCard/SmallPullRequestCard.tsx new file mode 100644 index 0000000000..3bf53b4487 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/SmallPullRequestCard/SmallPullRequestCard.tsx @@ -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) => { + 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 ( + + { + containReviews && ( + + {!!approvedReviews.length && ( + + + + )} + {!!commentsReviews.length && ( + + )} + + ) + } + + ); +}; + +export default SmallPullRequestCard; diff --git a/plugins/github-pull-requests-board/src/components/SmallPullRequestCard/index.ts b/plugins/github-pull-requests-board/src/components/SmallPullRequestCard/index.ts new file mode 100644 index 0000000000..eeffd436d8 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/SmallPullRequestCard/index.ts @@ -0,0 +1 @@ +export { default as SmallPullRequestCard } from './SmallPullRequestCard'; diff --git a/plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/TeamPullRequestsPage.tsx b/plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/TeamPullRequestsPage.tsx new file mode 100644 index 0000000000..eccb0b29b3 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/TeamPullRequestsPage.tsx @@ -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([]); + const { repositories } = useUserRepositories(); + const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(repositories); + + const CardComponent = infoCardFormat.includes('compacted') + ? SmallPullRequestCard + : PullRequestCard; + + const header = ( + + setInfoCardFormat(newFormats)} + value={infoCardFormat} + options={[ + { + icon: , + value: 'compacted', + ariaLabel: 'Cards compacted' + }, + { + icon: , + value: 'draft', + ariaLabel: 'Show draft PRs' + }, + ]} + /> + + ); + + const getContent = () => { + if (loading) { + return ; + } + + return ( + + {pullRequests.length ? ( + pullRequests.map(({ title: columnTitle, content }) => ( + + + {columnTitle} + + {content.map(({ + id, + title, + createdAt, + lastEditedAt, + author, + url, + latestReviews, + repository, + isDraft + }, index) => ( + isDraft ? (infoCardFormat.includes('draft') === isDraft) && + + : + ))} + + )) + ) : ( + No pull requests found + )} + + ); + }; + + return {getContent()}; +}; + +export default TeamPullRequestsPage; diff --git a/plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/index.ts b/plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/index.ts new file mode 100644 index 0000000000..b58aed2482 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/TeamPullRequestsPage/index.ts @@ -0,0 +1 @@ +export { default as TeamPullRequestsPage } from './TeamPullRequestsPage'; diff --git a/plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/TeamPullRequestsTable.tsx b/plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/TeamPullRequestsTable.tsx new file mode 100644 index 0000000000..e80bab7837 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/TeamPullRequestsTable.tsx @@ -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([]); + const { repositories } = useUserRepositories(); + const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(repositories); + + const CardComponent = infoCardFormat.includes('compacted') + ? SmallPullRequestCard + : PullRequestCard; + + const header = ( + + setInfoCardFormat(newFormats)} + value={infoCardFormat} + options={[ + { + icon: , + value: 'draft', + ariaLabel: 'Show draft PRs' + }, + { + icon: , + value: 'compacted', + ariaLabel: 'Cards compacted' + }, + { + icon: , + value: 'fullscreen', + ariaLabel: 'Info card is set to fullscreen' + } + ]} + /> + + ); + + const getContent = () => { + if (loading) { + return ; + } + + return ( + + {pullRequests.length ? ( + pullRequests.map(({ title: columnTitle, content }) => ( + + + {columnTitle} + + {content.map(({ + id, + title, + createdAt, + lastEditedAt, + author, + url, + latestReviews, + repository, + isDraft + }, index) => ( + isDraft ? (infoCardFormat.includes('draft') === isDraft) && + + : + ))} + + )) + ) : ( + No pull requests found + )} + + ); + }; + + return {getContent()}; +}; + +export default TeamPullRequestsTable; diff --git a/plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/index.ts b/plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/index.ts new file mode 100644 index 0000000000..0da871526a --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/TeamPullRequestsTable/index.ts @@ -0,0 +1 @@ +export { default as TeamPullRequestsTable } from './TeamPullRequestsTable'; diff --git a/plugins/github-pull-requests-board/src/components/UserHeader/UserHeader.tsx b/plugins/github-pull-requests-board/src/components/UserHeader/UserHeader.tsx new file mode 100644 index 0000000000..e7f4c1604d --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/UserHeader/UserHeader.tsx @@ -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 ( + + + {name} + + + + ); +}; + +export default UserHeader; diff --git a/plugins/github-pull-requests-board/src/components/UserHeader/index.ts b/plugins/github-pull-requests-board/src/components/UserHeader/index.ts new file mode 100644 index 0000000000..7229897183 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/UserHeader/index.ts @@ -0,0 +1 @@ +export { default as UserHeader } from './UserHeader'; diff --git a/plugins/github-pull-requests-board/src/components/UserHeaderList/UserHeaderList.tsx b/plugins/github-pull-requests-board/src/components/UserHeaderList/UserHeaderList.tsx new file mode 100644 index 0000000000..0f93fbfbc2 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/UserHeaderList/UserHeaderList.tsx @@ -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 ( + + {label && {label}} + {filterSameUser(users).map(({ login, avatarUrl }) => )} + + ); +}; + +export default UserHeaderList; diff --git a/plugins/github-pull-requests-board/src/components/UserHeaderList/index.ts b/plugins/github-pull-requests-board/src/components/UserHeaderList/index.ts new file mode 100644 index 0000000000..88fdb2c4e8 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/UserHeaderList/index.ts @@ -0,0 +1 @@ +export { default as UserHeaderList } from './UserHeaderList'; diff --git a/plugins/github-pull-requests-board/src/components/Wrapper/Wrapper.tsx b/plugins/github-pull-requests-board/src/components/Wrapper/Wrapper.tsx new file mode 100644 index 0000000000..2197e15a06 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/Wrapper/Wrapper.tsx @@ -0,0 +1,20 @@ +import React, { PropsWithChildren } from 'react'; +import { Grid, Box } from '@material-ui/core'; + +type Props = { + fullscreen: boolean; +} + +const Wrapper = (props: PropsWithChildren) => { + const { children, fullscreen } = props; + + return ( + + + {children} + + + ) +}; + +export default Wrapper; diff --git a/plugins/github-pull-requests-board/src/components/Wrapper/index.ts b/plugins/github-pull-requests-board/src/components/Wrapper/index.ts new file mode 100644 index 0000000000..50472e028e --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/Wrapper/index.ts @@ -0,0 +1 @@ +export { default as Wrapper } from './Wrapper'; diff --git a/plugins/github-pull-requests-board/src/components/icons/DraftPr/DraftPr.tsx b/plugins/github-pull-requests-board/src/components/icons/DraftPr/DraftPr.tsx new file mode 100644 index 0000000000..22e96f71f3 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/icons/DraftPr/DraftPr.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +const DraftPr = () => ( + +); + +export default DraftPr; diff --git a/plugins/github-pull-requests-board/src/components/icons/DraftPr/index.ts b/plugins/github-pull-requests-board/src/components/icons/DraftPr/index.ts new file mode 100644 index 0000000000..a91e0d1cb7 --- /dev/null +++ b/plugins/github-pull-requests-board/src/components/icons/DraftPr/index.ts @@ -0,0 +1 @@ +export { default as DraftPrIcon } from './DraftPr'; diff --git a/plugins/github-pull-requests-board/src/hooks/usePullRequestsByTeam.tsx b/plugins/github-pull-requests-board/src/hooks/usePullRequestsByTeam.tsx new file mode 100644 index 0000000000..42135379b2 --- /dev/null +++ b/plugins/github-pull-requests-board/src/hooks/usePullRequestsByTeam.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const getPullRequests = useGetPullRequestsFromRepository(); + const getPullRequestDetails = useGetPullRequestDetails(); + + const getPRsPerRepository = useCallback(async (repository: string): Promise => { + + 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 => { + + 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, + }; +} diff --git a/plugins/github-pull-requests-board/src/hooks/useUserRepositories.tsx b/plugins/github-pull-requests-board/src/hooks/useUserRepositories.tsx new file mode 100644 index 0000000000..a463734a60 --- /dev/null +++ b/plugins/github-pull-requests-board/src/hooks/useUserRepositories.tsx @@ -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([]); + + 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, + }; +} diff --git a/plugins/github-pull-requests-board/src/index.ts b/plugins/github-pull-requests-board/src/index.ts new file mode 100644 index 0000000000..4c22dc54be --- /dev/null +++ b/plugins/github-pull-requests-board/src/index.ts @@ -0,0 +1 @@ +export { TeamPullRequestsTable, TeamPullRequestsPage } from './plugin'; diff --git a/plugins/github-pull-requests-board/src/plugin.test.ts b/plugins/github-pull-requests-board/src/plugin.test.ts new file mode 100644 index 0000000000..4bef78d8ee --- /dev/null +++ b/plugins/github-pull-requests-board/src/plugin.test.ts @@ -0,0 +1,7 @@ +import { TeamPullRequestsTable } from './plugin'; + +describe('github-pull-requests-board', () => { + it('should export TeamPullRequestsTable', () => { + expect(TeamPullRequestsTable).toBeDefined(); + }); +}); diff --git a/plugins/github-pull-requests-board/src/plugin.ts b/plugins/github-pull-requests-board/src/plugin.ts new file mode 100644 index 0000000000..aef2782579 --- /dev/null +++ b/plugins/github-pull-requests-board/src/plugin.ts @@ -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, + }), +); diff --git a/plugins/github-pull-requests-board/src/routes.ts b/plugins/github-pull-requests-board/src/routes.ts new file mode 100644 index 0000000000..170bb9fbfe --- /dev/null +++ b/plugins/github-pull-requests-board/src/routes.ts @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'github-pull-requests-board', +}); diff --git a/plugins/github-pull-requests-board/src/setupTests.ts b/plugins/github-pull-requests-board/src/setupTests.ts new file mode 100644 index 0000000000..48c09b5346 --- /dev/null +++ b/plugins/github-pull-requests-board/src/setupTests.ts @@ -0,0 +1,2 @@ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/github-pull-requests-board/src/utils/constants.ts b/plugins/github-pull-requests-board/src/utils/constants.ts new file mode 100644 index 0000000000..05cef50634 --- /dev/null +++ b/plugins/github-pull-requests-board/src/utils/constants.ts @@ -0,0 +1,5 @@ +export const COLUMNS = Object.freeze({ + REVIEW_REQUIRED: '🔍 Review required', + REVIEW_IN_PROGRESS: '📝 Review in progress', + APPROVED: '👍 Approved' +}) diff --git a/plugins/github-pull-requests-board/src/utils/functions.ts b/plugins/github-pull-requests-board/src/utils/functions.ts new file mode 100644 index 0000000000..c005d0ba8e --- /dev/null +++ b/plugins/github-pull-requests-board/src/utils/functions.ts @@ -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); + + return [ + { title: COLUMNS.REVIEW_REQUIRED, content: reviewDecisions.REVIEW_REQUIRED }, + { title: COLUMNS.REVIEW_IN_PROGRESS, content: reviewDecisions.IN_PROGRESS }, + { title: COLUMNS.APPROVED, content: reviewDecisions.APPROVED }, + ]; +}; \ No newline at end of file diff --git a/plugins/github-pull-requests-board/src/utils/types.tsx b/plugins/github-pull-requests-board/src/utils/types.tsx new file mode 100644 index 0000000000..789aa8a704 --- /dev/null +++ b/plugins/github-pull-requests-board/src/utils/types.tsx @@ -0,0 +1,69 @@ +export type GraphQlPullRequest = { + repository: { + pullRequest: T + } +} + +export type GraphQlPullRequests = { + 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' \ No newline at end of file