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