Merge pull request #28633 from tjuedema/githubPRCreateWhenEmpty

Adds createWhenEmpty option to publish:github:pull-request action
This commit is contained in:
Ben Lambert
2025-02-11 15:04:35 +01:00
committed by GitHub
10 changed files with 290 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': minor
---
**BREAKING**: The `remoteUrl` output is no longer required, it can be empty only when using the new `createWhenEmpty` boolean flag.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': patch
'@backstage/plugin-scaffolder': patch
---
Added conditional rendering of `oneOf` output schemas on the Installed Actions page for scaffolder actions
@@ -469,6 +469,7 @@ export const createPublishGithubPullRequestAction: (
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
forceEmptyGitAuthor?: boolean | undefined;
createWhenEmpty?: boolean | undefined;
},
JsonObject
>;
@@ -617,13 +617,7 @@ describe('publish:github:pull-request examples', () => {
expect(mockContext.output).toHaveBeenCalledWith('pullRequestNumber', 123);
});
it('Create a pull request with all parameters', async () => {
mockDir.setContent({
[workspacePath]: {
source: { 'foo.txt': 'Hello there!' },
irrelevant: { 'bar.txt': 'Nothing to see here' },
},
});
it('Does not create an empty pull request', async () => {
const input = yaml.parse(examples[12].example).steps[0].input;
await action.handler({
@@ -638,8 +632,56 @@ describe('publish:github:pull-request examples', () => {
title: 'Create my new app',
body: 'This PR is really good',
head: 'new-app',
draft: true,
draft: undefined,
createWhenEmpty: false,
changes: [
{
commit: 'Create my new app',
files: {
'file.txt': {
content: Buffer.from('Hello there!').toString('base64'),
encoding: 'base64',
mode: '100644',
},
},
},
],
});
expect(fakeClient.rest.pulls.requestReviewers).not.toHaveBeenCalled();
expect(mockContext.output).toHaveBeenCalledTimes(3);
expect(mockContext.output).toHaveBeenCalledWith('targetBranchName', 'main');
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/myorg/myrepo/pull/123',
);
expect(mockContext.output).toHaveBeenCalledWith('pullRequestNumber', 123);
});
it('Create a pull request with all parameters', async () => {
mockDir.setContent({
[workspacePath]: {
source: { 'foo.txt': 'Hello there!' },
irrelevant: { 'bar.txt': 'Nothing to see here' },
},
});
const input = yaml.parse(examples[13].example).steps[0].input;
await action.handler({
...mockContext,
workspacePath,
input,
});
expect(fakeClient.createPullRequest).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
title: 'Create my new app',
body: 'This PR is really good',
head: 'new-app',
base: 'test',
draft: true,
createWhenEmpty: true,
changes: [
{
commit: 'Commit for foo changes',
@@ -23,7 +23,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -40,7 +40,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest with target branch name',
name: 'Create a pull request with target branch name',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -58,7 +58,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest as draft',
name: 'Create a pull request as draft',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -76,7 +76,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest with target path',
name: 'Create a pull request with target path',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -94,7 +94,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest with source path',
name: 'Create a pull request with source path',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -112,7 +112,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -130,7 +130,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest with reviewers',
name: 'Create a pull request with reviewers',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -148,7 +148,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest with team reviewers',
name: 'Create a pull request with team reviewers',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -166,7 +166,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -184,7 +184,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -203,7 +203,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -223,7 +223,7 @@ export const examples: TemplateExample[] = [
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -237,13 +237,31 @@ export const examples: TemplateExample[] = [
],
}),
},
{
description: 'Do not create empty pull request',
example: yaml.stringify({
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
title: 'Create my new app',
description: 'This PR is really good',
createWhenEmpty: false,
},
},
],
}),
},
{
description: 'Create a pull request with all parameters',
example: yaml.stringify({
steps: [
{
action: 'publish:github:pull-request',
name: 'Create a pull reuqest',
name: 'Create a pull request',
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
branchName: 'new-app',
@@ -259,6 +277,7 @@ export const examples: TemplateExample[] = [
commitMessage: 'Commit for foo changes',
gitAuthorName: 'Foo Bar',
gitAuthorEmail: 'foo@bar.example',
createWhenEmpty: true,
},
},
],
@@ -1062,4 +1062,119 @@ describe('createPublishGithubPullRequestAction', () => {
});
});
});
describe('with createWhenEmpty equals true', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
beforeEach(() => {
input = {
repoUrl: 'github.com?owner=myorg&repo=myrepo',
title: 'Create my new app',
branchName: 'new-app',
description: 'This PR is really good',
createWhenEmpty: true,
};
mockDir.setContent({
[workspacePath]: { 'file.txt': 'Hello there!' },
});
ctx = createMockActionContext({ input, workspacePath });
});
it('creates a pull request', async () => {
await instance.handler(ctx);
expect(fakeClient.createPullRequest).toHaveBeenCalledWith({
owner: 'myorg',
repo: 'myrepo',
title: 'Create my new app',
head: 'new-app',
body: 'This PR is really good',
createWhenEmpty: true,
changes: [
{
commit: 'Create my new app',
files: {
'file.txt': {
content: Buffer.from('Hello there!').toString('base64'),
encoding: 'base64',
mode: '100644',
},
},
},
],
});
});
it('creates outputs for the pull request url and number', async () => {
await instance.handler(ctx);
expect(ctx.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/myorg/myrepo/pull/123',
);
expect(ctx.output).toHaveBeenCalledWith('pullRequestNumber', 123);
});
it('throws when creating a pull request fails', async () => {
fakeClient.createPullRequest.mockResolvedValueOnce(null);
await expect(instance.handler(ctx)).rejects.toThrow(
'null response from Github',
);
});
});
describe('with createWhenEmpty equals false', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
beforeEach(() => {
fakeClient.createPullRequest.mockResolvedValueOnce(null);
input = {
repoUrl: 'github.com?owner=myorg&repo=myrepo',
title: 'Create my new app',
branchName: 'new-app',
description: 'This PR is really good',
createWhenEmpty: false,
};
mockDir.setContent({
[workspacePath]: { 'file.txt': 'Hello there!' },
});
ctx = createMockActionContext({ input, workspacePath });
});
it('creates a pull request', async () => {
await instance.handler(ctx);
expect(fakeClient.createPullRequest).toHaveBeenCalledWith({
owner: 'myorg',
repo: 'myrepo',
title: 'Create my new app',
head: 'new-app',
body: 'This PR is really good',
createWhenEmpty: false,
changes: [
{
commit: 'Create my new app',
files: {
'file.txt': {
content: Buffer.from('Hello there!').toString('base64'),
encoding: 'base64',
mode: '100644',
},
},
},
],
});
});
it('does not create outputs for the pull request url and number', async () => {
await instance.handler(ctx);
expect(ctx.output).not.toHaveBeenCalled();
});
});
});
@@ -145,6 +145,7 @@ export const createPublishGithubPullRequestAction = (
gitAuthorName?: string;
gitAuthorEmail?: string;
forceEmptyGitAuthor?: boolean;
createWhenEmpty?: boolean;
}>({
id: 'publish:github:pull-request',
examples,
@@ -251,10 +252,16 @@ export const createPublishGithubPullRequestAction = (
description:
'Forces the author to be empty. This is useful when using a Github App, it permit the commit to be verified on Github',
},
createWhenEmpty: {
type: 'boolean',
title: 'Create When Empty',
description:
'Set whether to create pull request when there are no changes to commit. The default value is true. If set to false, remoteUrl is no longer a required output.',
},
},
},
output: {
required: ['remoteUrl'],
required: [],
type: 'object',
properties: {
targetBranchName: {
@@ -293,6 +300,7 @@ export const createPublishGithubPullRequestAction = (
gitAuthorEmail,
gitAuthorName,
forceEmptyGitAuthor,
createWhenEmpty,
} = ctx.input;
const { owner, repo, host } = parseRepoUrl(repoUrl, integrations);
@@ -379,6 +387,7 @@ export const createPublishGithubPullRequestAction = (
draft,
update,
forceFork,
createWhenEmpty,
};
const gitAuthorInfo = {
@@ -417,6 +426,11 @@ export const createPublishGithubPullRequestAction = (
}
const response = await client.createPullRequest(createOptions);
if (createWhenEmpty === false && !response) {
ctx.logger.info('No changes to commit, pull request was not created');
return;
}
if (!response) {
throw new GithubResponseError('null response from Github');
}
+1
View File
@@ -345,6 +345,7 @@ export const createPublishGithubPullRequestAction: (
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
forceEmptyGitAuthor?: boolean | undefined;
createWhenEmpty?: boolean | undefined;
},
JsonObject
>;
@@ -125,6 +125,63 @@ describe('TemplatePage', () => {
expect(rendered.getByText('Test output')).toBeInTheDocument();
});
it('renders action with oneOf output', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test title',
type: 'string',
},
},
},
output: {
oneOf: [
{
type: 'object',
properties: {
buzz: {
title: 'Test output1',
type: 'string',
},
},
},
{
type: 'object',
properties: {
buzz: {
title: 'Test output2',
type: 'string',
},
},
},
],
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.getByText('oneOf')).toBeInTheDocument();
expect(rendered.getByText('Test title')).toBeInTheDocument();
expect(rendered.getByText('Test output1')).toBeInTheDocument();
expect(rendered.getByText('Test output2')).toBeInTheDocument();
});
it('renders action with multiple input types', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
@@ -340,11 +340,16 @@ export const ActionPageContent = () => {
return undefined;
}
const oneOf = renderTables(
const oneOfInput = renderTables(
'oneOf',
`${action.id}.input`,
action.schema?.input?.oneOf,
);
const oneOfOutput = renderTables(
'oneOf',
`${action.id}.output`,
action.schema?.output?.oneOf,
);
return (
<Box pb={3} key={action.id}>
<Box display="flex" alignItems="center">
@@ -374,7 +379,7 @@ export const ActionPageContent = () => {
{renderTable(
formatRows(`${action.id}.input`, action?.schema?.input),
)}
{oneOf}
{oneOfInput}
</Box>
)}
{action.schema?.output && (
@@ -385,6 +390,7 @@ export const ActionPageContent = () => {
{renderTable(
formatRows(`${action.id}.output`, action?.schema?.output),
)}
{oneOfOutput}
</Box>
)}
{action.examples && (