feat: add gitlab create issues custom action
Signed-off-by: Elaine Mattos <elaine.mattos@gmail.com>
This commit is contained in:
committed by
Elaine Mattos
parent
701fdd4cad
commit
d5ede226d6
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user