From e1c5cd7dbe3f67af2012d1ea447197691ff0b3ab Mon Sep 17 00:00:00 2001 From: Kamil Wolny Date: Tue, 9 Aug 2022 23:34:21 +0100 Subject: [PATCH] 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 --- .../src/api/gitHubIssuesApi.test.ts | 300 ++++++++++++++++++ .../github-issues/src/api/gitHubIssuesApi.ts | 207 ++++++++++++ plugins/github-issues/src/api/index.ts | 16 + plugins/github-issues/src/plugin.ts | 17 + 4 files changed, 540 insertions(+) create mode 100644 plugins/github-issues/src/api/gitHubIssuesApi.test.ts create mode 100644 plugins/github-issues/src/api/gitHubIssuesApi.ts create mode 100644 plugins/github-issues/src/api/index.ts diff --git a/plugins/github-issues/src/api/gitHubIssuesApi.test.ts b/plugins/github-issues/src/api/gitHubIssuesApi.test.ts new file mode 100644 index 0000000000..4d1c3ffff3 --- /dev/null +++ b/plugins/github-issues/src/api/gitHubIssuesApi.test.ts @@ -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'.", + }, + ], + }), + ); + }); +}); diff --git a/plugins/github-issues/src/api/gitHubIssuesApi.ts b/plugins/github-issues/src/api/gitHubIssuesApi.ts new file mode 100644 index 0000000000..06fdf3d452 --- /dev/null +++ b/plugins/github-issues/src/api/gitHubIssuesApi.ts @@ -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 = { + edges: Array<{ + node: T; + }>; +}; + +type IssueAuthor = { + login: string; +}; + +export type Issue = { + assignees: EdgesWithNodes; + 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; +}; +export type IssuesByRepo = Record; + +export type GitHubIssuesApi = ReturnType; + +export const gitHubIssuesApiRef = createApiRef({ + 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, + itemsPerRepo: number, + ): Promise => { + const graphql = await getOctokit(); + const safeNames: Array = []; + + 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; +} diff --git a/plugins/github-issues/src/api/index.ts b/plugins/github-issues/src/api/index.ts new file mode 100644 index 0000000000..4cc3372c45 --- /dev/null +++ b/plugins/github-issues/src/api/index.ts @@ -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'; diff --git a/plugins/github-issues/src/plugin.ts b/plugins/github-issues/src/plugin.ts index ece3fd09a2..3d845f4adf 100644 --- a/plugins/github-issues/src/plugin.ts +++ b/plugins/github-issues/src/plugin.ts @@ -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, },