From 32c51c0491d67a5fd7cfad3542dbdf18464ecc4e Mon Sep 17 00:00:00 2001 From: Jellyfrog Date: Tue, 3 Feb 2026 14:57:06 +0100 Subject: [PATCH] feat(scaffolder-backend-module-gitlab): add gitlab:user:info action (#32556) Signed-off-by: Jellyfrog --- .changeset/quiet-coats-sleep.md | 5 + .../report.api.md | 22 ++ .../actions/gitlabUserInfo.examples.test.ts | 157 +++++++++++++ .../src/actions/gitlabUserInfo.examples.ts | 84 +++++++ .../src/actions/gitlabUserInfo.test.ts | 208 ++++++++++++++++++ .../src/actions/gitlabUserInfo.ts | 136 ++++++++++++ .../src/actions/index.ts | 1 + .../src/module.ts | 2 + 8 files changed, 615 insertions(+) create mode 100644 .changeset/quiet-coats-sleep.md create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.test.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.test.ts create mode 100644 plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.ts diff --git a/.changeset/quiet-coats-sleep.md b/.changeset/quiet-coats-sleep.md new file mode 100644 index 0000000000..2656a1eb8d --- /dev/null +++ b/.changeset/quiet-coats-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added new `gitlab:user:info` scaffolder action that retrieves information about a GitLab user. The action can fetch either the current authenticated user or a specific user by ID. diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 2ff099d469..58fedf0877 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -140,6 +140,28 @@ export const createGitlabRepoPushAction: (options: { 'v2' >; +// @public +export const createGitlabUserInfoAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction< + { + repoUrl: string; + token?: string | undefined; + userId?: number | undefined; + }, + { + id: number; + username: string; + name: string; + state: string; + webUrl: string; + email?: string | undefined; + createdAt?: string | undefined; + publicEmail?: string | undefined; + }, + 'v2' +>; + // @public export function createPublishGitlabAction(options: { integrations: ScmIntegrationRegistry; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.test.ts new file mode 100644 index 0000000000..1d110fa64e --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.test.ts @@ -0,0 +1,157 @@ +/* + * 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 { createGitlabUserInfoAction } from './gitlabUserInfo'; +import { examples } from './gitlabUserInfo.examples'; +import yaml from 'yaml'; +import { mockServices } from '@backstage/backend-test-utils'; + +const mockGitlabClient = { + Users: { + show: jest.fn(), + showCurrentUser: jest.fn(), + }, +}; +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:user:info examples', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const config = mockServices.rootConfig({ + data: { + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'sample-token', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + { + host: 'gitlab.example.com', + token: 'example-token', + apiBaseUrl: 'https://gitlab.example.com/api/v4', + }, + ], + }, + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabUserInfoAction({ integrations }); + + it(`should ${examples[0].description}`, async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + + const mockContext = createMockActionContext({ + input, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ + id: 1, + username: 'currentuser', + name: 'Current User', + state: 'active', + web_url: 'https://gitlab.com/currentuser', + }); + + await action.handler(mockContext); + + expect(mockGitlabClient.Users.showCurrentUser).toHaveBeenCalled(); + expect(mockContext.output).toHaveBeenCalledWith('id', 1); + expect(mockContext.output).toHaveBeenCalledWith('username', 'currentuser'); + }); + + it(`should ${examples[1].description}`, async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + + const mockContext = createMockActionContext({ + input, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.show.mockResolvedValue({ + id: 12345, + username: 'user12345', + name: 'User 12345', + state: 'active', + web_url: 'https://gitlab.com/user12345', + }); + + await action.handler(mockContext); + + expect(mockGitlabClient.Users.show).toHaveBeenCalledWith(12345); + expect(mockContext.output).toHaveBeenCalledWith('id', 12345); + }); + + it(`should ${examples[2].description}`, async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + + const mockContext = createMockActionContext({ + input, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.show.mockResolvedValue({ + id: 12345, + username: 'user12345', + name: 'User 12345', + state: 'active', + web_url: 'https://gitlab.com/user12345', + }); + + await action.handler(mockContext); + + expect(mockGitlabClient.Users.show).toHaveBeenCalledWith(12345); + expect(mockContext.output).toHaveBeenCalledWith('id', 12345); + }); + + it(`should ${examples[3].description}`, async () => { + const input = yaml.parse(examples[3].example).steps[0].input; + + const mockContext = createMockActionContext({ + input, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.show.mockResolvedValue({ + id: 12345, + username: 'user12345', + name: 'User 12345', + state: 'active', + web_url: 'https://gitlab.example.com/user12345', + }); + + await action.handler(mockContext); + + expect(mockGitlabClient.Users.show).toHaveBeenCalledWith(12345); + expect(mockContext.output).toHaveBeenCalledWith('id', 12345); + expect(mockContext.output).toHaveBeenCalledWith( + 'webUrl', + 'https://gitlab.example.com/user12345', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.ts new file mode 100644 index 0000000000..7c84d6b475 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.examples.ts @@ -0,0 +1,84 @@ +/* + * 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: 'Get current authenticated user information', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabUserInfo', + name: 'Get Current User Info', + action: 'gitlab:user:info', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + }, + }, + ], + }), + }, + { + description: 'Get user information by user ID', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabUserInfo', + name: 'Get User Info', + action: 'gitlab:user:info', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + userId: 12345, + }, + }, + ], + }), + }, + { + description: 'Get user information with a custom token', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabUserInfo', + name: 'Get User Info', + action: 'gitlab:user:info', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + token: '${{ secrets.GITLAB_TOKEN }}', + userId: 12345, + }, + }, + ], + }), + }, + { + description: 'Get user information from a self-hosted GitLab instance', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabUserInfo', + name: 'Get User Info', + action: 'gitlab:user:info', + input: { + repoUrl: 'gitlab.example.com?repo=repo&owner=owner', + userId: 12345, + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.test.ts new file mode 100644 index 0000000000..33dd95797d --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.test.ts @@ -0,0 +1,208 @@ +/* + * 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 { createGitlabUserInfoAction } from './gitlabUserInfo'; +import { mockServices } from '@backstage/backend-test-utils'; + +const mockGitlabClient = { + Users: { + show: jest.fn(), + showCurrentUser: jest.fn(), + }, +}; +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:user:info', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const config = mockServices.rootConfig({ + data: { + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'myIntegrationsToken', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabUserInfoAction({ integrations }); + + it('should return current user info when no userId is specified', async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + }, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ + id: 1, + username: 'johndoe', + name: 'John Doe', + state: 'active', + web_url: 'https://gitlab.com/johndoe', + email: 'john@example.com', + created_at: '2020-01-01T00:00:00Z', + public_email: 'john.public@example.com', + }); + + await action.handler(mockContext); + + expect(mockGitlabClient.Users.showCurrentUser).toHaveBeenCalled(); + expect(mockGitlabClient.Users.show).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('id', 1); + expect(mockContext.output).toHaveBeenCalledWith('username', 'johndoe'); + expect(mockContext.output).toHaveBeenCalledWith('name', 'John Doe'); + expect(mockContext.output).toHaveBeenCalledWith('state', 'active'); + expect(mockContext.output).toHaveBeenCalledWith( + 'webUrl', + 'https://gitlab.com/johndoe', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'email', + 'john@example.com', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'createdAt', + '2020-01-01T00:00:00Z', + ); + expect(mockContext.output).toHaveBeenCalledWith( + 'publicEmail', + 'john.public@example.com', + ); + }); + + it('should return user info when userId is specified', async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + userId: 123, + }, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.show.mockResolvedValue({ + id: 123, + username: 'janedoe', + name: 'Jane Doe', + state: 'active', + web_url: 'https://gitlab.com/janedoe', + }); + + await action.handler(mockContext); + + expect(mockGitlabClient.Users.show).toHaveBeenCalledWith(123); + expect(mockGitlabClient.Users.showCurrentUser).not.toHaveBeenCalled(); + + expect(mockContext.output).toHaveBeenCalledWith('id', 123); + expect(mockContext.output).toHaveBeenCalledWith('username', 'janedoe'); + expect(mockContext.output).toHaveBeenCalledWith('name', 'Jane Doe'); + expect(mockContext.output).toHaveBeenCalledWith('state', 'active'); + expect(mockContext.output).toHaveBeenCalledWith( + 'webUrl', + 'https://gitlab.com/janedoe', + ); + }); + + it('should work with a custom token', async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + token: 'custom-oauth-token', + }, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ + id: 1, + username: 'johndoe', + name: 'John Doe', + state: 'active', + web_url: 'https://gitlab.com/johndoe', + }); + + await action.handler(mockContext); + + expect(mockGitlabClient.Users.showCurrentUser).toHaveBeenCalled(); + expect(mockContext.output).toHaveBeenCalledWith('id', 1); + }); + + it('should handle minimal user response', async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + userId: 456, + }, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.show.mockResolvedValue({ + id: 456, + username: 'minimaluser', + name: 'Minimal User', + state: 'blocked', + web_url: 'https://gitlab.com/minimaluser', + }); + + await action.handler(mockContext); + + expect(mockContext.output).toHaveBeenCalledWith('id', 456); + expect(mockContext.output).toHaveBeenCalledWith('username', 'minimaluser'); + expect(mockContext.output).toHaveBeenCalledWith('name', 'Minimal User'); + expect(mockContext.output).toHaveBeenCalledWith('state', 'blocked'); + expect(mockContext.output).toHaveBeenCalledWith( + 'webUrl', + 'https://gitlab.com/minimaluser', + ); + // Optional fields should not be output if not present + expect(mockContext.output).not.toHaveBeenCalledWith( + 'email', + expect.anything(), + ); + }); + + it('should throw an error when the API call fails', async () => { + const mockContext = createMockActionContext({ + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + userId: 999, + }, + workspacePath: '/tmp/workspace', + }); + + mockGitlabClient.Users.show.mockRejectedValue(new Error('User not found')); + + await expect(action.handler(mockContext)).rejects.toThrow( + 'Failed to retrieve GitLab user info: Error: User not found', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.ts new file mode 100644 index 0000000000..987b0568d6 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabUserInfo.ts @@ -0,0 +1,136 @@ +/* + * 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 { examples } from './gitlabUserInfo.examples'; +import { getClient, parseRepoUrl } from '../util'; +import { getErrorMessage } from './helpers'; + +/** + * Creates a `gitlab:user:info` Scaffolder action. + * + * @param options - Templating configuration. + * @public + */ +export const createGitlabUserInfoAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + + return createTemplateAction({ + id: 'gitlab:user:info', + description: + 'Retrieves information about a GitLab user or the current authenticated user', + examples, + schema: { + input: { + repoUrl: z => + z.string({ + description: `Accepts the format 'gitlab.com?repo=project_name&owner=group_name' where 'project_name' is the repository name and 'group_name' is a group or username`, + }), + token: z => + z + .string({ + description: 'The token to use for authorization to GitLab', + }) + .optional(), + userId: z => + z + .number({ + description: + 'User ID. If not provided, returns current authenticated user', + }) + .optional(), + }, + output: { + id: z => + z.number({ + description: 'The user ID', + }), + username: z => + z.string({ + description: 'The username', + }), + name: z => + z.string({ + description: 'The display name', + }), + state: z => + z.string({ + description: 'User state (active, blocked, etc.)', + }), + webUrl: z => + z.string({ + description: 'URL to user profile', + }), + email: z => + z + .string({ + description: + 'Email address (only available for current user or admins)', + }) + .optional(), + createdAt: z => + z + .string({ + description: 'User creation date', + }) + .optional(), + publicEmail: z => + z + .string({ + description: 'Public email address', + }) + .optional(), + }, + }, + async handler(ctx) { + try { + const { repoUrl, token, userId } = ctx.input; + + const { host } = parseRepoUrl(repoUrl, integrations); + const api = getClient({ host, integrations, token }); + + const userInfo = + userId !== undefined + ? await api.Users.show(userId) + : await api.Users.showCurrentUser(); + + ctx.output('id', userInfo.id); + ctx.output('username', userInfo.username); + ctx.output('name', userInfo.name); + ctx.output('state', userInfo.state); + ctx.output('webUrl', userInfo.web_url as string); + + if (userInfo.email) { + ctx.output('email', userInfo.email as string); + } + if (userInfo.created_at) { + ctx.output('createdAt', userInfo.created_at as string); + } + if (userInfo.public_email) { + ctx.output('publicEmail', userInfo.public_email as string); + } + } catch (error: any) { + throw new InputError( + `Failed to retrieve GitLab user info: ${getErrorMessage(error)}`, + ); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts index c601c4656e..2a74e78a41 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts @@ -23,4 +23,5 @@ export * from './gitlabProjectAccessTokenCreate'; export * from './gitlabProjectDeployTokenCreate'; export * from './gitlabProjectVariableCreate'; export * from './gitlabRepoPush'; +export * from './gitlabUserInfo'; export { IssueType, IssueStateEvent } from '../commonGitlabConfig'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/module.ts b/plugins/scaffolder-backend-module-gitlab/src/module.ts index 3c04f57077..61525753a2 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/module.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/module.ts @@ -26,6 +26,7 @@ import { createGitlabProjectDeployTokenAction, createGitlabProjectVariableAction, createGitlabRepoPushAction, + createGitlabUserInfoAction, createPublishGitlabAction, createPublishGitlabMergeRequestAction, createTriggerGitlabPipelineAction, @@ -60,6 +61,7 @@ export const gitlabModule = createBackendModule({ createGitlabProjectDeployTokenAction({ integrations }), createGitlabProjectVariableAction({ integrations }), createGitlabRepoPushAction({ integrations }), + createGitlabUserInfoAction({ integrations }), editGitlabIssueAction({ integrations }), createPublishGitlabAction({ config, integrations }), createPublishGitlabMergeRequestAction({ integrations }),