feat: github graphql communicationg moved to new github issues api + fixes the issue with no data rendered on partial failure from api

Signed-off-by: Kamil Wolny <mrwolny@gmail.com>
This commit is contained in:
Kamil Wolny
2022-08-09 23:34:21 +01:00
parent a2ee1d73d1
commit e1c5cd7dbe
4 changed files with 540 additions and 0 deletions
@@ -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,207 @@
/*
* 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';
type Assignee = {
avatarUrl: string;
login: string;
};
type EdgesWithNodes<T> = {
edges: Array<{
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 IssuesByRepo = Record<string, RepoIssues>;
export type GitHubIssuesApi = ReturnType<typeof gitHubIssuesApi>;
export const gitHubIssuesApiRef = createApiRef<GitHubIssuesApi>({
id: 'plugin.githubissues.service',
});
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';
+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,
},