diff --git a/.changeset/fair-files-rest.md b/.changeset/fair-files-rest.md new file mode 100644 index 0000000000..68d3246aa4 --- /dev/null +++ b/.changeset/fair-files-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +GitHub create repository webhook action: `github:webhook` for Backstage plugin Scaffolder has been added. diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index f763904fe7..dfda1ff2fc 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -114,6 +114,13 @@ export function createGithubActionsDispatchAction(options: { integrations: ScmIntegrationRegistry; }): TemplateAction; +// Warning: (ae-missing-release-tag) "createGithubWebhookAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export function createGithubWebhookAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + // Warning: (ae-missing-release-tag) "createPublishAzureAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts b/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts index 7505c3a30e..b60c3fcb2d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/__mocks__/@octokit/rest/index.ts @@ -23,6 +23,7 @@ export const mockGithubClient = { repos: { createInOrg: jest.fn(), createForAuthenticatedUser: jest.fn(), + createWebhook: jest.fn(), addCollaborator: jest.fn(), replaceAllTopics: jest.fn(), }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 5a16ef9a55..d38dfe3cdc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -37,7 +37,10 @@ import { createPublishGithubPullRequestAction, createPublishGitlabAction, } from './publish'; -import { createGithubActionsDispatchAction } from './github'; +import { + createGithubActionsDispatchAction, + createGithubWebhookAction, +} from './github'; export const createBuiltinActions = (options: { reader: UrlReader; @@ -90,5 +93,8 @@ export const createBuiltinActions = (options: { createGithubActionsDispatchAction({ integrations, }), + createGithubWebhookAction({ + integrations, + }), ]; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts new file mode 100644 index 0000000000..8f11eefce1 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts @@ -0,0 +1,220 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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. + */ + +jest.mock('@octokit/rest'); + +import { createGithubWebhookAction } from './githubWebhook'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; + +describe('github:repository:webhook:create', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createGithubWebhookAction({ integrations }); + + const mockContext = { + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + webhookUrl: 'https://example.com/payload', + webhookSecret: 'aafdfdivierernfdk23f', + }, + workspacePath: 'lol', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + const { mockGithubClient } = require('@octokit/rest'); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw an error when the repoUrl is not well formed', async () => { + await expect( + action.handler({ + ...mockContext, + input: { repoUrl: 'github.com?repo=bob' }, + }), + ).rejects.toThrow(/missing owner/); + + await expect( + action.handler({ + ...mockContext, + input: { repoUrl: 'github.com?owner=owner' }, + }), + ).rejects.toThrow(/missing repo/); + }); + + it('should throw if there is no integration config provided', async () => { + await expect( + action.handler({ + ...mockContext, + input: { repoUrl: 'missing.com?repo=bob&owner=owner' }, + }), + ).rejects.toThrow(/No matching integration configuration/); + }); + + it('should throw if there is no token in the integration config that is returned', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + repoUrl: 'ghe.github.com?repo=bob&owner=owner', + }, + }), + ).rejects.toThrow(/No token available for host/); + }); + + it('should call the githubApi for creating repository Webhook', async () => { + const repoUrl = 'github.com?repo=repo&owner=owner'; + const webhookUrl = 'https://example.com/payload'; + const webhookSecret = 'aafdfdivierernfdk23f'; + const ctx = Object.assign({}, mockContext, { + input: { repoUrl, webhookUrl, webhookSecret }, + }); + await action.handler(ctx); + + expect(mockGithubClient.repos.createWebhook).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + events: ['push'], + active: true, + config: { + url: webhookUrl, + content_type: 'form', + secret: webhookSecret, + insecure_ssl: '0', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + events: ['push', 'pull_request'], + }, + }); + + expect(mockGithubClient.repos.createWebhook).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + events: ['push', 'pull_request'], + active: true, + config: { + url: webhookUrl, + content_type: 'form', + secret: webhookSecret, + insecure_ssl: '0', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + contentType: 'json', + }, + }); + + expect(mockGithubClient.repos.createWebhook).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + events: ['push'], + active: true, + config: { + url: webhookUrl, + content_type: 'json', + secret: webhookSecret, + insecure_ssl: '0', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + insecureSsl: true, + }, + }); + + expect(mockGithubClient.repos.createWebhook).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + events: ['push'], + active: true, + config: { + url: webhookUrl, + content_type: 'form', + secret: webhookSecret, + insecure_ssl: '1', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + insecureSsl: true, + }, + }); + + expect(mockGithubClient.repos.createWebhook).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + events: ['push'], + active: true, + config: { + url: webhookUrl, + content_type: 'form', + secret: webhookSecret, + insecure_ssl: '1', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + active: false, + }, + }); + + expect(mockGithubClient.repos.createWebhook).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + events: ['push'], + active: false, + config: { + url: webhookUrl, + content_type: 'form', + secret: webhookSecret, + insecure_ssl: '0', + }, + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts new file mode 100644 index 0000000000..50877d3bca --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { InputError } from '@backstage/errors'; +import { + GithubCredentialsProvider, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { Octokit } from '@octokit/rest'; +import { parseRepoUrl } from '../publish/util'; +import { createTemplateAction } from '../../createTemplateAction'; + +type ContentType = 'form' | 'json'; + +export function createGithubWebhookAction(options: { + integrations: ScmIntegrationRegistry; +}) { + const { integrations } = options; + + const credentialsProviders = new Map( + integrations.github.list().map(integration => { + const provider = GithubCredentialsProvider.create(integration.config); + return [integration.config.host, provider]; + }), + ); + + return createTemplateAction<{ + repoUrl: string; + webhookUrl: string; + webhookSecret?: string; + events?: string[]; + active?: boolean; + contentType?: ContentType; + insecureSsl?: boolean; + }>({ + id: 'github:webhook', + description: 'Creates webhook for a repository on GitHub.', + schema: { + input: { + type: 'object', + required: ['repoUrl', 'webhookUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`, + type: 'string', + }, + webhookUrl: { + title: 'Webhook URL', + description: 'The URL to which the payloads will be delivered', + type: 'string', + }, + webhookSecret: { + title: 'Webhook Secret', + description: 'Webhook secret value', + type: 'string', + }, + events: { + title: 'Triggering Events', + description: + 'Determines what events the hook is triggered for. Default: push', + type: 'array', + items: { + type: 'string', + }, + }, + active: { + title: 'Active', + type: 'boolean', + description: `Determines if notifications are sent when the webhook is triggered. Default: true`, + }, + contentType: { + title: 'Content Type', + type: 'string', + enum: ['form', 'json'], + description: `The media type used to serialize the payloads. The default is 'form'`, + }, + insecureSsl: { + title: 'Insecure SSL', + type: 'boolean', + description: `Determines whether the SSL certificate of the host for url will be verified when delivering payloads. Default 'false'`, + }, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + webhookUrl, + webhookSecret, + events = ['push'], + active = true, + contentType = 'form', + insecureSsl = false, + } = ctx.input; + + const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!owner) { + throw new InputError(`No owner provided for repo ${repoUrl}`); + } + + ctx.logger.info(`Creating webhook ${webhookUrl} for repo ${repoUrl}`); + + const credentialsProvider = credentialsProviders.get(host); + const integrationConfig = integrations.github.byHost(host); + + if (!credentialsProvider || !integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + const { token } = await credentialsProvider.getCredentials({ + url: `https://${host}/${encodeURIComponent(owner)}/${encodeURIComponent( + repo, + )}`, + }); + + if (!token) { + throw new InputError( + `No token available for host: ${host}, with owner ${owner}, and repo ${repo}`, + ); + } + + const client = new Octokit({ + auth: token, + baseUrl: integrationConfig.config.apiBaseUrl, + previews: ['nebula-preview'], + }); + + try { + const insecure_ssl = insecureSsl ? '1' : '0'; + await client.repos.createWebhook({ + owner, + repo, + config: { + url: webhookUrl, + content_type: contentType, + secret: webhookSecret, + insecure_ssl, + }, + events, + active, + }); + ctx.logger.info(`Webhook '${webhookUrl}' created successfully`); + } catch (e) { + ctx.logger.warn( + `Failed: create webhook '${webhookUrl}' on repo: '${repo}', ${e.message}`, + ); + } + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/index.ts index 50abd2c3dc..07614e8a03 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/index.ts @@ -15,3 +15,4 @@ */ export { createGithubActionsDispatchAction } from './githubActionsDispatch'; +export { createGithubWebhookAction } from './githubWebhook';