diff --git a/.changeset/yellow-windows-fix.md b/.changeset/yellow-windows-fix.md new file mode 100644 index 0000000000..7572fdce88 --- /dev/null +++ b/.changeset/yellow-windows-fix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +refactor: extract common Octokit related code and use it in actions: `publish:github`, `github:actions:dispatch`, `github:webhook`. diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md index dfda1ff2fc..5d848e42d2 100644 --- a/plugins/scaffolder-backend/api-report.md +++ b/plugins/scaffolder-backend/api-report.md @@ -14,6 +14,7 @@ import express from 'express'; import { JsonObject } from '@backstage/config'; import { JsonValue } from '@backstage/config'; import { Logger as Logger_2 } from 'winston'; +import { Octokit } from '@octokit/rest'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { Schema } from 'jsonschema'; import { ScmIntegrationRegistry } from '@backstage/integration'; @@ -200,6 +201,16 @@ export function fetchContents({ outputPath: string; }): Promise; +// Warning: (ae-missing-release-tag) "OctokitProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export class OctokitProvider { + constructor(integrations: ScmIntegrationRegistry); + // Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen + // Warning: (ae-forgotten-export) The symbol "OctokitIntegration" needs to be exported by the entry point index.d.ts + getOctokit(repoUrl: string): Promise; +} + // Warning: (ae-missing-release-tag) "RouterOptions" 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/actions/builtin/github/OctokitProvider.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts new file mode 100644 index 0000000000..91b23f40dd --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { OctokitProvider } from './OctokitProvider'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; + +describe('getOctokit', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const octokitProvider = new OctokitProvider(integrations); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw an error when the repoUrl is not well formed', async () => { + await expect( + octokitProvider.getOctokit('github.com?repo=bob'), + ).rejects.toThrow(/missing owner/); + + await expect( + octokitProvider.getOctokit('github.com?owner=owner'), + ).rejects.toThrow(/missing repo/); + }); + + it('should throw if there is no integration config provided', async () => { + await expect( + octokitProvider.getOctokit('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( + octokitProvider.getOctokit('ghe.github.com?repo=bob&owner=owner'), + ).rejects.toThrow(/No token available for host/); + }); + + it('should return proper Octokit', async () => { + const { client, token, owner, repo } = await octokitProvider.getOctokit( + 'github.com?repo=bob&owner=owner', + ); + expect(client).toBeDefined(); + expect(token).toBe('tokenlols'); + expect(owner).toBe('owner'); + expect(repo).toBe('bob'); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts new file mode 100644 index 0000000000..bea027a91b --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/OctokitProvider.ts @@ -0,0 +1,97 @@ +/* + * 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'; + +export type OctokitIntegration = { + client: Octokit; + token: string; + owner: string; + repo: string; +}; +/** + * OctokitProvider provides Octokit client based on ScmIntegrationsRegistry configuration. + * OctokitProvider supports GitHub credentials caching out of the box. + */ +export class OctokitProvider { + private readonly integrations: ScmIntegrationRegistry; + private readonly credentialsProviders: Map; + + constructor(integrations: ScmIntegrationRegistry) { + this.integrations = integrations; + this.credentialsProviders = new Map( + integrations.github.list().map(integration => { + const provider = GithubCredentialsProvider.create(integration.config); + return [integration.config.host, provider]; + }), + ); + } + + /** + * gets standard Octokit client based on repository URL. + * + * @param repoUrl Repository URL + */ + async getOctokit(repoUrl: string): Promise { + const { owner, repo, host } = parseRepoUrl(repoUrl, this.integrations); + + if (!owner) { + throw new InputError(`No owner provided for repo ${repoUrl}`); + } + + const integrationConfig = this.integrations.github.byHost(host)?.config; + + if (!integrationConfig) { + throw new InputError(`No integration for host ${host}`); + } + + const credentialsProvider = this.credentialsProviders.get(host); + + if (!credentialsProvider) { + throw new InputError( + `No matching credentials for host ${host}, please check your integrations config`, + ); + } + + // TODO(blam): Consider changing this API to have owner, repo interface instead of URL as the it's + // needless to create URL and then parse again the other side. + 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.apiBaseUrl, + previews: ['nebula-preview'], + }); + + return { client, token, owner, repo }; + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts index 29e4338a39..ab1bbfc488 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.test.ts @@ -54,42 +54,6 @@ describe('github:actions:dispatch', () => { 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 githubApis for creating WorkflowDispatch', async () => { mockGithubClient.rest.actions.createWorkflowDispatch.mockResolvedValue({ data: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts index 5dfba7776e..661101d184 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts @@ -13,26 +13,15 @@ * 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 { ScmIntegrationRegistry } from '@backstage/integration'; import { createTemplateAction } from '../../createTemplateAction'; +import { OctokitProvider } from './OctokitProvider'; export function createGithubActionsDispatchAction(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]; - }), - ); + const octokitProvider = new OctokitProvider(integrations); return createTemplateAction<{ repoUrl: string; @@ -69,44 +58,11 @@ export function createGithubActionsDispatchAction(options: { async handler(ctx) { const { repoUrl, workflowId, branchOrTagName } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); - - if (!owner) { - throw new InputError( - `No owner provided for host: ${host}, and repo ${repo}`, - ); - } - ctx.logger.info( `Dispatching workflow ${workflowId} for repo ${repoUrl} on ${branchOrTagName}`, ); - 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'], - }); + const { client, owner, repo } = await octokitProvider.getOctokit(repoUrl); await client.rest.actions.createWorkflowDispatch({ owner, 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 index 8f11eefce1..3ea3037b90 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.test.ts @@ -54,42 +54,6 @@ describe('github:repository:webhook:create', () => { 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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts index 50877d3bca..33ed415062 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubWebhook.ts @@ -13,14 +13,9 @@ * 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 { ScmIntegrationRegistry } from '@backstage/integration'; import { createTemplateAction } from '../../createTemplateAction'; +import { OctokitProvider } from './OctokitProvider'; type ContentType = 'form' | 'json'; @@ -28,13 +23,7 @@ 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]; - }), - ); + const octokitProvider = new OctokitProvider(integrations); return createTemplateAction<{ repoUrl: string; @@ -106,40 +95,9 @@ export function createGithubWebhookAction(options: { 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'], - }); + const { client, owner, repo } = await octokitProvider.getOctokit(repoUrl); try { const insecure_ssl = insecureSsl ? '1' : '0'; 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 07614e8a03..865c433c16 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/index.ts @@ -16,3 +16,4 @@ export { createGithubActionsDispatchAction } from './githubActionsDispatch'; export { createGithubWebhookAction } from './githubWebhook'; +export { OctokitProvider } from './OctokitProvider'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 4461d227e8..6a3ccf5e38 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -60,42 +60,6 @@ describe('publish:github', () => { 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 githubApis with the correct values for createInOrg', async () => { mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'Organization' }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 5a358822af..b32f9b5b3f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -13,19 +13,15 @@ * 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 { ScmIntegrationRegistry } from '@backstage/integration'; import { enableBranchProtectionOnDefaultRepoBranch, initRepoAndPush, } from '../helpers'; -import { getRepoSourceDirectory, parseRepoUrl } from './util'; +import { getRepoSourceDirectory } from './util'; import { createTemplateAction } from '../../createTemplateAction'; import { Config } from '@backstage/config'; +import { OctokitProvider } from '../github/OctokitProvider'; type Permission = 'pull' | 'push' | 'admin' | 'maintain' | 'triage'; type Collaborator = { access: Permission; username: string }; @@ -35,13 +31,7 @@ export function createPublishGithubAction(options: { config: Config; }) { const { integrations, config } = options; - - const credentialsProviders = new Map( - integrations.github.list().map(integration => { - const provider = GithubCredentialsProvider.create(integration.config); - return [integration.config.host, provider]; - }), - ); + const octokitProvider = new OctokitProvider(integrations); return createTemplateAction<{ repoUrl: string; @@ -151,42 +141,9 @@ export function createPublishGithubAction(options: { topics, } = ctx.input; - const { owner, repo, host } = parseRepoUrl(repoUrl, integrations); - - if (!owner) { - throw new InputError( - `No owner provided for host: ${host}, and repo ${repo}`, - ); - } - - 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`, - ); - } - - // TODO(blam): Consider changing this API to have owner, repo interface instead of URL as the it's - // needless to create URL and then parse again the other side. - 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'], - }); + const { client, token, owner, repo } = await octokitProvider.getOctokit( + repoUrl, + ); const user = await client.users.getByUsername({ username: owner,