Merge pull request #20323 from sonikro/fix-github-issues-hostname

fix(github-issues): fixes bug where entities that did not belong to the configured github instance were trying to be fetched
This commit is contained in:
Ben Lambert
2023-10-10 11:28:23 +02:00
committed by GitHub
6 changed files with 127 additions and 36 deletions
@@ -21,9 +21,22 @@ jest.mock('octokit', () => ({
import { ConfigApi, ErrorApi } from '@backstage/core-plugin-api';
import { ForwardedError } from '@backstage/errors';
import { createFilterByClause, githubIssuesApi } from './githubIssuesApi';
import type { GithubIssuesFilters } from './githubIssuesApi';
import {
createFilterByClause,
githubIssuesApi,
Repository,
GithubIssuesFilters,
} from './githubIssuesApi';
function entityRepository(
name: string,
locationHostname: string = 'github.com',
): Repository {
return {
locationHostname,
name,
};
}
function getFragment(
filterBy = '',
orderBy = 'field: UPDATED_AT, direction: DESC',
@@ -110,7 +123,26 @@ describe('githubIssuesApi', () => {
it('should call GitHub API with correct query with fragment for each repo', async () => {
await api.fetchIssuesByRepoFromGithub(
['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'],
[
entityRepository('mrwolny/yo-yo'),
entityRepository('mrwolny/yoyo'),
entityRepository('mrwolny/yo.yo'),
],
10,
);
expect(mockGraphQLQuery).toHaveBeenCalledTimes(1);
expect(mockGraphQLQuery).toHaveBeenCalledWith(getFragment());
});
it("should only fetch data for entities hosted in the same GitHub instance as the plugin's config", async () => {
await api.fetchIssuesByRepoFromGithub(
[
entityRepository('mrwolny/yo-yo'),
entityRepository('mrwolny/yoyo'),
entityRepository('mrwolny/yo.yo'),
entityRepository('mrwolny/another-repo', 'enterprise.github.com'), // This one should be filtered out
],
10,
);
@@ -120,7 +152,11 @@ describe('githubIssuesApi', () => {
it('should call Github API with the correct filterBy and orderBy clauses', async () => {
await api.fetchIssuesByRepoFromGithub(
['mrwolny/yo-yo', 'mrwolny/yoyo', 'mrwolny/yo.yo'],
[
entityRepository('mrwolny/yo-yo'),
entityRepository('mrwolny/yoyo'),
entityRepository('mrwolny/yo.yo'),
],
10,
{
filterBy: {
@@ -231,7 +267,7 @@ describe('githubIssuesApi', () => {
);
const data = await api.fetchIssuesByRepoFromGithub(
['mrwolny/yo-yo', 'mrwolny/notfound'],
[entityRepository('mrwolny/yo-yo'), entityRepository('mrwolny/notfound')],
10,
);
@@ -301,7 +337,7 @@ describe('githubIssuesApi', () => {
);
const data = await api.fetchIssuesByRepoFromGithub(
['mrwolny/notfound'],
[entityRepository('mrwolny/notfound')],
10,
);
@@ -341,7 +377,10 @@ describe('githubIssuesApi', () => {
mockErrorApi as unknown as ErrorApi,
);
await api.fetchIssuesByRepoFromGithub(['mrwolny/notfound'], 10);
await api.fetchIssuesByRepoFromGithub(
[entityRepository('mrwolny/notfound')],
10,
);
expect(mockErrorApi.post).toHaveBeenCalledTimes(1);
expect(mockErrorApi.post).toHaveBeenCalledWith(
@@ -24,6 +24,11 @@ import {
import { readGithubIntegrationConfigs } from '@backstage/integration';
import { ForwardedError } from '@backstage/errors';
/** @internal */
export type Repository = {
name: string;
locationHostname: string;
};
/** @internal */
export type Assignee = {
avatarUrl: string;
@@ -116,9 +121,10 @@ export const githubIssuesApi = (
let octokit: Octokit;
const getOctokit = async () => {
const baseUrl = readGithubIntegrationConfigs(
const githubConfig = readGithubIntegrationConfigs(
configApi.getOptionalConfigArray('integrations.github') ?? [],
)[0].apiBaseUrl;
)[0];
const baseUrl = githubConfig.apiBaseUrl;
const token = await githubAuthApi.getAccessToken(['repo']);
@@ -126,11 +132,11 @@ export const githubIssuesApi = (
octokit = new Octokit({ auth: token, ...(baseUrl && { baseUrl }) });
}
return octokit.graphql;
return { graphql: octokit.graphql, hostname: githubConfig.host };
};
const fetchIssuesByRepoFromGithub = async (
repos: Array<string>,
repos: Array<Repository>,
itemsPerRepo: number,
{
filterBy,
@@ -140,30 +146,35 @@ export const githubIssuesApi = (
},
}: GithubIssuesByRepoOptions = {},
): Promise<IssuesByRepo> => {
const graphql = await getOctokit();
const { graphql, hostname } = await getOctokit();
const safeNames: Array<string> = [];
const repositories = repos
// only tries to fetch issues from repositories that are hosted on the same GitHub instance as the octokit
.filter(repo => repo.locationHostname === hostname)
.map(repo => {
const [owner, name] = repo.name.split('/');
const repositories = repos.map(repo => {
const [owner, name] = repo.split('/');
const safeNameRegex = /-|\./gi;
let safeName = name.replace(safeNameRegex, '');
const safeNameRegex = /-|\./gi;
let safeName = name.replace(safeNameRegex, '');
while (safeNames.includes(safeName)) {
safeName += 'x';
}
while (safeNames.includes(safeName)) {
safeName += 'x';
}
safeNames.push(safeName);
safeNames.push(safeName);
return {
safeName,
name,
owner,
};
});
return {
safeName,
name,
owner,
};
});
let issuesByRepo: IssuesByRepo = {};
try {
if (repositories.length === 0) {
throw new Error(`No repositories found for ${hostname}`);
}
issuesByRepo = await graphql(
createIssueByRepoQuery(repositories, itemsPerRepo, {
filterBy,
@@ -68,6 +68,8 @@ const entityComponent = {
metadata: {
annotations: {
'github.com/project-slug': 'backstage/backstage',
'backstage.io/source-location':
'url:https://github.com/backstage/backstage',
},
name: 'backstage',
},
@@ -14,10 +14,15 @@
* limitations under the License.
*/
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
Entity,
stringifyEntityRef,
getEntitySourceLocation,
} from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef, useEntity } from '@backstage/plugin-catalog-react';
import { useCallback, useEffect, useState } from 'react';
import { Repository } from '../api';
const GITHUB_PROJECT_SLUG_ANNOTATION = 'github.com/project-slug';
@@ -25,18 +30,28 @@ export const getProjectNameFromEntity = (entity: Entity): string => {
return entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION] ?? '';
};
export const getHostnameFromEntity = (entity: Entity): string => {
const { target } = getEntitySourceLocation(entity);
return new URL(target).hostname;
};
export function useEntityGithubRepositories() {
const { entity } = useEntity();
const catalogApi = useApi(catalogApiRef);
const [repositories, setRepositories] = useState<string[]>([]);
const [repositories, setRepositories] = useState<Repository[]>([]);
const getRepositoriesNames = useCallback(async () => {
if (entity.kind === 'Component' || entity.kind === 'API') {
const entityName = getProjectNameFromEntity(entity);
const locationHostname = getHostnameFromEntity(entity);
if (entityName) {
setRepositories([entityName]);
setRepositories([
{
name: entityName,
locationHostname,
},
]);
}
return;
@@ -49,11 +64,26 @@ export function useEntityGithubRepositories() {
},
});
const entitiesNames: string[] = entitiesList.items.map(componentEntity =>
getProjectNameFromEntity(componentEntity),
const repositoryEntities: Repository[] = entitiesList.items.reduce(
(acc: Repository[], componentEntity: Entity) => {
const entityName = getProjectNameFromEntity(componentEntity);
const entityLocationHostname = getHostnameFromEntity(componentEntity);
if (
entityName &&
!acc.some((it: Repository) => it.name === entityName) &&
entityName.length
) {
acc.push({
name: entityName,
locationHostname: entityLocationHostname,
});
}
return acc;
},
[],
);
setRepositories([...new Set(entitiesNames)].filter(name => name.length));
setRepositories(repositoryEntities);
}, [catalogApi, entity]);
useEffect(() => {
@@ -16,10 +16,14 @@
import { useApi } from '@backstage/core-plugin-api';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import { githubIssuesApiRef, GithubIssuesByRepoOptions } from '../api';
import {
Repository,
githubIssuesApiRef,
GithubIssuesByRepoOptions,
} from '../api';
export const useGetIssuesByRepoFromGithub = (
repos: Array<string>,
repos: Array<Repository>,
itemsPerRepo: number,
options?: GithubIssuesByRepoOptions,
) => {