Merge pull request #12875 from getndazn/feat/github-issues-board

feat: new plugin GitHub Issues
This commit is contained in:
Ben Lambert
2022-08-02 20:35:59 +02:00
committed by GitHub
27 changed files with 1328 additions and 4 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-github-issues': minor
---
New plugin for displaying GitHub Issues added
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+63
View File
@@ -0,0 +1,63 @@
# GitHub Issues plugin
Welcome to the GitHub Issues plugin!
Based on the [well-known GitHub slug annotation](https://backstage.io/docs/features/software-catalog/well-known-annotations#githubcomproject-slug) associated with the Entity, it renders the list of Open issues in GitHub.
The plugin is designed to work with four Entity kinds, and it behaves a bit differently depending on that kind:
- Kind: Group/User: plugin renders issues from all repositories for which the Entity is the owner.
- Kind: API/Component: plugin renders issues from only one repository assigned to the Entity
**Issues are sorted from the recently updated DESC order (the plugin might not render all issues from a single repo next to each other).**
## Prerequisites
- [GitHub Authentication Provider](https://backstage.io/docs/auth/github/provider)
## Usage
Install the plugin by running the following command **from your Backstage root directory**
`yarn --cwd packages/app add @backstage/plugin-github-issues`
After installation, the plugin can be used as a Card or as a Page.
```typescript
import {
GitHubIssuesCard,
GitHubIssuesPage,
} from '@backstage/plugin-github-issues';
// To use as a page Plugin needs to be wrapped in EntityLayout.Route
const RenderGitHubIssuesPage = () => (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<EntityLayout.Route path="github-issues" title="GitHub Issues">
<GitHubIssuesPage />
</EntityLayout.Route>
<EntityLayout.Route />
</EntityLayoutWrapper>
);
// To use as a card and make it render correctly please place it inside appropriate Grid elements
const RenderGitHubIssuesCard = () => (
<EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3}>
<Grid item xs={12}>
<GitHubIssuesCard />
</Grid>
</Grid>
<EntityLayout.Route />
</EntityLayoutWrapper>
);
```
## Configuration
Both `GitHubIssuesPage` and `GitHubIssuesCard` provide default configuration. It is ready to use out of the box.
However, you can configure the plugin with props:
- `itemsPerPage: number = 10` - Issues in the list are paginated, number of issues on a single page is controlled with this prop
- `itemsPerRepo: number = 40` - the plugin doesn't download all Issues available on GitHub. By default, it will get at most 40 Issues - this prop controls this behaviour
+33
View File
@@ -0,0 +1,33 @@
## API Report File for "@backstage/plugin-github-issues"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteRef } from '@backstage/core-plugin-api';
// @public (undocumented)
export const GitHubIssuesCard: (props: GitHubIssuesProps) => JSX.Element;
// @public (undocumented)
export const GitHubIssuesPage: (props: GitHubIssuesProps) => JSX.Element;
// @public (undocumented)
export const gitHubIssuesPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{},
{}
>;
// @public (undocumented)
export type GitHubIssuesProps = {
itemsPerPage?: number;
itemsPerRepo?: number;
};
// (No @packageDocumentation comment for this package)
```
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@backstage/plugin-github-issues",
"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"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"prettier": "@spotify/prettier-config",
"dependencies": {
"@backstage/catalog-model": "^1.0.3",
"@backstage/core-components": "^0.10.1-next.0",
"@backstage/core-plugin-api": "^1.0.5-next.0",
"@backstage/integration": "^1.3.0-next.0",
"@backstage/plugin-catalog-react": "^1.1.3-next.0",
"@backstage/theme": "^0.2.15",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
"luxon": "^2.4.0",
"octokit": "^2.0.4",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.18.1-next.0",
"@backstage/core-app-api": "^1.0.5-next.0",
"@backstage/dev-utils": "^1.0.5-next.0",
"@backstage/test-utils": "^1.1.3-next.0",
"@spotify/prettier-config": "^13.0.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@types/jest": "*",
"@types/node": "*",
"@types/react": "^16.13.1 || ^17.0.0",
"cross-fetch": "^3.1.5",
"msw": "^0.44.0",
"prettier": "^2.7.1"
},
"files": [
"dist"
]
}
@@ -0,0 +1,89 @@
/*
* 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 { Box, IconButton, Typography } from '@material-ui/core';
import { InfoCard, Progress } from '@backstage/core-components';
import RefreshIcon from '@material-ui/icons/Refresh';
import { useEntityGitHubRepositories } from '../../hooks/useEntityGitHubRepositories';
import {
RepoIssues,
useGetIssuesByRepoFromGitHub,
} from '../../hooks/useGetIssuesByRepoFromGitHub';
import { IssueList } from './IssuesList';
import { NoRepositoriesInfo } from './NoRepositoriesInfo';
/**
* @public
*/
export type GitHubIssuesProps = {
itemsPerPage?: number;
itemsPerRepo?: number;
};
export const GitHubIssues = (props: GitHubIssuesProps) => {
const { itemsPerPage = 10, itemsPerRepo = 40 } = props;
const [isLoading, setIsLoading] = React.useState(true);
const [issuesByRepository, setIssuesByRepository] =
React.useState<Record<string, RepoIssues>>();
const { repositories } = useEntityGitHubRepositories();
const getIssues = useGetIssuesByRepoFromGitHub();
const fetchGitHubIssues = React.useCallback(async () => {
setIsLoading(true);
const issuesByRepo = await getIssues(repositories, itemsPerRepo);
setIssuesByRepository(issuesByRepo);
setIsLoading(false);
}, [itemsPerRepo, getIssues, repositories]);
React.useEffect(() => {
if (repositories.length) {
fetchGitHubIssues();
} else {
setIsLoading(false);
}
}, [repositories.length, fetchGitHubIssues]);
if (!repositories.length) {
return <NoRepositoriesInfo />;
}
return (
<InfoCard
title={
<Box display="flex" justifyContent="flex-start" alignItems="center">
<Typography variant="h5">Open GitHub Issues</Typography>
<IconButton color="secondary" onClick={fetchGitHubIssues}>
<RefreshIcon />
</IconButton>
</Box>
}
>
{isLoading && <Progress />}
<IssueList
issuesByRepository={issuesByRepository}
itemsPerPage={itemsPerPage}
/>
</InfoCard>
);
};
@@ -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 from 'react';
import { Typography, Box, Avatar, makeStyles } from '@material-ui/core';
type AssigneesProps = {
name?: string;
avatar?: string;
};
const useStyles = makeStyles(theme => ({
small: {
width: theme.spacing(4),
height: theme.spacing(4),
marginLeft: theme.spacing(1),
},
noAssignees: {
height: theme.spacing(4),
},
}));
export const Assignees = (props: AssigneesProps) => {
const { name, avatar } = props;
const classes = useStyles();
// todo: many assignees -> NUM assignees + stock images on each other
return name ? (
<Box display="flex" alignItems="center" marginX={1}>
<Typography color="primary" variant="body2" component="p">
{name}
</Typography>
<Avatar alt={name} src={avatar} className={classes.small} />
</Box>
) : (
<Box display="flex" alignItems="center" marginX={1}>
<Typography
color="primary"
variant="body2"
component="p"
className={classes.noAssignees}
>
No assignees
</Typography>
</Box>
);
};
@@ -0,0 +1,40 @@
/*
* 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 { ChatIcon } from '@backstage/core-components';
import { Box, Badge } from '@material-ui/core';
type CommentsCountProps = {
commentsCount: number;
};
export const CommentsCount = (props: CommentsCountProps) => {
const { commentsCount } = props;
return (
<Box
marginBottom={1}
style={{ marginRight: '12px' }}
display="flex"
justifyContent="flex-start"
alignSelf="flex-end"
>
<Badge badgeContent={commentsCount} color="primary">
<ChatIcon />
</Badge>
</Box>
);
};
@@ -0,0 +1,102 @@
/*
* 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 { DateTime } from 'luxon';
import {
Box,
Paper,
Typography,
CardActionArea,
Link,
} from '@material-ui/core';
import { Assignees } from './Assignees';
import { CommentsCount } from './CommentsCount';
import Divider from '@material-ui/core/Divider';
type IssueCardProps = {
title: string;
createdAt: string;
updatedAt?: string;
url: string;
authorName: string;
assigneeName?: string;
assigneeAvatar?: string;
authorAvatar?: string;
repositoryName: string;
commentsCount: number;
even: boolean;
};
const getElapsedTime = (isoDate: string) =>
DateTime.fromISO(isoDate).toRelative();
export const IssueCard = (props: IssueCardProps) => {
const {
title,
createdAt,
updatedAt,
url,
assigneeName,
assigneeAvatar,
authorName,
repositoryName,
commentsCount,
} = props;
return (
<Box marginBottom={1}>
<Paper variant="outlined">
<CardActionArea href={url} target="_blank">
<Box padding={1}>
<Box display="flex" justifyContent="space-between">
<Link
href={`https://github.com/${repositoryName}/issues`}
target="_blank"
>
{repositoryName}
</Link>
<Assignees name={assigneeName} avatar={assigneeAvatar} />
</Box>
<Box>
<Typography component="h2">
<b>{title}</b>
</Typography>
</Box>
<Divider variant="middle" />
<Box display="flex" justifyContent="space-between">
<Box marginY={1}>
<Typography variant="body2" component="p">
Created at: <strong>{getElapsedTime(createdAt)}</strong> by{' '}
<strong>{authorName}</strong>
</Typography>
{updatedAt && (
<Typography variant="body2" component="p">
Last update at: <strong>{getElapsedTime(updatedAt)}</strong>
</Typography>
)}
</Box>
{commentsCount > 0 && (
<CommentsCount commentsCount={commentsCount} />
)}
</Box>
</Box>
</CardActionArea>
</Paper>
</Box>
);
};
@@ -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 { IssueCard } from './IssueCard';
@@ -0,0 +1,67 @@
/*
* 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 { Select, SelectedItems, SelectItem } from '@backstage/core-components';
import { makeStyles, Box, Typography } from '@material-ui/core';
type RepositoryFiltersProps = {
items: Array<SelectItem>;
totalIssuesInGitHub: number;
placeholder: string;
onChange: (active: Array<string>) => void;
};
const useStyles = makeStyles(theme => ({
filters: {
margin: theme.spacing(0, 0, 2, 0),
'& > div': {
maxWidth: '800px',
'& > div': {
maxWidth: '800px',
},
},
},
}));
const checkSelectedItems: (
onChange: (active: Array<string>) => void,
) => (active: SelectedItems) => void = onChange => active => {
return onChange(active as Array<string>);
};
export const RepositoryFilters = ({
items,
onChange,
placeholder,
}: RepositoryFiltersProps) => {
const css = useStyles();
return (
<Box className={css.filters}>
<Select
placeholder={placeholder}
label=""
items={items}
multiple
onChange={checkSelectedItems(onChange)}
/>
<Typography variant="caption">
*Repositories with more Issues on GitHub than available to view in
Backstage. To view them go to GitHub.
</Typography>
</Box>
);
};
@@ -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 * from './Filters';
@@ -0,0 +1,167 @@
/*
* 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 { Box } from '@material-ui/core';
import { Pagination } from '@material-ui/lab';
import { IssueCard } from '../IssueCard';
import { RepoIssues } from '../../../hooks/useGetIssuesByRepoFromGitHub';
import { RepositoryFilters } from './Filters';
export type PluginMode = 'page' | 'card';
export type IssueListProps = {
itemsPerPage?: number;
issuesByRepository?: Record<string, RepoIssues>;
};
const getIssuesCountForFilterLabel = (
totalIssues: number,
issuesAvailable: number,
) =>
`(${totalIssues} ${totalIssues === 1 ? 'Issue' : `Issues`})${
issuesAvailable < totalIssues ? '*' : ''
}`;
export const IssueList = ({
itemsPerPage = 10,
issuesByRepository,
}: IssueListProps) => {
const [currentPage, setCurrentPage] = React.useState(1);
const [activeFilter, setActiveFilter] = React.useState<Array<string>>([]);
const filters = React.useMemo(
() =>
issuesByRepository
? Object.keys(issuesByRepository)
.filter(repo => issuesByRepository[repo].issues.totalCount > 0)
.map(repo => ({
label: `${repo} ${getIssuesCountForFilterLabel(
issuesByRepository[repo].issues.totalCount,
issuesByRepository[repo].issues.edges.length,
)}`,
value: repo,
}))
: [],
[issuesByRepository],
);
const totalIssuesInGitHub = React.useMemo(
() =>
issuesByRepository
? Object.values(issuesByRepository).reduce(
(acc, { issues: { totalCount } }) => acc + totalCount,
0,
)
: 0,
[issuesByRepository],
);
const filteredRepos = React.useMemo(
() =>
issuesByRepository && activeFilter.length
? activeFilter.reduce(
(acc, val) => ({
[val]: issuesByRepository[val],
...acc,
}),
{},
)
: issuesByRepository,
[issuesByRepository, activeFilter],
);
const issues = React.useMemo(
() =>
filteredRepos
? Object.values(filteredRepos)
.map(({ issues: { edges } }) => edges)
.flat()
.sort((a, b) => {
if (a.node.updatedAt > b.node.updatedAt) {
return -1;
} else if (b.node.updatedAt > a.node.updatedAt) {
return 1;
}
return 0;
})
: [],
[filteredRepos],
);
const displayIssues = issues.slice(
(currentPage - 1) * itemsPerPage,
(currentPage - 1) * itemsPerPage + itemsPerPage,
);
return (
<Box>
{issues.length > 0 && (
<RepositoryFilters
placeholder={`All repositories ${getIssuesCountForFilterLabel(
totalIssuesInGitHub,
issues.length,
)}`}
items={filters}
onChange={setActiveFilter}
totalIssuesInGitHub={totalIssuesInGitHub}
/>
)}
{displayIssues.length > 0 ? (
displayIssues.map(
(
{
node: {
title,
comments,
author,
createdAt,
updatedAt,
repository,
assignees,
url,
},
},
index,
) => (
<IssueCard
even={Boolean(index % 2)}
title={title}
createdAt={createdAt}
assigneeAvatar={assignees.edges[0]?.node.avatarUrl}
assigneeName={assignees.edges[0]?.node.login}
authorName={author.login}
updatedAt={updatedAt}
repositoryName={repository.nameWithOwner}
url={url}
commentsCount={comments.totalCount}
/>
),
)
) : (
<h1>Hurray! No Issues 🚀</h1>
)}
{issues.length / itemsPerPage > 1 ? (
<Pagination
count={Math.ceil(issues.length / itemsPerPage)}
onChange={(_, page) => setCurrentPage(page)}
/>
) : null}
</Box>
);
};
@@ -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 * from './IssuesList';
@@ -0,0 +1,27 @@
/*
* 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 { EmptyState } from '@backstage/core-components';
export const NoRepositoriesInfo = () => {
return (
<EmptyState
title="There are no GitHub repositories connected to this entity."
missing="data"
/>
);
};
@@ -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 * from './NoRepositoriesInfo';
@@ -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 * from './GitHubIssues';
@@ -0,0 +1,69 @@
/*
* 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 { useApi } from '@backstage/core-plugin-api';
import {
catalogApiRef,
humanizeEntityRef,
useEntity,
} from '@backstage/plugin-catalog-react';
import { useCallback, useEffect, useState } from 'react';
const GITHUB_PROJECT_SLUG_ANNOTATION = 'github.com/project-slug';
export const getProjectNameFromEntity = (entity: Entity): string => {
return entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION] ?? '';
};
export function useEntityGitHubRepositories() {
const { entity } = useEntity();
const catalogApi = useApi(catalogApiRef);
const [repositories, setRepositories] = useState<string[]>([]);
const getRepositoriesNames = useCallback(async () => {
if (entity.kind === 'Component' || entity.kind === 'API') {
const entityName = getProjectNameFromEntity(entity);
if (entityName) {
setRepositories([entityName]);
}
return;
}
const entitiesList = await catalogApi.getEntities({
filter: {
kind: ['Component', 'API'],
'spec.owner': humanizeEntityRef(entity, { defaultKind: 'group' }),
},
});
const entitiesNames: string[] = entitiesList.items.map(componentEntity =>
getProjectNameFromEntity(componentEntity),
);
setRepositories([...new Set(entitiesNames)].filter(name => name.length));
}, [catalogApi, entity]);
useEffect(() => {
getRepositoriesNames();
}, [getRepositoriesNames]);
return {
repositories,
};
}
@@ -0,0 +1,100 @@
/*
* 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.
*/
const mockGraphQLQuery = jest.fn(() => ({}));
jest.mock('./useOctokitGraphQL', () => ({
useOctokitGraphQL: jest.fn(() => mockGraphQLQuery),
}));
import React from 'react';
import { render } from '@testing-library/react';
import { useGetIssuesByRepoFromGitHub } from './useGetIssuesByRepoFromGitHub';
describe('useGetIssuesBeRepoFromGitHub', () => {
it('should call GitHub API with correct query with fragment for each repo', async () => {
const Helper = () => {
const getIssues = useGetIssuesByRepoFromGitHub();
getIssues(['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'], 10);
return <div />;
};
render(<Helper />);
expect(mockGraphQLQuery).toHaveBeenCalledTimes(1);
expect(mockGraphQLQuery).toHaveBeenCalledWith(
'\n' +
' \n' +
' fragment issues on Repository {\n' +
' issues(\n' +
' states: OPEN\n' +
' first: 10\n' +
' orderBy: { field: UPDATED_AT, direction: DESC }\n' +
' ) {\n' +
' totalCount\n' +
' edges {\n' +
' node {\n' +
' assignees(first: 10) {\n' +
' edges {\n' +
' node {\n' +
' avatarUrl\n' +
' login\n' +
' }\n' +
' }\n' +
' }\n' +
' author {\n' +
' login\n' +
' avatarUrl\n' +
' url\n' +
' }\n' +
' repository {\n' +
' nameWithOwner\n' +
' }\n' +
' title\n' +
' url\n' +
' participants {\n' +
' totalCount\n' +
' }\n' +
' updatedAt\n' +
' createdAt\n' +
' comments(last: 1) {\n' +
' totalCount\n' +
' }\n' +
' }\n' +
' }\n' +
' }\n' +
' }\n' +
' \n' +
'\n' +
' query {\n' +
' \n' +
' yoyo: repository(name: "yo-yo", owner: "mrwolny") {\n' +
' ...issues\n' +
' }\n' +
' ,\n' +
' yoyox: repository(name: "yoyo", owner: "mrwolny") {\n' +
' ...issues\n' +
' }\n' +
' ,\n' +
' yoyoxx: repository(name: "yo.yo", owner: "mrwolny") {\n' +
' ...issues\n' +
' }\n' +
' \n' +
' } \n' +
' ',
);
});
});
@@ -0,0 +1,173 @@
/*
* 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 { useOctokitGraphQL } from './useOctokitGraphQL';
type Assignee = {
avatarUrl: string;
login: string;
};
export type EdgesWithNodes<T> = {
edges: Array<{
node: T;
}>;
};
export type Node<T> = {
node: T;
};
type IssueAuthor = {
login: string;
};
export type Issue = {
assignees: EdgesWithNodes<Assignee>;
author: IssueAuthor;
repository: {
nameWithOwner: string;
};
title: string;
url: string;
participants: {
totalCount: number;
};
createdAt: string;
updatedAt: string;
comments: {
totalCount: number;
};
};
export type RepoIssues = {
issues: {
totalCount: number;
} & EdgesWithNodes<Issue>;
};
export type RepoIssuesQueryResults = Record<string, RepoIssues>;
const createQuery = (
repositories: Array<{
safeName: string;
name: string;
owner: string;
}>,
itemsPerRepo: number,
): string => {
const fragment = `
fragment issues on Repository {
issues(
states: OPEN
first: ${itemsPerRepo}
orderBy: { field: UPDATED_AT, direction: DESC }
) {
totalCount
edges {
node {
assignees(first: 10) {
edges {
node {
avatarUrl
login
}
}
}
author {
login
avatarUrl
url
}
repository {
nameWithOwner
}
title
url
participants {
totalCount
}
updatedAt
createdAt
comments(last: 1) {
totalCount
}
}
}
}
}
`;
const query = `
${fragment}
query {
${repositories.map(
({ safeName, name, owner }) => `
${safeName}: repository(name: "${name}", owner: "${owner}") {
...issues
}
`,
)}
}
`;
return query;
};
export const useGetIssuesByRepoFromGitHub = () => {
const graphql = useOctokitGraphQL<RepoIssuesQueryResults>();
const fn = React.useRef(
async (
repos: Array<string>,
itemsPerRepo: number,
): Promise<Record<string, RepoIssues>> => {
const safeNames: Array<string> = [];
const repositories = repos.map(repo => {
const [owner, name] = repo.split('/');
const safeNameRegex = /-|\./gi;
let safeName = name.replace(safeNameRegex, '');
while (safeNames.includes(safeName)) {
safeName += 'x';
}
safeNames.push(safeName);
return {
safeName,
name,
owner,
};
});
const issuesByRepo: RepoIssuesQueryResults = await graphql(
createQuery(repositories, itemsPerRepo),
);
return repositories.reduce((acc, { safeName, name, owner }) => {
acc[`${owner}/${name}`] = issuesByRepo[safeName];
return acc;
}, {} as Record<string, RepoIssues>);
},
);
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';
import {
useApi,
githubAuthApiRef,
configApiRef,
} from '@backstage/core-plugin-api';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
let octokit: Octokit;
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);
});
};
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 {
gitHubIssuesPlugin,
GitHubIssuesPage,
GitHubIssuesCard,
} from './plugin';
export type { GitHubIssuesProps } from './components/GitHubIssues';
+22
View File
@@ -0,0 +1,22 @@
/*
* 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 { gitHubIssuesPlugin } from './plugin';
describe('github-issues', () => {
it('should export plugin', () => {
expect(gitHubIssuesPlugin).toBeDefined();
});
});
+50
View File
@@ -0,0 +1,50 @@
/*
* 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';
/** @public */
export const gitHubIssuesPlugin = createPlugin({
id: 'github-issues',
routes: {
root: rootRouteRef,
},
});
/** @public */
export const GitHubIssuesCard = gitHubIssuesPlugin.provide(
createComponentExtension({
name: 'GitHubIssuesCard',
component: {
lazy: () => import('./components/GitHubIssues').then(m => m.GitHubIssues),
},
}),
);
/** @public */
export const GitHubIssuesPage = gitHubIssuesPlugin.provide(
createRoutableExtension({
name: 'GitHubIssuesPage',
component: () =>
import('./components/GitHubIssues').then(m => m.GitHubIssues),
mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -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-issues',
});
+17
View File
@@ -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';
+14 -4
View File
@@ -4859,7 +4859,7 @@
react-beautiful-dnd "^13.0.0"
react-double-scrollbar "0.0.15"
"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2", "@material-ui/core@^4.9.10", "@material-ui/core@^4.9.13":
"@material-ui/core@^4.11.0", "@material-ui/core@^4.11.3", "@material-ui/core@^4.12.1", "@material-ui/core@^4.12.2", "@material-ui/core@^4.12.4", "@material-ui/core@^4.9.10", "@material-ui/core@^4.9.13":
version "4.12.4"
resolved "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz#4ac17488e8fcaf55eb6a7f5efb2a131e10138a73"
integrity sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==
@@ -4906,7 +4906,7 @@
prop-types "^15.7.2"
react-is "^16.8.0 || ^17.0.0"
"@material-ui/lab@^4.0.0-alpha.57", "@material-ui/lab@^4.0.0-alpha.60":
"@material-ui/lab@^4.0.0-alpha.57", "@material-ui/lab@^4.0.0-alpha.60", "@material-ui/lab@^4.0.0-alpha.61":
version "4.0.0-alpha.61"
resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz#9bf8eb389c0c26c15e40933cc114d4ad85e3d978"
integrity sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==
@@ -6270,6 +6270,11 @@
resolved "https://registry.npmjs.org/@spotify/eslint-config-typescript/-/eslint-config-typescript-14.0.1.tgz#850a435b0defebbb3de591d83e3fc369bbc51753"
integrity sha512-y/8on49Wtg3HvKd9A32Q7iJaOgngcSJR8hjGx/POFhJFcPRcZuTJSlhd31CF8fg78bMuzdvYH8au7FhNKjnEVw==
"@spotify/prettier-config@^13.0.1":
version "13.0.1"
resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-13.0.1.tgz#0fdceb3d4ab543259ce6adc0ec1d10e34898b812"
integrity sha512-oVd4hjx2+y0MeUdk1l+ItwVLwlrDlvTlGwXBWMMzPYc7DLyxuxFvDfoHGkAQkrikfAgtdnzxrW6u9a8ywUqdfw==
"@spotify/prettier-config@^14.0.0":
version "14.0.1"
resolved "https://registry.npmjs.org/@spotify/prettier-config/-/prettier-config-14.0.1.tgz#a3a2342ec07693647ab76a64a623fbb79d464fa7"
@@ -18266,6 +18271,11 @@ luxon@^1.23.x:
resolved "https://registry.npmjs.org/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf"
integrity sha512-TfTiyvZhwBYM/7QdAVDh+7dBTBA29v4ik0Ce9zda3Mnf8on1S5KJI8P2jKFZ8+5C0jhmr0KwJEO/Wdpm0VeWJQ==
luxon@^2.4.0:
version "2.5.0"
resolved "https://registry.npmjs.org/luxon/-/luxon-2.5.0.tgz#098090f67d690b247e83c090267a60b1aa8ea96c"
integrity sha512-IDkEPB80Rb6gCAU+FEib0t4FeJ4uVOuX1CQ9GsvU3O+JAGIgu0J7sf1OarXKaKDygTZIoJyU6YdZzTFRu+YR0A==
luxon@^3.0.0:
version "3.0.1"
resolved "https://registry.npmjs.org/luxon/-/luxon-3.0.1.tgz#6901111d10ad06fd267ad4e4128a84bef8a77299"
@@ -20166,7 +20176,7 @@ octokit-plugin-create-pull-request@^3.10.0:
dependencies:
"@octokit/types" "^6.8.2"
octokit@^2.0.0:
octokit@^2.0.0, octokit@^2.0.4:
version "2.0.4"
resolved "https://registry.npmjs.org/octokit/-/octokit-2.0.4.tgz#cfd3adee6b775d3fa8cd8746590bed36127cc0a0"
integrity sha512-9QvgYGzrSTGmr3koSGtbgeMgqYI20QI0Vv8Bk9y6phchk6L2aHFhcrUOIeNUPj1Z+KZnEBd6A/8faNpDFNfVjg==
@@ -21611,7 +21621,7 @@ prettier@^1.16.4, prettier@^1.19.1:
resolved "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
prettier@^2.2.1:
prettier@^2.2.1, prettier@^2.7.1:
version "2.7.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz#e235806850d057f97bb08368a4f7d899f7760c64"
integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==