diff --git a/.changeset/bumpy-keys-pay.md b/.changeset/bumpy-keys-pay.md new file mode 100644 index 0000000000..fdd698fd7b --- /dev/null +++ b/.changeset/bumpy-keys-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added new `gitlab:group:access` scaffolder action to add or remove users and groups as members of GitLab groups. The action supports specifying members via `userIds` and/or `groupIds` array parameters, configurable access levels (Guest, Reporter, Developer, Maintainer, Owner), and defaults to the 'add' action when not specified. diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 519e3938e2..cbe31b02f3 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -8,6 +8,28 @@ import { Config } from '@backstage/config'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +// @public +export const createGitlabGroupAccessAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction< + { + repoUrl: string; + path: string | number; + token?: string | undefined; + userIds?: number[] | undefined; + groupIds?: number[] | undefined; + action?: 'add' | 'remove' | undefined; + accessLevel?: string | number | undefined; + }, + { + userIds?: number[] | undefined; + groupIds?: number[] | undefined; + path?: string | number | undefined; + accessLevel?: number | undefined; + }, + 'v2' +>; + // @public export const createGitlabGroupEnsureExistsAction: (options: { integrations: ScmIntegrationRegistry; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts new file mode 100644 index 0000000000..3f15816e68 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2026 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 { ScmIntegrations } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import yaml from 'yaml'; +import { createGitlabGroupAccessAction } from './gitlabGroupAccessAction'; +import { examples } from './gitlabGroupAccessAction.examples'; +import { mockServices } from '@backstage/backend-test-utils'; + +const mockGitlabClient = { + GroupMembers: { + add: jest.fn(), + remove: jest.fn(), + }, + Groups: { + share: jest.fn(), + unshare: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:group:access examples', () => { + const mockContext = createMockActionContext(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const config = mockServices.rootConfig({ + data: { + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabGroupAccessAction({ integrations }); + + it(`Should ${examples[0].description}`, async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[0].example).steps[0].input, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 789, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it(`Should ${examples[1].description}`, async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[1].example).steps[0].input, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 'group1', + 456, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 'group1'); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it(`Should ${examples[2].description}`, async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[2].example).steps[0].input, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 789); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); + + it(`Should ${examples[3].description}`, async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[3].example).steps[0].input, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 456, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it(`Should ${examples[4].description}`, async () => { + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[4].example).steps[0].input, + }); + + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 456, {}); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.ts new file mode 100644 index 0000000000..8ff69f3b9d --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.examples.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2026 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Add users to a group using numeric group ID', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Add Users to Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + userIds: [456, 789], + accessLevel: 30, + }, + }, + ], + }), + }, + { + description: 'Add users to a group using string path', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Add Users to Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 'group1', + userIds: [456], + accessLevel: 'developer', + }, + }, + ], + }), + }, + { + description: 'Remove users from a group', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Remove Users from Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + userIds: [456, 789], + action: 'remove', + }, + }, + ], + }), + }, + { + description: 'Share a group with another group', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Share Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }, + ], + }), + }, + { + description: 'Unshare a group', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroupAccess', + name: 'Unshare Group', + action: 'gitlab:group:access', + input: { + repoUrl: 'gitlab.com', + path: 123, + groupIds: [456], + action: 'remove', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts new file mode 100644 index 0000000000..79edc8f457 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.test.ts @@ -0,0 +1,718 @@ +/* + * Copyright 2026 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 { ScmIntegrations } from '@backstage/integration'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { createGitlabGroupAccessAction } from './gitlabGroupAccessAction'; +import { getClient } from '../util'; +import { mockServices } from '@backstage/backend-test-utils'; + +const mockGitlabClient = { + GroupMembers: { + add: jest.fn(), + edit: jest.fn(), + remove: jest.fn(), + }, + Groups: { + share: jest.fn(), + unshare: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +jest.mock('../util', () => ({ + getClient: jest.fn().mockImplementation(() => mockGitlabClient), + parseRepoHost: (repoUrl: string) => repoUrl, +})); + +describe('gitlab:group:access', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const config = mockServices.rootConfig({ + data: { + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabGroupAccessAction({ integrations }); + + const mockContext = createMockActionContext(); + + // User tests + it('should add a single user to a group with the specified access level', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({ + id: 1, + user_id: 456, + group_id: 123, + access_level: 30, + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should add multiple users to a group with the specified access level', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789, 101], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(3); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 789, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 101, + 30, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789, 101]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should default to add action when action is not specified', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.remove).not.toHaveBeenCalled(); + }); + + it('should remove a single user from a group', async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); + + it('should remove multiple users from a group', async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 789); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + }); + + it('should default to accessLevel 30 (Developer) when not specified', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should not call API on dryRun for add action', async () => { + await action.handler({ + ...mockContext, + isDryRun: true, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should not call API on dryRun for remove action', async () => { + await action.handler({ + ...mockContext, + isDryRun: true, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'accessLevel', + expect.anything(), + ); + }); + + it('should use the token from the integration config when none is provided', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(getClient).toHaveBeenCalledWith( + expect.not.objectContaining({ + token: expect.anything(), + }), + ); + }); + + it('should use a provided token for authentication', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + token: 'mysecrettoken', + }, + }); + + expect(getClient).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'mysecrettoken', + }), + ); + }); + + it('should add users as Guest (accessLevel 10)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 10, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 10, + ); + }); + + it('should add users as Maintainer (accessLevel 40)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 40, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 40, + ); + }); + + it('should add users as Owner (accessLevel 50)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 50, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 50, + ); + }); + + it('should accept string accessLevel "developer"', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 'developer', + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should accept string accessLevel "maintainer" (case insensitive)', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 'MAINTAINER', + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 40, + ); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 40); + }); + + it('should throw an error for invalid string accessLevel', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 'invalid_level', + }, + }), + ).rejects.toThrow('Invalid access level: "invalid_level"'); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + }); + + it('should handle 409 conflict by editing existing user member', async () => { + mockGitlabClient.GroupMembers.add.mockRejectedValue({ + cause: { response: { status: 409 } }, + }); + mockGitlabClient.GroupMembers.edit.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.edit).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + }); + + // Group sharing tests + it('should share a single group with another group', async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 456, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should share multiple groups with a group', async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456, 789], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 456, + 30, + {}, + ); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 789, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should unshare groups from a group', async () => { + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456, 789], + action: 'remove', + }, + }); + + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 456, {}); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 789, {}); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + }); + + it('should handle 409 conflict for group sharing by re-sharing', async () => { + mockGitlabClient.Groups.share.mockRejectedValueOnce({ + cause: { response: { status: 409 } }, + }); + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + mockGitlabClient.Groups.share.mockResolvedValueOnce({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 456, {}); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + }); + + it('should not call API on dryRun for group sharing', async () => { + await action.handler({ + ...mockContext, + isDryRun: true, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456, 789], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.Groups.share).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + // Mixed mode tests + it('should add users and share groups simultaneously', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456, 789], + groupIds: [101, 102], + accessLevel: 30, + }, + }); + + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 456, + 30, + ); + expect(mockGitlabClient.GroupMembers.add).toHaveBeenCalledWith( + 123, + 789, + 30, + ); + + expect(mockGitlabClient.Groups.share).toHaveBeenCalledTimes(2); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 101, + 30, + {}, + ); + expect(mockGitlabClient.Groups.share).toHaveBeenCalledWith( + 123, + 102, + 30, + {}, + ); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456, 789]); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [101, 102]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + expect(mockContext.output).toHaveBeenCalledWith('accessLevel', 30); + }); + + it('should remove users and unshare groups simultaneously', async () => { + mockGitlabClient.GroupMembers.remove.mockResolvedValue(undefined); + mockGitlabClient.Groups.unshare.mockResolvedValue(undefined); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + groupIds: [789], + action: 'remove', + }, + }); + + expect(mockGitlabClient.GroupMembers.remove).toHaveBeenCalledWith(123, 456); + expect(mockGitlabClient.Groups.unshare).toHaveBeenCalledWith(123, 789, {}); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [789]); + expect(mockContext.output).toHaveBeenCalledWith('path', 123); + }); + + // Validation tests + it('should throw an error when neither userIds nor groupIds provided', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + }, + }), + ).rejects.toThrow( + 'At least one of userIds or groupIds must be provided and non-empty', + ); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + expect(mockGitlabClient.Groups.share).not.toHaveBeenCalled(); + }); + + it('should throw an error when userIds and groupIds are both empty arrays', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [], + groupIds: [], + }, + }), + ).rejects.toThrow( + 'At least one of userIds or groupIds must be provided and non-empty', + ); + + expect(mockGitlabClient.GroupMembers.add).not.toHaveBeenCalled(); + expect(mockGitlabClient.Groups.share).not.toHaveBeenCalled(); + }); + + it('should not output userIds when only groupIds are provided', async () => { + mockGitlabClient.Groups.share.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + groupIds: [456], + accessLevel: 30, + }, + }); + + expect(mockContext.output).not.toHaveBeenCalledWith( + 'userIds', + expect.anything(), + ); + expect(mockContext.output).toHaveBeenCalledWith('groupIds', [456]); + }); + + it('should not output groupIds when only userIds are provided', async () => { + mockGitlabClient.GroupMembers.add.mockResolvedValue({}); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: 123, + userIds: [456], + accessLevel: 30, + }, + }); + + expect(mockContext.output).toHaveBeenCalledWith('userIds', [456]); + expect(mockContext.output).not.toHaveBeenCalledWith( + 'groupIds', + expect.anything(), + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts new file mode 100644 index 0000000000..f61b850667 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts @@ -0,0 +1,249 @@ +/* + * Copyright 2026 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 { ScmIntegrationRegistry } from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import { getClient, parseRepoHost } from '../util'; +import { examples } from './gitlabGroupAccessAction.examples'; + +const accessLevelMapping: Record = { + no_access: 0, + minimal_access: 5, + guest: 10, + planner: 15, + reporter: 20, + developer: 30, + maintainer: 40, + owner: 50, +}; + +function resolveAccessLevel(level: string | number): number { + if (typeof level === 'number') return level; + const resolved = accessLevelMapping[level.toLocaleLowerCase('en-US')]; + if (resolved === undefined) { + throw new InputError( + `Invalid access level: "${level}". Valid values are: ${Object.keys( + accessLevelMapping, + ).join(', ')} or a numeric GitLab access level`, + ); + } + return resolved; +} + +/** + * Creates a `gitlab:group:access` Scaffolder action. + * + * @public + */ +export const createGitlabGroupAccessAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + + return createTemplateAction({ + id: 'gitlab:group:access', + description: 'Adds or removes users and groups from a GitLab group', + supportsDryRun: true, + examples, + schema: { + input: { + repoUrl: z => + z.string({ + description: + "The host of the GitLab instance, for example 'gitlab.com' or 'gitlab.my-company.com'.", + }), + token: z => + z + .string({ + description: 'The token to use for authorization to GitLab', + }) + .optional(), + path: z => + z.union([z.number(), z.string()], { + description: + 'The ID or path of the group to add/remove members from', + }), + userIds: z => + z + .array(z.number(), { + description: 'The IDs of the users to add/remove', + }) + .optional(), + groupIds: z => + z + .array(z.number(), { + description: + 'The IDs of the groups to share with or unshare from the target group', + }) + .optional(), + action: z => + z + .enum(['add', 'remove'], { + description: + 'The action to perform: add or remove the members. Defaults to "add".', + }) + .default('add') + .optional(), + accessLevel: z => + z + .union([z.number(), z.string()], { + description: + 'The access level for the members. Can be a number (0=No access, 5=Minimal access, 10=Guest, 15=Planner, 20=Reporter, 30=Developer, 40=Maintainer, 50=Owner) or a string (e.g., "guest", "developer"). Defaults to 30 (Developer).', + }) + .default(30) + .optional(), + }, + output: { + userIds: z => + z + .array(z.number(), { + description: 'The IDs of the users that were added or removed', + }) + .optional(), + groupIds: z => + z + .array(z.number(), { + description: + 'The IDs of the groups that were shared with or unshared from', + }) + .optional(), + path: z => + z + .union([z.number(), z.string()], { + description: + 'The ID or path of the group the members were added to or removed from', + }) + .optional(), + accessLevel: z => + z + .number({ + description: + 'The access level granted to the members (only for add action)', + }) + .optional(), + }, + }, + async handler(ctx) { + const { + token, + repoUrl, + path, + userIds = [], + groupIds = [], + accessLevel: rawAccessLevel = 30, + action = 'add', + } = ctx.input; + + if (userIds.length === 0 && groupIds.length === 0) { + throw new InputError( + 'At least one of userIds or groupIds must be provided and non-empty', + ); + } + + const accessLevel = + action === 'add' ? resolveAccessLevel(rawAccessLevel) : 0; + + if (ctx.isDryRun) { + if (userIds.length > 0) { + ctx.output('userIds', userIds); + } + if (groupIds.length > 0) { + ctx.output('groupIds', groupIds); + } + ctx.output('path', path); + if (action === 'add') { + ctx.output('accessLevel', accessLevel); + } + return; + } + + const host = parseRepoHost(repoUrl); + + const api = getClient({ host, integrations, token }); + + // Process users + for (const userId of userIds) { + ctx.logger.info( + `${action === 'add' ? 'Adding' : 'Removing'} user ${userId} ${ + action === 'add' ? 'to' : 'from' + } group ${path}`, + ); + await ctx.checkpoint({ + key: `gitlab.group.member.user.${action}.${path}.${userId}`, + fn: async () => { + if (action === 'add') { + try { + await api.GroupMembers.add(path, userId, accessLevel); + } catch (error: any) { + // If member already exists, try to edit instead + if (error.cause?.response?.status === 409) { + await api.GroupMembers.edit(path, userId, accessLevel); + return; + } + throw error; + } + } else { + await api.GroupMembers.remove(path, userId); + } + }, + }); + } + + // Process groups + for (const sharedGroupId of groupIds) { + ctx.logger.info( + `${action === 'add' ? 'Adding' : 'Removing'} group ${sharedGroupId} ${ + action === 'add' ? 'to' : 'from' + } group ${path}`, + ); + await ctx.checkpoint({ + key: `gitlab.group.member.group.${action}.${path}.${sharedGroupId}`, + fn: async () => { + if (action === 'add') { + try { + await api.Groups.share(path, sharedGroupId, accessLevel, {}); + } catch (error: any) { + // If group is already shared, unshare and re-share + if (error.cause?.response?.status === 409) { + await api.Groups.unshare(path, sharedGroupId, {}); + await api.Groups.share(path, sharedGroupId, accessLevel, {}); + return; + } + throw error; + } + } else { + await api.Groups.unshare(path, sharedGroupId, {}); + } + }, + }); + } + + ctx.output('path', path); + + if (userIds.length > 0) { + ctx.output('userIds', userIds); + } + if (groupIds.length > 0) { + ctx.output('groupIds', groupIds); + } + + if (action === 'add') { + ctx.output('accessLevel', accessLevel); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts index 2a74e78a41..17364e9916 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts @@ -15,6 +15,7 @@ */ export * from './gitlab'; export * from './gitlabGroupEnsureExists'; +export * from './gitlabGroupAccessAction'; export * from './gitlabIssueCreate'; export * from './gitlabIssueEdit'; export * from './gitlabMergeRequest'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/module.ts b/plugins/scaffolder-backend-module-gitlab/src/module.ts index 61525753a2..653ce6e377 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/module.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/module.ts @@ -21,6 +21,7 @@ import { ScmIntegrations } from '@backstage/integration'; import { scaffolderAutocompleteExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; import { createGitlabGroupEnsureExistsAction, + createGitlabGroupAccessAction, createGitlabIssueAction, createGitlabProjectAccessTokenAction, createGitlabProjectDeployTokenAction, @@ -55,6 +56,7 @@ export const gitlabModule = createBackendModule({ scaffolder.addActions( createGitlabGroupEnsureExistsAction({ integrations }), + createGitlabGroupAccessAction({ integrations }), createGitlabProjectMigrateAction({ integrations }), createGitlabIssueAction({ integrations }), createGitlabProjectAccessTokenAction({ integrations }),