diff --git a/.changeset/new-maps-complain.md b/.changeset/new-maps-complain.md new file mode 100644 index 0000000000..199a0c3280 --- /dev/null +++ b/.changeset/new-maps-complain.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Added an integration for Gerrit diff --git a/docs/integrations/gerrit/locations.md b/docs/integrations/gerrit/locations.md new file mode 100644 index 0000000000..3e76d966ad --- /dev/null +++ b/docs/integrations/gerrit/locations.md @@ -0,0 +1,36 @@ +--- +id: locations +title: Gerrit Locations +sidebar_label: Locations +description: Integrating source code stored in Gerrit into the Backstage catalog +--- + +The Gerrit integration supports loading catalog entities from Gerrit hosted gits. Entities can +be added to [static catalog configuration](../../features/software-catalog/configuration.md), +or registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin. + +## Configuration + +To use this integration, add configuration to your root `app-config.yaml`: + +```yaml +integrations: + gerrit: + - host: gerrit.company.com + apiBaseUrl: gerrit.company.com/gerrit + username: ${GERRIT_USERNAME} + password: ${GERRIT_PASSWORD} +``` + +Directly under the `gerrit` key is a list of provider configurations, where +you can list the Gerrit instances you want to fetch data from. Each entry is +a structure with up to four elements: + +- `host`: The host of the Gerrit instance, e.g. `gerrit.company.com`. +- `apiBaseUrl`: The base url of the Gerrit API. This would typically be the address + up to but not including the authentication ("/a/") prefix. +- `username` (optional): The Gerrit username to use in API requests. If + neither a username nor password are supplied, anonymous access will be used. +- `password` (optional): The password or http token for the Gerrit user. diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index 8873f0f220..06ab359e4a 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -60,6 +60,32 @@ export interface Config { appPassword?: string; }>; + /** Integration configuration for Gerrit */ + gerrit?: Array<{ + /** + * The hostname of the given Gerrit instance + * @visibility frontend + */ + host: string; + /** + * The base url for the Gerrit API. + * @visibility frontend + */ + apiBaseUrl?: string; + /** + * The username to use for authenticated requests. + * @visibility secret + */ + username?: string; + /** + * Gerrit password used to authenticate requests. This can be either a password + * or a generated access token. + * . + * @visibility secret + */ + password?: string; + }>; + /** Integration configuration for GitHub */ github?: Array<{ /** diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts index f0a6e5d72d..2e6f2f1472 100644 --- a/packages/integration/src/ScmIntegrations.test.ts +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -19,6 +19,8 @@ import { AzureIntegrationConfig } from './azure'; import { AzureIntegration } from './azure/AzureIntegration'; import { BitbucketIntegrationConfig } from './bitbucket'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GerritIntegrationConfig } from './gerrit'; +import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegrationConfig } from './github'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegrationConfig } from './gitlab'; @@ -39,6 +41,10 @@ describe('ScmIntegrations', () => { host: 'bitbucket.local', } as BitbucketIntegrationConfig); + const gerrit = new GerritIntegration({ + host: 'gerrit.local', + } as GerritIntegrationConfig); + const github = new GitHubIntegration({ host: 'github.local', } as GitHubIntegrationConfig); @@ -51,6 +57,7 @@ describe('ScmIntegrations', () => { awsS3: basicIntegrations([awsS3], item => item.config.host), azure: basicIntegrations([azure], item => item.config.host), bitbucket: basicIntegrations([bitbucket], item => item.config.host), + gerrit: basicIntegrations([gerrit], item => item.config.host), github: basicIntegrations([github], item => item.config.host), gitlab: basicIntegrations([gitlab], item => item.config.host), }); @@ -59,13 +66,14 @@ describe('ScmIntegrations', () => { expect(i.awsS3.byUrl('https://awss3.local')).toBe(awsS3); expect(i.azure.byUrl('https://azure.local')).toBe(azure); expect(i.bitbucket.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.gerrit.byUrl('https://gerrit.local')).toBe(gerrit); expect(i.github.byUrl('https://github.local')).toBe(github); expect(i.gitlab.byUrl('https://gitlab.local')).toBe(gitlab); }); it('can list', () => { expect(i.list()).toEqual( - expect.arrayContaining([awsS3, azure, bitbucket, github, gitlab]), + expect.arrayContaining([awsS3, azure, bitbucket, gerrit, github, gitlab]), ); }); @@ -73,12 +81,14 @@ describe('ScmIntegrations', () => { expect(i.byUrl('https://awss3.local')).toBe(awsS3); expect(i.byUrl('https://azure.local')).toBe(azure); expect(i.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.byUrl('https://gerrit.local')).toBe(gerrit); expect(i.byUrl('https://github.local')).toBe(github); expect(i.byUrl('https://gitlab.local')).toBe(gitlab); expect(i.byHost('awss3.local')).toBe(awsS3); expect(i.byHost('azure.local')).toBe(azure); expect(i.byHost('bitbucket.local')).toBe(bitbucket); + expect(i.byHost('gerrit.local')).toBe(gerrit); expect(i.byHost('github.local')).toBe(github); expect(i.byHost('gitlab.local')).toBe(gitlab); }); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index add9e4e016..8638b814ec 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -18,6 +18,7 @@ import { Config } from '@backstage/config'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegration } from './azure/AzureIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; import { defaultScmResolveUrl } from './helpers'; @@ -33,6 +34,7 @@ export interface IntegrationsByType { awsS3: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; bitbucket: ScmIntegrationsGroup; + gerrit: ScmIntegrationsGroup; github: ScmIntegrationsGroup; gitlab: ScmIntegrationsGroup; } @@ -50,6 +52,7 @@ export class ScmIntegrations implements ScmIntegrationRegistry { awsS3: AwsS3Integration.factory({ config }), azure: AzureIntegration.factory({ config }), bitbucket: BitbucketIntegration.factory({ config }), + gerrit: GerritIntegration.factory({ config }), github: GitHubIntegration.factory({ config }), gitlab: GitLabIntegration.factory({ config }), }); @@ -71,6 +74,10 @@ export class ScmIntegrations implements ScmIntegrationRegistry { return this.byType.bitbucket; } + get gerrit(): ScmIntegrationsGroup { + return this.byType.gerrit; + } + get github(): ScmIntegrationsGroup { return this.byType.github; } diff --git a/packages/integration/src/gerrit/GerritIntegration.test.ts b/packages/integration/src/gerrit/GerritIntegration.test.ts new file mode 100644 index 0000000000..5b31b975f3 --- /dev/null +++ b/packages/integration/src/gerrit/GerritIntegration.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { GerritIntegration } from './GerritIntegration'; + +describe('GerritIntegration', () => { + it('has a working factory', () => { + const integrations = GerritIntegration.factory({ + config: new ConfigReader({ + integrations: { + gerrit: [ + { + host: 'gerrit-review.example.com', + username: 'gerrituser', + apiBaseUrl: 'https://gerrit-review.example.com/gerrit', + password: '1234', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(1); + expect(integrations.list()[0].config.host).toBe( + 'gerrit-review.example.com', + ); + }); + + it('returns the basics', () => { + const integration = new GerritIntegration({ + host: 'gerrit-review.example.com', + apiBaseUrl: 'https://gerrit-review.example.com/gerrit', + } as any); + expect(integration.type).toBe('gerrit'); + expect(integration.title).toBe('gerrit-review.example.com'); + }); + + describe('resolveUrl', () => { + it('works for valid urls', () => { + const integration = new GerritIntegration({ + host: 'gerrit-review.example.com', + apiBaseUrl: 'https://gerrit-review.example.com/gerrit', + } as any); + + expect( + integration.resolveUrl({ + url: 'https://gerrit-review.example.com/catalog-info.yaml', + base: 'https://gerrit-review.example.com/catalog-info.yaml', + lineNumber: 9, + }), + ).toBe('https://gerrit-review.example.com/catalog-info.yaml#9'); + }); + }); + + describe('resolves with a relative url', () => { + it('works for valid urls', () => { + const integration = new GerritIntegration({ + host: 'gerrit-review.example.com', + apiBaseUrl: 'https://gerrit-review.example.com/gerrit', + } as any); + + expect( + integration.resolveUrl({ + url: './skeleton', + base: 'https://gerrit-review.example.com/gerrit/plugins/repo/+/refs/heads/master/template.yaml', + }), + ).toBe( + 'https://gerrit-review.example.com/gerrit/plugins/repo/+/refs/heads/master/skeleton', + ); + }); + }); + + it('resolve edit URL', () => { + const integration = new GerritIntegration({ + host: 'gerrit-review.example.com', + apiBaseUrl: 'https://gerrit-review.example.com/gerrit', + } as any); + + // Resolve edit URLs is not applicable for gerrit. Return the input + // url as is. + expect( + integration.resolveEditUrl( + 'https://gerrit-review.example.com/catalog-info.yaml', + ), + ).toBe('https://gerrit-review.example.com/catalog-info.yaml'); + }); +}); diff --git a/packages/integration/src/gerrit/GerritIntegration.ts b/packages/integration/src/gerrit/GerritIntegration.ts new file mode 100644 index 0000000000..791adc318d --- /dev/null +++ b/packages/integration/src/gerrit/GerritIntegration.ts @@ -0,0 +1,76 @@ +/* + * 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 { basicIntegrations } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + GerritIntegrationConfig, + readGerritIntegrationConfigs, +} from './config'; + +/** + * A Gerrit based integration. + * + * @public + */ +export class GerritIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ config }) => { + const configs = readGerritIntegrationConfigs( + config.getOptionalConfigArray('integrations.gerrit') ?? [], + ); + return basicIntegrations( + configs.map(c => new GerritIntegration(c)), + i => i.config.host ?? '', + ); + }; + + constructor(private readonly integrationConfig: GerritIntegrationConfig) {} + + get type(): string { + return 'gerrit'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): GerritIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { + const { url, base, lineNumber } = options; + let updated; + if (url) { + updated = new URL(url, base).toString(); + } else { + updated = base; + } + if (lineNumber) { + return `${updated}#${lineNumber}`; + } + return updated; + } + + resolveEditUrl(url: string): string { + // Not applicable for gerrit. + return url; + } +} diff --git a/packages/integration/src/gerrit/config.test.ts b/packages/integration/src/gerrit/config.test.ts new file mode 100644 index 0000000000..3377a3d0ff --- /dev/null +++ b/packages/integration/src/gerrit/config.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { Config, ConfigReader } from '@backstage/config'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { + GerritIntegrationConfig, + readGerritIntegrationConfig, + readGerritIntegrationConfigs, +} from './config'; + +describe('readGerritIntegrationConfig', () => { + function buildConfig(data: Partial): Config { + return new ConfigReader(data); + } + + async function buildFrontendConfig( + data: Partial, + ): Promise { + const fullSchema = await loadConfigSchema({ + dependencies: ['@backstage/integration'], + }); + const serializedSchema = fullSchema.serialize() as { + schemas: { value: { properties?: { integrations?: object } } }[]; + }; + const schema = await loadConfigSchema({ + serialized: { + ...serializedSchema, // only include schemas that apply to integrations + schemas: serializedSchema.schemas.filter( + s => s.value?.properties?.integrations, + ), + }, + }); + const processed = schema.process( + [{ data: { integrations: { gerrit: [data] } }, context: 'app' }], + { visibility: ['frontend'] }, + ); + return new ConfigReader((processed[0].data as any).integrations.gerrit[0]); + } + + it('reads all values', () => { + const output = readGerritIntegrationConfig( + buildConfig({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + username: 'u', + password: 'p', + }), + ); + expect(output).toEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + username: 'u', + password: 'p', + }); + }); + + it('rejects funky configs', () => { + const valid: any = { + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + username: 'u', + appPassword: 'p', + }; + expect(() => + readGerritIntegrationConfig(buildConfig({ ...valid, host: 2 })), + ).toThrow(/host/); + expect(() => + readGerritIntegrationConfig(buildConfig({ ...valid, apiBaseUrl: 2 })), + ).toThrow(/apiBaseUrl/); + }); + + it('works on the frontend', async () => { + expect( + readGerritIntegrationConfig( + await buildFrontendConfig({ + host: 'a.com', + apiBaseUrl: 'https://a.com/gerrit', + username: 'u', + password: 'p', + }), + ), + ).toEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/gerrit', + }); + }); +}); + +describe('readGerritIntegrationConfigs', () => { + function buildConfig(data: Partial[]): Config[] { + return data.map(item => new ConfigReader(item)); + } + + it('reads all values', () => { + const output = readGerritIntegrationConfigs( + buildConfig([ + { + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + username: 'u', + password: 'p', + }, + { + host: 'b.com', + apiBaseUrl: 'https://b.com/api', + }, + ]), + ); + expect(output).toEqual([ + { + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + username: 'u', + password: 'p', + }, + { + host: 'b.com', + apiBaseUrl: 'https://b.com/api', + username: undefined, + password: undefined, + }, + ]); + }); +}); diff --git a/packages/integration/src/gerrit/config.ts b/packages/integration/src/gerrit/config.ts new file mode 100644 index 0000000000..d118e22278 --- /dev/null +++ b/packages/integration/src/gerrit/config.ts @@ -0,0 +1,96 @@ +/* + * 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 { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; +import { isValidHost, isValidUrl } from '../helpers'; + +/** + * The configuration parameters for a single Gerrit API provider. + * + * @public + */ +export type GerritIntegrationConfig = { + /** + * The host of the target that this matches on, e.g. "gerrit-review.com" + */ + host: string; + + /** + * The base URL of the API of this provider, e.g. "https://gerrit-review.com/gerrit", + * with no trailing slash. + */ + apiBaseUrl: string; + + /** + * The username to use for requests to gerrit. + */ + username?: string; + + /** + * The password or http token to use for authentication. + */ + password?: string; +}; + +/** + * Reads a single Gerrit integration config. + * + * @param config - The config object of a single integration + * + * @public + */ +export function readGerritIntegrationConfig( + config: Config, +): GerritIntegrationConfig { + const host = config.getString('host'); + let apiBaseUrl = config.getString('apiBaseUrl'); + const username = config.getOptionalString('username'); + const password = config.getOptionalString('password'); + + if (!isValidHost(host)) { + throw new Error( + `Invalid Gerrit integration config, '${host}' is not a valid host`, + ); + } else if (!apiBaseUrl || !isValidUrl(apiBaseUrl)) { + throw new Error( + `Invalid Gerrit integration config, '${apiBaseUrl}' is not a valid apiBaseUrl`, + ); + } + if (apiBaseUrl) { + apiBaseUrl = trimEnd(apiBaseUrl, '/'); + } + + return { + host, + apiBaseUrl, + username, + password, + }; +} + +/** + * Reads a set of Gerrit integration configs. + * + * @param configs - All of the integration config objects + * + * @public + */ +export function readGerritIntegrationConfigs( + configs: Config[], +): GerritIntegrationConfig[] { + return configs.map(readGerritIntegrationConfig); +} diff --git a/packages/integration/src/gerrit/index.ts b/packages/integration/src/gerrit/index.ts new file mode 100644 index 0000000000..baad597a22 --- /dev/null +++ b/packages/integration/src/gerrit/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { GerritIntegration } from './GerritIntegration'; +export { + readGerritIntegrationConfig, + readGerritIntegrationConfigs, +} from './config'; +export type { GerritIntegrationConfig } from './config'; diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index 2f33b76ab3..cf0eeca51a 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -22,6 +22,7 @@ export * from './azure'; export * from './bitbucket'; +export * from './gerrit'; export * from './github'; export * from './gitlab'; export * from './googleGcs'; diff --git a/packages/integration/src/registry.ts b/packages/integration/src/registry.ts index 5864695e25..5ab5d049fb 100644 --- a/packages/integration/src/registry.ts +++ b/packages/integration/src/registry.ts @@ -18,6 +18,7 @@ import { ScmIntegration, ScmIntegrationsGroup } from './types'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegration } from './azure/AzureIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; @@ -31,6 +32,7 @@ export interface ScmIntegrationRegistry awsS3: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; bitbucket: ScmIntegrationsGroup; + gerrit: ScmIntegrationsGroup; github: ScmIntegrationsGroup; gitlab: ScmIntegrationsGroup;