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}`;