Merge pull request #21895 from jayfray12/master

Enhance bitbucket pull request action to add new content
This commit is contained in:
Ben Lambert
2024-01-23 12:46:33 +01:00
committed by GitHub
9 changed files with 683 additions and 4 deletions
+5
View File
@@ -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
+5
View File
@@ -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
@@ -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"
},
@@ -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,
+72
View File
@@ -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<void>;
// @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<void>;
// @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<void>;
// @public
export const createTemplateAction: <
TInputParams extends JsonObject = JsonObject,
@@ -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',
});
});
});
@@ -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<void> {
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<void> {
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<void> {
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 };
}
+8 -1
View File
@@ -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';
+1
View File
@@ -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