Merge pull request #33028 from dfl-aeb/feat/gitlab-publish-settings-name-owner-username

feat(scaffolder-backend-module-gitlab): add settings.name and ownerUsername to publish:gitlab
This commit is contained in:
Patrik Oldsberg
2026-03-17 21:47:32 +01:00
committed by GitHub
5 changed files with 229 additions and 0 deletions
@@ -0,0 +1,8 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Added two optional inputs to the `publish:gitlab` action:
- `settings.name`: set a custom human-readable project title that differs from the repository slug.
- `ownerUsername`: add a specific GitLab user as project owner (access level 50) of the newly created repository. Requires a privileged token in the integration configuration.
@@ -203,9 +203,11 @@ export function createPublishGitlabAction(options: {
skipExisting?: boolean | undefined;
token?: string | undefined;
setUserAsOwner?: boolean | undefined;
ownerUsername?: string | undefined;
topics?: string[] | undefined;
settings?:
| {
name?: string | undefined;
visibility?: 'internal' | 'private' | 'public' | undefined;
path?: string | undefined;
description?: string | undefined;
@@ -221,4 +221,40 @@ export const examples: TemplateExample[] = [
],
}),
},
{
description:
'Initializes a GitLab repository with a custom project title that differs from the repository slug.',
example: yaml.stringify({
steps: [
{
id: 'publish',
action: 'publish:gitlab',
name: 'Publish to GitLab',
input: {
repoUrl: 'gitlab.com?repo=my-project-slug&owner=group_name',
settings: {
name: 'My Project Title',
},
},
},
],
}),
},
{
description:
'Initializes a GitLab repository and adds a specific user as project owner.',
example: yaml.stringify({
steps: [
{
id: 'publish',
action: 'publish:gitlab',
name: 'Publish to GitLab',
input: {
repoUrl: 'gitlab.com?repo=project_name&owner=group_name',
ownerUsername: 'john.doe',
},
},
],
}),
},
];
@@ -45,6 +45,7 @@ const mockGitlabClient = {
Users: {
showCurrentUser: jest.fn(),
allProjects: jest.fn(),
all: jest.fn(),
},
ProjectMembers: {
add: jest.fn(),
@@ -220,6 +221,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'bob',
path: 'bob',
visibility: 'private',
});
expect(mockGitlabClient.Branches.create).not.toHaveBeenCalled();
@@ -240,6 +242,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'repo',
path: 'repo',
visibility: 'private',
ci_config_path: '.gitlab-ci.yml',
});
@@ -263,6 +266,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 12345,
name: 'repo',
path: 'repo',
visibility: 'private',
ci_config_path: '.gitlab-ci.yml',
});
@@ -348,6 +352,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'repo',
path: 'repo',
visibility: 'private',
ci_config_path: '.gitlab-ci.yml',
});
@@ -372,6 +377,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'repo',
path: 'repo',
visibility: 'internal',
topics: ['topic1', 'topic2'],
ci_config_path: '.gitlab-ci.yml',
@@ -398,6 +404,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'repo',
path: 'repo',
visibility: 'private',
});
expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(2);
@@ -442,6 +449,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'repo',
path: 'repo',
visibility: 'private',
});
@@ -774,6 +782,7 @@ describe('publish:gitlab', () => {
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'repo',
path: 'repo',
visibility: 'private',
});
});
@@ -798,4 +807,137 @@ describe('publish:gitlab', () => {
`The namespace ${owner} is not found or the user doesn't have permissions to access it`,
);
});
it('should use settings.name as the project title when provided', async () => {
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({
id: 1234,
kind: 'group',
});
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
});
await action.handler({
...mockContext,
input: {
repoUrl: 'gitlab.com?repo=my-project-slug&owner=owner',
settings: {
name: 'My Project Title',
},
},
});
expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({
namespaceId: 1234,
name: 'My Project Title',
path: 'my-project-slug',
visibility: 'private',
});
});
it('should add ownerUsername as project owner when provided', async () => {
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({
id: 1234,
kind: 'group',
});
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
mockGitlabClient.Projects.create.mockResolvedValue({
id: 123456,
http_url_to_repo: 'http://mockurl.git',
});
mockGitlabClient.Users.all.mockResolvedValue([{ id: 99999 }]);
await action.handler({
...mockContext,
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
ownerUsername: 'target-owner',
},
});
expect(mockGitlabClient.Users.all).toHaveBeenCalledWith({
username: 'target-owner',
});
expect(mockGitlabClient.ProjectMembers.add).toHaveBeenCalledWith(
123456,
50,
{ userId: 99999 },
);
});
it('should warn and continue when ownerUsername is not found', async () => {
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({
id: 1234,
kind: 'group',
});
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
mockGitlabClient.Projects.create.mockResolvedValue({
id: 123456,
http_url_to_repo: 'http://mockurl.git',
});
mockGitlabClient.Users.all.mockResolvedValue([]);
const ctx = {
...mockContext,
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
ownerUsername: 'unknown-user',
},
};
ctx.logger.warn = jest.fn();
await action.handler(ctx);
expect(mockGitlabClient.Users.all).toHaveBeenCalledWith({
username: 'unknown-user',
});
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not find GitLab user'),
);
expect(mockGitlabClient.ProjectMembers.add).not.toHaveBeenCalled();
});
it('should warn and continue when adding ownerUsername as project member fails', async () => {
mockGitlabClient.Users.showCurrentUser.mockResolvedValue({ id: 12345 });
mockGitlabClient.Namespaces.show.mockResolvedValue({
id: 1234,
kind: 'group',
});
mockGitlabClient.Groups.allProjects.mockResolvedValue([]);
mockGitlabClient.Projects.create.mockResolvedValue({
id: 123456,
http_url_to_repo: 'http://mockurl.git',
});
mockGitlabClient.Users.all.mockResolvedValue([{ id: 99999 }]);
mockGitlabClient.ProjectMembers.add.mockRejectedValue(
new Error('Forbidden'),
);
const ctx = {
...mockContext,
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
ownerUsername: 'target-owner',
},
};
ctx.logger.warn = jest.fn();
await action.handler(ctx);
expect(mockGitlabClient.ProjectMembers.add).toHaveBeenCalledWith(
123456,
50,
{ userId: 99999 },
);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to add user'),
);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Forbidden'),
);
});
});
@@ -112,6 +112,13 @@ export function createPublishGitlabAction(options: {
'Set the token user as owner of the newly created repository. Requires a token authorized to do the edit in the integration configuration for the matching host',
})
.optional(),
ownerUsername: z =>
z
.string({
description:
'Username of a GitLab user to add as owner (access level 50) of the newly created project. Requires a privileged token in the integration configuration for the matching host.',
})
.optional(),
topics: z =>
z
.array(z.string(), {
@@ -122,6 +129,12 @@ export function createPublishGitlabAction(options: {
settings: z =>
z
.object({
name: z
.string({
description:
'Human-readable project name (title). If not provided, the repository name from repoUrl is used.',
})
.optional(),
path: z
.string({
description:
@@ -323,6 +336,7 @@ export function createPublishGitlabAction(options: {
await client.Projects.create({
namespaceId: targetNamespaceId,
name: repo,
path: repo,
visibility: repoVisibility,
...(topics.length ? { topics } : {}),
...(Object.keys(settings).length ? { ...settings } : {}),
@@ -343,6 +357,33 @@ export function createPublishGitlabAction(options: {
await adminClient.ProjectMembers.add(projectId, 50, { userId });
}
if (ctx.input.ownerUsername && integrationConfig.config.token) {
try {
const adminClient = new Gitlab({
host: integrationConfig.config.baseUrl,
token: integrationConfig.config.token,
});
const users = await adminClient.Users.all({
username: ctx.input.ownerUsername,
});
if (users.length > 0) {
await adminClient.ProjectMembers.add(projectId, 50, {
userId: users[0].id,
});
} else {
ctx.logger.warn(
`Could not find GitLab user with username '${ctx.input.ownerUsername}'. Skipping owner assignment.`,
);
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
ctx.logger.warn(
`Failed to add user '${ctx.input.ownerUsername}' as owner: ${errorMessage}. Proceeding without owner assignment.`,
);
}
}
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
const repoContentsUrl = `${remoteUrl}/-/blob/${defaultBranch}`;