Merge pull request #4284 from nirga/org-repo

Add support for github-discovery locations
This commit is contained in:
Patrik Oldsberg
2021-02-10 19:42:56 +01:00
committed by GitHub
8 changed files with 459 additions and 1 deletions
+15
View File
@@ -0,0 +1,15 @@
---
'@backstage/plugin-catalog-backend': patch
---
Added an option to scan GitHub for repositories using a new location type `github-discovery`.
Example:
```yaml
type: 'github-discovery',
target:
'https://github.com/backstage/techdocs-*/blob/master/catalog.yaml'
```
You can use wildcards (`*`) as well. This will add `location` entities for each matching repository.
Currently though, you must specify the exact path of the `catalog.yaml` file in the repository.
@@ -0,0 +1,222 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { LocationSpec } from '@backstage/catalog-model';
import { GithubDiscoveryProcessor, parseUrl } from './GithubDiscoveryProcessor';
import { getOrganizationRepositories } from './github';
import { ConfigReader } from '@backstage/config';
jest.mock('./github');
const mockGetOrganizationRepositories = getOrganizationRepositories as jest.MockedFunction<
typeof getOrganizationRepositories
>;
describe('GithubDiscoveryProcessor', () => {
describe('parseUrl', () => {
it('parses well formed URLs', () => {
expect(
parseUrl('https://github.com/foo/proj/blob/master/catalog.yaml'),
).toEqual({
org: 'foo',
repoSearchPath: /^proj$/,
catalogPath: '/blob/master/catalog.yaml',
});
expect(
parseUrl('https://github.com/foo/proj*/blob/master/catalog.yaml'),
).toEqual({
org: 'foo',
repoSearchPath: /^proj.*$/,
catalogPath: '/blob/master/catalog.yaml',
});
});
it('throws on incorrectly formed URLs', () => {
expect(() => parseUrl('https://github.com')).toThrow();
expect(() => parseUrl('https://github.com//')).toThrow();
expect(() => parseUrl('https://github.com/foo')).toThrow();
expect(() => parseUrl('https://github.com//foo')).toThrow();
expect(() => parseUrl('https://github.com/org/teams')).toThrow();
expect(() => parseUrl('https://github.com/org//teams')).toThrow();
});
});
describe('reject unrelated entries', () => {
it('rejects unknown types', async () => {
const processor = GithubDiscoveryProcessor.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'blob' }],
},
}),
{ logger: getVoidLogger() },
);
const location: LocationSpec = {
type: 'not-github-discovery',
target: 'https://github.com',
};
await expect(
processor.readLocation(location, false, () => {}),
).resolves.toBeFalsy();
});
it('rejects unknown targets', async () => {
const processor = GithubDiscoveryProcessor.fromConfig(
new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'blob' },
{ host: 'ghe.example.net', token: 'blob' },
],
},
}),
{ logger: getVoidLogger() },
);
const location: LocationSpec = {
type: 'github-discovery',
target: 'https://not.github.com/apa',
};
await expect(
processor.readLocation(location, false, () => {}),
).rejects.toThrow(
/There is no GitHub integration that matches https:\/\/not.github.com\/apa/,
);
});
});
describe('handles repositories', () => {
const processor = GithubDiscoveryProcessor.fromConfig(
new ConfigReader({
integrations: {
github: [{ host: 'github.com', token: 'blob' }],
},
}),
{ logger: getVoidLogger() },
);
beforeEach(() => {
mockGetOrganizationRepositories.mockClear();
});
it('output all repositories', async () => {
const location: LocationSpec = {
type: 'github-discovery',
target: 'https://github.com/backstage/*/blob/master/catalog.yaml',
};
mockGetOrganizationRepositories.mockResolvedValueOnce({
repositories: [
{ name: 'backstage', url: 'https://github.com/backstage/backstage' },
{ name: 'demo', url: 'https://github.com/backstage/demo' },
],
});
const emitter = jest.fn();
await processor.readLocation(location, false, emitter);
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/catalog.yaml',
},
optional: false,
});
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target: 'https://github.com/backstage/demo/blob/master/catalog.yaml',
},
optional: false,
});
});
it('output repositories with wildcards', async () => {
const location: LocationSpec = {
type: 'github-discovery',
target:
'https://github.com/backstage/techdocs-*/blob/master/catalog.yaml',
};
mockGetOrganizationRepositories.mockResolvedValueOnce({
repositories: [
{ name: 'backstage', url: 'https://github.com/backstage/backstage' },
{
name: 'techdocs-cli',
url: 'https://github.com/backstage/techdocs-cli',
},
{
name: 'techdocs-container',
url: 'https://github.com/backstage/techdocs-container',
},
],
});
const emitter = jest.fn();
await processor.readLocation(location, false, emitter);
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target:
'https://github.com/backstage/techdocs-cli/blob/master/catalog.yaml',
},
optional: false,
});
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target:
'https://github.com/backstage/techdocs-container/blob/master/catalog.yaml',
},
optional: false,
});
});
it('filter unrelated repositories', async () => {
const location: LocationSpec = {
type: 'github-discovery',
target: 'https://github.com/backstage/test/blob/master/catalog.yaml',
};
mockGetOrganizationRepositories.mockResolvedValueOnce({
repositories: [
{ name: 'abstest', url: 'https://github.com/backstage/abctest' },
{
name: 'test',
url: 'https://github.com/backstage/test',
},
{
name: 'testxyz',
url: 'https://github.com/backstage/testxyz',
},
],
});
const emitter = jest.fn();
await processor.readLocation(location, false, emitter);
expect(emitter).toHaveBeenCalledWith({
type: 'location',
location: {
type: 'url',
target: 'https://github.com/backstage/test/blob/master/catalog.yaml',
},
optional: false,
});
});
});
});
@@ -0,0 +1,130 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { LocationSpec } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import {
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { graphql } from '@octokit/graphql';
import { Logger } from 'winston';
import { getOrganizationRepositories } from './github';
import * as results from './results';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
/**
* Extracts repositories out of a GitHub org.
*/
export class GithubDiscoveryProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrations;
private readonly logger: Logger;
static fromConfig(config: Config, options: { logger: Logger }) {
const integrations = ScmIntegrations.fromConfig(config);
return new GithubDiscoveryProcessor({
...options,
integrations,
});
}
constructor(options: { integrations: ScmIntegrations; logger: Logger }) {
this.integrations = options.integrations;
this.logger = options.logger;
}
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean> {
if (location.type !== 'github-discovery') {
return false;
}
const gitHubConfig = this.integrations.github.byUrl(location.target)
?.config;
if (!gitHubConfig) {
throw new Error(
`There is no GitHub integration that matches ${location.target}. Please add a configuration entry for it under integrations.github`,
);
}
const { headers } = await GithubCredentialsProvider.create(
gitHubConfig,
).getCredentials({ url: location.target });
const { org, repoSearchPath, catalogPath } = parseUrl(location.target);
const client = graphql.defaults({
baseUrl: gitHubConfig.apiBaseUrl,
headers,
});
// Read out all of the raw data
const startTimestamp = Date.now();
this.logger.info(`Reading GitHub repositories from ${location.target}`);
const { repositories } = await getOrganizationRepositories(client, org);
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
this.logger.debug(
`Read ${repositories.length} GitHub repositories in ${duration} seconds`,
);
for (const repository of repositories) {
if (!repoSearchPath.test(repository.name)) {
continue;
}
emit(
results.location(
{
type: 'url',
target: `${repository.url}${catalogPath}`,
},
false,
),
);
}
return true;
}
}
/*
* Helpers
*/
export function parseUrl(
urlString: string,
): { org: string; repoSearchPath: RegExp; catalogPath: string } {
const url = new URL(urlString);
const path = url.pathname.substr(1).split('/');
// /backstage/techdocs-*/blob/master/catalog-info.yaml
if (path.length > 2 && path[0].length && path[1].length) {
return {
org: decodeURIComponent(path[0]),
repoSearchPath: escapeRegExp(decodeURIComponent(path[1])),
catalogPath: `/${decodeURIComponent(path.slice(2).join('/'))}`,
};
}
throw new Error(`Failed to parse ${urlString}`);
}
export function escapeRegExp(str: string): RegExp {
return new RegExp(`^${str.replace(/\*/g, '.*')}$`);
}
@@ -22,6 +22,7 @@ import {
getOrganizationTeams,
getOrganizationUsers,
getTeamMembers,
getOrganizationRepositories,
QueryResponse,
} from './github';
@@ -151,4 +152,48 @@ describe('github', () => {
await expect(getTeamMembers(graphql, 'a', 'b')).resolves.toEqual(output);
});
});
describe('getOrganizationRepositories', () => {
it('read repositories', async () => {
const input: QueryResponse = {
organization: {
repositories: {
nodes: [
{
name: 'backstage',
url: 'https://github.com/backstage/backstage',
},
{
name: 'demo',
url: 'https://github.com/backstage/demo',
},
],
pageInfo: {
hasNextPage: false,
},
},
},
};
const output = {
repositories: [
{ name: 'backstage', url: 'https://github.com/backstage/backstage' },
{
name: 'demo',
url: 'https://github.com/backstage/demo',
},
],
};
server.use(
graphqlMsw.query('repositories', (_req, res, ctx) =>
res(ctx.data(input)),
),
);
await expect(getOrganizationRepositories(graphql, 'a')).resolves.toEqual(
output,
);
});
});
});
@@ -27,6 +27,7 @@ export type Organization = {
membersWithRole?: Connection<User>;
team?: Team;
teams?: Connection<Team>;
repositories?: Connection<Repository>;
};
export type PageInfo = {
@@ -52,6 +53,11 @@ export type Team = {
members: Connection<User>;
};
export type Repository = {
name: string;
url: string;
};
export type Connection<T> = {
pageInfo: PageInfo;
nodes: T[];
@@ -216,6 +222,39 @@ export async function getOrganizationTeams(
return { groups, groupMemberUsers };
}
export async function getOrganizationRepositories(
client: typeof graphql,
org: string,
): Promise<{ repositories: Repository[] }> {
const query = `
query repositories($org: String!, $cursor: String) {
organization(login: $org) {
name
repositories(first: 100, after: $cursor) {
nodes {
name
url
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
const repositories = await queryWithPaging(
client,
query,
r => r.organization?.repositories,
x => x,
{ org },
);
return { repositories };
}
/**
* Gets all the users out of a GitHub organization.
*
@@ -16,4 +16,8 @@
export { readGithubConfig } from './config';
export type { ProviderConfig } from './config';
export { getOrganizationTeams, getOrganizationUsers } from './github';
export {
getOrganizationTeams,
getOrganizationUsers,
getOrganizationRepositories,
} from './github';
@@ -21,6 +21,7 @@ export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAcco
export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor';
export { CodeOwnersProcessor } from './CodeOwnersProcessor';
export { FileReaderProcessor } from './FileReaderProcessor';
export { GithubDiscoveryProcessor } from './GithubDiscoveryProcessor';
export { GithubOrgReaderProcessor } from './GithubOrgReaderProcessor';
export { LdapOrgReaderProcessor } from './LdapOrgReaderProcessor';
export { LocationEntityProcessor } from './LocationEntityProcessor';
@@ -43,6 +43,7 @@ import {
CatalogProcessorParser,
CodeOwnersProcessor,
FileReaderProcessor,
GithubDiscoveryProcessor,
GithubOrgReaderProcessor,
HigherOrderOperation,
HigherOrderOperations,
@@ -303,6 +304,7 @@ export class CatalogBuilder {
if (!this.processorsReplace) {
processors.push(
new FileReaderProcessor(),
GithubDiscoveryProcessor.fromConfig(config, { logger }),
GithubOrgReaderProcessor.fromConfig(config, { logger }),
LdapOrgReaderProcessor.fromConfig(config, { logger }),
MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }),