diff --git a/.changeset/tasty-apples-sell.md b/.changeset/tasty-apples-sell.md new file mode 100644 index 0000000000..d24cd61e37 --- /dev/null +++ b/.changeset/tasty-apples-sell.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Adds a collaborator field to GitHub publish action for multiple users and access levels diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index a3a61975f1..c86004d7a4 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -55,6 +55,7 @@ "handlebars": "^4.7.6", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", + "jest-when": "^3.1.0", "jsonschema": "^1.2.6", "knex": "^0.95.1", "lodash": "^4.17.21", diff --git a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml index 91f887ed0a..82fde335c5 100644 --- a/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml @@ -19,6 +19,7 @@ spec: - component_id - use_typescript - description + - collaborators properties: component_id: title: Name @@ -39,4 +40,28 @@ spec: type: boolean description: Use Github Actions default: true - + collaborators: + title: Collaborators + description: Provide users with permissions + type: array + ui:options: + orderable: false + items: + type: object + required: + - username + - access + properties: + access: + type: string + description: The type of access for the user + default: pull + enum: + - push + - pull + - admin + - maintain + - triage + username: + type: string + description: The username or group 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 9880dda621..dc2f602778 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 @@ -22,6 +22,7 @@ import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; import { PassThrough } from 'stream'; import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { when } from 'jest-when'; describe('publish:github', () => { const integrations = ScmIntegrations.fromConfig( @@ -232,6 +233,114 @@ describe('publish:github', () => { }); }); + it('should add multiple collaborators when provided', async () => { + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + collaborators: [ + { + access: 'pull', + username: 'robot-1', + }, + { + access: 'push', + username: 'robot-2', + }, + ], + }, + }); + + const commonProperties = { + org: 'owner', + owner: 'owner', + repo: 'repo', + }; + + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[1], + ).toEqual([ + { + ...commonProperties, + team_slug: 'robot-1', + permission: 'pull', + }, + ]); + + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[2], + ).toEqual([ + { + ...commonProperties, + team_slug: 'robot-2', + permission: 'push', + }, + ]); + }); + + it('should ignore failures when adding multiple collaborators', async () => { + mockGithubClient.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + when(mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg) + .calledWith({ + org: 'owner', + owner: 'owner', + repo: 'repo', + team_slug: 'robot-1', + permission: 'pull', + }) + .mockRejectedValueOnce(new Error('Something bad happened') as never); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + collaborators: [ + { + access: 'pull', + username: 'robot-1', + }, + { + access: 'push', + username: 'robot-2', + }, + ], + }, + }); + + expect( + mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[2], + ).toEqual([ + { + org: 'owner', + owner: 'owner', + repo: 'repo', + team_slug: 'robot-2', + permission: 'push', + }, + ]); + }); + it('should call output with the remoteUrl and the repoContentsUrl', async () => { mockGithubClient.users.getByUsername.mockResolvedValue({ data: { type: 'User' }, 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 70394b0039..e58f2c68aa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -23,6 +23,9 @@ import { initRepoAndPush } from '../../../stages/publish/helpers'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; +type Permission = 'pull' | 'push' | 'admin' | 'maintain' | 'triage'; +type Collaborator = { access: Permission; username: string }; + export function createPublishGithubAction(options: { integrations: ScmIntegrationRegistry; }) { @@ -41,6 +44,7 @@ export function createPublishGithubAction(options: { access?: string; sourcePath?: string; repoVisibility: 'private' | 'internal' | 'public'; + collaborators: Collaborator[]; }>({ id: 'publish:github', description: @@ -72,6 +76,26 @@ export function createPublishGithubAction(options: { 'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the respository.', type: 'string', }, + collaborators: { + title: 'Collaborators', + description: 'Provide users with permissions', + type: 'array', + items: { + type: 'object', + required: ['username', 'access'], + properties: { + access: { + type: 'string', + description: 'The type of access for the user', + enum: ['push', 'pull', 'admin', 'maintain', 'triage'], + }, + username: { + type: 'string', + description: 'The username or group', + }, + }, + }, + }, }, }, output: { @@ -94,6 +118,7 @@ export function createPublishGithubAction(options: { description, access, repoVisibility = 'private', + collaborators, } = ctx.input; const { owner, repo, host } = parseRepoUrl(repoUrl); @@ -165,6 +190,27 @@ export function createPublishGithubAction(options: { }); } + if (collaborators) { + for (const { + access: permission, + username: team_slug, + } of collaborators) { + try { + await client.teams.addOrUpdateRepoPermissionsInOrg({ + org: owner, + team_slug, + owner, + repo, + permission, + }); + } catch (e) { + ctx.logger.warn( + `Skipping ${permission} access for ${team_slug}, ${e.message}`, + ); + } + } + } + const remoteUrl = data.clone_url; const repoContentsUrl = `${data.html_url}/blob/master`;