diff --git a/.changeset/clever-walls-bow.md b/.changeset/clever-walls-bow.md new file mode 100644 index 0000000000..36e56f6e37 --- /dev/null +++ b/.changeset/clever-walls-bow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add built-in publish action for creating GitHub pull requests. diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 9bd631a2e9..659ecdfe8a 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -57,8 +57,10 @@ "isomorphic-git": "^1.8.0", "jsonschema": "^1.2.6", "knex": "^0.95.1", + "lodash": "^4.17.21", "luxon": "^1.26.0", "morgan": "^1.10.0", + "octokit-plugin-create-pull-request": "^3.9.3", "uuid": "^8.2.0", "winston": "^3.2.1", "yaml": "^1.10.0" diff --git a/plugins/scaffolder-backend/sample-templates/local-templates.yaml b/plugins/scaffolder-backend/sample-templates/local-templates.yaml index b0e16f9dea..76c76bc97a 100644 --- a/plugins/scaffolder-backend/sample-templates/local-templates.yaml +++ b/plugins/scaffolder-backend/sample-templates/local-templates.yaml @@ -10,3 +10,4 @@ spec: - ./react-ssr-template/template.yaml - ./springboot-grpc-template/template.yaml - ./v1beta2-demo/template.yaml + - ./pull-request/template.yaml diff --git a/plugins/scaffolder-backend/sample-templates/pull-request/template.yaml b/plugins/scaffolder-backend/sample-templates/pull-request/template.yaml new file mode 100644 index 0000000000..cad2a58d1f --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/pull-request/template.yaml @@ -0,0 +1,76 @@ +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: pull-request + title: Pull Request Action template + description: scaffolder v1beta2 template demo publishing to PR on existing git repository +spec: + owner: backstage/techdocs-core + type: service + + parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + description: + title: Description + type: string + description: Description of the component + targetPath: + title: Target Path in repo + type: string + description: Name of the directory to create in the repository + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com + + steps: + - id: fetch-base + name: Fetch Base + action: fetch:cookiecutter + input: + url: ./template + values: + name: '{{parameters.name}}' + + - id: fetch-docs + name: Fetch Docs + action: fetch:plain + input: + targetPath: ./community + url: https://github.com/backstage/community/tree/main/backstage-community-sessions + + - id: publish + name: Publish + action: publish:github:pull-request + input: + repoUrl: '{{ parameters.repoUrl }}' + title: 'Create new project: {{parameters.name}}' + branchName: 'create-{{parameters.name}}' + description: | + # New project: {{parameters.name}} + + {{#if parameters.description}} + {{parameters.description}} + {{/if}} + host: '{{parameters.host}}' + targetPath: '{{#if parameters.targetPath}}{{parameters.targetPath}}{{else}}{{parameters.name}}{{/if}}' + + output: + remoteUrl: '{{steps.publish.output.remoteUrl}}' diff --git a/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml new file mode 100644 index 0000000000..dd1e0ebd09 --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/pull-request/template/catalog-info.yaml @@ -0,0 +1,8 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: {{cookiecutter.name | jsonify}} +spec: + type: website + lifecycle: experimental + owner: guest diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 0829a101f2..2d4b8fb4c5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -23,6 +23,7 @@ import { createPublishAzureAction, createPublishBitbucketAction, createPublishGithubAction, + createPublishGithubPullRequestAction, createPublishGitlabAction, } from './publish'; import Docker from 'dockerode'; @@ -57,6 +58,9 @@ export const createBuiltinActions = (options: { createPublishGithubAction({ integrations, }), + createPublishGithubPullRequestAction({ + integrations, + }), createPublishGitlabAction({ integrations, }), diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts new file mode 100644 index 0000000000..fb9d3aeaa6 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.test.ts @@ -0,0 +1,236 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import mockFs from 'mock-fs'; +import { Writable } from 'stream'; +import { + PullRequestCreator, + GithubPullRequestActionInput, + createPublishGithubPullRequestAction, + ClientFactoryInput, +} from './githubPullRequest'; +import { ActionContext, TemplateAction } from '../../types'; +import { getRootLogger } from '@backstage/backend-common'; + +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; + +const id = 'createPublishGithubPullRequestAction'; + +describe('createPublishGithubPullRequestAction', () => { + let instance: TemplateAction; + let fakeClient: PullRequestCreator; + + let clientFactory: (input: ClientFactoryInput) => Promise; + + beforeEach(() => { + const integrations = ScmIntegrations.fromConfig(new ConfigReader({})); + fakeClient = { + createPullRequest: jest.fn(async (_: any) => { + return { + url: 'https://api.github.com/myorg/myrepo/pull/123', + headers: {}, + status: 201, + data: { + html_url: 'https://github.com/myorg/myrepo/pull/123', + }, + }; + }), + }; + clientFactory = jest.fn(async () => fakeClient); + + instance = createPublishGithubPullRequestAction({ + integrations, + clientFactory, + }); + }); + + describe('with no sourcePath', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + }; + + mockFs({ + [id]: { 'file.txt': 'Hello there!' }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath: id, + }; + }); + 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', + changes: [ + { + commit: 'Create my new app', + files: { + 'file.txt': 'Hello there!', + }, + }, + ], + }); + }); + + it('creates outputs for the url', async () => { + await instance.handler(ctx); + + expect(ctx.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://github.com/myorg/myrepo/pull/123', + ); + }); + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + }); + + describe('with sourcePath', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + owner: 'myorg', + repo: 'myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + sourcePath: 'source', + }; + + mockFs({ + [id]: { + source: { 'foo.txt': 'Hello there!' }, + irrelevant: { 'bar.txt': 'Nothing to see here' }, + }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath: id, + }; + }); + + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('creates a pull request with only relevant files', 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', + changes: [ + { + commit: 'Create my new app', + files: { + 'foo.txt': 'Hello there!', + }, + }, + ], + }); + }); + }); + + describe('with repoUrl', () => { + let input: GithubPullRequestActionInput; + let ctx: ActionContext; + + beforeEach(() => { + input = { + repoUrl: 'github.com?owner=myorg&repo=myrepo', + title: 'Create my new app', + branchName: 'new-app', + description: 'This PR is really good', + }; + + mockFs({ + [id]: { 'file.txt': 'Hello there!' }, + }); + + ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath: id, + }; + }); + 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', + changes: [ + { + commit: 'Create my new app', + files: { + 'file.txt': 'Hello there!', + }, + }, + ], + }); + }); + + it('creates outputs for the url', async () => { + await instance.handler(ctx); + + expect(ctx.output).toHaveBeenCalledWith( + 'remoteUrl', + 'https://github.com/myorg/myrepo/pull/123', + ); + }); + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts new file mode 100644 index 0000000000..3a00610a92 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/githubPullRequest.ts @@ -0,0 +1,243 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readFile } from 'fs-extra'; +import path from 'path'; +import { parseRepoUrl } from './util'; + +import { + GithubCredentialsProvider, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { zipObject } from 'lodash'; +import { createTemplateAction } from '../../createTemplateAction'; +import { Octokit } from '@octokit/rest'; +import { InputError, CustomErrorBase } from '@backstage/errors'; +import { createPullRequest } from 'octokit-plugin-create-pull-request'; +import globby from 'globby'; + +class GithubResponseError extends CustomErrorBase {} + +type CreatePullRequestResponse = { + data: { html_url: string }; +}; + +export interface PullRequestCreator { + createPullRequest( + options: createPullRequest.Options, + ): Promise; +} + +export type PullRequestCreatorConstructor = ( + octokit: Octokit, +) => PullRequestCreator; + +export type GithubPullRequestActionInput = { + title: string; + branchName: string; + description: string; + owner?: string; + repo?: string; + repoUrl?: string; + host?: string; + targetPath?: string; + sourcePath?: string; +}; + +export type ClientFactoryInput = { + integrations: ScmIntegrationRegistry; + host: string; + owner: string; + repo: string; +}; + +export const defaultClientFactory = async ({ + integrations, + owner, + repo, + host = 'github.com', +}: ClientFactoryInput): Promise => { + const integrationConfig = integrations.github.byHost(host)?.config; + + if (!integrationConfig) { + throw new InputError(`No integration for host ${host}`); + } + + const credentialsProvider = GithubCredentialsProvider.create( + integrationConfig, + ); + + if (!credentialsProvider) { + throw new InputError( + `No matching credentials for host ${host}, please check your integrations config`, + ); + } + + const { token } = await credentialsProvider.getCredentials({ + url: `${host}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + }); + + if (!token) { + throw new InputError( + `No token available for host: ${host}, with owner ${owner}, and repo ${repo}`, + ); + } + + const OctokitPR = Octokit.plugin(createPullRequest); + + return new OctokitPR({ + auth: token, + baseUrl: integrationConfig.apiBaseUrl, + }); +}; + +interface CreateGithubPullRequestActionOptions { + integrations: ScmIntegrationRegistry; + clientFactory?: (input: ClientFactoryInput) => Promise; +} + +export const createPublishGithubPullRequestAction = ({ + integrations, + clientFactory = defaultClientFactory, +}: CreateGithubPullRequestActionOptions) => { + return createTemplateAction({ + id: 'publish:github:pull-request', + schema: { + input: { + required: ['owner', 'repo', 'title', 'description', 'branchName'], + type: 'object', + properties: { + owner: { + type: 'string', + title: 'Repository owner', + description: 'The owner of the target repository', + }, + repo: { + type: 'string', + title: 'Repository', + description: 'The github repository to create the file in', + }, + branchName: { + type: 'string', + title: 'Branch Name', + description: 'The name for the branch', + }, + title: { + type: 'string', + title: 'Pull Request Name', + description: 'The name for the pull request', + }, + description: { + type: 'string', + title: 'Pull Request Description', + description: 'The description of the pull request', + }, + sourcePath: { + type: 'string', + title: 'Working Subdirectory', + description: + 'Subdirectory of working directory to copy changes from', + }, + targetPath: { + type: 'string', + title: 'Repository Subdirectory', + description: 'Subdirectory of repository to apply changes to', + }, + }, + }, + output: { + required: ['remoteUrl'], + type: 'object', + properties: { + remoteUrl: { + type: 'string', + title: 'Pull Request URL', + description: 'Link to the pull request in Github', + }, + }, + }, + }, + async handler(ctx) { + let { owner, repo } = ctx.input; + let host = 'github.com'; + const { + repoUrl, + branchName, + title, + description, + targetPath, + sourcePath, + } = ctx.input; + + if (repoUrl) { + const parsed = parseRepoUrl(repoUrl); + host = parsed.host; + owner = parsed.owner; + repo = parsed.repo; + } + + if (!host || !owner || !repo) { + throw new InputError( + 'must provide either valid repo URL or owner and repo as parameters', + ); + } + + const client = await clientFactory({ integrations, host, owner, repo }); + const fileRoot = sourcePath + ? path.join(ctx.workspacePath, sourcePath) + : ctx.workspacePath; + const localFilePaths = await globby(`${fileRoot}/**/*.*`); + + const fileContents = await Promise.all( + localFilePaths.map(p => readFile(p)), + ); + + const repoFilePaths = localFilePaths.map(p => { + const relativePath = path.relative(fileRoot, p); + return targetPath ? `${targetPath}/${relativePath}` : relativePath; + }); + + const changes = [ + { + files: zipObject( + repoFilePaths, + fileContents.map(buf => buf.toString()), + ), + commit: title, + }, + ]; + + try { + const response = await client.createPullRequest({ + owner, + repo, + title, + changes, + body: description, + head: branchName, + }); + + if (!response) { + throw new GithubResponseError('null response from Github'); + } + + ctx.output('remoteUrl', response.data.html_url); + } catch (e) { + throw new GithubResponseError('Pull request creation failed', e); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index 419922704d..537a2b882d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -15,6 +15,7 @@ */ export { createPublishGithubAction } from './github'; +export { createPublishGithubPullRequestAction } from './githubPullRequest'; export { createPublishAzureAction } from './azure'; export { createPublishGitlabAction } from './gitlab'; export { createPublishBitbucketAction } from './bitbucket'; diff --git a/yarn.lock b/yarn.lock index ac7f53e651..af3ce6ab94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19885,6 +19885,13 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== +octokit-plugin-create-pull-request@^3.9.3: + version "3.9.3" + resolved "https://registry.npmjs.org/octokit-plugin-create-pull-request/-/octokit-plugin-create-pull-request-3.9.3.tgz#f99f53907ac322a3494cc970514a023d7b659e2b" + integrity sha512-lTyNnCRoT4IvCQx2Cb4eFMqg8aIpsaDd59MNwf4OPnWAJM7hT6g7RW/icImvAzZLR4t5ENSLNzWarv2XqLL+Lg== + dependencies: + "@octokit/types" "^6.8.2" + oidc-token-hash@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888"