Added scaffolder support for publishing to Gerrit

This patch enables support for publishing the workspace content to new
project in Gerrit.

This can be broken down to three things:

* "resolveUrl" for the Gerrit integration have been updated to handle
  absolute paths correctly.
* "RepoUrlPicker" has been updated to handle gerrit hosts.
* A new scaffolder action has been added that will publish the workspace
  content to a newly created Gerrit project.

Signed-off-by: Niklas Aronsson <niklasar@axis.com>
This commit is contained in:
Niklas Aronsson
2022-05-10 14:28:02 +02:00
parent 490d663a55
commit 72dfcbc8bf
17 changed files with 615 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
A new scaffolder action has been added: `gerrit:publish`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': patch
---
Gerrit Integration: Handle absolute paths in `resolveUrl` properly.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Gerrit Integration: Implemented a `RepoUrlPicker` for Gerrit.
@@ -97,6 +97,24 @@ describe('GerritIntegration', () => {
});
});
describe('resolves with an absolute url', () => {
it('works for valid urls', () => {
const integration = new GerritIntegration({
host: 'gerrit-review.example.com',
gitilesBaseUrl: 'https://gerrit-review.example.com/gitiles',
} as any);
expect(
integration.resolveUrl({
url: '/catalog-info.yaml',
base: 'https://gerrit-review.example.com/gitiles/repo/+/refs/heads/master/',
}),
).toBe(
'https://gerrit-review.example.com/gitiles/repo/+/refs/heads/master/catalog-info.yaml',
);
});
});
it('resolve edit URL', () => {
const integration = new GerritIntegration({
host: 'gerrit-review.example.com',
@@ -20,6 +20,7 @@ import {
GerritIntegrationConfig,
readGerritIntegrationConfigs,
} from './config';
import { parseGerritGitilesUrl, builldGerritGitilesUrl } from './core';
/**
* A Gerrit based integration.
@@ -58,6 +59,10 @@ export class GerritIntegration implements ScmIntegration {
}): string {
const { url, base, lineNumber } = options;
let updated;
if (url.startsWith('/')) {
const { branch, project } = parseGerritGitilesUrl(this.config, base);
return builldGerritGitilesUrl(this.config, project, branch, url);
}
if (url) {
updated = new URL(url, base);
} else {
@@ -20,6 +20,7 @@ import fetch from 'cross-fetch';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { GerritIntegrationConfig } from './config';
import {
builldGerritGitilesUrl,
getGerritBranchApiUrl,
getGerritCloneRepoUrl,
getGerritRequestOptions,
@@ -32,6 +33,20 @@ describe('gerrit core', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
describe('builldGerritGitilesUrl', () => {
it('can create an url from arguments', () => {
const config: GerritIntegrationConfig = {
host: 'gerrit.com',
gitilesBaseUrl: 'https://gerrit.com/gitiles',
};
expect(
builldGerritGitilesUrl(config, 'repo', 'dev', 'catalog-info.yaml'),
).toEqual(
'https://gerrit.com/gitiles/repo/+/refs/heads/dev/catalog-info.yaml',
);
});
});
describe('getGerritRequestOptions', () => {
it('adds headers when a password is specified', () => {
const authRequest: GerritIntegrationConfig = {
+20
View File
@@ -70,6 +70,26 @@ export function parseGerritGitilesUrl(
};
}
/**
* Build a Gerrit Gitiles url that targets a specific path.
*
* @param config - A Gerrit provider config.
* @param project - The name of the git project
* @param branch - The branch we will target.
* @param filePath - The absolute file path.
* @public
*/
export function builldGerritGitilesUrl(
config: GerritIntegrationConfig,
project: string,
branch: string,
filePath: string,
): string {
return `${
config.gitilesBaseUrl
}/${project}/+/refs/heads/${branch}/${trimStart(filePath, '/')}`;
}
/**
* Return the authentication prefix.
*
+13
View File
@@ -247,6 +247,19 @@ export function createPublishFileAction(): TemplateAction<{
path: string;
}>;
// @public
export function createPublishGerritAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<{
repoUrl: string;
description: string;
defaultBranch?: string | undefined;
gitCommitMessage?: string | undefined;
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
}>;
// @public
export function createPublishGithubAction(options: {
integrations: ScmIntegrationRegistry;
@@ -39,6 +39,7 @@ import {
createPublishBitbucketAction,
createPublishBitbucketCloudAction,
createPublishBitbucketServerAction,
createPublishGerritAction,
createPublishGithubAction,
createPublishGithubPullRequestAction,
createPublishGitlabAction,
@@ -111,6 +112,10 @@ export const createBuiltinActions = (
reader,
additionalTemplateFilters,
}),
createPublishGerritAction({
integrations,
config,
}),
createPublishGithubAction({
integrations,
config,
@@ -0,0 +1,143 @@
/*
* 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.
*/
jest.mock('../helpers');
import { createPublishGerritAction } from './gerrit';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../helpers';
describe('publish:gerrit', () => {
const config = new ConfigReader({
integrations: {
gerrit: [
{
host: 'gerrithost.org',
username: 'gerrituser',
password: 'usertoken',
},
],
},
});
const description = 'for the lols';
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishGerritAction({ integrations, config });
const mockContext = {
input: {
repoUrl:
'gerrithost.org?owner=owner&workspace=parent&project=project&repo=repo',
description,
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
const server = setupServer();
setupRequestMockHandlers(server);
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw an error when the repoUrl is not well formed', async () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'gerrithost.org?workspace=w&repo=repo', description },
}),
).rejects.toThrow(/missing owner/);
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'gerrithost.org?workspace=w&owner=o', description },
}),
).rejects.toThrow(/missing repo/);
});
it('should throw if there is no integration config provided', async () => {
await expect(
action.handler({
...mockContext,
input: {
repoUrl: 'missing.com?workspace=w&owner=o&repo=repo',
description,
},
}),
).rejects.toThrow(/No matching integration configuration/);
});
it('can correctly create a new project', async () => {
expect.assertions(5);
server.use(
rest.put('https://gerrithost.org/a/projects/repo', (req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(
'Basic Z2Vycml0dXNlcjp1c2VydG9rZW4=',
);
expect(req.body).toEqual({
create_empty_commit: false,
owners: ['owner'],
description,
parent: 'workspace',
});
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({}),
);
}),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'gerrithost.org?workspace=workspace&owner=owner&repo=repo',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://gerrithost.org/a/repo',
defaultBranch: 'master',
auth: { username: 'gerrituser', password: 'usertoken' },
logger: mockContext.logger,
commitMessage: expect.stringContaining('initial commit\n\nChange-Id:'),
gitAuthorInfo: {},
});
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://gerrithost.org/a/repo',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://gerrithost.org/repo/+/refs/heads/master',
);
});
afterEach(() => {
jest.resetAllMocks();
});
});
@@ -0,0 +1,216 @@
/*
* 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 crypto from 'crypto';
import { InputError } from '@backstage/errors';
import { Config } from '@backstage/config';
import {
GerritIntegrationConfig,
getGerritRequestOptions,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { createTemplateAction } from '../../createTemplateAction';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import fetch, { Response, RequestInit } from 'node-fetch';
import { initRepoAndPush } from '../helpers';
const createGerritProject = async (
config: GerritIntegrationConfig,
options: {
projectName: string;
parent: string;
owner: string;
description: string;
},
): Promise<void> => {
const { projectName, parent, owner, description } = options;
const fetchOptions: RequestInit = {
method: 'PUT',
body: JSON.stringify({
parent,
description,
owners: [owner],
create_empty_commit: false,
}),
headers: {
...getGerritRequestOptions(config).headers,
'Content-Type': 'application/json',
},
};
const response: Response = await fetch(
`${config.baseUrl}/a/projects/${encodeURIComponent(projectName)}`,
fetchOptions,
);
if (response.status !== 201) {
throw new Error(
`Unable to create repository, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
};
const generateCommitMessage = (
config: Config,
commitSubject?: string,
): string => {
const changeId = crypto.randomBytes(20).toString('hex');
const msg = `${
config.getOptionalString('scaffolder.defaultCommitMessage') || commitSubject
}\n\nChange-Id: I${changeId}`;
return msg;
};
/**
* Creates a new action that initializes a git repository of the content in the workspace
* and publishes it to a Gerrit instance.
* @public
*/
export function createPublishGerritAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
description: string;
defaultBranch?: string;
gitCommitMessage?: string;
gitAuthorName?: string;
gitAuthorEmail?: string;
}>({
id: 'publish:gerrit',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Gerrit.',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
description: {
title: 'Repository Description',
type: 'string',
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
gitCommitMessage: {
title: 'Git Commit Message',
type: 'string',
description: `Sets the commit message on the repository. The default value is 'initial commit'`,
},
gitAuthorName: {
title: 'Default Author Name',
type: 'string',
description: `Sets the default author name for the commit. The default value is 'Scaffolder'`,
},
gitAuthorEmail: {
title: 'Default Author Email',
type: 'string',
description: `Sets the default author email for the commit.`,
},
},
},
output: {
type: 'object',
properties: {
remoteUrl: {
title: 'A URL to the repository with the provider',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
description,
defaultBranch = 'master',
gitAuthorName,
gitAuthorEmail,
gitCommitMessage = 'initial commit',
} = ctx.input;
const { repo, host, owner, workspace } = parseRepoUrl(
repoUrl,
integrations,
);
const integrationConfig = integrations.gerrit.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
if (!owner) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing owner`,
);
}
if (!workspace) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing workspace`,
);
}
await createGerritProject(integrationConfig.config, {
description,
owner: owner,
projectName: repo,
parent: workspace,
});
const auth = {
username: integrationConfig.config.username!,
password: integrationConfig.config.password!,
};
const gitAuthorInfo = {
name: gitAuthorName
? gitAuthorName
: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: gitAuthorEmail
? gitAuthorEmail
: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
const remoteUrl = `${integrationConfig.config.cloneUrl}/a/${repo}`;
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, undefined),
remoteUrl,
auth,
defaultBranch,
logger: ctx.logger,
commitMessage: generateCommitMessage(config, gitCommitMessage),
gitAuthorInfo,
});
const repoContentsUrl = `${integrationConfig.config.gitilesBaseUrl}/${repo}/+/refs/heads/${defaultBranch}`;
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
@@ -19,6 +19,7 @@ export { createPublishBitbucketAction } from './bitbucket';
export { createPublishBitbucketCloudAction } from './bitbucketCloud';
export { createPublishBitbucketServerAction } from './bitbucketServer';
export { createPublishFileAction } from './file';
export { createPublishGerritAction } from './gerrit';
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
export type {
+1
View File
@@ -194,6 +194,7 @@ export interface RepoUrlPickerUiOptions {
requestUserCredentials?: {
secretsKey: string;
additionalScopes?: {
gerrit?: string[];
github?: string[];
gitlab?: string[];
bitbucket?: string[];
+1
View File
@@ -84,6 +84,7 @@ export class ScaffolderClient implements ScaffolderApi {
),
...this.scmIntegrationsApi.bitbucketCloud.list(),
...this.scmIntegrationsApi.bitbucketServer.list(),
...this.scmIntegrationsApi.gerrit.list(),
...this.scmIntegrationsApi.github.list(),
...this.scmIntegrationsApi.gitlab.list(),
]
@@ -0,0 +1,78 @@
/*
* 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 React from 'react';
import { GerritRepoPicker } from './GerritRepoPicker';
import { render, fireEvent } from '@testing-library/react';
describe('BitbucketRepoPicker', () => {
describe('owner input field', () => {
it('calls onChange when the owner input changes', () => {
const onChange = jest.fn();
const { getAllByRole } = render(
<GerritRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'gerrithost.org' }}
/>,
);
const ownerInput = getAllByRole('textbox')[0];
fireEvent.change(ownerInput, { target: { value: 'test-owner' } });
expect(onChange).toHaveBeenCalledWith({ owner: 'test-owner' });
});
});
describe('parent field', () => {
it('calls onChange when the parent changes', () => {
const onChange = jest.fn();
const { getAllByRole } = render(
<GerritRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'gerrithost.org' }}
/>,
);
const parentInput = getAllByRole('textbox')[1];
fireEvent.change(parentInput, { target: { value: 'test-parent' } });
expect(onChange).toHaveBeenCalledWith({ workspace: 'test-parent' });
});
});
describe('repoName field', () => {
it('calls onChange when the repoName changes', () => {
const onChange = jest.fn();
const { getAllByRole } = render(
<GerritRepoPicker
onChange={onChange}
rawErrors={[]}
state={{ host: 'gerrithost.org' }}
/>,
);
const repoNameInput = getAllByRole('textbox')[2];
fireEvent.change(repoNameInput, { target: { value: 'test-repo' } });
expect(onChange).toHaveBeenCalledWith({ repoName: 'test-repo' });
});
});
});
@@ -0,0 +1,75 @@
/*
* 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 React from 'react';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import Input from '@material-ui/core/Input';
import InputLabel from '@material-ui/core/InputLabel';
import { RepoUrlPickerState } from './types';
export const GerritRepoPicker = (props: {
onChange: (state: RepoUrlPickerState) => void;
state: RepoUrlPickerState;
rawErrors: string[];
}) => {
const { onChange, rawErrors, state } = props;
const { workspace, repoName, owner } = state;
return (
<>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !workspace}
>
<InputLabel htmlFor="ownerInput">Owner</InputLabel>
<Input
id="ownerInput"
onChange={e => onChange({ owner: e.target.value })}
value={owner}
/>
<FormHelperText>The owner of the project</FormHelperText>
</FormControl>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !workspace}
>
<InputLabel htmlFor="parentInput">Parent</InputLabel>
<Input
id="parentInput"
onChange={e => onChange({ workspace: e.target.value })}
value={workspace}
/>
<FormHelperText>
The project parent that the repo will belong to
</FormHelperText>
</FormControl>
<FormControl
margin="normal"
required
error={rawErrors?.length > 0 && !repoName}
>
<InputLabel htmlFor="repoNameInput">Repository</InputLabel>
<Input
id="repoNameInput"
onChange={e => onChange({ repoName: e.target.value })}
value={repoName}
/>
<FormHelperText>The name of the repository</FormHelperText>
</FormControl>
</>
);
};
@@ -23,6 +23,7 @@ import { GithubRepoPicker } from './GithubRepoPicker';
import { GitlabRepoPicker } from './GitlabRepoPicker';
import { AzureRepoPicker } from './AzureRepoPicker';
import { BitbucketRepoPicker } from './BitbucketRepoPicker';
import { GerritRepoPicker } from './GerritRepoPicker';
import { FieldExtensionComponentProps } from '../../../extensions';
import { RepoUrlPickerHost } from './RepoUrlPickerHost';
import { parseRepoPickerUrl, serializeRepoPickerUrl } from './utils';
@@ -42,6 +43,7 @@ export interface RepoUrlPickerUiOptions {
requestUserCredentials?: {
secretsKey: string;
additionalScopes?: {
gerrit?: string[];
github?: string[];
gitlab?: string[];
bitbucket?: string[];
@@ -170,6 +172,13 @@ export const RepoUrlPicker = (
onChange={updateLocalState}
/>
)}
{hostType === 'gerrit' && (
<GerritRepoPicker
rawErrors={rawErrors}
state={state}
onChange={updateLocalState}
/>
)}
</>
);
};