feat: github issues plugin mvp

Signed-off-by: Kamil Wolny <mrwolny@gmail.com>
This commit is contained in:
Kamil Wolny
2022-07-28 14:35:54 +01:00
parent c7dd03bf96
commit c9c6bf3932
35 changed files with 1770 additions and 20 deletions
@@ -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 React from 'react';
import { GitHubIssues } from './GitHubIssues';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import {
setupRequestMockHandlers,
renderInTestApp,
} from '@backstage/test-utils';
// test for repo duplicates
describe('GitHubIssues', () => {
const server = setupServer();
// Enable sane handlers for network requests
setupRequestMockHandlers(server);
// setup mock response
beforeEach(() => {
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
});
it('should render', async () => {
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<GitHubIssues mode="card" />
</ThemeProvider>,
);
expect(rendered.getByText('Welcome to github-issues!')).toBeInTheDocument();
});
});
@@ -0,0 +1,150 @@
/*
* 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';
export type PluginMode = 'page' | 'card';
export type Props = {
mode: PluginMode;
itemsPerPage?: number;
itemsPerRepo?: number;
};
export const GitHubIssues = ({
itemsPerPage = 10,
itemsPerRepo = 40,
}: Props) => {
const [isLoading, setIsLoading] = React.useState(true);
const [activeFilter, setActiveFilter] = React.useState<Array<string>>([]);
const [issuesByRepository, setIssuesByRepository] =
React.useState<Record<string, RepoIssues>>();
const { repositories } = useEntityGitHubRepositories();
const getIssues = useGetIssuesByRepoFromGitHub();
const filters = React.useMemo(
() =>
issuesByRepository
? Object.keys(issuesByRepository)
.filter(repo => issuesByRepository[repo].issues.totalCount > 0)
.map(repo => ({
label: `${repo} (${issuesByRepository[repo].issues.totalCount})`,
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 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
itemsPerPage={itemsPerPage}
issues={issues}
totalIssuesInGitHub={totalIssuesInGitHub}
setActiveFilter={setActiveFilter}
filters={filters}
/>
</InfoCard>
);
};
@@ -0,0 +1,61 @@
/*
* 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),
},
noAssignees: {
height: theme.spacing(4),
},
}));
export const Assignees: FunctionComponent<Props> = (props: Props) => {
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>
);
};
export default Assignees;
@@ -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, { FunctionComponent } from 'react';
import { ChatIcon } from '@backstage/core-components';
import { Box, Badge } from '@material-ui/core';
type Props = {
commentsCount: number;
};
export const CommentsCount: FunctionComponent<Props> = (props: Props) => {
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,93 @@
/*
* 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 { DateTime } from 'luxon';
import { Box, Paper, Typography, CardActionArea } from '@material-ui/core';
import Assignees from './Assignees';
import { CommentsCount } from './CommentsCount';
import Divider from '@material-ui/core/Divider';
type Props = {
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: FunctionComponent<Props> = (props: Props) => {
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">
<Typography color="primary" variant="body2" component="h2">
{repositoryName}
</Typography>
<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,64 @@
/*
* 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 } from '@backstage/core-components';
import { SelectedItems } from '@backstage/core-components';
import { makeStyles, Box } from '@material-ui/core';
export type FilterItem = {
label: string;
value: string;
};
type Props = {
items: Array<FilterItem>;
totalIssuesInGitHub: number;
onChange: (active: Array<string>) => void;
};
const useStyles = makeStyles(theme => ({
filters: {
margin: theme.spacing(0, 0, 2, 0),
'& > div': {
width: '600px',
'& > div': {
maxWidth: '600px',
},
},
},
}));
const checkSelectedItems: (
onChange: (active: Array<string>) => void,
) => (active: SelectedItems) => void = onChange => active => {
return onChange(active as Array<string>);
};
export const Filters = ({ items, totalIssuesInGitHub, onChange }: Props) => {
const css = useStyles();
return (
<Box className={css.filters}>
<Select
placeholder={`All repositories (${totalIssuesInGitHub})`}
label=""
items={items}
multiple
onChange={checkSelectedItems(onChange)}
/>
</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,101 @@
/*
* 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 { Issue } from '../../../hooks/useGetIssuesByRepoFromGitHub';
import { Filters, FilterItem } from './Filters';
export type PluginMode = 'page' | 'card';
export type Props = {
itemsPerPage?: number;
issues: Array<{
node: Issue;
}>;
filters: Array<FilterItem>;
totalIssuesInGitHub: number;
setActiveFilter: (active: Array<string>) => void;
};
export const IssueList = ({
itemsPerPage = 10,
issues,
filters,
setActiveFilter,
totalIssuesInGitHub,
}: Props) => {
const [currentPage, setCurrentPage] = React.useState(1);
const displayIssues = issues.slice(
(currentPage - 1) * itemsPerPage,
(currentPage - 1) * itemsPerPage + itemsPerPage,
);
return (
<Box>
<Filters
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>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,24 @@
/*
* 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 { GitHubIssues, Props as GitHubIssuesProps } from '../GitHubIssues';
type Props = Omit<GitHubIssuesProps, 'mode'>;
export const GitHubIssuesCard: FunctionComponent<Props> = props => {
return <GitHubIssues mode="card" {...props} />;
};
@@ -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 './GitHubIssuesCard';
@@ -0,0 +1,23 @@
/*
* 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 { GitHubIssues, Props as GitHubIssuesProps } from '../GitHubIssues';
type Props = Omit<GitHubIssuesProps, 'mode'>;
export const GitHubIssuesPage: FunctionComponent<Props> = props => {
return <GitHubIssues mode="page" {...props} />;
};
@@ -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 './GitHubIssuesPage';
@@ -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,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.
*/
const mockGraphQLQuery = jest.fn(() => 'ti ri ri ri ra!');
jest.mock('./useOctokitGraphQL', () => ({
useOctokitGraphQL: jest.fn(() => mockGraphQLQuery),
}));
import React from 'react';
import { render } from '@testing-library/react';
import { useGetIssuesByRepoFromGitHub } from './useGetIssuesByRepoFromGitHub';
// fragment issues on Repository {
// issues(
// states: OPEN
// first: 40
// orderBy: { field: UPDATED_AT, direction: DESC }
// ) {
// totalCount
// edges {
// cursor
// node {
// assignees(first: 10) {
// edges {
// node {
// avatarUrl
// login
// }
// }
// }
// author {
// login
// avatarUrl
// url
// }
// repository {
// nameWithOwner
// }
// body
// title
// url
// participants {
// totalCount
// }
// updatedAt
// createdAt
// comments(last: 1) {
// totalCount
// }
// }
// }
// }
// }
// query {
// rateLimit {
// cost
// remaining
// }
// yomovie: repository(name: "yo-movie", owner: "mrwolny") {
// ...issues
// }
// ,
// yoanchor: repository(name: "yo-anchor", owner: "mrwolny") {
// ...issues
// }
// ,
// yomoviex: repository(name: "yo-movie", owner: "mrwolny") {
// ...issues
// }
// ,
// yoanchorx: repository(name: "yo-anchor", owner: "mrwolny") {
// ...issues
// }
// }
describe('useGetIssuesBeRepoFromGitHub', () => {
it('should rock!', async () => {
const Helper = () => {
const getIssues = useGetIssuesByRepoFromGitHub();
getIssues(['mrwolny/yo-yo', 'mrwolny/yoyo'], 10);
return <></>;
};
render(<Helper />);
expect(mockGraphQLQuery).toHaveBeenCalled();
});
});
@@ -0,0 +1,177 @@
/*
* 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 = {
cursor: string;
assignees: EdgesWithNodes<Assignee>;
author: IssueAuthor;
repository: {
nameWithOwner: string;
};
body: 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 {
cursor
node {
assignees(first: 10) {
edges {
node {
avatarUrl
login
}
}
}
author {
login
avatarUrl
url
}
repository {
nameWithOwner
}
body
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,21 @@
/*
* 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 { Issue } from './useGetIssuesByRepoFromGitHub';
export type GitHubIssues = {
getIssues: (repo?: string) => Array<Issue>;
getIssuesTotalCountByRepo: () => Record<string, { totalCount: number }>;
};
@@ -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: 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);
});
};
+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.
*/
export {
gitHubIssuesPlugin,
GitHubIssuesPage,
GitHubIssuesCard,
} from './plugin';
+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();
});
});
+48
View File
@@ -0,0 +1,48 @@
/*
* 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';
export const gitHubIssuesPlugin = createPlugin({
id: 'github-issues',
routes: {
root: rootRouteRef,
},
});
export const GitHubIssuesCard = gitHubIssuesPlugin.provide(
createComponentExtension({
name: 'GitHubIssuesCard',
component: {
lazy: () =>
import('./components/GitHubIssuesCard').then(m => m.GitHubIssuesCard),
},
}),
);
export const GitHubIssuesPage = gitHubIssuesPlugin.provide(
createRoutableExtension({
name: 'GitHubIssuesPage',
component: () =>
import('./components/GitHubIssuesPage').then(m => m.GitHubIssuesPage),
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';