diff --git a/.changeset/social-insects-cheat.md b/.changeset/social-insects-cheat.md new file mode 100644 index 0000000000..494cba15e7 --- /dev/null +++ b/.changeset/social-insects-cheat.md @@ -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 + +``` diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 28f710557a..2941be7702 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -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; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts index 670af15595..5a8019579b 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts @@ -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; + + 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; diff --git a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts index 54b90e3544..7d1e88d0c7 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.ts @@ -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) {