feat(scaffolder-backend-module-gitlab): add gitlab:group:access action (#32528)
* feat(scaffolder-backend-module-gitlab): add gitlab:group:access action Add a new scaffolder action to add or remove users and groups as members of GitLab groups. The action supports specifying access levels (Guest, Reporter, Developer, Maintainer, Owner) when adding members and includes dry-run support. Signed-off-by: Jellyfrog <Jellyfrog@users.noreply.github.com> * Update plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts Signed-off-by: Fredrik Adelöw <freben@gmail.com> * Update plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jellyfrog <Jellyfrog@users.noreply.github.com> * Update plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupAccessAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jellyfrog <Jellyfrog@users.noreply.github.com> * Update gitlabGroupAccessAction to use parseRepoHost and conditionally resolve access level Use parseRepoHost instead of parseRepoUrl since only the host is needed, and skip resolveAccessLevel for remove actions where access level is unused. Signed-off-by: Jellyfrog <Jellyfrog@users.noreply.github.com> --------- Signed-off-by: Jellyfrog <Jellyfrog@users.noreply.github.com> Signed-off-by: Fredrik Adelöw <freben@gmail.com> Co-authored-by: Fredrik Adelöw <freben@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
+168
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
+110
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number> = {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
export * from './gitlab';
|
||||
export * from './gitlabGroupEnsureExists';
|
||||
export * from './gitlabGroupAccessAction';
|
||||
export * from './gitlabIssueCreate';
|
||||
export * from './gitlabIssueEdit';
|
||||
export * from './gitlabMergeRequest';
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user