feat(catalog-backend): use scm integration for codeowners
Signed-off-by: Andrew Thauer <athauer@wealthsimple.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Refactor CodeOwnersProcessor to use ScmIntegrations
|
||||
@@ -16,59 +16,23 @@
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { LocationSpec } from '@backstage/catalog-model';
|
||||
import { CodeOwnersEntry } from 'codeowners-utils';
|
||||
import {
|
||||
buildCodeOwnerUrl,
|
||||
buildUrl,
|
||||
CodeOwnersProcessor,
|
||||
findPrimaryCodeOwner,
|
||||
findRawCodeOwners,
|
||||
normalizeCodeOwner,
|
||||
parseCodeOwners,
|
||||
resolveCodeOwner,
|
||||
} from './CodeOwnersProcessor';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { CodeOwnersProcessor } from './CodeOwnersProcessor';
|
||||
|
||||
const logger = getVoidLogger();
|
||||
const mockCodeOwnersText = () => `
|
||||
* @acme/team-foo @acme/team-bar
|
||||
/docs @acme/team-bar
|
||||
`;
|
||||
|
||||
describe('CodeOwnersProcessor', () => {
|
||||
const mockUrl = ({ basePath = '' } = {}): string =>
|
||||
`https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`;
|
||||
const mockLocation = ({
|
||||
basePath = '',
|
||||
type = 'github',
|
||||
} = {}): LocationSpec => ({
|
||||
type,
|
||||
target: mockUrl({ basePath }),
|
||||
target: `https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`,
|
||||
});
|
||||
|
||||
const mockReadUrl = (basePath = '') =>
|
||||
`https://github.com/backstage/backstage/blob/master/${basePath}CODEOWNERS`;
|
||||
|
||||
const mockGitUri = (codeOwnersPath: string = '') => {
|
||||
return {
|
||||
source: 'github.com',
|
||||
owner: 'backstage',
|
||||
name: 'backstage',
|
||||
codeOwnersPath,
|
||||
};
|
||||
};
|
||||
|
||||
const mockCodeOwnersText = () => `
|
||||
# https://help.github.com/articles/about-codeowners/
|
||||
* @spotify/backstage-core @acme/team-foo
|
||||
/plugins/techdocs @spotify/techdocs-core
|
||||
`;
|
||||
|
||||
const mockCodeOwners = (): CodeOwnersEntry[] => {
|
||||
return [
|
||||
{
|
||||
pattern: '/plugins/techdocs',
|
||||
owners: ['@spotify/techdocs-core'],
|
||||
},
|
||||
{ pattern: '*', owners: ['@spotify/backstage-core', '@acme/team-foo'] },
|
||||
];
|
||||
};
|
||||
|
||||
const mockReadResult = ({
|
||||
error = undefined,
|
||||
data = undefined,
|
||||
@@ -82,156 +46,19 @@ describe('CodeOwnersProcessor', () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
describe('buildUrl', () => {
|
||||
it.each([['azure.com'], ['dev.azure.com']])(
|
||||
'should throw not implemented error',
|
||||
source => {
|
||||
expect(() => buildUrl({ ...mockGitUri(), source })).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it('should build github.com url', () => {
|
||||
expect(
|
||||
buildUrl({
|
||||
...mockGitUri(),
|
||||
codeOwnersPath: '/.github/CODEOWNERS',
|
||||
}),
|
||||
).toBe(
|
||||
'https://github.com/backstage/backstage/blob/master/.github/CODEOWNERS',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCodeOwnerUrl', () => {
|
||||
it('should build a location spec to the codeowners', () => {
|
||||
expect(buildCodeOwnerUrl(mockUrl(), '/docs/CODEOWNERS')).toEqual(
|
||||
'https://github.com/backstage/backstage/blob/master/docs/CODEOWNERS',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nested paths from original location spec', () => {
|
||||
expect(
|
||||
buildCodeOwnerUrl(
|
||||
mockUrl({ basePath: 'packages/foo/' }),
|
||||
'/CODEOWNERS',
|
||||
),
|
||||
).toEqual(
|
||||
'https://github.com/backstage/backstage/blob/master/CODEOWNERS',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCodeOwners', () => {
|
||||
it('should parse the codeowners file', () => {
|
||||
expect(parseCodeOwners(mockCodeOwnersText())).toEqual(mockCodeOwners());
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCodeOwner', () => {
|
||||
it('should remove the @ symbol', () => {
|
||||
expect(normalizeCodeOwner('@yoda')).toBe('yoda');
|
||||
});
|
||||
|
||||
it('should remove org from org/team format', () => {
|
||||
expect(normalizeCodeOwner('@acme/foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('should return username from email format', () => {
|
||||
expect(normalizeCodeOwner('foo@acme.com')).toBe('foo');
|
||||
});
|
||||
|
||||
it.each([['acme/foo'], ['dacme/foo']])(
|
||||
'should return string everything else',
|
||||
owner => {
|
||||
expect(normalizeCodeOwner(owner)).toBe(owner);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('findPrimaryCodeOwner', () => {
|
||||
it('should return the primary owner', () => {
|
||||
expect(findPrimaryCodeOwner(mockCodeOwners())).toBe('backstage-core');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRawCodeOwners', () => {
|
||||
it('should return found codeowner', async () => {
|
||||
const ownersText = mockCodeOwnersText();
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockReadResult({ data: ownersText }));
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
const result = await findRawCodeOwners(mockLocation(), {
|
||||
reader,
|
||||
logger,
|
||||
});
|
||||
expect(result).toEqual(ownersText);
|
||||
});
|
||||
|
||||
it('should return undefined when no codeowner', async () => {
|
||||
const read = jest.fn().mockRejectedValue(mockReadResult());
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
|
||||
await expect(
|
||||
findRawCodeOwners(mockLocation(), { reader, logger }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should look at known codeowner locations', async () => {
|
||||
const ownersText = mockCodeOwnersText();
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => mockReadResult({ error: 'foo' }))
|
||||
.mockImplementationOnce(() => mockReadResult({ error: 'bar' }))
|
||||
.mockResolvedValue(mockReadResult({ data: ownersText }));
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
|
||||
const result = await findRawCodeOwners(mockLocation(), {
|
||||
reader,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(read.mock.calls.length).toBe(5);
|
||||
expect(read.mock.calls[0]).toEqual([mockReadUrl('')]);
|
||||
expect(read.mock.calls[1]).toEqual([mockReadUrl('docs/')]);
|
||||
expect(read.mock.calls[2]).toEqual([mockReadUrl('.bitbucket/')]);
|
||||
expect(read.mock.calls[3]).toEqual([mockReadUrl('.github/')]);
|
||||
expect(read.mock.calls[4]).toEqual([mockReadUrl('.gitlab/')]);
|
||||
expect(result).toEqual(ownersText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCodeOwner', () => {
|
||||
it('should return found codeowner', async () => {
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
|
||||
const owner = await resolveCodeOwner(mockLocation(), { reader, logger });
|
||||
expect(owner).toBe('backstage-core');
|
||||
});
|
||||
|
||||
it('should return undefined when no codeowner', async () => {
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockImplementation(() => mockReadResult({ error: 'error: foo' }));
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
|
||||
await expect(
|
||||
resolveCodeOwner(mockLocation(), { reader, logger }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeOwnersProcessor', () => {
|
||||
describe('preProcessEntity', () => {
|
||||
const setupTest = ({ kind = 'Component', spec = {} } = {}) => {
|
||||
const entity = { kind, spec };
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() }));
|
||||
|
||||
const config = new ConfigReader({});
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
const processor = new CodeOwnersProcessor({ reader, logger });
|
||||
const processor = CodeOwnersProcessor.fromConfig(config, {
|
||||
logger: getVoidLogger(),
|
||||
reader,
|
||||
});
|
||||
|
||||
return { entity, processor, read };
|
||||
};
|
||||
@@ -249,18 +76,15 @@ describe('CodeOwnersProcessor', () => {
|
||||
expect(result).toEqual(entity);
|
||||
});
|
||||
|
||||
it('should handle url locations', async () => {
|
||||
it('should ingore invalid locations type', async () => {
|
||||
const { entity, processor } = setupTest();
|
||||
|
||||
const result = await processor.preProcessEntity(
|
||||
entity as any,
|
||||
mockLocation({ type: 'url' }),
|
||||
mockLocation({ type: 'github-org' }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
...entity,
|
||||
spec: { owner: 'backstage-core' },
|
||||
});
|
||||
expect(result).toEqual(entity);
|
||||
});
|
||||
|
||||
it('should ignore invalid kinds', async () => {
|
||||
@@ -284,7 +108,7 @@ describe('CodeOwnersProcessor', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
...entity,
|
||||
spec: { owner: 'backstage-core' },
|
||||
spec: { owner: 'team-foo' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,19 +15,11 @@
|
||||
*/
|
||||
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
Entity,
|
||||
LocationSpec,
|
||||
stringifyLocationReference,
|
||||
} from '@backstage/catalog-model';
|
||||
import * as codeowners from 'codeowners-utils';
|
||||
import { CodeOwnersEntry } from 'codeowners-utils';
|
||||
// NOTE: This can be removed when ES2021 is implemented
|
||||
import 'core-js/features/promise';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { filter, get, head, pipe, reverse } from 'lodash/fp';
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { Logger } from 'winston';
|
||||
import { findCodeOwnerByTarget } from './codeowners';
|
||||
import { CatalogProcessor } from './types';
|
||||
|
||||
const ALLOWED_KINDS = ['API', 'Component', 'Domain', 'Resource', 'System'];
|
||||
@@ -42,18 +34,32 @@ const ALLOWED_LOCATION_TYPES = [
|
||||
'gitlab/api',
|
||||
];
|
||||
|
||||
// TODO(Rugvip): We want to properly detect out repo provider, but for now it's
|
||||
// best to wait for GitHub Apps to be properly introduced and see
|
||||
// what kind of APIs that integrations will expose.
|
||||
const KNOWN_LOCATIONS = ['', '/docs', '/.bitbucket', '/.github', '/.gitlab'];
|
||||
|
||||
type Options = {
|
||||
reader: UrlReader;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
constructor(private readonly options: Options) {}
|
||||
private readonly integrations: ScmIntegrations;
|
||||
private readonly logger: Logger;
|
||||
private readonly reader: UrlReader;
|
||||
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: { logger: Logger; reader: UrlReader },
|
||||
) {
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
|
||||
return new CodeOwnersProcessor({
|
||||
...options,
|
||||
integrations,
|
||||
});
|
||||
}
|
||||
|
||||
constructor(options: {
|
||||
integrations: ScmIntegrations;
|
||||
logger: Logger;
|
||||
reader: UrlReader;
|
||||
}) {
|
||||
this.integrations = options.integrations;
|
||||
this.logger = options.logger;
|
||||
this.reader = options.reader;
|
||||
}
|
||||
|
||||
async preProcessEntity(
|
||||
entity: Entity,
|
||||
@@ -69,8 +75,21 @@ export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
return entity;
|
||||
}
|
||||
|
||||
const owner = await resolveCodeOwner(location, this.options);
|
||||
const scmIntegration = this.integrations.byUrl(location.target);
|
||||
if (!scmIntegration) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
const owner = await findCodeOwnerByTarget(
|
||||
this.reader,
|
||||
location.target,
|
||||
scmIntegration,
|
||||
);
|
||||
|
||||
if (!owner) {
|
||||
this.logger.debug(
|
||||
`CodeOwnerProcessor could not resolve owner for ${location.target}`,
|
||||
);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -80,112 +99,3 @@ export class CodeOwnersProcessor implements CatalogProcessor {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCodeOwner(
|
||||
location: LocationSpec,
|
||||
options: Options,
|
||||
): Promise<string | undefined> {
|
||||
const ownersText = await findRawCodeOwners(location, options);
|
||||
if (!ownersText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const owners = parseCodeOwners(ownersText);
|
||||
|
||||
return findPrimaryCodeOwner(owners);
|
||||
}
|
||||
|
||||
export async function findRawCodeOwners(
|
||||
location: LocationSpec,
|
||||
options: Options,
|
||||
): Promise<string | undefined> {
|
||||
const readOwnerLocation = async (basePath: string): Promise<string> => {
|
||||
const ownerUrl = buildCodeOwnerUrl(
|
||||
location.target,
|
||||
`${basePath}/CODEOWNERS`,
|
||||
);
|
||||
const data = await options.reader.read(ownerUrl);
|
||||
return data.toString();
|
||||
};
|
||||
|
||||
const candidates = KNOWN_LOCATIONS.map(readOwnerLocation);
|
||||
return Promise.any(candidates).catch((aggregateError: AggregateError) => {
|
||||
const hardError = aggregateError.errors.find(
|
||||
error => !(error instanceof NotFoundError),
|
||||
);
|
||||
if (hardError) {
|
||||
options.logger.warn(
|
||||
`Failed to read codeowners for location ${stringifyLocationReference(
|
||||
location,
|
||||
)}, ${hardError}`,
|
||||
);
|
||||
} else {
|
||||
options.logger.debug(
|
||||
`Failed to find codeowners for location ${stringifyLocationReference(
|
||||
location,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCodeOwnerUrl(
|
||||
basePath: string,
|
||||
codeOwnersPath: string,
|
||||
): string {
|
||||
return buildUrl({ ...parseGitUrl(basePath), codeOwnersPath });
|
||||
}
|
||||
|
||||
export function parseCodeOwners(ownersText: string) {
|
||||
return codeowners.parse(ownersText);
|
||||
}
|
||||
|
||||
export function findPrimaryCodeOwner(
|
||||
owners: CodeOwnersEntry[],
|
||||
): string | undefined {
|
||||
return pipe(
|
||||
filter((e: CodeOwnersEntry) => e.pattern === '*'),
|
||||
reverse,
|
||||
head,
|
||||
get('owners'),
|
||||
head,
|
||||
normalizeCodeOwner,
|
||||
)(owners);
|
||||
}
|
||||
|
||||
export function normalizeCodeOwner(owner: string) {
|
||||
if (owner.match(/^@.*\/.*/)) {
|
||||
return owner.split('/')[1];
|
||||
} else if (owner.match(/^@.*/)) {
|
||||
return owner.substring(1);
|
||||
} else if (owner.match(/^.*@.*\..*$/)) {
|
||||
return owner.split('@')[0];
|
||||
}
|
||||
|
||||
return owner;
|
||||
}
|
||||
|
||||
export function buildUrl({
|
||||
protocol = 'https',
|
||||
source = 'github.com',
|
||||
owner,
|
||||
name,
|
||||
ref = 'master',
|
||||
codeOwnersPath = '/CODEOWNERS',
|
||||
}: {
|
||||
protocol?: string;
|
||||
source?: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
ref?: string;
|
||||
codeOwnersPath?: string;
|
||||
}) {
|
||||
switch (source) {
|
||||
case 'dev.azure.com':
|
||||
case 'azure.com':
|
||||
throw Error('Azure codeowner url builder not implemented');
|
||||
default:
|
||||
return `${protocol}://${source}/${owner}/${name}/blob/${ref}${codeOwnersPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { findCodeOwnerByTarget, readCodeOwners } from './read';
|
||||
export { resolveCodeOwner } from './resolve';
|
||||
export { scmCodeOwnersPaths } from './scm';
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { findCodeOwnerByTarget, readCodeOwners } from './read';
|
||||
|
||||
const sourceUrl = 'https://github.com/acme/foobar/tree/master/';
|
||||
|
||||
const mockCodeowners = `
|
||||
* @acme/team-foo @acme/team-bar
|
||||
/docs @acme/team-bar
|
||||
`;
|
||||
|
||||
const mockReadResult = ({
|
||||
error = undefined,
|
||||
data = undefined,
|
||||
}: {
|
||||
error?: string;
|
||||
data?: string;
|
||||
} = {}) => {
|
||||
if (error) {
|
||||
throw Error(error);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
describe('readCodeOwners', () => {
|
||||
it('should return found codeowners file', async () => {
|
||||
const ownersText = mockCodeowners;
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockReadResult({ data: ownersText }));
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
const result = await readCodeOwners(reader, sourceUrl, [
|
||||
'.github/CODEOWNERS',
|
||||
]);
|
||||
expect(result).toEqual(ownersText);
|
||||
});
|
||||
|
||||
it('should return undefined when no codeowner', async () => {
|
||||
const read = jest.fn().mockRejectedValue(mockReadResult());
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
|
||||
await expect(
|
||||
readCodeOwners(reader, sourceUrl, ['.github/CODEOWNERS']),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should look at multiple locations', async () => {
|
||||
const ownersText = mockCodeowners;
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => mockReadResult({ error: 'not found' }))
|
||||
.mockResolvedValue(mockReadResult({ data: ownersText }));
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
|
||||
const result = await readCodeOwners(reader, sourceUrl, [
|
||||
'.github/CODEOWNERS',
|
||||
'docs/CODEOWNERS',
|
||||
]);
|
||||
|
||||
expect(read.mock.calls.length).toBe(2);
|
||||
expect(read.mock.calls[0]).toEqual([`${sourceUrl}.github/CODEOWNERS`]);
|
||||
expect(read.mock.calls[1]).toEqual([`${sourceUrl}docs/CODEOWNERS`]);
|
||||
expect(result).toEqual(ownersText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCodeOwnerByLocation', () => {
|
||||
const setupTest = ({
|
||||
target = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
|
||||
codeownersContents: codeOwnersContents = mockCodeowners,
|
||||
}: { target?: string; codeownersContents?: string } = {}) => {
|
||||
const read = jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockReadResult({ data: codeOwnersContents }));
|
||||
|
||||
const scmIntegration = ScmIntegrations.fromConfig(
|
||||
new ConfigReader({}),
|
||||
).byUrl(target);
|
||||
|
||||
const reader = { read, readTree: jest.fn(), search: jest.fn() };
|
||||
|
||||
return { target, reader, scmIntegration, codeOwnersContents };
|
||||
};
|
||||
|
||||
it('should return an owner', async () => {
|
||||
const { target, reader, scmIntegration } = setupTest({
|
||||
target:
|
||||
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
|
||||
});
|
||||
|
||||
const result = await findCodeOwnerByTarget(
|
||||
reader,
|
||||
target,
|
||||
scmIntegration as any,
|
||||
);
|
||||
|
||||
expect(result).toBe('team-foo');
|
||||
});
|
||||
|
||||
it('should return undefined for invalid scm', async () => {
|
||||
const { target, reader, scmIntegration } = setupTest({
|
||||
target:
|
||||
'https://unknown-git-host/backstage/backstage/blob/master/catalog-info.yaml',
|
||||
codeownersContents: undefined,
|
||||
});
|
||||
|
||||
const result = await findCodeOwnerByTarget(
|
||||
reader,
|
||||
target,
|
||||
scmIntegration as any,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 { UrlReader } from '@backstage/backend-common';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { ScmIntegration } from '@backstage/integration';
|
||||
import 'core-js/features/promise'; // NOTE: This can be removed when ES2021 is implemented
|
||||
import { resolveCodeOwner } from './resolve';
|
||||
import { scmCodeOwnersPaths } from './scm';
|
||||
|
||||
export async function readCodeOwners(
|
||||
reader: UrlReader,
|
||||
sourceUrl: string,
|
||||
codeownersPaths: string[],
|
||||
): Promise<string | undefined> {
|
||||
const readOwnerLocation = async (path: string): Promise<string> => {
|
||||
const url = `${sourceUrl}${path}`;
|
||||
const data = await reader.read(url);
|
||||
return data.toString();
|
||||
};
|
||||
|
||||
const candidates = codeownersPaths.map(readOwnerLocation);
|
||||
|
||||
return Promise.any(candidates).catch((aggregateError: AggregateError) => {
|
||||
const hardError = aggregateError.errors.find(
|
||||
error => !(error instanceof NotFoundError),
|
||||
);
|
||||
|
||||
if (hardError) {
|
||||
throw hardError;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export async function findCodeOwnerByTarget(
|
||||
reader: UrlReader,
|
||||
targetUrl: string,
|
||||
scmIntegration: ScmIntegration,
|
||||
): Promise<string | undefined> {
|
||||
const codeownersPaths = scmCodeOwnersPaths[scmIntegration?.type ?? ''];
|
||||
|
||||
const sourceUrl = scmIntegration?.resolveUrl({
|
||||
url: '/',
|
||||
base: targetUrl,
|
||||
});
|
||||
|
||||
if (!sourceUrl || !codeownersPaths) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contents = await readCodeOwners(reader, sourceUrl, codeownersPaths);
|
||||
|
||||
if (!contents) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const owner = resolveCodeOwner(contents);
|
||||
|
||||
return owner;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 { normalizeCodeOwner, resolveCodeOwner } from './resolve';
|
||||
|
||||
const mockCodeOwnersText = () => `
|
||||
* @acme/team-foo @acme/team-bar
|
||||
/docs @acme/team-bar
|
||||
`;
|
||||
|
||||
describe('resolveCodeOwner', () => {
|
||||
it('should parse the codeowners file', () => {
|
||||
expect(resolveCodeOwner(mockCodeOwnersText())).toBe('team-foo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCodeOwner', () => {
|
||||
it('should remove the @ symbol', () => {
|
||||
expect(normalizeCodeOwner('@yoda')).toBe('yoda');
|
||||
});
|
||||
|
||||
it('should remove org from org/team format', () => {
|
||||
expect(normalizeCodeOwner('@acme/foo')).toBe('foo');
|
||||
});
|
||||
|
||||
it('should return username from email format', () => {
|
||||
expect(normalizeCodeOwner('foo@acme.com')).toBe('foo');
|
||||
});
|
||||
|
||||
it.each([['acme/foo'], ['dacme/foo']])(
|
||||
'should return string everything else',
|
||||
owner => {
|
||||
expect(normalizeCodeOwner(owner)).toBe(owner);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 * as codeowners from 'codeowners-utils';
|
||||
import { CodeOwnersEntry } from 'codeowners-utils';
|
||||
import { filter, get, head, pipe, reverse } from 'lodash/fp';
|
||||
|
||||
export function resolveCodeOwner(
|
||||
contents: string,
|
||||
pattern = '*',
|
||||
): string | undefined {
|
||||
const owners = codeowners.parse(contents);
|
||||
|
||||
return pipe(
|
||||
filter((e: CodeOwnersEntry) => e.pattern === pattern),
|
||||
reverse,
|
||||
head,
|
||||
get('owners'),
|
||||
head,
|
||||
normalizeCodeOwner,
|
||||
)(owners);
|
||||
}
|
||||
|
||||
export function normalizeCodeOwner(owner: string) {
|
||||
if (owner.match(/^@.*\/.*/)) {
|
||||
return owner.split('/')[1];
|
||||
} else if (owner.match(/^@.*/)) {
|
||||
return owner.substring(1);
|
||||
} else if (owner.match(/^.*@.*\..*$/)) {
|
||||
return owner.split('@')[0];
|
||||
}
|
||||
|
||||
return owner;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const CODEOWNERS = 'CODEOWNERS';
|
||||
|
||||
export const scmCodeOwnersPaths: Record<string, string[]> = {
|
||||
// https://mibexsoftware.atlassian.net/wiki/spaces/CODEOWNERS/pages/222822413/Usage
|
||||
bitbucket: [CODEOWNERS, `.bitbucket/${CODEOWNERS}`],
|
||||
|
||||
// https://docs.gitlab.com/ee/user/project/code_owners.html#how-to-set-up-code-owners
|
||||
gitlab: [CODEOWNERS, `.gitlab/${CODEOWNERS}`, `docs/${CODEOWNERS}`],
|
||||
|
||||
// https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-file-location
|
||||
github: [CODEOWNERS, `.github/${CODEOWNERS}`, `docs/${CODEOWNERS}`],
|
||||
};
|
||||
@@ -309,7 +309,7 @@ export class CatalogBuilder {
|
||||
LdapOrgReaderProcessor.fromConfig(config, { logger }),
|
||||
MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }),
|
||||
new UrlReaderProcessor({ reader, logger }),
|
||||
new CodeOwnersProcessor({ reader, logger }),
|
||||
CodeOwnersProcessor.fromConfig(config, { logger, reader }),
|
||||
new LocationEntityProcessor({ integrations }),
|
||||
new AnnotateLocationEntityProcessor({ integrations }),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user