feat(scaffolder-backend-module-gitlab): add gitlab:user:info action (#32556)

Signed-off-by: Jellyfrog <Jellyfrog@users.noreply.github.com>
This commit is contained in:
Jellyfrog
2026-02-03 14:57:06 +01:00
committed by GitHub
parent 87613bf39b
commit 32c51c0491
8 changed files with 615 additions and 0 deletions
+5
View File
@@ -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.
@@ -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;
@@ -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',
);
});
});
@@ -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,
},
},
],
}),
},
];
@@ -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',
);
});
});
@@ -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)}`,
);
}
},
});
};
@@ -23,4 +23,5 @@ export * from './gitlabProjectAccessTokenCreate';
export * from './gitlabProjectDeployTokenCreate';
export * from './gitlabProjectVariableCreate';
export * from './gitlabRepoPush';
export * from './gitlabUserInfo';
export { IssueType, IssueStateEvent } from '../commonGitlabConfig';
@@ -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 }),