Merge pull request #13059 from getndazn/feat/github-issues-api

feat: (GitHub Issues Plugin) Dedicated plugin API to communicate with GitHub + correctly handle partial failures from GitHub API
This commit is contained in:
Johan Haals
2022-08-16 11:15:08 +02:00
committed by GitHub
16 changed files with 2031 additions and 333 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-github-issues': patch
---
Moved communication with GitHub graphql API to the dedicated plugin API.
Fixes issue when no GitHub Issues are rendered when partial failure is returned from GitHub API.
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
/*
* Copyright 2021 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 { createDevApp } from '@backstage/dev-utils';
import {
CatalogApi,
catalogApiRef,
EntityProvider,
} from '@backstage/plugin-catalog-react';
import { gitHubIssuesPlugin, GitHubIssuesPage } from '../src';
import { GitHubIssuesApi, gitHubIssuesApiRef } from '../src/api';
import testData from './__fixtures__/component-issues-data.json';
createDevApp()
.registerPlugin(gitHubIssuesPlugin)
.registerApi({
api: gitHubIssuesApiRef,
deps: {},
factory: () =>
({
fetchIssuesByRepoFromGitHub: async () => testData,
} as GitHubIssuesApi),
})
.registerApi({
api: catalogApiRef,
deps: {},
factory: () =>
({
getEntities: () => ({}),
} as CatalogApi),
})
.addPage({
title: 'Component Issues',
element: (
<EntityProvider
entity={{
metadata: {
annotations: {
'github.com/project-slug': 'backstage/backstage',
},
name: 'backstage',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
}}
>
<GitHubIssuesPage />
</EntityProvider>
),
})
.render();
+3 -1
View File
@@ -25,6 +25,7 @@
"@backstage/catalog-model": "^1.0.3",
"@backstage/core-components": "^0.11.0-next.2",
"@backstage/core-plugin-api": "^1.0.5-next.0",
"@backstage/errors": "^1.1.0",
"@backstage/integration": "^1.3.0-next.0",
"@backstage/plugin-catalog-react": "^1.1.3-next.2",
"@backstage/theme": "^0.2.15",
@@ -33,7 +34,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"luxon": "^2.4.0",
"octokit": "^2.0.4",
"react-use": "^17.2.4"
"react-use": "^17.4.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
@@ -43,6 +44,7 @@
"@backstage/core-app-api": "^1.0.5-next.0",
"@backstage/dev-utils": "^1.0.5-next.1",
"@backstage/test-utils": "^1.1.3-next.0",
"@spotify/prettier-config": "^14.0.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
@@ -0,0 +1,300 @@
/*
* 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('octokit', () => ({
Octokit: jest.fn(() => ({ graphql: mockGraphQLQuery })),
}));
import { ConfigApi, ErrorApi } from '@backstage/core-plugin-api';
import { ForwardedError } from '@backstage/errors';
import { gitHubIssuesApi } from './gitHubIssuesApi';
describe('gitHubIssuesApi', () => {
describe('fetchIssuesByRepoFromGitHub', () => {
it('should call GitHub API with correct query with fragment for each repo', async () => {
const api = gitHubIssuesApi(
{ getAccessToken: jest.fn() },
{
getOptionalConfigArray: jest.fn(),
} as unknown as ConfigApi,
{ post: jest.fn() } as unknown as ErrorApi,
);
await api.fetchIssuesByRepoFromGitHub(
['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'],
10,
);
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' +
' }\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' +
' ',
);
});
});
it('should return data for repos with successfully retrieved issues when GitHub returns partial failure', async () => {
mockGraphQLQuery.mockImplementationOnce(() =>
Promise.reject({
data: {
yoyo: {
issues: {
totalCount: 1,
edges: [
{
node: {
assignees: {
edges: [],
},
author: {
login: 'mrwolny',
},
repository: {
nameWithOwner: 'mrwolny/yo-yo',
},
title: "It's the ISSUE!",
url: 'https://github.com/mrwolny/yo-yo/issues/1',
participants: {
totalCount: 1,
},
updatedAt: '2022-07-04T18:47:33Z',
createdAt: '2022-06-23T18:14:26Z',
comments: {
totalCount: 4,
},
},
},
],
},
},
notfound: null,
},
errors: [
{
type: 'NOT_FOUND',
path: ['notfound'],
locations: [
{
line: 48,
column: 9,
},
],
message:
"Could not resolve to a Repository with the name 'notfound/notfound'.",
},
],
}),
);
const api = gitHubIssuesApi(
{ getAccessToken: jest.fn() },
{
getOptionalConfigArray: jest.fn(),
} as unknown as ConfigApi,
{ post: jest.fn() } as unknown as ErrorApi,
);
const data = await api.fetchIssuesByRepoFromGitHub(
['mrwolny/yo-yo', 'mrwolny/notfound'],
10,
);
expect(data).toEqual({
'mrwolny/yo-yo': {
issues: {
totalCount: 1,
edges: [
{
node: {
assignees: {
edges: [],
},
author: {
login: 'mrwolny',
},
repository: {
nameWithOwner: 'mrwolny/yo-yo',
},
title: "It's the ISSUE!",
url: 'https://github.com/mrwolny/yo-yo/issues/1',
participants: {
totalCount: 1,
},
updatedAt: '2022-07-04T18:47:33Z',
createdAt: '2022-06-23T18:14:26Z',
comments: {
totalCount: 4,
},
},
},
],
},
},
});
});
it('should return empty object when GitHub returns failure with no data', async () => {
mockGraphQLQuery.mockImplementationOnce(() =>
Promise.reject({
data: {
notfound: null,
},
errors: [
{
type: 'NOT_FOUND',
path: ['notfound'],
locations: [
{
line: 48,
column: 9,
},
],
message:
"Could not resolve to a Repository with the name 'notfound/notfound'.",
},
],
}),
);
const api = gitHubIssuesApi(
{ getAccessToken: jest.fn() },
{
getOptionalConfigArray: jest.fn(),
} as unknown as ConfigApi,
{ post: jest.fn() } as unknown as ErrorApi,
);
const data = await api.fetchIssuesByRepoFromGitHub(
['mrwolny/notfound'],
10,
);
expect(data).toEqual({});
});
it('should post error to the backstage error API when GitHub returns failure', async () => {
mockGraphQLQuery.mockImplementationOnce(() =>
Promise.reject({
data: {
notfound: null,
},
errors: [
{
type: 'NOT_FOUND',
path: ['notfound'],
locations: [
{
line: 48,
column: 9,
},
],
message:
"Could not resolve to a Repository with the name 'notfound/notfound'.",
},
],
}),
);
const mockErrorApi = { post: jest.fn() };
const api = gitHubIssuesApi(
{ getAccessToken: jest.fn() },
{
getOptionalConfigArray: jest.fn(),
} as unknown as ConfigApi,
mockErrorApi as unknown as ErrorApi,
);
await api.fetchIssuesByRepoFromGitHub(['mrwolny/notfound'], 10);
expect(mockErrorApi.post).toHaveBeenCalledTimes(1);
expect(mockErrorApi.post).toHaveBeenCalledWith(
new ForwardedError('GitHub Issues Plugin failure', {
data: {
notfound: null,
},
errors: [
{
type: 'NOT_FOUND',
path: ['notfound'],
locations: [
{
line: 48,
column: 9,
},
],
message:
"Could not resolve to a Repository with the name 'notfound/notfound'.",
},
],
}),
);
});
});
@@ -0,0 +1,217 @@
/*
* 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 {
createApiRef,
ConfigApi,
ErrorApi,
OAuthApi,
} from '@backstage/core-plugin-api';
import { readGitHubIntegrationConfigs } from '@backstage/integration';
import { ForwardedError } from '@backstage/errors';
/** @internal */
export type Assignee = {
avatarUrl: string;
login: string;
};
/** @internal */
export type EdgesWithNodes<T> = {
edges: Array<{
node: T;
}>;
};
/** @internal */
export type IssueAuthor = {
login: string;
};
/** @internal */
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;
};
};
/** @internal */
export type RepoIssues = {
issues: {
totalCount: number;
} & EdgesWithNodes<Issue>;
};
/** @internal */
export type IssuesByRepo = Record<string, RepoIssues>;
/** @internal */
export type GitHubIssuesApi = ReturnType<typeof gitHubIssuesApi>;
/** @internal */
export const gitHubIssuesApiRef = createApiRef<GitHubIssuesApi>({
id: 'plugin.githubissues.service',
});
/** @internal */
export const gitHubIssuesApi = (
githubAuthApi: OAuthApi,
configApi: ConfigApi,
errorApi: ErrorApi,
) => {
let octokit: Octokit;
const getOctokit = async () => {
const baseUrl = readGitHubIntegrationConfigs(
configApi.getOptionalConfigArray('integrations.github') ?? [],
)[0].apiBaseUrl;
const token = await githubAuthApi.getAccessToken(['repo']);
if (!octokit) {
octokit = new Octokit({ auth: token, ...(baseUrl && { baseUrl }) });
}
return octokit.graphql;
};
const fetchIssuesByRepoFromGitHub = async (
repos: Array<string>,
itemsPerRepo: number,
): Promise<IssuesByRepo> => {
const graphql = await getOctokit();
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,
};
});
let issuesByRepo: IssuesByRepo = {};
try {
issuesByRepo = await graphql(
createIssueByRepoQuery(repositories, itemsPerRepo),
);
} catch (e) {
if (e.data) {
issuesByRepo = e.data;
}
errorApi.post(new ForwardedError('GitHub Issues Plugin failure', e));
}
return repositories.reduce((acc, { safeName, name, owner }) => {
if (issuesByRepo[safeName]) {
acc[`${owner}/${name}`] = issuesByRepo[safeName];
}
return acc;
}, {} as IssuesByRepo);
};
return { fetchIssuesByRepoFromGitHub };
};
function createIssueByRepoQuery(
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
}
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;
}
+16
View File
@@ -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 './gitHubIssuesApi';
@@ -0,0 +1,161 @@
/*
* 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
EntityProvider,
catalogApiRef,
CatalogApi,
} from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import { GitHubIssuesApi, gitHubIssuesApiRef, Issue } from '../../api';
import { GitHubIssues } from './GitHubIssues';
const getTestIssue = (overwrites: Partial<Issue> = {}): { node: Issue } => ({
node: {
...{
assignees: {
edges: [
{
node: {
avatarUrl:
'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1112.jpg',
login: 'worthless-horse',
},
},
],
},
author: {
login: 'next-dog',
},
repository: {
nameWithOwner: 'backstage/backstage',
},
title: 'quasi labore qui',
url: 'http://flowery-muscatel.net',
participants: {
totalCount: 3,
},
updatedAt: '2022-05-02T09:46:35.885Z',
createdAt: '2022-06-03T07:11:22.320Z',
comments: {
totalCount: 6,
},
},
...overwrites,
},
});
jest
.useFakeTimers()
.setSystemTime(new Date('2020-04-20T08:15:47.614Z').getTime());
const entityComponent = {
metadata: {
annotations: {
'github.com/project-slug': 'backstage/backstage',
},
name: 'backstage',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
} as unknown as Entity;
const mockCatalogApi = {
getEntities: () => ({}),
} as CatalogApi;
describe('GitHubIssues', () => {
it('should render correctly when there are no issues in GitHub', async () => {
const mockApi = {
fetchIssuesByRepoFromGitHub: async () => ({
backstage: {
issues: {
totalCount: 0,
edges: [],
},
},
}),
} as GitHubIssuesApi;
const apis = [
[gitHubIssuesApiRef, mockApi],
[catalogApiRef, mockCatalogApi],
] as const;
const { getByTestId } = await renderInTestApp(
<TestApiProvider apis={apis}>
<EntityProvider entity={entityComponent}>
<GitHubIssues />
</EntityProvider>
</TestApiProvider>,
);
expect(getByTestId('no-issues-msg')).toHaveTextContent(
'Hurray! No Issues 🚀',
);
});
it('should render correctly', async () => {
const testIssue = getTestIssue({
createdAt: '2020-04-19T10:15:47.614Z',
updatedAt: '2020-04-20T00:15:47.614Z',
});
const mockApi = {
fetchIssuesByRepoFromGitHub: async () => ({
backstage: {
issues: {
totalCount: 1,
edges: [testIssue],
},
},
}),
} as GitHubIssuesApi;
const apis = [
[gitHubIssuesApiRef, mockApi],
[catalogApiRef, mockCatalogApi],
] as const;
const { getByText, getByTestId } = await renderInTestApp(
<TestApiProvider apis={apis}>
<EntityProvider entity={entityComponent}>
<GitHubIssues />
</EntityProvider>
</TestApiProvider>,
);
getByText('All repositories (1 Issue)');
expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
testIssue.node.title,
);
expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
testIssue.node.repository.nameWithOwner,
);
expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
`Created at: 22 hours ago by ${testIssue.node.author.login}`,
);
expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
`Last update at: 8 hours ago`,
);
});
});
@@ -20,12 +20,9 @@ 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 { useGetIssuesByRepoFromGitHub } from '../../hooks/useGetIssuesByRepoFromGitHub';
import { IssueList } from './IssuesList';
import { IssuesList } from './IssuesList';
import { NoRepositoriesInfo } from './NoRepositoriesInfo';
/**
@@ -39,29 +36,12 @@ export type GitHubIssuesProps = {
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]);
const {
isLoading,
gitHubIssuesByRepo: issuesByRepository,
retry,
} = useGetIssuesByRepoFromGitHub(repositories, itemsPerRepo);
if (!repositories.length) {
return <NoRepositoriesInfo />;
@@ -72,7 +52,7 @@ export const GitHubIssues = (props: GitHubIssuesProps) => {
title={
<Box display="flex" justifyContent="flex-start" alignItems="center">
<Typography variant="h5">Open GitHub Issues</Typography>
<IconButton color="secondary" onClick={fetchGitHubIssues}>
<IconButton color="secondary" onClick={retry}>
<RefreshIcon />
</IconButton>
</Box>
@@ -80,7 +60,7 @@ export const GitHubIssues = (props: GitHubIssuesProps) => {
>
{isLoading && <Progress />}
<IssueList
<IssuesList
issuesByRepository={issuesByRepository}
itemsPerPage={itemsPerPage}
/>
@@ -59,7 +59,7 @@ export const IssueCard = (props: IssueCardProps) => {
} = props;
return (
<Box marginBottom={1}>
<Box marginBottom={1} data-testid={`issue-${url}`}>
<Paper variant="outlined">
<CardActionArea href={url} target="_blank">
<Box padding={1}>
@@ -19,14 +19,14 @@ import { Box } from '@material-ui/core';
import { Pagination } from '@material-ui/lab';
import { IssueCard } from '../IssueCard';
import { RepoIssues } from '../../../hooks/useGetIssuesByRepoFromGitHub';
import { IssuesByRepo } from '../../../api';
import { RepositoryFilters } from './Filters';
export type PluginMode = 'page' | 'card';
export type IssueListProps = {
itemsPerPage?: number;
issuesByRepository?: Record<string, RepoIssues>;
issuesByRepository?: IssuesByRepo;
};
const getIssuesCountForFilterLabel = (
@@ -37,7 +37,7 @@ const getIssuesCountForFilterLabel = (
issuesAvailable < totalIssues ? '*' : ''
}`;
export const IssueList = ({
export const IssuesList = ({
itemsPerPage = 10,
issuesByRepository,
}: IssueListProps) => {
@@ -140,6 +140,7 @@ export const IssueList = ({
index,
) => (
<IssueCard
key={url}
even={Boolean(index % 2)}
title={title}
createdAt={createdAt}
@@ -154,7 +155,7 @@ export const IssueList = ({
),
)
) : (
<h1>Hurray! No Issues 🚀</h1>
<h1 data-testid="no-issues-msg">Hurray! No Issues 🚀</h1>
)}
{issues.length / itemsPerPage > 1 ? (
<Pagination
@@ -1,100 +0,0 @@
/*
* 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' +
' ',
);
});
});
@@ -13,161 +13,31 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useOctokitGraphQL } from './useOctokitGraphQL';
import { useApi } from '@backstage/core-plugin-api';
type Assignee = {
avatarUrl: string;
login: string;
};
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import { gitHubIssuesApiRef } from '../api';
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;
}>,
export const useGetIssuesByRepoFromGitHub = (
repos: Array<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 gitHubIssuesApi = useApi(gitHubIssuesApiRef);
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),
const {
value: issues,
loading: isLoading,
retry,
} = useAsyncRetry(async () => {
if (repos.length > 0) {
return await gitHubIssuesApi.fetchIssuesByRepoFromGitHub(
repos,
itemsPerRepo,
);
}
return repositories.reduce((acc, { safeName, name, owner }) => {
acc[`${owner}/${name}`] = issuesByRepo[safeName];
return {};
}, [repos]);
return acc;
}, {} as Record<string, RepoIssues>);
},
);
return fn.current;
return { isLoading, gitHubIssuesByRepo: issues, retry };
};
@@ -1,47 +0,0 @@
/*
* 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);
});
};
+17
View File
@@ -15,15 +15,32 @@
*/
import {
createPlugin,
createApiFactory,
createComponentExtension,
createRoutableExtension,
configApiRef,
errorApiRef,
githubAuthApiRef,
} from '@backstage/core-plugin-api';
import { gitHubIssuesApi, gitHubIssuesApiRef } from './api';
import { rootRouteRef } from './routes';
/** @public */
export const gitHubIssuesPlugin = createPlugin({
id: 'github-issues',
apis: [
createApiFactory({
api: gitHubIssuesApiRef,
deps: {
configApi: configApiRef,
githubAuthApi: githubAuthApiRef,
errorApi: errorApiRef,
},
factory: ({ configApi, githubAuthApi, errorApi }) =>
gitHubIssuesApi(githubAuthApi, configApi, errorApi),
}),
],
routes: {
root: rootRouteRef,
},
+1 -1
View File
@@ -22401,7 +22401,7 @@ react-universal-interface@^0.6.2:
resolved "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b"
integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==
react-use@^17.2.4, react-use@^17.3.1, react-use@^17.3.2:
react-use@^17.2.4, react-use@^17.3.1, react-use@^17.3.2, react-use@^17.4.0:
version "17.4.0"
resolved "https://registry.npmjs.org/react-use/-/react-use-17.4.0.tgz#cefef258b0a6c534a5c8021c2528ac6e1a4cdc6d"
integrity sha512-TgbNTCA33Wl7xzIJegn1HndB4qTS9u03QUwyNycUnXaweZkE4Kq2SB+Yoxx8qbshkZGYBDvUXbXWRUmQDcZZ/Q==