Merge pull request #21929 from elaine-mattos/feature/gitlab-issues
scaffolder-backend-module-gitlab: add gitlab:issues:create custom action
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
|
||||
---
|
||||
|
||||
Add Scaffolder custom action that creates GitLab issues called `gitlab:issues:create`
|
||||
@@ -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 }}
|
||||
```
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
+85
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<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,
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }}',
|
||||
};
|
||||
|
||||
@@ -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<any> => {
|
||||
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<any> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<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 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<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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user