feat: Add 'gitlab:repo:push' scaffolder action

Signed-off-by: Arthur Gavlyukovskiy <agavlyukovskiy@gmail.com>
This commit is contained in:
Arthur Gavlyukovskiy
2023-12-21 20:57:23 +01:00
parent bb6bce457e
commit 7c522c5ef8
8 changed files with 709 additions and 24 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': minor
'@backstage/plugin-scaffolder-backend': minor
---
Add 'gitlab:repo:push' scaffolder action to push files to arbitrary branch without creating a Merge Request
@@ -76,6 +76,22 @@ export const createGitlabProjectVariableAction: (options: {
JsonObject
>;
// @public
export const createGitlabRepoPushAction: (options: {
integrations: ScmIntegrationRegistry;
}) => TemplateAction<
{
repoUrl: string;
branchName: string;
commitMessage: string;
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'update' | 'delete' | 'create' | undefined;
},
JsonObject
>;
// @public
export function createPublishGitlabAction(options: {
integrations: ScmIntegrationRegistry;
@@ -19,12 +19,12 @@ import {
parseRepoUrl,
serializeDirectoryContents,
} from '@backstage/plugin-scaffolder-node';
import { Gitlab } from '@gitbeaker/node';
import { Types } from '@gitbeaker/core';
import path from 'path';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { InputError } from '@backstage/errors';
import { resolveSafeChildPath } from '@backstage/backend-common';
import { createGitlabApi } from './helpers';
/**
* Create a new action that creates a gitlab merge request.
@@ -158,33 +158,16 @@ export const createPublishGitlabMergeRequestAction = (options: {
targetPath,
sourcePath,
title,
token: providedToken,
token,
} = ctx.input;
const { host, owner, repo, project } = parseRepoUrl(
repoUrl,
integrations,
);
const { owner, repo, project } = parseRepoUrl(repoUrl, integrations);
const repoID = project ? project : `${owner}/${repo}`;
const integrationConfig = integrations.gitlab.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
if (!integrationConfig.config.token && !providedToken) {
throw new InputError(`No token available for host ${host}`);
}
const token = providedToken ?? integrationConfig.config.token!;
const tokenType = providedToken ? 'oauthToken' : 'token';
const api = new Gitlab({
host: integrationConfig.config.baseUrl,
[tokenType]: token,
const api = createGitlabApi({
integrations,
token,
repoUrl,
});
let assigneeId = undefined;
@@ -0,0 +1,436 @@
/*
* 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 { createRootLogger, getRootLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { Writable } from 'stream';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { createGitlabRepoPushAction } from './gitlabRepoPush';
// Make sure root logger is initialized ahead of FS mock
createRootLogger();
const mockGitlabClient = {
Projects: {
create: jest.fn(),
show: jest.fn(async (_: any) => {
return {
default_branch: 'main',
};
}),
},
Branches: {
create: jest.fn(),
show: jest.fn(),
},
Commits: {
create: jest.fn(async (_: any) => ({
id: 'bb6bce457ed069a38ef8d16ef38602972c7735c5',
})),
},
};
jest.mock('@gitbeaker/node', () => ({
Gitlab: class {
constructor() {
return mockGitlabClient;
}
},
}));
describe('createGitLabCommit', () => {
let instance: TemplateAction<any>;
const mockDir = createMockDirectory();
const workspacePath = mockDir.resolve('workspace');
beforeEach(() => {
mockDir.clear();
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'token',
apiBaseUrl: 'https://api.gitlab.com',
},
{
host: 'hosted.gitlab.com',
apiBaseUrl: 'https://api.hosted.gitlab.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
instance = createGitlabRepoPushAction({ integrations });
});
describe('createGitLabCommitWithoutCommitAction', () => {
it('default commitAction is create', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
};
mockDir.setContent({
[workspacePath]: {
'foo.txt': 'Hello there!',
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'Create my new commit',
[
{
action: 'create',
filePath: 'foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
],
);
expect(ctx.output).toHaveBeenCalledWith(
'commitHash',
'bb6bce457ed069a38ef8d16ef38602972c7735c5',
);
});
});
describe('createGitLabCommitWithCommitAction', () => {
it('commitAction is create when create is passed in options', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
commitAction: 'create',
};
mockDir.setContent({
[workspacePath]: {
'foo.txt': 'Hello there!',
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'Create my new commit',
[
{
action: 'create',
filePath: 'foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
],
);
expect(ctx.output).toHaveBeenCalledWith(
'commitHash',
'bb6bce457ed069a38ef8d16ef38602972c7735c5',
);
});
it('commitAction is update when update is passed in options', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
commitAction: 'update',
};
mockDir.setContent({
[workspacePath]: {
'foo.txt': 'Hello there!',
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'Create my new commit',
[
{
action: 'update',
filePath: 'foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
],
);
expect(ctx.output).toHaveBeenCalledWith(
'commitHash',
'bb6bce457ed069a38ef8d16ef38602972c7735c5',
);
});
it('commitAction is delete when delete is passed in options', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
commitAction: 'delete',
};
mockDir.setContent({
[workspacePath]: {
'foo.txt': 'Hello there!',
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'Create my new commit',
[
{
action: 'delete',
filePath: 'foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
],
);
expect(ctx.output).toHaveBeenCalledWith(
'commitHash',
'bb6bce457ed069a38ef8d16ef38602972c7735c5',
);
});
});
describe('with sourcePath', () => {
it('creates a commit with only relevant files', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
sourcePath: 'source',
commitAction: 'create',
};
mockDir.setContent({
[workspacePath]: {
source: { 'foo.txt': 'Hello there!' },
irrelevant: { 'bar.txt': 'Nothing to see here' },
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'Create my new commit',
[
{
action: 'create',
filePath: 'foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
],
);
expect(ctx.output).toHaveBeenCalledWith(
'commitHash',
'bb6bce457ed069a38ef8d16ef38602972c7735c5',
);
});
it('creates a commit with only relevant files placed under different targetPath', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
sourcePath: 'source',
targetPath: 'target',
commitAction: 'create',
};
mockDir.setContent({
[workspacePath]: {
source: { 'foo.txt': 'Hello there!' },
irrelevant: { 'bar.txt': 'Nothing to see here' },
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'Create my new commit',
[
{
action: 'create',
filePath: 'target/foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
],
);
expect(ctx.output).toHaveBeenCalledWith(
'commitHash',
'bb6bce457ed069a38ef8d16ef38602972c7735c5',
);
});
it('should not allow to use files outside of the workspace', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
sourcePath: '../../test',
commitAction: 'create',
};
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await expect(instance.handler(ctx)).rejects.toThrow(
'Relative path is not allowed to refer to a directory outside its parent',
);
});
});
describe('createCommitToBranchThatDoesNotExist', () => {
it('should create a new branch', async () => {
mockGitlabClient.Branches.show.mockRejectedValue({
response: {
statusCode: 404,
},
});
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
commitMessage: 'Create my new commit',
branchName: 'some-branch',
};
mockDir.setContent({
[workspacePath]: {
'foo.txt': 'Hello there!',
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'some-branch',
'Create my new commit',
[
{
action: 'create',
filePath: 'foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
],
);
expect(ctx.output).toHaveBeenCalledWith(
'commitHash',
'bb6bce457ed069a38ef8d16ef38602972c7735c5',
);
});
});
});
@@ -0,0 +1,189 @@
/*
* 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 {
createTemplateAction,
parseRepoUrl,
serializeDirectoryContents,
} from '@backstage/plugin-scaffolder-node';
import { Types } from '@gitbeaker/core';
import path from 'path';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { InputError } from '@backstage/errors';
import { resolveSafeChildPath } from '@backstage/backend-common';
import { createGitlabApi } from './helpers';
/**
* Create a new action that commits into a gitlab repository.
*
* @public
*/
export const createGitlabRepoPushAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
branchName: string;
commitMessage: string;
sourcePath?: string;
targetPath?: string;
token?: string;
commitAction?: 'create' | 'delete' | 'update';
}>({
id: 'gitlab:repo:push',
schema: {
input: {
required: ['repoUrl', 'branchName', 'commitMessage'],
type: 'object',
properties: {
repoUrl: {
type: 'string',
title: 'Repository Location',
description: `Accepts the format 'gitlab.com?repo=project_name&owner=group_name' where 'project_name' is the repository name and 'group_name' is a group or username`,
},
branchName: {
type: 'string',
title: 'Source Branch Name',
description: 'The branch name for the commit',
},
commitMessage: {
type: 'string',
title: 'Commit Message',
description: `The commit message`,
},
sourcePath: {
type: 'string',
title: 'Working Subdirectory',
description:
'Subdirectory of working directory to copy changes from',
},
targetPath: {
type: 'string',
title: 'Repository Subdirectory',
description: 'Subdirectory of repository to apply changes to',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
commitAction: {
title: 'Commit action',
type: 'string',
enum: ['create', 'update', 'delete'],
description:
'The action to be used for git commit. Defaults to create.',
},
},
},
output: {
type: 'object',
properties: {
projectid: {
title: 'Gitlab Project id/Name(slug)',
type: 'string',
},
projectPath: {
title: 'Gitlab Project path',
type: 'string',
},
commitHash: {
title: 'The git commit hash of the commit',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
branchName,
repoUrl,
targetPath,
sourcePath,
token,
commitAction,
} = ctx.input;
const { owner, repo, project } = parseRepoUrl(repoUrl, integrations);
const repoID = project ? project : `${owner}/${repo}`;
const api = createGitlabApi({
integrations,
token,
repoUrl,
});
let fileRoot: string;
if (sourcePath) {
fileRoot = resolveSafeChildPath(ctx.workspacePath, sourcePath);
} else {
fileRoot = ctx.workspacePath;
}
const fileContents = await serializeDirectoryContents(fileRoot, {
gitignore: true,
});
const actions: Types.CommitAction[] = fileContents.map(file => ({
action: commitAction ?? 'create',
filePath: targetPath
? path.posix.join(targetPath, file.path)
: file.path,
encoding: 'base64',
content: file.content.toString('base64'),
execute_filemode: file.executable,
}));
try {
await api.Branches.show(repoID, branchName);
} catch (e) {
if (e.response?.statusCode !== 404) {
throw new InputError(
`Failed to check status of branch '${branchName}'. Please make sure that branch already exists or Backstage has permissions to create one. ${e}`,
);
}
// create a branch using the default branch as ref
try {
const projects = await api.Projects.show(repoID);
const { default_branch: defaultBranch } = projects;
await api.Branches.create(repoID, branchName, String(defaultBranch));
} catch (e2) {
throw new InputError(
`The branch '${branchName}' was not found and creation failed with error. Please make sure that branch already exists or Backstage has permissions to create one. ${e2}`,
);
}
}
try {
const commit = await api.Commits.create(
repoID,
branchName,
ctx.input.commitMessage,
actions,
);
ctx.output('projectid', repoID);
ctx.output('projectPath', repoID);
ctx.output('commitHash', commit.id);
} catch (e) {
throw new InputError(
`Committing the changes to ${branchName} failed. Please check that none of the files created by the template already exists. ${e}`,
);
}
},
});
};
@@ -0,0 +1,50 @@
/*
* 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 { parseRepoUrl } from '@backstage/plugin-scaffolder-node';
import { InputError } from '@backstage/errors';
import { Gitlab } from '@gitbeaker/node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Resources } from '@gitbeaker/core';
export function createGitlabApi(options: {
integrations: ScmIntegrationRegistry;
token?: string;
repoUrl: string;
}): Resources.Gitlab {
const { integrations, token: providedToken, repoUrl } = options;
const { host } = parseRepoUrl(repoUrl, integrations);
const integrationConfig = integrations.gitlab.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
if (!integrationConfig.config.token && !providedToken) {
throw new InputError(`No token available for host ${host}`);
}
const token = providedToken ?? integrationConfig.config.token!;
const tokenType = providedToken ? 'oauthToken' : 'token';
return new Gitlab({
host: integrationConfig.config.baseUrl,
[tokenType]: token,
});
}
@@ -19,3 +19,4 @@ export * from './createGitlabProjectAccessTokenAction';
export * from './createGitlabProjectVariableAction';
export * from './gitlab';
export * from './gitlabMergeRequest';
export * from './gitlabRepoPush';
@@ -69,6 +69,7 @@ import {
import {
createPublishGitlabAction,
createGitlabRepoPushAction,
createPublishGitlabMergeRequestAction,
} from '@backstage/plugin-scaffolder-backend-module-gitlab';
@@ -165,6 +166,9 @@ export const createBuiltinActions = (
createPublishGitlabMergeRequestAction({
integrations,
}),
createGitlabRepoPushAction({
integrations,
}),
createPublishBitbucketAction({
integrations,
config,