feat: add gitlab create issues custom action

Signed-off-by: Elaine Mattos <elaine.mattos@gmail.com>
This commit is contained in:
Elaine De-Mattos-Silva-Bezerra
2023-12-03 11:43:30 +01:00
committed by Elaine Mattos
parent 701fdd4cad
commit d5ede226d6
8 changed files with 423 additions and 2 deletions
@@ -23,6 +23,13 @@ import { Router } from 'express';
import type { PluginEnvironment } from '../types';
import { ScmIntegrations } from '@backstage/integration';
import { createConfluenceToMarkdownAction } from '@backstage/plugin-scaffolder-backend-module-confluence-to-markdown';
import {
createGitlabProjectAccessTokenAction,
createGitlabProjectDeployTokenAction,
createGitlabProjectVariableAction,
createGitlabGroupEnsureExistsAction,
createGitlabIssueAction,
} from '@backstage/plugin-scaffolder-backend-module-gitlab';
export default async function createPlugin(
env: PluginEnvironment,
@@ -47,6 +54,11 @@ export default async function createPlugin(
config: env.config,
reader: env.reader,
}),
createGitlabProjectAccessTokenAction({ integrations: integrations }),
createGitlabProjectDeployTokenAction({ integrations: integrations }),
createGitlabProjectVariableAction({ integrations: integrations }),
createGitlabGroupEnsureExistsAction({ integrations: integrations }),
createGitlabIssueAction({ integrations: integrations }),
];
return await createRouter({
@@ -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
@@ -53,6 +55,8 @@ return await createRouter({
database: env.database,
reader: env.reader,
});
// TODO: incorporate Issues creation in example
```
After that you can use the action in your template:
@@ -36,6 +36,7 @@
"@backstage/integration": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@gitbeaker/node": "^35.8.0",
"@gitbeaker/rest": "^39.25.0",
"yaml": "^2.0.0",
"zod": "^3.21.4"
},
@@ -0,0 +1,218 @@
/*
* 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 {
TemplateExample,
createTemplateAction,
} from '@backstage/plugin-scaffolder-node';
import * as yaml from 'yaml';
import {
commonGitlabConfig,
commonGitlabConfigExample,
} from '../commonGitlabConfig';
import { z } from 'zod';
import { checkEpicScope, convertDate, getClient, parseRepoUrl } from '../util';
import { Gitlab, CreateIssueOptions, IssueSchema } from '@gitbeaker/rest';
/**
* Creates a `gitlab:issues:create` Scaffolder action.
*
* @param {} - Templating configuration.
* @public
*/
export const createGitlabIssueAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction({
id: 'gitlab:issues:create',
description: 'Creates a Gitlab issue.',
examples: getExamples(),
schema: {
input: commonGitlabConfig.merge(
z.object({
projectId: z.number({ description: '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({ description: 'Issue description' })
.optional(),
createdAt: z.string({ description: 'Creation date/time' }).optional(),
dueDate: z.string({ description: 'Due date/time' }).optional(),
discussionToResolve: z
.string({
description: 'Id of a discussion to resolve',
})
.optional(),
epicId: z.number({ description: 'Id of the linked Epic' }).optional(),
labels: z.string({ description: 'Labels to apply' }).optional(),
}),
),
output: z.object({
issueUrl: z.string({ description: 'Issue Url' }),
issueId: z.number({ description: 'Issue Id' }),
}),
},
async handler(ctx) {
try {
const {
repoUrl,
projectId,
title,
description = '',
confidential = false,
assignees = [],
createdAt = '',
dueDate,
discussionToResolve = '',
epicId,
labels = '',
token,
} = 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<typeof Gitlab>,
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,
};
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) {
throw new InputError(`Failed to create GitLab issue: ${error.message}`);
}
},
});
};
function getExamples(): TemplateExample[] {
return [
{
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: 'dxc:gitlab:issues:create',
input: {
...commonGitlabConfigExample,
projectId: '12',
title: 'Test Issue',
assignees: '18',
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: 'test-label1,test-label2',
},
},
],
}),
},
];
}
@@ -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 }}',
};
@@ -23,3 +23,4 @@ export * from './actions/createGitlabGroupEnsureExistsAction';
export * from './actions/createGitlabProjectDeployTokenAction';
export * from './actions/createGitlabProjectAccessTokenAction';
export * from './actions/createGitlabProjectVariableAction';
export * from './actions/createGitlabIssueAction';
@@ -21,6 +21,7 @@ import {
} from '@backstage/integration';
import { z } from 'zod';
import commonGitlabConfig from './commonGitlabConfig';
import { Gitlab, GroupSchema } from '@gitbeaker/rest';
export const parseRepoHost = (repoUrl: string): string => {
let parsed;
@@ -56,3 +57,140 @@ 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<typeof Gitlab> {
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<typeof Gitlab>,
groupId: number,
): Promise<GroupSchema> {
try {
const topParentGroup = await client.Groups.show(groupId);
if (topParentGroup.parent_id) {
return 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<typeof Gitlab>,
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}`);
}
}
+44 -2
View File
@@ -8655,7 +8655,7 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-scaffolder-backend-module-gitlab@workspace:plugins/scaffolder-backend-module-gitlab":
"@backstage/plugin-scaffolder-backend-module-gitlab@workspace:^, @backstage/plugin-scaffolder-backend-module-gitlab@workspace:plugins/scaffolder-backend-module-gitlab":
version: 0.0.0-use.local
resolution: "@backstage/plugin-scaffolder-backend-module-gitlab@workspace:plugins/scaffolder-backend-module-gitlab"
dependencies:
@@ -8667,6 +8667,7 @@ __metadata:
"@backstage/integration": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@gitbeaker/node": ^35.8.0
"@gitbeaker/rest": ^39.25.0
yaml: ^2.0.0
zod: ^3.21.4
languageName: unknown
@@ -11569,6 +11570,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"
@@ -11593,6 +11605,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"
@@ -21062,6 +21096,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"
@@ -26880,6 +26921,7 @@ __metadata:
"@backstage/plugin-rollbar-backend": "workspace:^"
"@backstage/plugin-scaffolder-backend": "workspace:^"
"@backstage/plugin-scaffolder-backend-module-confluence-to-markdown": "workspace:^"
"@backstage/plugin-scaffolder-backend-module-gitlab": "workspace:^"
"@backstage/plugin-scaffolder-backend-module-rails": "workspace:^"
"@backstage/plugin-search-backend": "workspace:^"
"@backstage/plugin-search-backend-module-catalog": "workspace:^"
@@ -38184,7 +38226,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: