diff --git a/.changeset/nine-grapes-turn.md b/.changeset/nine-grapes-turn.md new file mode 100644 index 0000000000..263618722c --- /dev/null +++ b/.changeset/nine-grapes-turn.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added the GerritUrlReader that implements "readUrl". diff --git a/.changeset/tidy-days-warn.md b/.changeset/tidy-days-warn.md new file mode 100644 index 0000000000..e84d563f01 --- /dev/null +++ b/.changeset/tidy-days-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': minor +--- + +Gerrit integration: Added an optional configuration to set the Gitiles base url. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index a1d7397172..3910caccc6 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -108,6 +108,7 @@ Francesco Gerrit gitbeaker github +Gitiles gitlab GitLab Gource diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 1b901ea5ac..cd4d08df87 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -17,6 +17,7 @@ import Docker from 'dockerode'; import { Duration } from 'luxon'; import { ErrorRequestHandler } from 'express'; import express from 'express'; +import { GerritIntegration } from '@backstage/integration'; import { GithubCredentialsProvider } from '@backstage/integration'; import { GitHubIntegration } from '@backstage/integration'; import { GitLabIntegration } from '@backstage/integration'; @@ -252,6 +253,23 @@ export type FromReadableArrayOptions = Array<{ path: string; }>; +// @public +export class GerritUrlReader implements UrlReader { + constructor(integration: GerritIntegration); + // (undocumented) + static factory: ReaderFactory; + // (undocumented) + read(url: string): Promise; + // (undocumented) + readTree(): Promise; + // (undocumented) + readUrl(url: string, options?: ReadUrlOptions): Promise; + // (undocumented) + search(): Promise; + // (undocumented) + toString(): string; +} + // @public export function getRootLogger(): winston.Logger; diff --git a/packages/backend-common/src/reading/GerritUrlReader.test.ts b/packages/backend-common/src/reading/GerritUrlReader.test.ts new file mode 100644 index 0000000000..bc88fdcb20 --- /dev/null +++ b/packages/backend-common/src/reading/GerritUrlReader.test.ts @@ -0,0 +1,173 @@ +/* + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { getVoidLogger } from '../logging'; +import { DefaultReadTreeResponseFactory } from './tree'; +import { UrlReaderPredicateTuple } from './types'; +import { + GerritIntegration, + readGerritIntegrationConfig, +} from '@backstage/integration'; +import { GerritUrlReader } from './GerritUrlReader'; + +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); + +const gerritProcessor = new GerritUrlReader( + new GerritIntegration( + readGerritIntegrationConfig( + new ConfigReader({ + host: 'gerrit.com', + }), + ), + ), +); + +const createReader = (config: JsonObject): UrlReaderPredicateTuple[] => { + return GerritUrlReader.factory({ + config: new ConfigReader(config), + logger: getVoidLogger(), + treeResponseFactory, + }); +}; + +describe('GerritUrlReader', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('reader factory', () => { + it('creates a reader.', () => { + const readers = createReader({ + integrations: { + gerrit: [{ host: 'gerrit.com' }], + }, + }); + expect(readers).toHaveLength(1); + }); + + it('should not create a default entry.', () => { + const readers = createReader({ + integrations: {}, + }); + expect(readers).toHaveLength(0); + }); + }); + + describe('predicates without Gitiles', () => { + const readers = createReader({ + integrations: { + gerrit: [{ host: 'gerrit.com' }], + }, + }); + const predicate = readers[0].predicate; + + it('returns true for the configured host', () => { + expect(predicate(new URL('https://gerrit.com/path'))).toBe(true); + }); + + it('returns false for a different host.', () => { + expect(predicate(new URL('https://github.com/path'))).toBe(false); + }); + }); + + describe('predicates with gitilesBaseUrl set.', () => { + const readers = createReader({ + integrations: { + gerrit: [ + { host: 'gerrit-review.com', gitilesBaseUrl: 'https://gerrit.com' }, + ], + }, + }); + const predicate = readers[0].predicate; + + it('returns false since gitilesBaseUrl is set to the api host.', () => { + expect(predicate(new URL('https://gerrit-review.com/path'))).toBe(false); + }); + + it('returns false for host.', () => { + expect(predicate(new URL('https://gerrit.com/path'))).toBe(true); + }); + }); + + describe('readUrl', () => { + const responseBuffer = Buffer.from('Apache License'); + it('should be able to read file contents', async () => { + worker.use( + rest.get( + 'https://gerrit.com/projects/web%2Fproject/branches/master/files/LICENSE/content', + (_, res, ctx) => { + return res( + ctx.status(200), + ctx.body(responseBuffer.toString('base64')), + ); + }, + ), + ); + + const result = await gerritProcessor.readUrl( + 'https://gerrit.com/web/project/+/refs/heads/master/LICENSE', + ); + const buffer = await result.buffer(); + expect(buffer.toString()).toBe(responseBuffer.toString()); + }); + + it('should raise NotFoundError on 404.', async () => { + worker.use( + rest.get( + 'https://gerrit.com/projects/web%2Fproject/branches/master/files/LICENSE/content', + (_, res, ctx) => { + return res(ctx.status(404, 'File not found.')); + }, + ), + ); + + await expect( + gerritProcessor.readUrl( + 'https://gerrit.com/web/project/+/refs/heads/master/LICENSE', + ), + ).rejects.toThrow( + 'File https://gerrit.com/web/project/+/refs/heads/master/LICENSE not found.', + ); + }); + + it('should throw an error on non 404 errors.', async () => { + worker.use( + rest.get( + 'https://gerrit.com/projects/web%2Fproject/branches/master/files/LICENSE/content', + (_, res, ctx) => { + return res(ctx.status(500, 'Error!!!')); + }, + ), + ); + + await expect( + gerritProcessor.readUrl( + 'https://gerrit.com/web/project/+/refs/heads/master/LICENSE', + ), + ).rejects.toThrow( + 'https://gerrit.com/web/project/+/refs/heads/master/LICENSE' + + ' could not be read as https://gerrit.com/projects/web%2Fproject' + + '/branches/master/files/LICENSE/content, 500 Error!!!', + ); + }); + }); +}); diff --git a/packages/backend-common/src/reading/GerritUrlReader.ts b/packages/backend-common/src/reading/GerritUrlReader.ts new file mode 100644 index 0000000000..7cc0b4731b --- /dev/null +++ b/packages/backend-common/src/reading/GerritUrlReader.ts @@ -0,0 +1,124 @@ +/* + * 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 { NotFoundError } from '@backstage/errors'; +import { + GerritIntegration, + getGerritFileContentsApiUrl, + getGerritRequestOptions, +} from '@backstage/integration'; +import fetch, { Response } from 'node-fetch'; +import { + ReaderFactory, + ReadTreeResponse, + ReadUrlOptions, + ReadUrlResponse, + SearchResponse, + UrlReader, +} from './types'; +import { ScmIntegrations } from '@backstage/integration'; + +/** + * Implements a {@link UrlReader} for files in Gerrit. + * + * @remarks + * To be able to link to Git contents for Gerrit providers in a user friendly + * way we are depending on that there is a Gitiles installation somewhere + * that we can link to. It is perfectly possible to integrate Gerrit with + * Backstage without Gitiles since all API calls goes directly to Gerrit. + * + * The "host" variable in the config is the Gerrit host. The address where + * Gitiles is installed may be on the same host but it could be on a + * separate host. For example a Gerrit instance could be hosted on + * "gerrit-review.company.com" but the repos could be browsable on a separate + * host, e.g. "gerrit.company.com" and the human readable URL would then + * not point to the API host. + * + * @public + */ +export class GerritUrlReader implements UrlReader { + static factory: ReaderFactory = ({ config }) => { + const integrations = ScmIntegrations.fromConfig(config); + if (!integrations.gerrit) { + return []; + } + return integrations.gerrit.list().map(integration => { + const reader = new GerritUrlReader(integration); + const predicate = (url: URL) => { + const gitilesUrl = new URL(integration.config.gitilesBaseUrl!); + // If gitilesUrl is not specfified it will default to + // "integration.config.host". + return url.host === gitilesUrl.host; + }; + return { reader, predicate }; + }); + }; + + constructor(private readonly integration: GerritIntegration) {} + + async read(url: string): Promise { + const response = await this.readUrl(url); + return response.buffer(); + } + + async readUrl( + url: string, + options?: ReadUrlOptions, + ): Promise { + const apiUrl = getGerritFileContentsApiUrl(this.integration.config, url); + let response: Response; + try { + response = await fetch(apiUrl, { + method: 'GET', + ...getGerritRequestOptions(this.integration.config), + // TODO(freben): The signal cast is there because pre-3.x versions of + // node-fetch have a very slightly deviating AbortSignal type signature. + // The difference does not affect us in practice however. The cast can + // be removed after we support ESM for CLI dependencies and migrate to + // version 3 of node-fetch. + // https://github.com/backstage/backstage/issues/8242 + signal: options?.signal as any, + }); + } catch (e) { + throw new Error(`Unable to read gerrit file ${url}, ${e}`); + } + if (response.ok) { + const responseBody = await response.text(); + return { + buffer: async () => Buffer.from(responseBody, 'base64'), + }; + } + if (response.status === 404) { + throw new NotFoundError(`File ${url} not found.`); + } + throw new Error( + `${url} could not be read as ${apiUrl}, ${response.status} ${response.statusText}`, + ); + } + + async readTree(): Promise { + throw new Error('GerritReader does not implement readTree'); + } + + async search(): Promise { + throw new Error('GerritReader does not implement search'); + } + + toString() { + const { host, password } = this.integration.config; + return `gerrit{host=${host},authed=${Boolean(password)}}`; + } +} diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index c0aae25808..e676d0e14c 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -20,6 +20,7 @@ import { ReaderFactory, UrlReader } from './types'; import { UrlReaderPredicateMux } from './UrlReaderPredicateMux'; import { AzureUrlReader } from './AzureUrlReader'; import { BitbucketUrlReader } from './BitbucketUrlReader'; +import { GerritUrlReader } from './GerritUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; import { DefaultReadTreeResponseFactory } from './tree'; @@ -82,6 +83,7 @@ export class UrlReaders { factories: factories.concat([ AzureUrlReader.factory, BitbucketUrlReader.factory, + GerritUrlReader.factory, GithubUrlReader.factory, GitlabUrlReader.factory, GoogleGcsUrlReader.factory, diff --git a/packages/backend-common/src/reading/index.ts b/packages/backend-common/src/reading/index.ts index 9c29bc93fe..c2c82e2bd5 100644 --- a/packages/backend-common/src/reading/index.ts +++ b/packages/backend-common/src/reading/index.ts @@ -16,6 +16,7 @@ export { AzureUrlReader } from './AzureUrlReader'; export { BitbucketUrlReader } from './BitbucketUrlReader'; +export { GerritUrlReader } from './GerritUrlReader'; export { GithubUrlReader } from './GithubUrlReader'; export { GitlabUrlReader } from './GitlabUrlReader'; export { AwsS3UrlReader } from './AwsS3UrlReader'; diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index d86acb291a..cdb260f089 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -138,6 +138,7 @@ export class GerritIntegration implements ScmIntegration { export type GerritIntegrationConfig = { host: string; baseUrl?: string; + gitilesBaseUrl?: string; username?: string; password?: string; }; @@ -184,6 +185,17 @@ export function getBitbucketRequestOptions( headers: Record; }; +// @public +export function getGerritFileContentsApiUrl( + config: GerritIntegrationConfig, + url: string, +): string; + +// @public +export function getGerritRequestOptions(config: GerritIntegrationConfig): { + headers?: Record; +}; + // @public export function getGitHubFileFetchUrl( url: string, @@ -330,6 +342,9 @@ export interface IntegrationsByType { gitlab: ScmIntegrationsGroup; } +// @public +export function parseGerritJsonResponse(response: Response): Promise; + // @public export function readAwsS3IntegrationConfig( config: Config, diff --git a/packages/integration/src/gerrit/config.test.ts b/packages/integration/src/gerrit/config.test.ts index ce1ddccd75..9c0d022548 100644 --- a/packages/integration/src/gerrit/config.test.ts +++ b/packages/integration/src/gerrit/config.test.ts @@ -56,6 +56,7 @@ describe('readGerritIntegrationConfig', () => { buildConfig({ host: 'a.com', baseUrl: 'https://a.com/api', + gitilesBaseUrl: 'https://a.com/git', username: 'u', password: 'p', }), @@ -63,6 +64,7 @@ describe('readGerritIntegrationConfig', () => { expect(output).toEqual({ host: 'a.com', baseUrl: 'https://a.com/api', + gitilesBaseUrl: 'https://a.com/git', username: 'u', password: 'p', }); @@ -77,6 +79,7 @@ describe('readGerritIntegrationConfig', () => { expect(output).toEqual({ host: 'a.com', baseUrl: 'https://a.com', + gitilesBaseUrl: 'https://a.com', username: undefined, password: undefined, }); @@ -107,6 +110,7 @@ describe('readGerritIntegrationConfig', () => { ).toEqual({ host: 'a.com', baseUrl: 'https://a.com/gerrit', + gitilesBaseUrl: 'https://a.com', }); }); }); @@ -116,7 +120,7 @@ describe('readGerritIntegrationConfigs', () => { return data.map(item => new ConfigReader(item)); } - it('reads all values', () => { + it('reads multiple configs', () => { const output = readGerritIntegrationConfigs( buildConfig([ { @@ -135,12 +139,14 @@ describe('readGerritIntegrationConfigs', () => { { host: 'a.com', baseUrl: 'https://a.com/api', + gitilesBaseUrl: 'https://a.com', username: 'u', password: 'p', }, { host: 'b.com', baseUrl: 'https://b.com/api', + gitilesBaseUrl: 'https://b.com', username: undefined, password: undefined, }, diff --git a/packages/integration/src/gerrit/config.ts b/packages/integration/src/gerrit/config.ts index 339b590c11..7dbb05d15b 100644 --- a/packages/integration/src/gerrit/config.ts +++ b/packages/integration/src/gerrit/config.ts @@ -38,6 +38,14 @@ export type GerritIntegrationConfig = { */ baseUrl?: string; + /** + * Optional base url for Gitiles. This is needed for creating a valid + * user-friendly url that can be used for browsing the content of the + * provider. If not set a default value will be created in the same way + * as the "baseUrl" option. + */ + gitilesBaseUrl?: string; + /** * The username to use for requests to gerrit. */ @@ -61,6 +69,7 @@ export function readGerritIntegrationConfig( ): GerritIntegrationConfig { const host = config.getString('host'); let baseUrl = config.getOptionalString('baseUrl'); + let gitilesBaseUrl = config.getOptionalString('gitilesBaseUrl'); const username = config.getOptionalString('username'); const password = config.getOptionalString('password'); @@ -72,16 +81,26 @@ export function readGerritIntegrationConfig( throw new Error( `Invalid Gerrit integration config, '${baseUrl}' is not a valid baseUrl`, ); + } else if (gitilesBaseUrl && !isValidUrl(gitilesBaseUrl)) { + throw new Error( + `Invalid Gerrit integration config, '${gitilesBaseUrl}' is not a valid gitilesBaseUrl`, + ); } if (baseUrl) { baseUrl = trimEnd(baseUrl, '/'); } else { baseUrl = `https://${host}`; } + if (gitilesBaseUrl) { + gitilesBaseUrl = trimEnd(gitilesBaseUrl, '/'); + } else { + gitilesBaseUrl = `https://${host}`; + } return { host, baseUrl, + gitilesBaseUrl, username, password, }; diff --git a/packages/integration/src/gerrit/core.test.ts b/packages/integration/src/gerrit/core.test.ts new file mode 100644 index 0000000000..b966a31b9e --- /dev/null +++ b/packages/integration/src/gerrit/core.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2020 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import fetch from 'cross-fetch'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { GerritIntegrationConfig } from './config'; +import { + getGerritRequestOptions, + parseGerritJsonResponse, + parseGitilesUrl, + getGerritFileContentsApiUrl, +} from './core'; + +describe('gerrit core', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('getGerritRequestOptions', () => { + it('adds headers when a password is specified', () => { + const authRequest: GerritIntegrationConfig = { + host: 'gerrit.com', + username: 'U', + password: 'P', + }; + const anonymousRequest: GerritIntegrationConfig = { + host: 'gerrit.com', + }; + expect( + (getGerritRequestOptions(authRequest).headers as any).Authorization, + ).toEqual('Basic VTpQ'); + expect( + getGerritRequestOptions(anonymousRequest).headers as any, + ).toBeUndefined(); + }); + }); + + describe('parseGitilesUrl', () => { + it('can parse a valid gitiles urls.', () => { + const config: GerritIntegrationConfig = { + host: 'gerrit.com', + gitilesBaseUrl: 'https://gerrit.com/gitiles', + }; + const { branch, filePath, project } = parseGitilesUrl( + config, + 'https://gerrit.com/gitiles/web/project/+/refs/heads/master/README.md', + ); + expect(project).toEqual('web/project'); + expect(branch).toEqual('master'); + expect(filePath).toEqual('README.md'); + + const { filePath: rootPath } = parseGitilesUrl( + config, + 'https://gerrit.com/gitiles/web/project/+/refs/heads/master', + ); + expect(rootPath).toEqual('/'); + }); + it('throws on incorrect gitiles urls.', () => { + const config: GerritIntegrationConfig = { + host: 'gerrit.com', + gitilesBaseUrl: 'https://gerrit.com', + }; + expect(() => + parseGitilesUrl( + config, + 'https://gerrit.com/+/refs/heads/master/README.md', + ), + ).toThrow(/project/); + expect(() => + parseGitilesUrl( + config, + 'https://gerrit.com/web/project/+/refs/changes/1/11/master/README.md', + ), + ).toThrow(/branch/); + }); + }); + + describe('getGerritFileContentsApiUrl', () => { + it('can create an url for anonymous access to the file fetch api.', () => { + const config: GerritIntegrationConfig = { + host: 'gerrit.com', + baseUrl: 'https://gerrit.com', + gitilesBaseUrl: 'https://gerrit.com', + }; + const fileContentUrl = getGerritFileContentsApiUrl( + config, + 'https://gerrit.com/web/project/+/refs/heads/master/README.md', + ); + expect(fileContentUrl).toEqual( + 'https://gerrit.com/projects/web%2Fproject/branches/master/files/README.md/content', + ); + }); + it('can create an url for authenticated access to the file fetch api.', () => { + const authConfig: GerritIntegrationConfig = { + host: 'gerrit.com', + baseUrl: 'https://gerrit.com', + gitilesBaseUrl: 'https://gerrit.com', + username: 'u', + password: 'u', + }; + const authFileContentUrl = getGerritFileContentsApiUrl( + authConfig, + 'https://gerrit.com/web/project/+/refs/heads/master/README.md', + ); + expect(authFileContentUrl).toEqual( + 'https://gerrit.com/a/projects/web%2Fproject/branches/master/files/README.md/content', + ); + }); + }); + + describe('parseGerritJsonResponse', () => { + it('can strip the magic prefix from the response.', async () => { + const responseBody = ")]}'[]"; + const apiUrl = 'https://gerrit.com/projects/'; + worker.use( + rest.get(apiUrl, (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.text(responseBody), + ), + ), + ); + const response = await fetch(apiUrl, { method: 'GET' }); + const jsonData = await parseGerritJsonResponse(response); + expect(jsonData).toEqual([]); + }); + it('will throw if the magic prefix is missing from the response.', async () => { + const responseBody = '[]'; + const apiUrl = 'https://gerrit.com/projects/'; + worker.use( + rest.get(apiUrl, (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.text(responseBody), + ), + ), + ); + const response = await fetch(apiUrl, { method: 'GET' }); + await expect(parseGerritJsonResponse(response)).rejects.toThrow( + /body prefix missing/, + ); + }); + it('will throw on invalid json with the magic prefix.', async () => { + const responseBody = ")]}']{}["; + const apiUrl = 'https://gerrit.com/projects/'; + worker.use( + rest.get(apiUrl, (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.text(responseBody), + ), + ), + ); + const response = await fetch(apiUrl, { method: 'GET' }); + await expect(parseGerritJsonResponse(response)).rejects.toThrow( + /response from/, + ); + }); + }); +}); diff --git a/packages/integration/src/gerrit/core.ts b/packages/integration/src/gerrit/core.ts new file mode 100644 index 0000000000..517d27d3ec --- /dev/null +++ b/packages/integration/src/gerrit/core.ts @@ -0,0 +1,165 @@ +/* + * 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 { trimStart } from 'lodash'; +import { GerritIntegrationConfig } from '.'; + +const GERRIT_BODY_PREFIX = ")]}'"; + +type GitFile = { + branch: string; + filePath: string; + project: string; +}; + +/** + * Parse a Gitiles URL and return branch, file path and project. + * + * @remarks + * + * Gerrit only handles code reviews so it does not have a native way to browse + * or showing the content of gits. Image if Github only had the "pull requests" + * tab. + * + * Any source code browsing is instead handled by optional services outside + * Gerrit. The url format chosen for the Gerrit url reader is the one used by + * the Gitiles project. Gerrit will work perfectly with Backstage without + * having Gitiles installed but there are some places in the Backstage GUI + * with links to the url used by the url reader. These will not work unless + * the urls point to an actual Gitiles installation. + * + * Gitiles url: + * https://g.com/optional_path/{project}/+/refs/heads/{branch}/{filePath} + * + * + * @param url - An URL pointing to a file stored in git. + * @public + */ + +export function parseGitilesUrl( + config: GerritIntegrationConfig, + url: string, +): GitFile { + const urlPath = url.replace(config.gitilesBaseUrl!, ''); + const parts = urlPath.split('/').filter(p => !!p); + + const projectEndIndex = parts.indexOf('+'); + + if (projectEndIndex <= 0) { + throw new Error(`Unable to parse project from url: ${url}`); + } + const project = trimStart(parts.slice(0, projectEndIndex).join('/'), '/'); + + const branchIndex = parts.indexOf('heads'); + if (branchIndex <= 0) { + throw new Error(`Unable to parse branch from url: ${url}`); + } + const branch = parts[branchIndex + 1]; + const filePath = parts.slice(branchIndex + 2).join('/'); + + return { + branch, + filePath: filePath === '' ? '/' : filePath, + project, + }; +} + +/** + * Return the authentication prefix. + * + * @remarks + * + * To authenticate with a password the API url must be prefixed with "/a/". + * If no password is set anonymous access (without the prefix) will + * be used. + * + * @param config - A Gerrit provider config. + * @public + */ +export function getAuthenticationPrefix( + config: GerritIntegrationConfig, +): string { + return config.password ? '/a/' : '/'; +} + +/** + * Return the url to fetch the contents of a file using the Gerrit API. + * + * @param url - An url pointing to a file in git. + * @public + */ +export function getGerritFileContentsApiUrl( + config: GerritIntegrationConfig, + url: string, +) { + const { branch, filePath, project } = parseGitilesUrl(config, url); + + return `${config.baseUrl}${getAuthenticationPrefix( + config, + )}projects/${encodeURIComponent( + project, + )}/branches/${branch}/files/${encodeURIComponent(filePath)}/content`; +} + +/** + * Return request headers for a Gerrit provider. + * + * @param config - A Gerrit provider config + * @public + */ +export function getGerritRequestOptions(config: GerritIntegrationConfig): { + headers?: Record; +} { + const headers: Record = {}; + + if (!config.password) { + return headers; + } + const buffer = Buffer.from(`${config.username}:${config.password}`, 'utf8'); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + return { + headers, + }; +} + +/** + * Parse the json response from Gerrit and strip the magic prefix. + * + * @remarks + * + * To prevent against XSSI attacks the JSON response body from Gerrit starts + * with a magic prefix that must be stripped before it can be fed to a JSON + * parser. + * + * @param response - An API response. + * @public + */ +export async function parseGerritJsonResponse( + response: Response, +): Promise { + const responseBody = await response.text(); + if (responseBody.startsWith(GERRIT_BODY_PREFIX)) { + try { + return JSON.parse(responseBody.slice(GERRIT_BODY_PREFIX.length)); + } catch (ex) { + throw new Error( + `Invalid response from Gerrit: ${responseBody.slice(0, 10)} - ${ex}`, + ); + } + } + throw new Error( + `Gerrit JSON body prefix missing. Found: ${responseBody.slice(0, 10)}`, + ); +} diff --git a/packages/integration/src/gerrit/index.ts b/packages/integration/src/gerrit/index.ts index baad597a22..75e9779dbc 100644 --- a/packages/integration/src/gerrit/index.ts +++ b/packages/integration/src/gerrit/index.ts @@ -18,4 +18,10 @@ export { readGerritIntegrationConfig, readGerritIntegrationConfigs, } from './config'; +export { + getGerritFileContentsApiUrl, + getGerritRequestOptions, + parseGerritJsonResponse, +} from './core'; + export type { GerritIntegrationConfig } from './config';