Merge pull request #30252 from fearphage/allow-file-deletion

feat(scaffolder-github): Allow file deletion in pull requests
This commit is contained in:
Ben Lambert
2025-06-24 09:00:31 +02:00
committed by GitHub
4 changed files with 157 additions and 6 deletions
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Added support for file deletion to `publish:github:pull-request` action.
Example usage:
```diff
- action: publish:github:pull-request
id: clean-up-pr
input:
description: This is the description
+ filesToDelete:
+ - outdated/changelog.md
+ - sample-file.txt
owner: owner
repo: repo
title: Title Goes Here
```
@@ -446,6 +446,7 @@ export const createPublishGithubPullRequestAction: (
branchName: string;
title: string;
description: string;
filesToDelete?: string[] | undefined;
targetBranchName?: string | undefined;
draft?: boolean | undefined;
sourcePath?: string | undefined;
@@ -24,9 +24,11 @@ import {
TemplateAction,
} from '@backstage/plugin-scaffolder-node';
import fs from 'fs-extra';
import path from 'node:path';
import { createPublishGithubPullRequestAction } from './githubPullRequest';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { DELETE_FILE } from 'octokit-plugin-create-pull-request';
type GithubPullRequestActionInput = ReturnType<
typeof createPublishGithubPullRequestAction
@@ -50,8 +52,6 @@ describe('createPublishGithubPullRequestAction', () => {
const workspacePath = mockDir.resolve('workspace');
beforeEach(() => {
mockDir.clear();
config = new ConfigReader({});
integrations = ScmIntegrations.fromConfig(config);
fakeClient = {
@@ -92,6 +92,7 @@ describe('createPublishGithubPullRequestAction', () => {
});
afterEach(() => {
mockDir.clear();
jest.resetAllMocks();
});
@@ -304,6 +305,118 @@ describe('createPublishGithubPullRequestAction', () => {
});
});
describe('with filesToDelete', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
beforeEach(() => {
input = {
filesToDelete: ['changed-file-to-delete.txt', 'delete-me-too.md'],
repoUrl: 'github.com?owner=myorg&repo=myrepo',
title: 'Create my new app',
branchName: 'new-app',
description: 'This PR is really good',
};
mockDir.setContent({
[workspacePath]: {
'catpants.md': 'cat + pants',
'changed-file-to-delete.txt': 'file is changed and deleted',
'foobar.txt': 'Hello there!',
},
});
ctx = createMockActionContext({ input, workspacePath });
});
it('should delete named files', async () => {
await instance.handler(ctx);
expect(fakeClient.createPullRequest).toHaveBeenCalledWith({
owner: 'myorg',
repo: 'myrepo',
title: input.title,
head: input.branchName,
body: input.description,
changes: [
{
commit: input.title,
files: {
'catpants.md': {
content: Buffer.from('cat + pants').toString('base64'),
encoding: 'base64',
mode: '100644',
},
'foobar.txt': {
content: Buffer.from('Hello there!').toString('base64'),
encoding: 'base64',
mode: '100644',
},
'changed-file-to-delete.txt': DELETE_FILE,
'delete-me-too.md': DELETE_FILE,
},
},
],
});
});
describe('with targetPath', () => {
const targetPath = `target-path-${Date.now()}`;
beforeEach(() => {
Object.assign(input, {
filesToDelete: [
path.posix.join('nested', 'catpants.md'),
path.posix.join('nested', 'delete-me.too'),
],
targetPath,
});
mockDir.setContent({
[workspacePath]: {
'catpants.md': 'cat + pants',
'foobar.txt': 'Hello there!',
[path.posix.join('nested', 'catpants.md')]: 'delete me',
[path.posix.join('nested', 'delete-me.too')]: 'delete me too',
},
});
});
it('should delete named files', async () => {
await instance.handler(ctx);
expect(fakeClient.createPullRequest).toHaveBeenCalledWith({
owner: 'myorg',
repo: 'myrepo',
title: input.title,
head: input.branchName,
body: input.description,
changes: [
{
commit: input.title,
files: {
[path.posix.join(targetPath, 'catpants.md')]: {
content: Buffer.from('cat + pants').toString('base64'),
encoding: 'base64',
mode: '100644',
},
[path.posix.join(targetPath, 'foobar.txt')]: {
content: Buffer.from('Hello there!').toString('base64'),
encoding: 'base64',
mode: '100644',
},
[path.posix.join(targetPath, 'nested', 'catpants.md')]:
DELETE_FILE,
[path.posix.join(targetPath, 'nested', 'delete-me.too')]:
DELETE_FILE,
},
},
],
});
});
});
});
describe('with repoUrl', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput, any, any>;
@@ -27,7 +27,10 @@ import {
} from '@backstage/plugin-scaffolder-node';
import { Octokit } from 'octokit';
import { CustomErrorBase, InputError } from '@backstage/errors';
import { createPullRequest } from 'octokit-plugin-create-pull-request';
import {
createPullRequest,
DELETE_FILE,
} from 'octokit-plugin-create-pull-request';
import { getOctokitOptions } from '../util';
import { examples } from './githubPullRequest.examples';
import {
@@ -143,6 +146,12 @@ export const createPublishGithubPullRequestAction = (
z.string({
description: 'The name for the branch',
}),
filesToDelete: z =>
z
.array(z.string(), {
description: 'List of files that will be deleted',
})
.optional(),
targetBranchName: z =>
z
.string({
@@ -269,6 +278,7 @@ export const createPublishGithubPullRequestAction = (
const {
repoUrl,
branchName,
filesToDelete,
targetBranchName,
title,
description,
@@ -323,8 +333,8 @@ export const createPublishGithubPullRequestAction = (
file: SerializedFile,
): 'utf-8' | 'base64' => (file.symlink ? 'utf-8' : 'base64');
const files = Object.fromEntries(
directoryContents.map(file => [
const files = Object.fromEntries([
...directoryContents.map(file => [
targetPath ? path.posix.join(targetPath, file.path) : file.path,
{
// See the properties of tree items
@@ -341,7 +351,13 @@ export const createPublishGithubPullRequestAction = (
content: file.content.toString(determineFileEncoding(file)),
},
]),
);
// order of arrays is important so filesToDelete will overwrite
// changes from files above
...(filesToDelete || []).map(filePath => [
targetPath ? path.posix.join(targetPath, filePath) : filePath,
DELETE_FILE,
]),
]);
// If this is a dry run, log and return
if (ctx.isDryRun) {