feat(scaffolder): add a publish:gerrit:review action

Signed-off-by: Thomas Cardonne <thomas.cardonne@adevinta.com>
This commit is contained in:
Thomas Cardonne
2022-07-19 00:05:03 +02:00
parent e657ae1f90
commit fc8a5f797b
12 changed files with 553 additions and 4 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/backend-common': patch
---
Improve `scm/git` wrapper around `isomorphic-git` library :
- Add `checkout` function,
- Add optional `remoteRef` parameter in the `push` function.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add a `publish:gerrit:review` scaffolder action
+7 -1
View File
@@ -339,6 +339,8 @@ export class Git {
remote: string;
url: string;
}): Promise<void>;
// (undocumented)
checkout(options: { dir: string; ref: string }): Promise<void>;
clone(options: {
url: string;
dir: string;
@@ -387,7 +389,11 @@ export class Git {
};
}): Promise<MergeResult>;
// (undocumented)
push(options: { dir: string; remote: string }): Promise<PushResult>;
push(options: {
dir: string;
remote: string;
remoteRef?: string;
}): Promise<PushResult>;
readCommit(options: { dir: string; sha: string }): Promise<ReadCommitResult>;
resolveRef(options: { dir: string; ref: string }): Promise<string>;
}
@@ -60,6 +60,22 @@ describe('Git', () => {
});
});
describe('checkout', () => {
it('should call isomorphic-git with the correct arguments', async () => {
const git = Git.fromAuth({});
const dir = 'mockdirectory';
const ref = 'master';
await git.checkout({ dir, ref });
expect(isomorphic.checkout).toHaveBeenCalledWith({
fs,
dir,
ref,
});
});
});
describe('commit', () => {
it('should call isomorphic-git with the correct arguments', async () => {
const git = Git.fromAuth({});
@@ -310,6 +326,31 @@ describe('Git', () => {
onAuth: expect.any(Function),
});
});
it('should call isomorphic-git with remoteRef parameter', async () => {
const remote = 'origin';
const remoteRef = 'refs/for/master';
const dir = '/some/mock/dir';
const auth = {
username: 'blob',
password: 'hunter2',
};
const git = Git.fromAuth(auth);
await git.push({ dir, remote, remoteRef });
expect(isomorphic.push).toHaveBeenCalledWith({
fs,
http,
remote,
remoteRef,
dir,
onProgress: expect.any(Function),
headers: {
'user-agent': 'git/@isomorphic-git',
},
onAuth: expect.any(Function),
});
});
it('should pass a function that returns the authorization as the onAuth handler', async () => {
const remote = 'origin';
const dir = '/some/mock/dir';
+10 -2
View File
@@ -66,6 +66,13 @@ export class Git {
return git.addRemote({ fs, dir, remote, url });
}
async checkout(options: { dir: string; ref: string }): Promise<void> {
const { dir, ref } = options;
this.config.logger?.info(`Checking out branch {dir=${dir},ref=${ref}}`);
return git.checkout({ fs, dir, ref });
}
async commit(options: {
dir: string;
message: string;
@@ -190,8 +197,8 @@ export class Git {
});
}
async push(options: { dir: string; remote: string }) {
const { dir, remote } = options;
async push(options: { dir: string; remote: string; remoteRef?: string }) {
const { dir, remote, remoteRef } = options;
this.config.logger?.info(
`Pushing directory to remote {dir=${dir},remote=${remote}}`,
);
@@ -205,6 +212,7 @@ export class Git {
'user-agent': 'git/@isomorphic-git',
},
remote: remote,
remoteRef: remoteRef,
onAuth: this.onAuth,
});
} catch (ex) {
+13
View File
@@ -321,6 +321,19 @@ export function createPublishGerritAction(options: {
sourcePath?: string | undefined;
}>;
// @public
export function createPublishGerritReviewAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<{
repoUrl: string;
branch?: string | undefined;
sourcePath?: string | undefined;
gitCommitMessage?: string | undefined;
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
}>;
// @public
export function createPublishGithubAction(options: {
integrations: ScmIntegrationRegistry;
@@ -49,6 +49,7 @@ import {
createPublishBitbucketCloudAction,
createPublishBitbucketServerAction,
createPublishGerritAction,
createPublishGerritReviewAction,
createPublishGithubAction,
createPublishGithubPullRequestAction,
createPublishGitlabAction,
@@ -118,6 +119,10 @@ export const createBuiltinActions = (
integrations,
config,
}),
createPublishGerritReviewAction({
integrations,
config,
}),
createPublishGithubAction({
integrations,
config,
@@ -15,14 +15,16 @@
*/
import { Git, getVoidLogger } from '@backstage/backend-common';
import { initRepoAndPush } from './helpers';
import { commitAndPushRepo, initRepoAndPush } from './helpers';
jest.mock('@backstage/backend-common', () => ({
Git: {
fromAuth: jest.fn().mockReturnValue({
init: jest.fn(),
add: jest.fn(),
checkout: jest.fn(),
commit: jest.fn(),
fetch: jest.fn(),
addRemote: jest.fn(),
push: jest.fn(),
}),
@@ -146,3 +148,142 @@ describe('initRepoAndPush', () => {
});
});
});
describe('commitAndPushRepo', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe('with minimal parameters', () => {
beforeEach(async () => {
await commitAndPushRepo({
dir: '/test/repo/dir/',
auth: {
username: 'test-user',
password: 'test-password',
},
logger: getVoidLogger(),
commitMessage: 'commit message',
});
});
it('fetches commits', () => {
expect(mockedGit.fetch).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
});
});
it('checkouts to master', () => {
expect(mockedGit.checkout).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
ref: 'master',
});
});
it('stages all files in the repo', () => {
expect(mockedGit.add).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
filepath: '.',
});
});
it('creates a commit', () => {
expect(mockedGit.commit).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
message: 'commit message',
author: {
name: 'Scaffolder',
email: 'scaffolder@backstage.io',
},
committer: {
name: 'Scaffolder',
email: 'scaffolder@backstage.io',
},
});
});
it('pushes to the remote', () => {
expect(mockedGit.push).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
remote: 'origin',
remoteRef: 'refs/heads/master',
});
});
});
it('allows overriding the default branch', async () => {
await commitAndPushRepo({
dir: '/test/repo/dir/',
auth: {
username: 'test-user',
password: 'test-password',
},
logger: getVoidLogger(),
commitMessage: 'commit message',
branch: 'otherbranch',
});
expect(mockedGit.checkout).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
ref: 'otherbranch',
});
expect(mockedGit.push).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
remote: 'origin',
remoteRef: 'refs/heads/otherbranch',
});
});
it('allows overriding the remote ref', async () => {
await commitAndPushRepo({
dir: '/test/repo/dir/',
auth: {
username: 'test-user',
password: 'test-password',
},
logger: getVoidLogger(),
commitMessage: 'commit message',
remoteRef: 'refs/for/master',
});
expect(mockedGit.checkout).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
ref: 'master',
});
expect(mockedGit.push).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
remote: 'origin',
remoteRef: 'refs/for/master',
});
});
it('allows overriding the author', async () => {
await commitAndPushRepo({
dir: '/test/repo/dir/',
commitMessage: 'commit message',
gitAuthorInfo: {
name: 'Custom Scaffolder Author',
email: 'scaffolder@example.org',
},
auth: {
username: 'test-user',
password: 'test-password',
},
logger: getVoidLogger(),
branch: 'master',
});
expect(mockedGit.commit).toHaveBeenCalledWith({
dir: '/test/repo/dir/',
message: 'commit message',
author: {
name: 'Custom Scaffolder Author',
email: 'scaffolder@example.org',
},
committer: {
name: 'Custom Scaffolder Author',
email: 'scaffolder@example.org',
},
});
});
});
@@ -127,6 +127,53 @@ export async function initRepoAndPush({
});
}
export async function commitAndPushRepo({
dir,
auth,
logger,
commitMessage,
gitAuthorInfo,
branch = 'master',
remoteRef,
}: {
dir: string;
auth: { username: string; password: string };
logger: Logger;
commitMessage: string;
gitAuthorInfo?: { name?: string; email?: string };
branch?: string;
remoteRef?: string;
}): Promise<void> {
const git = Git.fromAuth({
username: auth.username,
password: auth.password,
logger,
});
await git.fetch({ dir });
await git.checkout({ dir, ref: branch });
await git.add({ dir, filepath: '.' });
// use provided info if possible, otherwise use fallbacks
const authorInfo = {
name: gitAuthorInfo?.name ?? 'Scaffolder',
email: gitAuthorInfo?.email ?? 'scaffolder@backstage.io',
};
await git.commit({
dir,
message: commitMessage,
author: authorInfo,
committer: authorInfo,
});
await git.push({
dir,
remote: 'origin',
remoteRef: remoteRef ?? `refs/heads/${branch}`,
});
}
type BranchProtectionOptions = {
client: Octokit;
owner: string;
@@ -0,0 +1,118 @@
/*
* 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 { createPublishGerritReviewAction } from './gerritReview';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { commitAndPushRepo } from '../helpers';
describe('publish:gerrit:review', () => {
const config = new ConfigReader({
integrations: {
gerrit: [
{
host: 'gerrithost.org',
username: 'gerrituser',
password: 'usertoken',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishGerritReviewAction({ integrations, config });
const mockContext = {
input: {
repoUrl:
'gerrithost.org?owner=owner&workspace=parent&project=project&repo=repo',
gitCommitMessage: 'Review from backstage',
},
workspacePath: 'workspace',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
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&owner=o',
},
}),
).rejects.toThrow(/missing repo/);
});
it('should throw an error when no commit message is provided', async () => {
await expect(
action.handler({
...mockContext,
input: { repoUrl: 'gerrithost.org?workspace=w&owner=o&repo=r' },
}),
).rejects.toThrow(/Missing gitCommitMessage input/);
});
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',
},
}),
).rejects.toThrow(/No matching integration configuration/);
});
it('can correctly create a review', async () => {
expect.assertions(3);
await action.handler(mockContext);
expect(commitAndPushRepo).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
auth: { username: 'gerrituser', password: 'usertoken' },
logger: mockContext.logger,
commitMessage: expect.stringContaining(
'Review from backstage\n\nChange-Id:',
),
gitAuthorInfo: {},
branch: 'master',
remoteRef: 'refs/for/master',
});
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://gerrithost.org/repo/+/refs/heads/master',
);
expect(mockContext.output).toHaveBeenCalledWith(
'reviewUrl',
expect.stringMatching(new RegExp('^https://gerrithost.org/#/q/I')),
);
});
afterEach(() => {
jest.resetAllMocks();
});
});
@@ -0,0 +1,156 @@
/*
* 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 { ScmIntegrationRegistry } from '@backstage/integration';
import { createTemplateAction } from '../../createTemplateAction';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { commitAndPushRepo } from '../helpers';
const generateGerritChangeId = (): string => {
const changeId = crypto.randomBytes(20).toString('hex');
return `I${changeId}`;
};
/**
* Creates a new action that creates a Gerrit review
* @public
*/
export function createPublishGerritReviewAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
branch?: string;
sourcePath?: string;
gitCommitMessage?: string;
gitAuthorName?: string;
gitAuthorEmail?: string;
}>({
id: 'publish:gerrit:review',
description: 'Creates a new Gerrit review.',
schema: {
input: {
type: 'object',
required: ['repoUrl', 'gitCommitMessage'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
branch: {
title: 'Repository branch',
type: 'string',
description:
'Branch of the repository the review will be created on',
},
sourcePath: {
type: 'string',
title: 'Working Subdirectory',
description:
'Subdirectory of working directory containing the repository',
},
gitCommitMessage: {
title: 'Git Commit Message',
type: 'string',
description: `Sets the commit message on the repository.`,
},
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: {
reviewUrl: {
title: 'A URL to the review',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
branch = 'master',
sourcePath,
gitAuthorName,
gitAuthorEmail,
gitCommitMessage,
} = ctx.input;
const { host, repo } = parseRepoUrl(repoUrl, integrations);
if (!gitCommitMessage) {
throw new InputError(`Missing gitCommitMessage input`);
}
const integrationConfig = integrations.gerrit.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
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 changeId = generateGerritChangeId();
const commitMessage = `${gitCommitMessage}\n\nChange-Id: ${changeId}`;
await commitAndPushRepo({
dir: getRepoSourceDirectory(ctx.workspacePath, sourcePath),
auth,
logger: ctx.logger,
commitMessage,
gitAuthorInfo,
branch,
remoteRef: `refs/for/${branch}`,
});
const repoContentsUrl = `${integrationConfig.config.gitilesBaseUrl}/${repo}/+/refs/heads/${branch}`;
const reviewUrl = `${integrationConfig.config.baseUrl}/#/q/${changeId}`;
ctx.logger?.info(`Review available on ${reviewUrl}`);
ctx.output('repoContentsUrl', repoContentsUrl);
ctx.output('reviewUrl', reviewUrl);
},
});
}
@@ -20,6 +20,7 @@ export { createPublishBitbucketCloudAction } from './bitbucketCloud';
export { createPublishBitbucketServerAction } from './bitbucketServer';
export { createPublishFileAction } from './file';
export { createPublishGerritAction } from './gerrit';
export { createPublishGerritReviewAction } from './gerritReview';
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
export type {