diff --git a/.changeset/flat-terms-provide.md b/.changeset/flat-terms-provide.md new file mode 100644 index 0000000000..f7a22fa7ea --- /dev/null +++ b/.changeset/flat-terms-provide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Add Scaffolder custom action that creates GitLab issues called `gitlab:issues:create` diff --git a/plugins/scaffolder-backend-module-gitlab/README.md b/plugins/scaffolder-backend-module-gitlab/README.md index 340b57bbf8..c946c8b476 100644 --- a/plugins/scaffolder-backend-module-gitlab/README.md +++ b/plugins/scaffolder-backend-module-gitlab/README.md @@ -24,6 +24,7 @@ import { createGitlabProjectDeployTokenAction, createGitlabProjectVariableAction, createGitlabGroupEnsureExistsAction, + createGitlabIssueAction, } from '@backstage/plugin-scaffolder-backend-module-gitlab'; // Create BuiltIn Actions @@ -41,6 +42,7 @@ const actions = [ createGitlabProjectDeployTokenAction({ integrations: integrations }), createGitlabProjectVariableAction({ integrations: integrations }), createGitlabGroupEnsureExistsAction({ integrations: integrations }), + createGitlabIssueAction({ integrations: integrations }), ]; // Create Scaffolder Router @@ -160,8 +162,26 @@ spec: repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} catalogInfoPath: '/catalog-info.yaml' + - id: gitlabIssue + name: Issues + action: gitlab:issues:create + input: + repoUrl: ${{ parameters.repoUrl }} + token: ${{ secrets.USER_OAUTH_TOKEN }} + projectId: 1111111 + title: Test Issue + assignees: + - 2222222 + description: This is the description of the issue + confidential: true + createdAt: 2022-09-27 18:00:00.000 + dueDate: 2024-09-28 12:00:00.000 + epicId: 3333333 + labels: phase1:label1,phase2:label2 output: links: - title: Repository url: ${{ steps['publish'].output.remoteUrl }} + - title: Link to new issue + url: ${{ steps['gitlabIssue'].output.issueUrl }} ``` diff --git a/plugins/scaffolder-backend-module-gitlab/api-report.md b/plugins/scaffolder-backend-module-gitlab/api-report.md index debc31412a..9ff9fb3dfc 100644 --- a/plugins/scaffolder-backend-module-gitlab/api-report.md +++ b/plugins/scaffolder-backend-module-gitlab/api-report.md @@ -22,6 +22,34 @@ export const createGitlabGroupEnsureExistsAction: (options: { } >; +// @public +export const createGitlabIssueAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction< + { + title: string; + repoUrl: string; + projectId: number; + token?: string | undefined; + assignees?: number[] | undefined; + confidential?: boolean | undefined; + description?: string | undefined; + createdAt?: string | undefined; + dueDate?: string | undefined; + discussionToResolve?: string | undefined; + epicId?: number | undefined; + labels?: string | undefined; + issueType?: IssueType | undefined; + mergeRequestToResolveDiscussionsOf?: number | undefined; + milestoneId?: number | undefined; + weight?: number | undefined; + }, + { + issueUrl: string; + issueId: number; + } +>; + // @public export const createGitlabProjectAccessTokenAction: (options: { integrations: ScmIntegrationRegistry; @@ -146,4 +174,14 @@ export const createPublishGitlabMergeRequestAction: (options: { }, JsonObject >; + +// @public +export enum IssueType { + // (undocumented) + INCIDENT = 'incident', + // (undocumented) + ISSUE = 'issue', + // (undocumented) + TEST = 'test_case', +} ``` diff --git a/plugins/scaffolder-backend-module-gitlab/package.json b/plugins/scaffolder-backend-module-gitlab/package.json index 338f7e812a..f6a15afa1a 100644 --- a/plugins/scaffolder-backend-module-gitlab/package.json +++ b/plugins/scaffolder-backend-module-gitlab/package.json @@ -38,13 +38,15 @@ "@backstage/plugin-scaffolder-node": "workspace:^", "@gitbeaker/core": "^35.8.0", "@gitbeaker/node": "^35.8.0", + "@gitbeaker/rest": "^39.25.0", "yaml": "^2.0.0", "zod": "^3.22.4" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/core-app-api": "workspace:^" + "@backstage/core-app-api": "workspace:^", + "jest-date-mock": "^1.0.8" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.examples.ts new file mode 100644 index 0000000000..760caf0f9f --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.examples.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2023 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'; +import { commonGitlabConfigExample } from '../commonGitlabConfig'; + +export const examples: TemplateExample[] = [ + { + description: 'Create a GitLab issue with minimal options', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'Issues', + action: 'gitlab:issues:create', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Test Issue', + description: 'This is the description of the issue', + }, + }, + ], + }), + }, + { + description: 'Create a GitLab issue with assignees and date options', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'Issues', + action: 'gitlab:issues:create', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Test Issue', + assignees: [18], + description: 'This is the description of the issue', + createdAt: '2022-09-27 18:00:00.000', + dueDate: '2022-09-28 12:00:00.000', + }, + }, + ], + }), + }, + { + description: 'Create a GitLab Issue with several options', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabIssue', + name: 'Issues', + action: 'gitlab:issues:create', + input: { + ...commonGitlabConfigExample, + projectId: 12, + title: 'Test Issue', + assignees: [18, 15], + description: 'This is the description of the issue', + confidential: false, + createdAt: '2022-09-27 18:00:00.000', + dueDate: '2022-09-28 12:00:00.000', + discussionToResolve: 1, + epicId: 1, + labels: 'phase1:label1,phase2:label2', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts new file mode 100644 index 0000000000..fe9e556656 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2021 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 { PassThrough } from 'stream'; +import { getVoidLogger } from '@backstage/backend-common'; +import { createGitlabIssueAction, IssueType } from './createGitlabIssueAction'; +import { ConfigReader } from '@backstage/core-app-api'; +import { ScmIntegrations } from '@backstage/integration'; +import { advanceTo, clear } from 'jest-date-mock'; + +const mockGitlabClient = { + Issues: { + create: jest.fn(), + }, +}; +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:issues:create', () => { + beforeEach(() => { + jest.clearAllMocks(); + advanceTo(new Date(1988, 5, 3, 12, 0, 0)); // Set the desired date and time + }); + + afterEach(() => { + clear(); // Reset the date mock after each test + }); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'myIntegrationsToken', + apiBaseUrl: 'https://gitlab.com/api/v4', + }, + ], + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabIssueAction({ integrations }); + + it('should return a Gitlab issue when called with minimal input params', async () => { + const mockContext = { + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + title: 'Computer banks to rule the world', + }, + workspacePath: 'seen2much', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + mockGitlabClient.Issues.create.mockResolvedValue({ + id: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + await action.handler({ + ...mockContext, + }); + + expect(mockGitlabClient.Issues.create).toHaveBeenCalledWith( + 123, + 'Computer banks to rule the world', + { + issueType: undefined, + description: '', + assigneeIds: [], + confidential: false, + epicId: undefined, + labels: '', + createdAt: new Date().toISOString(), + dueDate: undefined, + discussionToResolve: '', + mergeRequestToResolveDiscussionsOf: undefined, + milestoneId: undefined, + weight: undefined, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it('should return a Gitlab issue when called with oAuth Token', async () => { + const mockContext = { + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + title: 'Computer banks to rule the world', + token: 'myAwesomeToken', + }, + workspacePath: 'seen2much', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + mockGitlabClient.Issues.create.mockResolvedValue({ + id: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + await action.handler({ + ...mockContext, + }); + + expect(mockGitlabClient.Issues.create).toHaveBeenCalledWith( + 123, + 'Computer banks to rule the world', + { + issueType: undefined, + description: '', + assigneeIds: [], + confidential: false, + epicId: undefined, + labels: '', + createdAt: new Date().toISOString(), + dueDate: undefined, + discussionToResolve: '', + mergeRequestToResolveDiscussionsOf: undefined, + milestoneId: undefined, + weight: undefined, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); + + it('should return a Gitlab issue when called with several input params', async () => { + const mockContext = { + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + projectId: 123, + issueType: IssueType.INCIDENT, + title: 'Computer banks to rule the world', + description: + 'this issue should kickstart research on instruments to sight the stars', + dueDate: '1990-08-20T23:59:59Z', + token: 'myAwesomeToken', + assignees: [3, 14, 15], + labels: 'operation:mindcrime', + }, + workspacePath: 'seen2much', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + mockGitlabClient.Issues.create.mockResolvedValue({ + id: 42, + web_url: 'https://gitlab.com/hangar18-/issues/42', + }); + + await action.handler({ + ...mockContext, + }); + + expect(mockGitlabClient.Issues.create).toHaveBeenCalledWith( + 123, + 'Computer banks to rule the world', + { + issueType: 'incident', + description: + 'this issue should kickstart research on instruments to sight the stars', + assigneeIds: [3, 14, 15], + confidential: false, + epicId: undefined, + labels: 'operation:mindcrime', + createdAt: new Date().toISOString(), + dueDate: '1990-08-20T23:59:59.000Z', + discussionToResolve: '', + mergeRequestToResolveDiscussionsOf: undefined, + milestoneId: undefined, + weight: undefined, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith('issueId', 42); + expect(mockContext.output).toHaveBeenCalledWith( + 'issueUrl', + 'https://gitlab.com/hangar18-/issues/42', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.ts new file mode 100644 index 0000000000..bc9c796253 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/createGitlabIssueAction.ts @@ -0,0 +1,209 @@ +/* + * Copyright 2023 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 commonGitlabConfig from '../commonGitlabConfig'; +import { examples } from './createGitlabIssueAction.examples'; +import { z } from 'zod'; +import { checkEpicScope, convertDate, getClient, parseRepoUrl } from '../util'; +import { Gitlab, CreateIssueOptions, IssueSchema } from '@gitbeaker/rest'; + +/** + * Gitlab issue types + * + * @public + */ +export enum IssueType { + ISSUE = 'issue', + INCIDENT = 'incident', + TEST = 'test_case', +} + +const issueInputProperties = z.object({ + projectId: z.number().describe('Project Id'), + title: z.string({ description: 'Title of the issue' }), + assignees: z + .array(z.number(), { + description: 'IDs of the users to assign the issue to.', + }) + .optional(), + confidential: z.boolean({ description: 'Issue Confidentiality' }).optional(), + description: z.string().describe('Issue description').max(1048576).optional(), + createdAt: z + .string() + .describe('Creation date/time') + .regex( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/, + 'Invalid date format. Use YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss.SSSZ', + ) + .optional(), + dueDate: z + .string() + .describe('Due date/time') + .regex( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/, + 'Invalid date format. Use YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss.SSSZ', + ) + .optional(), + discussionToResolve: z + .string({ + description: + 'Id of a discussion to resolve. Use in combination with "merge_request_to_resolve_discussions_of"', + }) + .optional(), + epicId: z + .number({ description: 'Id of the linked Epic' }) + .min(0, 'Valid values should be equal or greater than zero') + .optional(), + labels: z.string({ description: 'Labels to apply' }).optional(), + issueType: z + .nativeEnum(IssueType, { + description: 'Type of the issue', + }) + .optional(), + mergeRequestToResolveDiscussionsOf: z + .number({ + description: 'IID of a merge request in which to resolve all issues', + }) + .optional(), + milestoneId: z + .number({ description: 'Global ID of a milestone to assign the issue' }) + .optional(), + weight: z + .number({ description: 'The issue weight' }) + .min(0) + .refine(value => { + const isValid = value >= 0; + if (!isValid) { + return { + message: 'Valid values should be equal or greater than zero', + }; + } + return isValid; + }) + .optional(), +}); + +const issueOutputProperties = z.object({ + issueUrl: z.string({ description: 'Issue Url' }), + issueId: z.number({ description: 'Issue Id' }), +}); + +/** + * Creates a `gitlab:issues:create` Scaffolder action. + * + * @param options - Templating configuration. + * @public + */ +export const createGitlabIssueAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + return createTemplateAction({ + id: 'gitlab:issues:create', + description: 'Creates a Gitlab issue.', + examples, + schema: { + input: commonGitlabConfig.merge(issueInputProperties), + output: issueOutputProperties, + }, + async handler(ctx) { + try { + const { + repoUrl, + projectId, + title, + description = '', + confidential = false, + assignees = [], + createdAt = '', + dueDate, + discussionToResolve = '', + epicId, + labels = '', + issueType, + mergeRequestToResolveDiscussionsOf, + milestoneId, + weight, + token, + } = commonGitlabConfig.merge(issueInputProperties).parse(ctx.input); + + const { host } = parseRepoUrl(repoUrl, integrations); + const api = getClient({ host, integrations, token }); + + let isEpicScoped = false; + + if (epicId) { + isEpicScoped = await checkEpicScope( + api as any as InstanceType, + projectId, + epicId, + ); + + if (isEpicScoped) { + ctx.logger.info('Epic is within Project Scope'); + } else { + ctx.logger.warn( + 'Chosen epic is not within the Project Scope. The issue will be created without an associated epic.', + ); + } + } + const mappedCreatedAt = convertDate( + String(createdAt), + new Date().toISOString(), + ); + const mappedDueDate = dueDate + ? convertDate(String(dueDate), new Date().toISOString()) + : undefined; + + const issueOptions: CreateIssueOptions = { + description, + assigneeIds: assignees, + confidential, + epicId: isEpicScoped ? epicId : undefined, + labels, + createdAt: mappedCreatedAt, + dueDate: mappedDueDate, + discussionToResolve, + issueType, + mergeRequestToResolveDiscussionsOf, + milestoneId, + weight, + }; + + const response = (await api.Issues.create( + projectId, + title, + issueOptions, + )) as IssueSchema; + + ctx.output('issueId', response.id); + ctx.output('issueUrl', response.web_url); + } catch (error: any) { + if (error instanceof z.ZodError) { + // Handling Zod validation errors + throw new InputError(`Validation error: ${error.message}`, { + validationErrors: error.errors, + }); + } + // Handling other errors + throw new InputError(`Failed to create GitLab issue: ${error.message}`); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts index e915293860..237ef7c271 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts @@ -17,5 +17,6 @@ export * from './createGitlabGroupEnsureExistsAction'; export * from './createGitlabProjectDeployTokenAction'; export * from './createGitlabProjectAccessTokenAction'; export * from './createGitlabProjectVariableAction'; +export * from './createGitlabIssueAction'; export * from './gitlab'; export * from './gitlabMergeRequest'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts b/plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts index 02724a3674..3c83eacf19 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/commonGitlabConfig.ts @@ -24,3 +24,8 @@ const commonGitlabConfig = z.object({ }); export default commonGitlabConfig; + +export const commonGitlabConfigExample = { + repoUrl: 'gitlab.com?owner=namespace-or-owner&repo=project-name', + token: '${{ secrets.USER_OAUTH_TOKEN }}', +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/util.test.ts b/plugins/scaffolder-backend-module-gitlab/src/util.test.ts new file mode 100644 index 0000000000..61c61e87c3 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/util.test.ts @@ -0,0 +1,388 @@ +/* + * Copyright 2022 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 * as util from './util'; +import { Gitlab, GroupSchema } from '@gitbeaker/rest'; +import { InputError } from '@backstage/errors'; + +// Mock the Gitlab client and its methods +const mockGitlabClient = { + Groups: { + show: jest.fn(), + }, + Projects: { + show: jest.fn(), + }, + Epics: { + all: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +const mockConfig = { + gitlab: [ + { + host: 'gitlab.com', + token: 'withToken', + apiBaseUrl: 'gitlab.com/api/v4', + }, + { + host: 'gitlab.com', + apiBaseUrl: 'gitlab.com/api/v4', + }, + ], +}; +describe('getTopLevelParentGroup', () => { + afterEach(() => jest.resetAllMocks()); + + // Mocked nested groups + const mockGroups: GroupSchema[] = [ + { + id: 789, + parent_id: 0, + path: '', + description: '', + visibility: '', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: '', + subgroup_creation_level: '', + lfs_enabled: false, + default_branch_protection: 0, + request_access_enabled: false, + created_at: '', + avatar_url: '', + full_name: '', + full_path: '', + web_url: '', + name: '', + }, + { + id: 456, + parent_id: 789, + path: '', + description: '', + visibility: '', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: '', + subgroup_creation_level: '', + lfs_enabled: false, + default_branch_protection: 0, + request_access_enabled: false, + created_at: '', + avatar_url: '', + full_name: '', + full_path: '', + web_url: '', + name: '', + }, + { + id: 123, + parent_id: 456, + path: '', + description: '', + visibility: '', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: '', + subgroup_creation_level: '', + lfs_enabled: false, + default_branch_protection: 0, + request_access_enabled: false, + created_at: '', + avatar_url: '', + full_name: '', + full_path: '', + web_url: '', + name: '', + }, + ]; + + // Top level group + const mockTopParentGroup: GroupSchema = { + id: 789, + parent_id: 0, + path: '', + description: '', + visibility: '', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: '', + subgroup_creation_level: '', + lfs_enabled: false, + default_branch_protection: 0, + request_access_enabled: false, + created_at: '', + avatar_url: '', + full_name: '', + full_path: '', + web_url: '', + name: '', + }; + + it('should return the top-level parent group if the input group has a parent in the hierarchy', async () => { + // Instance with token + const apiClient = new Gitlab({ + host: mockConfig.gitlab[0].host, + token: mockConfig.gitlab[0].token!, + }); + + const showSpy = jest.spyOn(mockGitlabClient.Groups, 'show'); + + // Mock implementation of Groups.show + showSpy.mockImplementation( + async (groupId: string | number): Promise => { + const id = + typeof groupId === 'number' ? groupId : parseInt(groupId, 10); + const mockGroup = mockGroups.find(group => group.id === id) || null; + return mockGroup as GroupSchema; + }, + ); + + const action = util.getTopLevelParentGroup(apiClient, 123); + + const result = await action; + expect(result).toEqual(mockTopParentGroup); + }); + + it('should return the input group if it has no parents in the hierarchy', async () => { + // Instance with token + const apiClient = new Gitlab({ + host: mockConfig.gitlab[0].host, + token: mockConfig.gitlab[0].token!, + }); + + const showSpy = jest.spyOn(mockGitlabClient.Groups, 'show'); + + // Mock implementation of Groups.show + showSpy.mockImplementation( + async (groupId: string | number): Promise => { + const id = + typeof groupId === 'number' ? groupId : parseInt(groupId, 10); + const mockGroup = mockGroups.find(group => group.id === id) || null; + return mockGroup as GroupSchema; + }, + ); + + const action = util.getTopLevelParentGroup(apiClient, 789); + + const result = await action; + expect(result).toEqual(mockTopParentGroup); + }); +}); + +describe('checkEpicScope', () => { + afterEach(() => jest.resetAllMocks()); + + it('should return true if the project is inside the epic scope', async () => { + const apiClient = new Gitlab({ + host: mockConfig.gitlab[0].host, + token: mockConfig.gitlab[0].token!, + }); + + const projectId = 123; + const epicId = 456; + + // Mock project, top-level parent group, and epic + const mockProject = { + id: 123, + name: 'You learn', + namespace: { id: 789 }, + path_with_namespace: 'at-once/you-learn', + }; + const mockTopParentGroup = { + id: 789, + name: 'LivingTwice', + full_path: 'at-once/you-learn', + }; + const mockEpic = { id: epicId, group_id: 789 }; + + mockGitlabClient.Projects.show.mockResolvedValue(mockProject); + mockGitlabClient.Groups.show.mockResolvedValue(mockTopParentGroup); + mockGitlabClient.Epics.all.mockResolvedValue([mockEpic]); + + const result = await util.checkEpicScope(apiClient, projectId, epicId); + + expect(result).toBe(true); + expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith(projectId); + expect(mockGitlabClient.Groups.show).toHaveBeenCalledWith( + mockProject.namespace.id, + ); + expect(mockGitlabClient.Epics.all).toHaveBeenCalledWith( + mockTopParentGroup.id, + ); + }); + + it('should return false if the project is not inside the epic scope', async () => { + const apiClient = new Gitlab({ + host: mockConfig.gitlab[0].host, + token: mockConfig.gitlab[0].token!, + }); + + const projectId = 123; + const epicId = 45; + + // Mock project, top-level parent group, and epic + const mockProject = { + id: 123, + name: 'You learn', + namespace: { id: 32 }, + path_with_namespace: 'at-once/you-learn', + }; + const mockTopParentGroup = { + id: 32, + name: 'TheWalls', + full_path: 'you-built/within', + }; + + const mockEpic = { id: epicId, group_id: 32 }; + + mockGitlabClient.Projects.show.mockResolvedValue(mockProject); + mockGitlabClient.Groups.show.mockResolvedValue(mockTopParentGroup); + mockGitlabClient.Epics.all.mockResolvedValue([mockEpic]); + + const result = await util.checkEpicScope(apiClient, projectId, epicId); + + expect(result).toBe(false); + expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith(projectId); + expect(mockGitlabClient.Groups.show).toHaveBeenCalledWith( + mockProject.namespace.id, + ); + expect(mockGitlabClient.Epics.all).toHaveBeenCalledWith( + mockTopParentGroup.id, + ); + }); + + it('should throw an InputError if the project is not found', async () => { + const apiClient = new Gitlab({ + host: mockConfig.gitlab[0].host, + token: mockConfig.gitlab[0].token!, + }); + + const projectId = 123; + const epicId = 456; + + mockGitlabClient.Projects.show.mockResolvedValue(null); + + await expect( + util.checkEpicScope(apiClient, projectId, epicId), + ).rejects.toThrow(InputError); + expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith(projectId); + }); + + it('should throw an InputError if the top-level parent group is not found', async () => { + const apiClient = new Gitlab({ + host: mockConfig.gitlab[0].host, + token: mockConfig.gitlab[0].token!, + }); + + const projectId = 123; + const epicId = 456; + + mockGitlabClient.Projects.show.mockResolvedValue({ + id: 123, + name: 'You learn', + namespace: { id: 789 }, + path_with_namespace: 'at-once/you-learn', + }); + mockGitlabClient.Groups.show.mockResolvedValue(null); + + await expect( + util.checkEpicScope(apiClient, projectId, epicId), + ).rejects.toThrow(InputError); + expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith(projectId); + expect(mockGitlabClient.Groups.show).toHaveBeenCalledWith(789); + }); + + it('should throw an InputError if the epic is not found', async () => { + const apiClient = new Gitlab({ + host: mockConfig.gitlab[0].host, + token: mockConfig.gitlab[0].token!, + }); + + const projectId = 123; + const epicId = 456; + + mockGitlabClient.Projects.show.mockResolvedValue({ + id: 123, + name: 'You learn', + namespace: { id: 789 }, + path_with_namespace: 'at-once/you-learn', + }); + mockGitlabClient.Groups.show.mockResolvedValue({ + id: 789, + name: 'LivingTwice', + full_path: 'at-once/you-learn', + }); + mockGitlabClient.Epics.all.mockResolvedValue([]); + + await expect( + util.checkEpicScope(apiClient, projectId, epicId), + ).rejects.toThrow(InputError); + expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith(projectId); + expect(mockGitlabClient.Groups.show).toHaveBeenCalledWith(789); + expect(mockGitlabClient.Epics.all).toHaveBeenCalledWith(789); + }); +}); + +describe('convertDate', () => { + it('should convert a valid input date with miliseconds to an ISO string', () => { + const inputDate = '1970-01-01T12:00:00.000Z'; + const defaultDate = '1978-10-09T12:00:00Z'; + + const result = util.convertDate(inputDate, defaultDate); + + expect(result).toEqual('1970-01-01T12:00:00.000Z'); + }); + + it('should convert a valid input date to an ISO string', () => { + const inputDate = '1970-01-01T12:00:00Z'; + const defaultDate = '1978-10-09T12:00:00Z'; + + const result = util.convertDate(inputDate, defaultDate); + + expect(result).toEqual('1970-01-01T12:00:00.000Z'); + }); + + it('should use default date if input date is undefined', () => { + const inputDate = undefined; + const defaultDate = '1970-01-01T12:00:00Z'; + + const result = util.convertDate(inputDate, defaultDate); + + expect(result).toEqual('1970-01-01T12:00:00.000Z'); + }); + + it('should throw an InputError if input date is invalid', () => { + const inputDate = 'invalidDate'; + const defaultDate = '2023-02-01T12:00:00Z'; + + // Expecting an InputError to be thrown + expect(() => util.convertDate(inputDate, defaultDate)).toThrow(InputError); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/util.ts b/plugins/scaffolder-backend-module-gitlab/src/util.ts index 13c95e48e8..a4d819d490 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/util.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/util.ts @@ -21,6 +21,8 @@ import { } from '@backstage/integration'; import { z } from 'zod'; import commonGitlabConfig from './commonGitlabConfig'; +import { Gitlab, GroupSchema } from '@gitbeaker/rest'; +import * as util from './util'; export const parseRepoHost = (repoUrl: string): string => { let parsed; @@ -56,3 +58,138 @@ export const getToken = ( return { token: token, integrationConfig: integrationConfig }; }; + +export type RepoSpec = { + repo: string; + host: string; + owner?: string; +}; + +export const parseRepoUrl = ( + repoUrl: string, + integrations: ScmIntegrationRegistry, +): RepoSpec => { + let parsed; + try { + parsed = new URL(`https://${repoUrl}`); + } catch (error) { + throw new InputError( + `Invalid repo URL passed to publisher, got ${repoUrl}, ${error}`, + ); + } + const host = parsed.host; + const owner = parsed.searchParams.get('owner') ?? undefined; + const repo: string = parsed.searchParams.get('repo')!; + + const type = integrations.byHost(host)?.type; + + if (!type) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + return { host, owner, repo }; +}; + +export function getClient(props: { + host: string; + token?: string; + integrations: ScmIntegrationRegistry; +}): InstanceType { + const { host, token, integrations } = props; + const integrationConfig = integrations.gitlab.byHost(host); + + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + const { config } = integrationConfig; + + if (!config.token && !token) { + throw new InputError(`No token available for host ${host}`); + } + + const requestToken = token || config.token!; + const tokenType = token ? 'oauthToken' : 'token'; + + const gitlabOptions: any = { + host: config.baseUrl, + }; + + gitlabOptions[tokenType] = requestToken; + return new Gitlab(gitlabOptions); +} + +export function convertDate( + inputDate: string | undefined, + defaultDate: string, +) { + try { + return inputDate + ? new Date(inputDate).toISOString() + : new Date(defaultDate).toISOString(); + } catch (error) { + throw new InputError(`Error converting input date - ${error}`); + } +} + +export async function getTopLevelParentGroup( + client: InstanceType, + groupId: number, +): Promise { + try { + const topParentGroup = await client.Groups.show(groupId); + if (topParentGroup.parent_id) { + return util.getTopLevelParentGroup( + client, + topParentGroup.parent_id as number, + ); + } + return topParentGroup as GroupSchema; + } catch (error: any) { + throw new InputError( + `Error finding top-level parent group ID: ${error.message}`, + ); + } +} + +export async function checkEpicScope( + client: InstanceType, + projectId: number, + epicId: number, +) { + try { + // If project exists, get the top level group id + const project = await client.Projects.show(projectId); + if (!project) { + throw new InputError( + `Project with id ${projectId} not found. Check your GitLab instance.`, + ); + } + const topParentGroup = await getTopLevelParentGroup( + client, + project.namespace.id, + ); + if (!topParentGroup) { + throw new InputError(`Couldn't find a suitable top-level parent group.`); + } + // Get the epic + const epic = (await client.Epics.all(topParentGroup.id)).find( + (x: any) => x.id === epicId, + ); + if (!epic) { + throw new InputError( + `Epic with id ${epicId} not found in the top-level parent group ${topParentGroup.name}.`, + ); + } + + const epicGroup = await client.Groups.show(epic.group_id as number); + const projectNamespace: string = project.path_with_namespace as string; + return projectNamespace.startsWith(epicGroup.full_path as string); + } catch (error: any) { + throw new InputError(`Could not find epic scope: ${error.message}`); + } +} diff --git a/yarn.lock b/yarn.lock index 6be53d3402..dcc38f71e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8291,6 +8291,8 @@ __metadata: "@backstage/plugin-scaffolder-node": "workspace:^" "@gitbeaker/core": ^35.8.0 "@gitbeaker/node": ^35.8.0 + "@gitbeaker/rest": ^39.25.0 + jest-date-mock: ^1.0.8 yaml: ^2.0.0 zod: ^3.22.4 languageName: unknown @@ -11117,6 +11119,17 @@ __metadata: languageName: node linkType: hard +"@gitbeaker/core@npm:^39.25.0": + version: 39.25.0 + resolution: "@gitbeaker/core@npm:39.25.0" + dependencies: + "@gitbeaker/requester-utils": ^39.25.0 + qs: ^6.11.2 + xcase: ^2.0.1 + checksum: 56ebe3c6ba1078c47a6cae0abcd5ae4fd928dc73c9eb7dc462894d497d6fa5373c0d09a7a7a0583097cffa641eeb0b28b1764d33a085a7b95f0115399240bc7e + languageName: node + linkType: hard + "@gitbeaker/node@npm:^35.1.0, @gitbeaker/node@npm:^35.8.0": version: 35.8.1 resolution: "@gitbeaker/node@npm:35.8.1" @@ -11141,6 +11154,28 @@ __metadata: languageName: node linkType: hard +"@gitbeaker/requester-utils@npm:^39.25.0": + version: 39.25.0 + resolution: "@gitbeaker/requester-utils@npm:39.25.0" + dependencies: + async-sema: ^3.1.1 + micromatch: ^4.0.5 + qs: ^6.11.2 + xcase: ^2.0.1 + checksum: 1ca90f5ac705d5ef9df682b3051106326bc660427b9b2a88762992690981ce0b6bed4b22bc08af0ba97043766f9898605ba58ed6c1a08b6711af34032f013ed2 + languageName: node + linkType: hard + +"@gitbeaker/rest@npm:^39.25.0": + version: 39.25.0 + resolution: "@gitbeaker/rest@npm:39.25.0" + dependencies: + "@gitbeaker/core": ^39.25.0 + "@gitbeaker/requester-utils": ^39.25.0 + checksum: 5732a34fe3f2fe0a048963baf90b237fde7a1be61fa327e2c7aa69266649394b5b48cd99a6c6abaf1cf8b32b84a293800af33eef49c011b252d6e061f7fe1af9 + languageName: node + linkType: hard + "@google-cloud/container@npm:^5.0.0": version: 5.4.0 resolution: "@google-cloud/container@npm:5.4.0" @@ -20799,6 +20834,13 @@ __metadata: languageName: node linkType: hard +"async-sema@npm:^3.1.1": + version: 3.1.1 + resolution: "async-sema@npm:3.1.1" + checksum: 07b8c51f6cab107417ecdd8126b7a9fe5a75151b7f69fdd420dcc8ee08f9e37c473a217247e894b56e999b088b32e902dbe41637e4e9b594d3f8dfcdddfadc5e + languageName: node + linkType: hard + "async@npm:^2.6.2, async@npm:^2.6.4": version: 2.6.4 resolution: "async@npm:2.6.4" @@ -30799,6 +30841,13 @@ __metadata: languageName: node linkType: hard +"jest-date-mock@npm:^1.0.8": + version: 1.0.8 + resolution: "jest-date-mock@npm:1.0.8" + checksum: 7b012870440b0df742d0b651435b91625dbbf02d916633a0c70c7deb5d5087f9aadc59847602312368826325909e70e95cd412896a0c9ee1d5ac382000bf5ef9 + languageName: node + linkType: hard + "jest-diff@npm:^28.1.3": version: 28.1.3 resolution: "jest-diff@npm:28.1.3" @@ -37871,7 +37920,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.11.0, qs@npm:^6.9.1, qs@npm:^6.9.4, qs@npm:^6.9.6": +"qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.9.1, qs@npm:^6.9.4, qs@npm:^6.9.6": version: 6.11.2 resolution: "qs@npm:6.11.2" dependencies: