diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/githubActionsDispatch.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/githubActionsDispatch.test.ts new file mode 100644 index 0000000000..606881e47a --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/githubActionsDispatch.test.ts @@ -0,0 +1,134 @@ +/* + * 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'); +jest.mock('@backstage/integration'); + +import { createGithubActionsDispatchAction } from './githubActionsDispatch'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; +// import { when } from 'jest-when'; + +describe('ci:github-actions-dispatch', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createGithubActionsDispatchAction({ integrations, config }); + + const mockContext = { + input: { + owner: 'a-owner', + repoUrl: 'github.com?repo=repo&owner=owner', + repoName: 'repository-name', + workflowId: 'a-workflow-id', + branchOrTagName: 'main', + }, + workspacePath: 'lol', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + const { mockGithubClient } = require('@octokit/rest'); + mockGithubClient.rest = { + actions: { + createWorkflowDispatch: jest.fn(), + }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + // it('should throw if there is no integration config provided', async () => { + // const actionWithNoIntegrations = createGithubActionsDispatchAction({ + // integrations: { + // ...integrations, + // github: { + // list: () => [], + // byHost: jest.fn(), + // byUrl: jest.fn(), + // }, + // }, + // config, + // }); + // await expect(actionWithNoIntegrations.handler(mockContext)).rejects.toThrow( + // /No matching integration configuration/, + // ); + // }); + + // it('should throw if there is no token in the integration config that is returned', async () => { + // const mockedProvider = jest.fn(); + // const mockedIntegrationConfig = jest.fn(); + // GithubCredentialsProvider.create.mockReturnOnce(mockedProvider); + // const mockedArray = [ + // { + // config: { + // host: 'github.com', + // }, + // }, + // ] as GitHubIntegration[]; + // const integrations1 = { + // github: { + // list: () => mockedArray, + // byHost: mockedIntegrationConfig, + // byUrl: jest.fn(), + // }, + // } as ScmIntegrationRegistry; + // const actionWithNoIntegrations = createGithubActionsDispatchAction({ + // integrations: integrations1, + // config, + // }); + // mockedProvider.mockResolvedValue(undefined); + + // await expect(actionWithNoIntegrations.handler(mockContext)).rejects.toThrow( + // /No token available for host/, + // ); + // }); + + it('should call the githubApis for creating WorkflowDispatch', async () => { + mockGithubClient.rest.actions.createWorkflowDispatch.mockResolvedValue({ + message: 'Success', + }); + const owner = 'dx'; + const repoName = 'test1'; + const workflowId = 'dispatch_workflow'; + const branchOrTagName = 'main'; + const ctx = Object.assign({}, mockContext, { + input: { owner, repoName, workflowId, branchOrTagName }, + }); + await action.handler(ctx); + + expect( + mockGithubClient.rest.actions.createWorkflowDispatch, + ).toHaveBeenCalledWith({ + owner, + repo: repoName, + workflow_id: workflowId, + ref: branchOrTagName, + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/githubActionsDispatch.ts new file mode 100644 index 0000000000..fbeeb4c9ac --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/githubActionsDispatch.ts @@ -0,0 +1,124 @@ +/* + * 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 { createTemplateAction } from '../../createTemplateAction'; +import { Config } from '@backstage/config'; + +const host = 'github.com'; + +export function createGithubActionsDispatchAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}) { + 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<{ + owner: string; + repoName: string; + workflowId: string; + branchOrTagName: string; + }>({ + id: 'ci:github-actions-dispatch', + description: + 'Dispatches a GitHub Action workflow for a given branch or tag', + schema: { + input: { + type: 'object', + required: ['owner', 'repoName', 'workflowId', 'branchOrTagName'], + properties: { + owner: { + title: 'Repository Owner', + description: 'GitHub Org or User name owner of the repository', + type: 'string', + }, + repoName: { + title: 'Repository Name', + description: `Repo`, + type: 'string', + }, + workflowId: { + title: 'Workflow ID', + description: 'The GitHub Action Workflow filename', + type: 'string', + }, + branchOrTagName: { + title: 'Branch or Tag name', + description: + 'The git branch or tag name used to dispatch the workflow', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { owner, repoName, workflowId, branchOrTagName } = ctx.input; + + ctx.logger.info( + `Dispatching workflow ${workflowId} for repo ${owner}/${repoName} 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`, + ); + } + + // 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( + repoName, + )}`, + }); + + if (!token) { + throw new InputError( + `No token available for host: ${host}, with owner ${owner}, and repo ${repoName}`, + ); + } + + const client = new Octokit({ + auth: ctx.token ?? token, + baseUrl: integrationConfig.config.apiBaseUrl, + previews: ['nebula-preview'], + }); + + await client.rest.actions.createWorkflowDispatch({ + owner, + repo, + workflow_id: workflowId, + ref: branchOrTagName, + }); + + ctx.logger.info(`Workflow ${workflowId} dispatched successfully`); + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/index.ts new file mode 100644 index 0000000000..50abd2c3dc --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/ci/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { createGithubActionsDispatchAction } from './githubActionsDispatch'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index ae5a7d0697..48c13436fa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -37,6 +37,7 @@ import { createPublishGithubPullRequestAction, createPublishGitlabAction, } from './publish'; +import { createGithubActionsDispatchAction } from './ci'; export const createBuiltinActions = (options: { reader: UrlReader; @@ -91,5 +92,9 @@ export const createBuiltinActions = (options: { createCatalogWriteAction(), createFilesystemDeleteAction(), createFilesystemRenameAction(), + createGithubActionsDispatchAction({ + integrations, + config, + }), ]; };