diff --git a/.changeset/nine-olives-swim.md b/.changeset/nine-olives-swim.md new file mode 100644 index 0000000000..40852c08c2 --- /dev/null +++ b/.changeset/nine-olives-swim.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': minor +--- + +Added functions to clone a repo, create a branch, add files and push and commit to the branch. This allows for files to be added to the a PR for use in the bitbucket pull request action for issue #21762 diff --git a/.changeset/unlucky-wasps-tan.md b/.changeset/unlucky-wasps-tan.md new file mode 100644 index 0000000000..97813cb416 --- /dev/null +++ b/.changeset/unlucky-wasps-tan.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket': patch +--- + +Enhanced the pull request action to allow for adding new content to the PR as described in this issue #21762 diff --git a/plugins/scaffolder-backend-module-bitbucket/package.json b/plugins/scaffolder-backend-module-bitbucket/package.json index 03ebab43fc..e045bb9dc8 100644 --- a/plugins/scaffolder-backend-module-bitbucket/package.json +++ b/plugins/scaffolder-backend-module-bitbucket/package.json @@ -28,6 +28,7 @@ "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", + "fs-extra": "10.1.0", "node-fetch": "^2.6.7", "yaml": "^2.0.0" }, diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts index dc75145ebc..fc123f0b80 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts @@ -21,10 +21,16 @@ import { } from '@backstage/integration'; import { createTemplateAction, + getRepoSourceDirectory, + commitAndPushBranch, + addFiles, + createBranch as createGitBranch, + cloneRepo, parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import fetch, { RequestInit, Response } from 'node-fetch'; import { Config } from '@backstage/config'; +import fs from 'fs-extra'; const createPullRequest = async (opts: { project: string; @@ -152,7 +158,51 @@ const findBranches = async (opts: { return undefined; }; +const createBranch = async (opts: { + project: string; + repo: string; + branchName: string; + authorization: string; + apiBaseUrl: string; + startPoint: string; +}) => { + const { project, repo, branchName, authorization, apiBaseUrl, startPoint } = + opts; + let response: Response; + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: branchName, + startPoint, + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/projects/${encodeURIComponent( + project, + )}/repos/${encodeURIComponent(repo)}/branches`, + options, + ); + } catch (e) { + throw new Error(`Unable to create branch, ${e}`); + } + + if (response.status !== 200) { + throw new Error( + `Unable to create branch, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + return await response.json(); +}; /** * Creates a BitbucketServer Pull Request action. * @public @@ -161,7 +211,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { integrations: ScmIntegrationRegistry; config: Config; }) { - const { integrations } = options; + const { integrations, config } = options; return createTemplateAction<{ repoUrl: string; @@ -268,7 +318,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { apiBaseUrl, }); - const fromRef = await findBranches({ + let fromRef = await findBranches({ project, repo, branchName: sourceBranch, @@ -276,6 +326,83 @@ export function createPublishBitbucketServerPullRequestAction(options: { apiBaseUrl, }); + if (!fromRef) { + // create branch + ctx.logger.info( + `source branch not found -> creating branch named: ${sourceBranch} lastCommit: ${toRef.latestCommit}`, + ); + const latestCommit = toRef.latestCommit; + + fromRef = await createBranch({ + project, + repo, + branchName: sourceBranch, + authorization, + apiBaseUrl, + startPoint: latestCommit, + }); + + const remoteUrl = `https://${host}/scm/${project}/${repo}.git`; + + const auth = authConfig.token + ? { + token: token!, + } + : { + username: authConfig.username!, + password: authConfig.password!, + }; + + const gitAuthorInfo = { + name: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + + const tempDir = await ctx.createTemporaryDirectory(); + const sourceDir = getRepoSourceDirectory(ctx.workspacePath, undefined); + await cloneRepo({ + url: remoteUrl, + dir: tempDir, + auth, + logger: ctx.logger, + ref: sourceBranch, + }); + + await createGitBranch({ + dir: tempDir, + auth, + logger: ctx.logger, + ref: sourceBranch, + }); + + // copy files + fs.cpSync(sourceDir, tempDir, { + recursive: true, + filter: path => { + return !(path.indexOf('.git') > -1); + }, + }); + + await addFiles({ + dir: tempDir, + auth, + logger: ctx.logger, + filepath: '.', + }); + + await commitAndPushBranch({ + dir: tempDir, + auth, + logger: ctx.logger, + commitMessage: + description ?? + config.getOptionalString('scaffolder.defaultCommitMessage') ?? + '', + gitAuthorInfo, + branch: sourceBranch, + }); + } + const pullRequestUrl = await createPullRequest({ project, repo, diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index 6bb1534679..1d9adb20d9 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -45,6 +45,63 @@ export type ActionContext< each?: JsonObject; }; +// @public (undocumented) +export function addFiles(options: { + dir: string; + filepath: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: Logger | undefined; +}): Promise; + +// @public (undocumented) +export function cloneRepo(options: { + url: string; + dir: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: Logger | undefined; + ref?: string | undefined; + depth?: number | undefined; + noCheckout?: boolean | undefined; +}): Promise; + +// @public (undocumented) +export function commitAndPushBranch(options: { + dir: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: Logger | undefined; + commitMessage: string; + gitAuthorInfo?: { + name?: string; + email?: string; + }; + branch?: string; + remoteRef?: string; + remote?: string; +}): Promise<{ + commitHash: string; +}>; + // @public (undocumented) export function commitAndPushRepo(input: { dir: string; @@ -68,6 +125,21 @@ export function commitAndPushRepo(input: { commitHash: string; }>; +// @public (undocumented) +export function createBranch(options: { + dir: string; + ref: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: Logger | undefined; +}): Promise; + // @public export const createTemplateAction: < TInputParams extends JsonObject = JsonObject, diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts index 7bc3f64f46..b230b2cd06 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts @@ -15,7 +15,14 @@ */ import { Git, getVoidLogger } from '@backstage/backend-common'; -import { commitAndPushRepo, initRepoAndPush } from './gitHelpers'; +import { + commitAndPushRepo, + initRepoAndPush, + commitAndPushBranch, + addFiles, + createBranch, + cloneRepo, +} from './gitHelpers'; jest.mock('@backstage/backend-common', () => ({ Git: { @@ -29,10 +36,14 @@ jest.mock('@backstage/backend-common', () => ({ fetch: jest.fn(), addRemote: jest.fn(), push: jest.fn(), + clone: jest.fn(), }), }, getVoidLogger: jest.requireActual('@backstage/backend-common').getVoidLogger, })); +jest.mock('fs-extra', () => ({ + cpSync: jest.fn(), +})); const mockedGit = Git.fromAuth({ logger: getVoidLogger(), @@ -303,3 +314,333 @@ describe('commitAndPushRepo', () => { }); }); }); + +describe('cloneRepo', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + }); + + it('clone the repo', () => { + expect(mockedGit.clone).toHaveBeenCalledWith({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: undefined, + depth: undefined, + noCheckout: undefined, + }); + }); + }); + + it('with token', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + auth: { + token: 'test-token', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: undefined, + depth: undefined, + noCheckout: undefined, + }); + }); + + it('allows overriding the default branch', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: 'trunk', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: 'trunk', + depth: undefined, + noCheckout: undefined, + }); + }); + + it('allows overriding the depth', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: 'trunk', + depth: 2, + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: 'trunk', + depth: 2, + noCheckout: undefined, + }); + }); + + it('allows overriding the noCheckout', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: 'trunk', + depth: 2, + noCheckout: true, + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: 'trunk', + depth: 2, + noCheckout: true, + }); + }); +}); + +describe('createBranch', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await createBranch({ + dir: '/tmp/repo/dir/', + ref: 'trunk', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + }); + + it('create the branch', () => { + expect(mockedGit.checkout).toHaveBeenCalledWith({ + ref: 'trunk', + dir: '/tmp/repo/dir/', + }); + }); + }); + + it('with token', async () => { + await createBranch({ + dir: '/tmp/repo/dir/', + ref: 'trunk', + auth: { + token: 'test-token', + }, + }); + + expect(mockedGit.checkout).toHaveBeenCalledWith({ + ref: 'trunk', + dir: '/tmp/repo/dir/', + }); + }); +}); + +describe('addFiles', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await addFiles({ + dir: '/tmp/repo/dir/', + filepath: '.', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + }); + + it('add files', () => { + expect(mockedGit.add).toHaveBeenCalledWith({ + filepath: '.', + dir: '/tmp/repo/dir/', + }); + }); + }); + + it('with token', async () => { + await addFiles({ + dir: '/tmp/repo/dir/', + filepath: '.', + auth: { + token: 'test-token', + }, + }); + + expect(mockedGit.add).toHaveBeenCalledWith({ + filepath: '.', + dir: '/tmp/repo/dir/', + }); + }); +}); + +describe('commitAndPushBranch', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await commitAndPushBranch({ + dir: '/tmp/repo/dir/', + auth: { + username: 'test-user', + password: 'test-password', + }, + commitMessage: 'commit message', + }); + }); + + it('create commit', () => { + expect(mockedGit.commit).toHaveBeenCalledWith({ + message: 'commit message', + dir: '/tmp/repo/dir/', + author: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + committer: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + }); + }); + + it('pushes to the remote', () => { + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'refs/heads/master', + }); + }); + }); + + it('with token', async () => { + await commitAndPushBranch({ + dir: '/tmp/repo/dir/', + auth: { + token: 'test-token', + }, + commitMessage: 'commit message', + gitAuthorInfo: { + name: 'gitCommitter', + email: 'gitCommitter@backstage.io', + }, + logger: getVoidLogger(), + }); + + expect(mockedGit.commit).toHaveBeenCalledWith({ + message: 'commit message', + dir: '/tmp/repo/dir/', + author: { + name: 'gitCommitter', + email: 'gitCommitter@backstage.io', + }, + committer: { + name: 'gitCommitter', + email: 'gitCommitter@backstage.io', + }, + }); + + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'refs/heads/master', + }); + }); + + it('allows overriding the default branch', async () => { + await commitAndPushBranch({ + dir: '/tmp/repo/dir/', + auth: { + username: 'test-user', + password: 'test-password', + }, + commitMessage: 'commit message', + branch: 'trunk', + }); + + expect(mockedGit.commit).toHaveBeenCalledWith({ + message: 'commit message', + dir: '/tmp/repo/dir/', + author: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + committer: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + }); + + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'refs/heads/trunk', + }); + }); + + it('allows overriding the remoteRef', async () => { + await commitAndPushBranch({ + dir: '/tmp/repo/dir/', + auth: { + username: 'test-user', + password: 'test-password', + }, + commitMessage: 'commit message', + remoteRef: 'ABC123', + }); + + expect(mockedGit.commit).toHaveBeenCalledWith({ + message: 'commit message', + dir: '/tmp/repo/dir/', + author: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + committer: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + }); + + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'ABC123', + }); + }); +}); diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.ts b/plugins/scaffolder-node/src/actions/gitHelpers.ts index 6d449a0a29..2026e75247 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.ts @@ -134,3 +134,123 @@ export async function commitAndPushRepo(input: { return { commitHash }; } + +/** + * @public + */ +export async function cloneRepo(options: { + url: string; + dir: string; + // For use cases where token has to be used with Basic Auth + // it has to be provided as password together with a username + // which may be a fixed value defined by the provider. + auth: { username: string; password: string } | { token: string }; + logger?: Logger | undefined; + ref?: string | undefined; + depth?: number | undefined; + noCheckout?: boolean | undefined; +}): Promise { + const { url, dir, auth, logger, ref, depth, noCheckout } = options; + + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.clone({ url, dir, ref, depth, noCheckout }); +} + +/** + * @public + */ +export async function createBranch(options: { + dir: string; + ref: string; + // For use cases where token has to be used with Basic Auth + // it has to be provided as password together with a username + // which may be a fixed value defined by the provider. + auth: { username: string; password: string } | { token: string }; + logger?: Logger | undefined; +}): Promise { + const { dir, ref, auth, logger } = options; + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.checkout({ dir, ref }); +} + +/** + * @public + */ +export async function addFiles(options: { + dir: string; + filepath: string; + // For use cases where token has to be used with Basic Auth + // it has to be provided as password together with a username + // which may be a fixed value defined by the provider. + auth: { username: string; password: string } | { token: string }; + logger?: Logger | undefined; +}): Promise { + const { dir, filepath, auth, logger } = options; + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.add({ dir, filepath }); +} + +/** + * @public + */ +export async function commitAndPushBranch(options: { + dir: string; + // For use cases where token has to be used with Basic Auth + // it has to be provided as password together with a username + // which may be a fixed value defined by the provider. + auth: { username: string; password: string } | { token: string }; + logger?: Logger | undefined; + commitMessage: string; + gitAuthorInfo?: { name?: string; email?: string }; + branch?: string; + remoteRef?: string; + remote?: string; +}): Promise<{ commitHash: string }> { + const { + dir, + auth, + logger, + commitMessage, + gitAuthorInfo, + branch = 'master', + remoteRef, + remote = 'origin', + } = options; + const git = Git.fromAuth({ + ...auth, + logger, + }); + + // use provided info if possible, otherwise use fallbacks + const authorInfo = { + name: gitAuthorInfo?.name ?? 'Scaffolder', + email: gitAuthorInfo?.email ?? 'scaffolder@backstage.io', + }; + + const commitHash = await git.commit({ + dir, + message: commitMessage, + author: authorInfo, + committer: authorInfo, + }); + + await git.push({ + dir, + remote, + remoteRef: remoteRef ?? `refs/heads/${branch}`, + }); + + return { commitHash }; +} diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index 74a0bb61f9..c1c8551c53 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -25,5 +25,12 @@ export { } from './executeShellCommand'; export { fetchContents, fetchFile } from './fetch'; export { type ActionContext, type TemplateAction } from './types'; -export { initRepoAndPush, commitAndPushRepo } from './gitHelpers'; +export { + initRepoAndPush, + commitAndPushRepo, + commitAndPushBranch, + addFiles, + createBranch, + cloneRepo, +} from './gitHelpers'; export { parseRepoUrl, getRepoSourceDirectory } from './util'; diff --git a/yarn.lock b/yarn.lock index 52b05b0192..003e6bd384 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8045,6 +8045,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + fs-extra: 10.1.0 msw: ^1.0.0 node-fetch: ^2.6.7 yaml: ^2.0.0