diff --git a/.changeset/calm-clocks-drum.md b/.changeset/calm-clocks-drum.md new file mode 100644 index 0000000000..feb35e0133 --- /dev/null +++ b/.changeset/calm-clocks-drum.md @@ -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. diff --git a/.changeset/fast-panthers-fold.md b/.changeset/fast-panthers-fold.md new file mode 100644 index 0000000000..f8adac413e --- /dev/null +++ b/.changeset/fast-panthers-fold.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add a `publish:gerrit:review` scaffolder action diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 6059e68792..d3726eb0e4 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -339,6 +339,8 @@ export class Git { remote: string; url: string; }): Promise; + // (undocumented) + checkout(options: { dir: string; ref: string }): Promise; clone(options: { url: string; dir: string; @@ -387,7 +389,11 @@ export class Git { }; }): Promise; // (undocumented) - push(options: { dir: string; remote: string }): Promise; + push(options: { + dir: string; + remote: string; + remoteRef?: string; + }): Promise; readCommit(options: { dir: string; sha: string }): Promise; resolveRef(options: { dir: string; ref: string }): Promise; } diff --git a/packages/backend-common/src/scm/git.test.ts b/packages/backend-common/src/scm/git.test.ts index 7b366e01c8..97342b3492 100644 --- a/packages/backend-common/src/scm/git.test.ts +++ b/packages/backend-common/src/scm/git.test.ts @@ -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'; diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts index e76f675821..b4f6644260 100644 --- a/packages/backend-common/src/scm/git.ts +++ b/packages/backend-common/src/scm/git.ts @@ -66,6 +66,13 @@ export class Git { return git.addRemote({ fs, dir, remote, url }); } + async checkout(options: { dir: string; ref: string }): Promise { + 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) { diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index 1b525cd2c6..830fe249ae 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -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; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 91f1e180d0..fcddf127e2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -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, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts index 78156a1075..efcf51c49c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts @@ -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', + }, + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts index dec62eb4a2..7a9127a206 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts @@ -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 { + 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; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.test.ts new file mode 100644 index 0000000000..55d7bde5a7 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.test.ts @@ -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(); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.ts new file mode 100644 index 0000000000..17068654c0 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/gerritReview.ts @@ -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); + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index 11ecd2b127..8819c93d08 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -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 {