Create builtin action for publishing to Github PR
Signed-off-by: James Turley <jamesturley1905@googlemail.com>
This commit is contained in:
committed by
James Turley
parent
d53d01dcf5
commit
d8ffec739e
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Add built-in publish action for creating GitHub pull requests.
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
+236
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+243
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user