diff --git a/.changeset/social-insects-cheat.md b/.changeset/social-insects-cheat.md new file mode 100644 index 0000000000..13593865c2 --- /dev/null +++ b/.changeset/social-insects-cheat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +Added support for deleting files 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..a8a0c8d8b1 100644 --- a/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts +++ b/plugins/scaffolder-backend-module-github/src/actions/githubPullRequest.test.ts @@ -27,6 +27,7 @@ import fs from 'fs-extra'; 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 @@ -46,12 +47,12 @@ describe('createPublishGithubPullRequestAction', () => { let config: Config; let integrations: ScmIntegrations; + const deletionMarker = + 'if-you-find-a-file-whose-contents-matches-this-delete-it'; const mockDir = createMockDirectory(); const workspacePath = mockDir.resolve('workspace'); beforeEach(() => { - mockDir.clear(); - config = new ConfigReader({}); integrations = ScmIntegrations.fromConfig(config); fakeClient = { @@ -92,6 +93,7 @@ describe('createPublishGithubPullRequestAction', () => { }); afterEach(() => { + mockDir.clear(); jest.resetAllMocks(); }); @@ -304,6 +306,103 @@ describe('createPublishGithubPullRequestAction', () => { }); }); + describe('with deletionMarker', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + deletionMarker, + 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', + 'foobar.txt': 'Hello there!', + }, + }); + + ctx = createMockActionContext({ input, workspacePath }); + }); + + it('should create a pull request when no files match the marker', 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', + }, + }, + }, + ], + }); + }); + + describe('when files are marked for deletion', () => { + beforeEach(() => { + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'Hello there!', + 'im-here-to-be-deleted': deletionMarker, + 'baz.txt': 'baz text', + 'delete-me-too': deletionMarker, + }, + }); + }); + + it('should delete marked 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: { + 'foo.txt': { + content: Buffer.from('Hello there!').toString('base64'), + encoding: 'base64', + mode: '100644', + }, + 'im-here-to-be-deleted': DELETE_FILE, + 'baz.txt': { + content: Buffer.from('baz text').toString('base64'), + encoding: 'base64', + mode: '100644', + }, + '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..c334d27781 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,15 @@ export const createPublishGithubPullRequestAction = ( z.string({ description: 'The name for the branch', }), + deletionMarker: z => + z + .string({ + description: 'Contents of files that will be deleted', + }) + .min(33, { + message: 'deletion marker must be at least 33 characters long', + }) + .optional(), targetBranchName: z => z .string({ @@ -269,6 +281,7 @@ export const createPublishGithubPullRequestAction = ( const { repoUrl, branchName, + deletionMarker, targetBranchName, title, description, @@ -323,24 +336,32 @@ export const createPublishGithubPullRequestAction = ( file: SerializedFile, ): 'utf-8' | 'base64' => (file.symlink ? 'utf-8' : 'base64'); + const encodedDeletionMarker = + deletionMarker && Buffer.from(deletionMarker).toString('base64'); const files = Object.fromEntries( - directoryContents.map(file => [ - targetPath ? path.posix.join(targetPath, file.path) : file.path, - { - // See the properties of tree items - // in https://docs.github.com/en/rest/reference/git#trees - mode: determineFileMode(file), - // Always use base64 encoding where possible to avoid doubling a binary file in size - // due to interpreting a binary file as utf-8 and sending github - // the utf-8 encoded content. Symlinks are kept as utf-8 to avoid them - // being formatted as a series of scrambled characters - // - // For example, the original gradle-wrapper.jar is 57.8k in https://github.com/kennethzfeng/pull-request-test/pull/5/files. - // Its size could be doubled to 98.3K (See https://github.com/kennethzfeng/pull-request-test/pull/4/files) - encoding: determineFileEncoding(file), - content: file.content.toString(determineFileEncoding(file)), - }, - ]), + directoryContents.map(file => { + const content = file.content.toString(determineFileEncoding(file)); + + return [ + targetPath ? path.posix.join(targetPath, file.path) : file.path, + content === encodedDeletionMarker + ? DELETE_FILE + : { + // See the properties of tree items + // in https://docs.github.com/en/rest/reference/git#trees + mode: determineFileMode(file), + // Always use base64 encoding where possible to avoid doubling a binary file in size + // due to interpreting a binary file as utf-8 and sending github + // the utf-8 encoded content. Symlinks are kept as utf-8 to avoid them + // being formatted as a series of scrambled characters + // + // For example, the original gradle-wrapper.jar is 57.8k in https://github.com/kennethzfeng/pull-request-test/pull/5/files. + // Its size could be doubled to 98.3K (See https://github.com/kennethzfeng/pull-request-test/pull/4/files) + encoding: determineFileEncoding(file), + content, + }, + ]; + }), ); // If this is a dry run, log and return