From 4b8fcf036fede31a1106835a821d321b1bfd637d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Fankh=C3=A4nel?= <128792991+dfl-aeb@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:26:11 +0100 Subject: [PATCH] feat(scaffolder-backend-module-gitlab): add settings.name and ownerUsername to publish:gitlab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `settings.name` input to allow setting a custom human-readable project title that differs from the repository slug (path) at creation time - Add `ownerUsername` input to add an arbitrary GitLab user as project owner (access level 50) after project creation, without requiring that user to be the token holder Both inputs are optional and backward-compatible. Signed-off-by: David Fankhänel <128792991+dfl-aeb@users.noreply.github.com> --- .changeset/gitlab-publish-enhancements.md | 8 ++ .../report.api.md | 12 ++- .../src/actions/gitlab.examples.ts | 36 +++++++ .../src/actions/gitlab.test.ts | 102 ++++++++++++++++++ .../src/actions/gitlab.ts | 41 +++++++ 5 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 .changeset/gitlab-publish-enhancements.md diff --git a/.changeset/gitlab-publish-enhancements.md b/.changeset/gitlab-publish-enhancements.md new file mode 100644 index 0000000000..7e0c02992a --- /dev/null +++ b/.changeset/gitlab-publish-enhancements.md @@ -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. diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 519e3938e2..41831a50f8 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -47,7 +47,7 @@ export const createGitlabIssueAction: (options: { discussionToResolve?: string | undefined; epicId?: number | undefined; labels?: string | undefined; - issueType?: 'issue' | 'task' | 'incident' | 'test_case' | undefined; + issueType?: 'issue' | 'incident' | 'test_case' | 'task' | undefined; mergeRequestToResolveDiscussionsOf?: number | undefined; milestoneId?: number | undefined; weight?: number | undefined; @@ -132,7 +132,7 @@ export const createGitlabRepoPushAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined; }, { projectid: string; @@ -181,13 +181,15 @@ 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; - merge_method?: 'merge' | 'rebase_merge' | 'ff' | undefined; + merge_method?: 'merge' | 'ff' | 'rebase_merge' | undefined; topics?: string[] | undefined; auto_devops_enabled?: boolean | undefined; only_allow_merge_if_pipeline_succeeds?: boolean | undefined; @@ -249,7 +251,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined; + commitAction?: 'auto' | 'update' | 'skip' | 'create' | 'delete' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; @@ -300,7 +302,7 @@ export const editGitlabIssueAction: (options: { discussionLocked?: boolean | undefined; dueDate?: string | undefined; epicId?: number | undefined; - issueType?: 'issue' | 'task' | 'incident' | 'test_case' | undefined; + issueType?: 'issue' | 'incident' | 'test_case' | 'task' | undefined; labels?: string | undefined; milestoneId?: number | undefined; removeLabels?: string | undefined; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.ts index ea2ff41ec5..4875b1812c 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.examples.ts @@ -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', + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts index 564075fe96..9e3e666bdb 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.test.ts @@ -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,97 @@ 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(); + }); }); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts index d743dd06ac..f634244f27 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts @@ -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}`;