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:
@@ -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
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user