feat: add new edit issue gitlab action

Signed-off-by: John Redwood <john.r.k.redwood@gmail.com>
This commit is contained in:
John Redwood
2024-05-28 02:06:20 +00:00
parent 69924c8fa3
commit cf96041109
11 changed files with 638 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Added `gitlab:issues:edit` action to edit existing GitLab issues
@@ -29,12 +29,12 @@ export const createGitlabIssueAction: (options: {
}) => TemplateAction<
{
title: string;
repoUrl: string;
projectId: number;
repoUrl: string;
labels?: string | undefined;
description?: string | undefined;
weight?: number | undefined;
token?: string | undefined;
weight?: number | undefined;
assignees?: number[] | undefined;
createdAt?: string | undefined;
confidential?: boolean | undefined;
@@ -57,12 +57,12 @@ export const createGitlabProjectAccessTokenAction: (options: {
integrations: ScmIntegrationRegistry;
}) => TemplateAction<
{
repoUrl: string;
projectId: string | number;
repoUrl: string;
name?: string | undefined;
token?: string | undefined;
scopes?: string[] | undefined;
expiresAt?: string | undefined;
scopes?: string[] | undefined;
accessLevel?: number | undefined;
},
{
@@ -76,10 +76,10 @@ export const createGitlabProjectDeployTokenAction: (options: {
}) => TemplateAction<
{
name: string;
repoUrl: string;
projectId: string | number;
username?: string | undefined;
repoUrl: string;
token?: string | undefined;
username?: string | undefined;
scopes?: string[] | undefined;
},
{
@@ -95,8 +95,8 @@ export const createGitlabProjectVariableAction: (options: {
{
key: string;
value: string;
repoUrl: string;
projectId: string | number;
repoUrl: string;
variableType: string;
raw?: boolean | undefined;
token?: string | undefined;
@@ -149,8 +149,8 @@ export function createPublishGitlabAction(options: {
squash_option?:
| 'always'
| 'never'
| 'default_off'
| 'default_on'
| 'default_off'
| undefined;
topics?: string[] | undefined;
visibility?: 'internal' | 'private' | 'public' | undefined;
@@ -207,8 +207,8 @@ export const createTriggerGitlabPipelineAction: (options: {
}) => TemplateAction<
{
branch: string;
repoUrl: string;
projectId: number;
repoUrl: string;
tokenDescription: string;
token?: string | undefined;
},
@@ -217,10 +217,54 @@ export const createTriggerGitlabPipelineAction: (options: {
}
>;
// @public
export const editGitlabIssueAction: (options: {
integrations: ScmIntegrationRegistry;
}) => TemplateAction<
{
projectId: number;
repoUrl: string;
issueIid: number;
title?: string | undefined;
labels?: string | undefined;
description?: string | undefined;
token?: string | undefined;
weight?: number | undefined;
assignees?: number[] | undefined;
addLabels?: string | undefined;
confidential?: boolean | undefined;
milestoneId?: number | undefined;
removeLabels?: string | undefined;
stateEvent?: IssueStateEvent | undefined;
discussionLocked?: boolean | undefined;
epicId?: number | undefined;
dueDate?: string | undefined;
updatedAt?: string | undefined;
issueType?: IssueType | undefined;
},
{
state: string;
title: string;
projectId: number;
updatedAt: string;
issueUrl: string;
issueId: number;
issueIid: number;
}
>;
// @public
const gitlabModule: () => BackendFeature;
export default gitlabModule;
// @public
export enum IssueStateEvent {
// (undocumented)
CLOSE = 'close',
// (undocumented)
REOPEN = 'reopen',
}
// @public
export enum IssueType {
// (undocumented)
@@ -228,6 +272,8 @@ export enum IssueType {
// (undocumented)
ISSUE = 'issue',
// (undocumented)
TASK = 'task',
// (undocumented)
TEST = 'test_case',
}
```
@@ -17,7 +17,8 @@
import { ConfigReader } from '@backstage/core-app-api';
import { ScmIntegrations } from '@backstage/integration';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { createGitlabIssueAction, IssueType } from './gitlabIssueCreate';
import { IssueType } from '../commonGitlabConfig';
import { createGitlabIssueAction } from './gitlabIssueCreate';
const mockGitlabClient = {
Issues: {
@@ -17,22 +17,11 @@
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import commonGitlabConfig from '../commonGitlabConfig';
import commonGitlabConfig, { IssueType } from '../commonGitlabConfig';
import { examples } from './gitlabIssueCreate.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',
}
import { CreateIssueOptions, IssueSchema } from '@gitbeaker/rest';
const issueInputProperties = z.object({
projectId: z.number().describe('Project Id'),
@@ -150,11 +139,7 @@ export const createGitlabIssueAction = (options: {
let isEpicScoped = false;
if (epicId) {
isEpicScoped = await checkEpicScope(
api as any as InstanceType<typeof Gitlab>,
projectId,
epicId,
);
isEpicScoped = await checkEpicScope(api, projectId, epicId);
if (isEpicScoped) {
ctx.logger.info('Epic is within Project Scope');
@@ -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: 'Edit a GitLab issue with minimal options',
example: yaml.stringify({
steps: [
{
id: 'gitlabIssue',
name: 'EditIssues',
action: 'gitlab:issues:edit',
input: {
...commonGitlabConfigExample,
projectId: 12,
title: 'Modified Test Issue',
description: 'This is a modified description of the issue',
},
},
],
}),
},
{
description: 'Edit a GitLab issue with assignees and date options',
example: yaml.stringify({
steps: [
{
id: 'gitlabIssue',
name: 'EditIssues',
action: 'gitlab:issues:edit',
input: {
...commonGitlabConfigExample,
projectId: 12,
title: 'Test Issue',
assignees: [18],
description: 'This is the edited description of the issue',
updatedAt: '2024-05-10 18:00:00.000',
dueDate: '2024-09-28',
},
},
],
}),
},
{
description: 'Create a GitLab Issue with several options',
example: yaml.stringify({
steps: [
{
id: 'gitlabIssue',
name: 'EditIssues',
action: 'gitlab:issues:edit',
input: {
...commonGitlabConfigExample,
projectId: 12,
title: 'Test Edit Issue',
assignees: [18, 15],
description: 'This is the description of the issue',
confidential: false,
updatedAt: '2024-05-10 18:00:00.000',
dueDate: '2024-09-28',
discussionLocked: true,
epicId: 1,
labels: 'phase1:label1,phase2:label2',
},
},
],
}),
},
];
@@ -0,0 +1,211 @@
/*
* 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 { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { ConfigReader } from '@backstage/core-app-api';
import { ScmIntegrations } from '@backstage/integration';
import { IssueType } from '../commonGitlabConfig';
import { editGitlabIssueAction } from './gitlabIssueEdit';
const mockGitlabClient = {
Issues: {
edit: jest.fn(),
},
};
jest.mock('@gitbeaker/rest', () => ({
Gitlab: class {
constructor() {
return mockGitlabClient;
}
},
}));
describe('gitlab:issues:edit', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({
now: new Date(1988, 5, 3, 12, 0, 0),
});
});
afterEach(() => {
jest.useRealTimers();
});
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'myIntegrationsToken',
apiBaseUrl: 'https://gitlab.com/api/v4',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = editGitlabIssueAction({ integrations });
it('should return a Gitlab issue when called with minimal input params', async () => {
const mockContext = createMockActionContext({
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
projectId: 123,
issueIid: 42,
title: 'Computer banks to rule the world',
},
workspacePath: 'seen2much',
});
mockGitlabClient.Issues.edit.mockResolvedValue({
id: 123,
iid: 42,
web_url: 'https://gitlab.com/hangar18-/issues/42',
});
await action.handler({
...mockContext,
});
expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(123, 42, {
title: 'Computer banks to rule the world',
issueType: undefined,
addLabels: undefined,
removeLabels: undefined,
description: undefined,
assigneeIds: [],
confidential: false,
discussionLocked: false,
epicId: undefined,
labels: undefined,
updatedAt: new Date().toISOString(),
dueDate: undefined,
milestoneId: undefined,
weight: undefined,
stateEvent: undefined,
});
expect(mockContext.output).toHaveBeenCalledWith('issueId', 123);
expect(mockContext.output).toHaveBeenCalledWith('issueIid', 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 = createMockActionContext({
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
projectId: 123,
issueIid: 42,
title: 'Computer banks to rule the world',
token: 'myAwesomeToken',
},
workspacePath: 'seen2much',
});
mockGitlabClient.Issues.edit.mockResolvedValue({
id: 123,
iid: 42,
web_url: 'https://gitlab.com/hangar18-/issues/42',
});
await action.handler({
...mockContext,
});
expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(123, 42, {
title: 'Computer banks to rule the world',
issueType: undefined,
addLabels: undefined,
removeLabels: undefined,
description: undefined,
assigneeIds: [],
confidential: false,
discussionLocked: false,
epicId: undefined,
labels: undefined,
updatedAt: new Date().toISOString(),
dueDate: undefined,
milestoneId: undefined,
weight: undefined,
stateEvent: undefined,
});
expect(mockContext.output).toHaveBeenCalledWith('issueId', 123);
expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42);
expect(mockContext.output).toHaveBeenCalledWith(
'issueUrl',
'https://gitlab.com/hangar18-/issues/42',
);
});
it('should return a Gitlab issue when modified with several input params', async () => {
const mockContext = createMockActionContext({
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
projectId: 123,
issueIid: 42,
issueType: IssueType.INCIDENT,
title: 'Computer banks to rule the world',
description:
'this issue should kickstart research on instruments to sight the stars',
dueDate: '2025-08-20',
token: 'myAwesomeToken',
assignees: [3, 14, 15],
labels: 'operation:mindcrime',
},
workspacePath: 'seen2much',
});
mockGitlabClient.Issues.edit.mockResolvedValue({
id: 123,
iid: 42,
web_url: 'https://gitlab.com/hangar18-/issues/42',
});
await action.handler({
...mockContext,
});
expect(mockGitlabClient.Issues.edit).toHaveBeenCalledWith(123, 42, {
title: 'Computer banks to rule the world',
issueType: 'incident',
addLabels: undefined,
removeLabels: undefined,
description:
'this issue should kickstart research on instruments to sight the stars',
assigneeIds: [3, 14, 15],
confidential: false,
discussionLocked: false,
epicId: undefined,
labels: 'operation:mindcrime',
updatedAt: new Date().toISOString(),
dueDate: '2025-08-20',
milestoneId: undefined,
weight: undefined,
stateEvent: undefined,
});
expect(mockContext.output).toHaveBeenCalledWith('issueId', 123);
expect(mockContext.output).toHaveBeenCalledWith('issueIid', 42);
expect(mockContext.output).toHaveBeenCalledWith(
'issueUrl',
'https://gitlab.com/hangar18-/issues/42',
);
});
});
@@ -0,0 +1,245 @@
/*
* 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, {
IssueType,
IssueStateEvent,
} from '../commonGitlabConfig';
import { examples } from './gitlabIssueEdit.examples';
import { z } from 'zod';
import { checkEpicScope, convertDate, getClient, parseRepoUrl } from '../util';
import { IssueSchema, EditIssueOptions } from '@gitbeaker/rest';
const editIssueInputProperties = z.object({
projectId: z
.number()
.describe(
'The global ID or URL-encoded path of the project owned by the authenticated user.',
),
issueIid: z.number().describe("The internal ID of a project's issue"),
addLabels: z
.string({
description:
'Comma-separated label names to add to an issue. If a label does not already exist, this creates a new project label and assigns it to the issue.',
})
.optional(),
assignees: z
.array(z.number(), {
description: 'IDs of the users to assign the issue to.',
})
.optional(),
confidential: z
.boolean({ description: 'Updates an issue to be confidential.' })
.optional(),
description: z
.string()
.describe('The description of an issue. Limited to 1,048,576 characters.')
.max(1048576)
.optional(),
discussionLocked: z
.boolean({
description:
'Flag indicating if the issues discussion is locked. If the discussion is locked only project members can add or edit comments.',
})
.optional(),
dueDate: z
.string()
.describe(
'The due date. Date time string in the format YYYY-MM-DD, for example 2016-03-11.',
)
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format. Use YYYY-MM-DD')
.optional(),
epicId: z
.number({
description:
'ID of the epic to add the issue to. Valid values are greater than or equal to 0.',
})
.min(0, 'Valid values should be equal or greater than zero')
.optional(),
issueType: z
.nativeEnum(IssueType, {
description:
'Updates the type of issue. One of issue, incident, test_case or task.',
})
.optional(),
labels: z
.string({
description:
'Comma-separated label names for an issue. Set to an empty string to unassign all labels. If a label does not already exist, this creates a new project label and assigns it to the issue.',
})
.optional(),
milestoneId: z
.number({
description:
'The global ID of a milestone to assign the issue to. Set to 0 or provide an empty value to unassign a milestone',
})
.optional(),
removeLabels: z
.string({
description: 'Comma-separated label names to remove from an issue.',
})
.optional(),
stateEvent: z
.nativeEnum(IssueStateEvent, {
description:
'The state event of an issue. To close the issue, use close, and to reopen it, use reopen.',
})
.optional(),
title: z.string().describe('The title of an issue.').optional(),
updatedAt: z
.string()
.describe(
'When the issue was updated. Date time string, ISO 8601 formatted',
)
.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(),
weight: z
.number({ description: 'The issue weight' })
.min(0, 'Valid values should be equal or greater than zero')
.max(10, 'Valid values should be equal or less than 10')
.optional(),
});
const editIssueOutputProperties = z.object({
issueUrl: z.string({ description: 'Issue WebUrl' }),
projectId: z.number({
description: 'The project id the issue belongs to WebUrl',
}),
issueId: z.number({ description: 'The issues Id' }),
issueIid: z.number({
description: "The issues internal ID of a project's issue",
}),
state: z.string({ description: 'The state event of an issue' }),
title: z.string({ description: 'The title of an issue.' }),
updatedAt: z.string({ description: 'The last updated time of the issue.' }),
});
/**
* Creates a `gitlab:issues:edit` Scaffolder action.
*
* @param options - Templating configuration.
* @public
*/
export const editGitlabIssueAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction({
id: 'gitlab:issues:edit',
description: 'Edit a Gitlab issue.',
examples,
schema: {
input: commonGitlabConfig.merge(editIssueInputProperties),
output: editIssueOutputProperties,
},
async handler(ctx) {
try {
const {
repoUrl,
projectId,
title,
addLabels,
removeLabels,
issueIid,
description,
confidential = false,
assignees = [],
updatedAt = '',
dueDate,
discussionLocked = false,
epicId,
labels,
issueType,
milestoneId,
stateEvent,
weight,
token,
} = commonGitlabConfig.merge(editIssueInputProperties).parse(ctx.input);
const { host } = parseRepoUrl(repoUrl, integrations);
const api = getClient({ host, integrations, token });
let isEpicScoped = false;
if (epicId) {
isEpicScoped = await checkEpicScope(api, 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 mappedUpdatedAt = convertDate(
String(updatedAt),
new Date().toISOString(),
);
const editIssueOptions: EditIssueOptions = {
addLabels,
assigneeIds: assignees,
confidential,
description,
discussionLocked,
dueDate,
epicId: isEpicScoped ? epicId : undefined,
issueType,
labels,
milestoneId,
removeLabels,
stateEvent,
title,
updatedAt: mappedUpdatedAt,
weight,
};
const response = (await api.Issues.edit(
projectId,
issueIid,
editIssueOptions,
)) as IssueSchema;
ctx.output('issueId', response.id);
ctx.output('projectId', response.project_id);
ctx.output('issueUrl', response.web_url);
ctx.output('issueIid', response.iid);
ctx.output('title', response.title);
ctx.output('state', response.state);
ctx.output('updatedAt', response.updated_at);
} 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 edit/modify GitLab issue: ${error.message}`,
);
}
},
});
};
@@ -16,9 +16,11 @@
export * from './gitlab';
export * from './gitlabGroupEnsureExists';
export * from './gitlabIssueCreate';
export * from './gitlabIssueEdit';
export * from './gitlabMergeRequest';
export * from './gitlabPipelineTrigger';
export * from './gitlabProjectAccessTokenCreate';
export * from './gitlabProjectDeployTokenCreate';
export * from './gitlabProjectVariableCreate';
export * from './gitlabRepoPush';
export { IssueType, IssueStateEvent } from '../commonGitlabConfig';
@@ -29,3 +29,25 @@ export const commonGitlabConfigExample = {
repoUrl: 'gitlab.com?owner=namespace-or-owner&repo=project-name',
token: '${{ secrets.USER_OAUTH_TOKEN }}',
};
/**
* Gitlab issue types as specified by gitlab api
*
* @public
*/
export enum IssueType {
ISSUE = 'issue',
INCIDENT = 'incident',
TEST = 'test_case',
TASK = 'task',
}
/**
* Gitlab issue state events for modifications
*
* @public
*/
export enum IssueStateEvent {
CLOSE = 'close',
REOPEN = 'reopen',
}
@@ -29,6 +29,7 @@ import {
createPublishGitlabAction,
createPublishGitlabMergeRequestAction,
createTriggerGitlabPipelineAction,
editGitlabIssueAction,
} from './actions';
/**
@@ -54,6 +55,7 @@ export const gitlabModule = createBackendModule({
createGitlabProjectDeployTokenAction({ integrations }),
createGitlabProjectVariableAction({ integrations }),
createGitlabRepoPushAction({ integrations }),
editGitlabIssueAction({ integrations }),
createPublishGitlabAction({ config, integrations }),
createPublishGitlabMergeRequestAction({ integrations }),
createTriggerGitlabPipelineAction({ integrations }),
+6 -6
View File
@@ -397,11 +397,11 @@ export const repoPickerValidation: (
export const RepoUrlPickerFieldExtension: FieldExtensionComponent_2<
string,
{
allowedHosts?: string[] | undefined;
allowedOrganizations?: string[] | undefined;
allowedOwners?: string[] | undefined;
allowedOrganizations?: string[] | undefined;
allowedProjects?: string[] | undefined;
allowedRepos?: string[] | undefined;
allowedHosts?: string[] | undefined;
requestUserCredentials?:
| {
secretsKey: string;
@@ -409,9 +409,9 @@ export const RepoUrlPickerFieldExtension: FieldExtensionComponent_2<
| {
azure?: string[] | undefined;
github?: string[] | undefined;
gitlab?: string[] | undefined;
bitbucket?: string[] | undefined;
gerrit?: string[] | undefined;
gitlab?: string[] | undefined;
gitea?: string[] | undefined;
}
| undefined;
@@ -424,11 +424,11 @@ export const RepoUrlPickerFieldExtension: FieldExtensionComponent_2<
export const RepoUrlPickerFieldSchema: FieldSchema<
string,
{
allowedHosts?: string[] | undefined;
allowedOrganizations?: string[] | undefined;
allowedOwners?: string[] | undefined;
allowedOrganizations?: string[] | undefined;
allowedProjects?: string[] | undefined;
allowedRepos?: string[] | undefined;
allowedHosts?: string[] | undefined;
requestUserCredentials?:
| {
secretsKey: string;
@@ -436,9 +436,9 @@ export const RepoUrlPickerFieldSchema: FieldSchema<
| {
azure?: string[] | undefined;
github?: string[] | undefined;
gitlab?: string[] | undefined;
bitbucket?: string[] | undefined;
gerrit?: string[] | undefined;
gitlab?: string[] | undefined;
gitea?: string[] | undefined;
}
| undefined;