diff --git a/.changeset/real-turtles-relax.md b/.changeset/real-turtles-relax.md new file mode 100644 index 0000000000000..82faa1687344b --- /dev/null +++ b/.changeset/real-turtles-relax.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Refactor CodeOwnersProcessor to use ScmIntegrations diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts index eb1187eda5ff3..d268606205a4d 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -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' }, }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts index 3855335eb93bb..e8ce75e7d921a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts @@ -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,123 +75,27 @@ export class CodeOwnersProcessor implements CatalogProcessor { return entity; } - const owner = await resolveCodeOwner(location, this.options); - if (!owner) { + const scmIntegration = this.integrations.byUrl(location.target); + if (!scmIntegration) { return entity; } - return { - ...entity, - spec: { ...entity.spec, owner }, - }; - } -} - -export async function resolveCodeOwner( - location: LocationSpec, - options: Options, -): Promise { - 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 { - const readOwnerLocation = async (basePath: string): Promise => { - const ownerUrl = buildCodeOwnerUrl( + const owner = await findCodeOwnerByTarget( + this.reader, location.target, - `${basePath}/CODEOWNERS`, + scmIntegration, ); - 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, - )}`, + if (!owner) { + this.logger.debug( + `CodeOwnerProcessor could not resolve owner for ${location.target}`, ); + return entity; } - 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}`; + return { + ...entity, + spec: { ...entity.spec, owner }, + }; } } diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/index.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/index.ts new file mode 100644 index 0000000000000..ccbb4378741fa --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/index.ts @@ -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'; diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.test.ts new file mode 100644 index 0000000000000..7427fde3d0c26 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.test.ts @@ -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(); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts new file mode 100644 index 0000000000000..62899aeaf4cbf --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts @@ -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 { + const readOwnerLocation = async (path: string): Promise => { + 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 { + 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; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.test.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.test.ts new file mode 100644 index 0000000000000..8023e1af27c6e --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.test.ts @@ -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); + }, + ); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.ts new file mode 100644 index 0000000000000..1c059ea48ec68 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.ts @@ -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; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/scm.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/scm.ts new file mode 100644 index 0000000000000..50559709b1e4c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/scm.ts @@ -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 = { + // 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}`], +}; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 126a138de49d4..e0b447c6869d6 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -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 }), );