Create builtin action for publishing to Github PR

Signed-off-by: James Turley <jamesturley1905@googlemail.com>
This commit is contained in:
James Turley
2021-03-27 12:18:32 +00:00
committed by James Turley
parent d53d01dcf5
commit d8ffec739e
10 changed files with 583 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Add built-in publish action for creating GitHub pull requests.
+2
View File
@@ -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"
@@ -10,3 +10,4 @@ spec:
- ./react-ssr-template/template.yaml
- ./springboot-grpc-template/template.yaml
- ./v1beta2-demo/template.yaml
- ./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}}'
@@ -0,0 +1,8 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: {{cookiecutter.name | jsonify}}
spec:
type: website
lifecycle: experimental
owner: guest
@@ -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,
}),
@@ -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<GithubPullRequestActionInput>;
let fakeClient: PullRequestCreator;
let clientFactory: (input: ClientFactoryInput) => Promise<PullRequestCreator>;
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<GithubPullRequestActionInput>;
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<GithubPullRequestActionInput>;
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<GithubPullRequestActionInput>;
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();
});
});
});
@@ -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<CreatePullRequestResponse | null>;
}
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<PullRequestCreator> => {
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<PullRequestCreator>;
}
export const createPublishGithubPullRequestAction = ({
integrations,
clientFactory = defaultClientFactory,
}: CreateGithubPullRequestActionOptions) => {
return createTemplateAction<GithubPullRequestActionInput>({
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);
}
},
});
};
@@ -15,6 +15,7 @@
*/
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
export { createPublishAzureAction } from './azure';
export { createPublishGitlabAction } from './gitlab';
export { createPublishBitbucketAction } from './bitbucket';
+7
View File
@@ -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"