Merge pull request #26845 from mbenson/gitlab-mr-skip

GitLab MR: introduce skip commit action.
This commit is contained in:
Ben Lambert
2024-10-15 11:45:27 +02:00
committed by GitHub
7 changed files with 421 additions and 49 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
GitLab MR: introduce 'skip' commit action.
@@ -55,6 +55,7 @@
"@gitbeaker/node": "^35.8.0",
"@gitbeaker/rest": "^39.25.0",
"luxon": "^3.0.0",
"winston": "^3.2.1",
"yaml": "^2.0.0",
"zod": "^3.22.4"
},
@@ -193,7 +193,7 @@ export const createPublishGitlabMergeRequestAction: (options: {
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined;
projectid?: string | undefined;
removeSourceBranch?: boolean | undefined;
assignee?: string | undefined;
@@ -30,6 +30,22 @@ const mockGitlabClient = {
},
Branches: {
create: jest.fn(),
show: jest.fn(async (_repoID: string | number, name: string) => {
if (['main', 'existing-branch'].includes(name)) {
return {
name,
merged: name === 'main',
protected: name === 'main',
default: name === 'main',
developers_can_push: name !== 'main',
developers_can_merge: name !== 'main',
can_push: name !== 'main',
web_url: `https://foo.bar.baz/owner/repo/-/tree/${name}`,
commit: { message: 'last change' },
};
}
throw new Error(`Unknown branch ${name}`);
}),
},
Commits: {
create: jest.fn(),
@@ -83,6 +99,28 @@ const mockGitlabClient = {
},
),
},
RepositoryFiles: {
show: jest.fn(
async (repoID: string | number, filePath: string, ref: string) => {
if (repoID !== 'owner/repo') throw new Error('repo does not exist');
if (filePath !== 'source/auto.txt')
throw new Error('filePath does not exist');
return {
file_name: 'auto.txt',
file_path: 'source/auto.txt',
size: 11,
encoding: 'base64',
content: 'Zm9vLWJhci1iYXo=',
content_sha256:
'269dce1a5bb90188b2d9cf542a7c30e410c7d8251e34a97bfea56062df51ae23',
ref,
blob_id: 'a1e8f8d745cc87e3a9248358d9352bb7f9a0aeba',
commit_id: 'd5a3ff139356ce33e37e73add446f16869741b50',
last_commit_id: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d',
};
},
),
},
};
jest.mock('@gitbeaker/node', () => ({
@@ -149,6 +187,7 @@ describe('createGitLabMergeRequest', () => {
'new-mr',
'test',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -184,6 +223,7 @@ describe('createGitLabMergeRequest', () => {
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -191,7 +231,6 @@ describe('createGitLabMergeRequest', () => {
'Create my new MR',
{ description: 'This MR is really good', removeSourceBranch: false },
);
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'main');
});
});
@@ -216,6 +255,12 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -244,6 +289,12 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -278,6 +329,12 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -311,6 +368,12 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -344,6 +407,12 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -376,6 +445,12 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -404,6 +479,11 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -425,6 +505,16 @@ describe('createGitLabMergeRequest', () => {
},
]),
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'This MR is really good',
removeSourceBranch: false,
},
);
});
});
@@ -446,6 +536,11 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -467,6 +562,16 @@ describe('createGitLabMergeRequest', () => {
},
]),
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'This MR is really good',
removeSourceBranch: false,
},
);
});
});
@@ -490,6 +595,11 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -504,6 +614,16 @@ describe('createGitLabMergeRequest', () => {
},
],
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
},
);
});
it('commitAction is update when update is passed in options', async () => {
@@ -525,6 +645,11 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -539,6 +664,16 @@ describe('createGitLabMergeRequest', () => {
},
],
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
},
);
});
it('commitAction is auto when auto is passed in options', async () => {
@@ -558,6 +693,11 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -579,6 +719,16 @@ describe('createGitLabMergeRequest', () => {
},
]),
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
},
);
});
it('commitAction is auto when auto is passed in options with targetPath', async () => {
@@ -600,6 +750,11 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -621,6 +776,16 @@ describe('createGitLabMergeRequest', () => {
},
]),
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
},
);
});
it('commitAction is delete when delete is passed in options', async () => {
@@ -642,6 +807,11 @@ describe('createGitLabMergeRequest', () => {
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -656,6 +826,119 @@ describe('createGitLabMergeRequest', () => {
},
],
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'other MR description',
removeSourceBranch: false,
},
);
});
it('commitAction skip skips commit', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
title: 'Create my new MR',
branchName: 'new-mr',
description: 'MR description',
commitAction: 'skip',
};
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
},
);
});
it('commitAction skip reuses existing branch', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
title: 'Create my new MR',
branchName: 'existing-branch',
description: 'MR description',
commitAction: 'skip',
};
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.show).toHaveBeenCalledWith(
'owner/repo',
'existing-branch',
);
expect(mockGitlabClient.Branches.create).not.toHaveBeenCalled();
expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled();
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'existing-branch',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
},
);
});
it('commitAction auto skips unmodified files', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
title: 'Create my new MR',
branchName: 'new-mr',
description: 'MR description',
commitAction: 'auto',
};
mockDir.setContent({
[workspacePath]: {
source: { 'foo.txt': 'Hello there!', 'auto.txt': 'foo-bar-baz' },
},
});
const ctx = createMockActionContext({ input, workspacePath });
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'Create my new MR',
expect.arrayContaining([
{
action: 'create',
filePath: 'source/foo.txt',
content: 'SGVsbG8gdGhlcmUh',
encoding: 'base64',
execute_filemode: false,
},
]),
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
'Create my new MR',
{
description: 'MR description',
removeSourceBranch: false,
},
);
});
});
@@ -681,6 +964,11 @@ describe('createGitLabMergeRequest', () => {
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -719,6 +1007,11 @@ describe('createGitLabMergeRequest', () => {
await instance.handler(ctx);
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -20,25 +20,58 @@ import {
SerializedFile,
serializeDirectoryContents,
} from '@backstage/plugin-scaffolder-node';
import { Types } from '@gitbeaker/core';
import { Gitlab, Types } from '@gitbeaker/core';
import path from 'path';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { InputError } from '@backstage/errors';
import { resolveSafeChildPath } from '@backstage/backend-plugin-api';
import {
LoggerService,
resolveSafeChildPath,
} from '@backstage/backend-plugin-api';
import { createGitlabApi } from './helpers';
import { examples } from './gitlabMergeRequest.examples';
import { createHash } from 'crypto';
function getFileAction(
fileInfo: { file: SerializedFile; targetPath: string | undefined },
function computeSha256(file: SerializedFile): string {
const hash = createHash('sha256');
hash.update(file.content);
return hash.digest('hex');
}
async function getFileAction(
fileInfo: { file: SerializedFile; targetPath?: string },
target: { repoID: string; branch: string },
api: Gitlab,
logger: LoggerService,
remoteFiles: Types.RepositoryTreeSchema[],
defaultCommitAction: 'create' | 'delete' | 'update' | 'auto' | undefined,
): 'create' | 'delete' | 'update' {
if (!defaultCommitAction || defaultCommitAction === 'auto') {
defaultCommitAction:
| 'create'
| 'delete'
| 'update'
| 'skip'
| 'auto' = 'auto',
): Promise<'create' | 'delete' | 'update' | 'skip'> {
if (defaultCommitAction === 'auto') {
const filePath = path.join(fileInfo.targetPath ?? '', fileInfo.file.path);
return remoteFiles &&
remoteFiles.some(remoteFile => remoteFile.path === filePath)
? 'update'
: 'create';
if (remoteFiles?.some(remoteFile => remoteFile.path === filePath)) {
try {
const targetFile = await api.RepositoryFiles.show(
target.repoID,
filePath,
target.branch,
);
if (computeSha256(fileInfo.file) === targetFile.content_sha256) {
return 'skip';
}
} catch (error) {
logger.warn(
`Unable to retrieve detailed information for remote file ${filePath}`,
);
}
return 'update';
}
return 'create';
}
return defaultCommitAction;
}
@@ -62,7 +95,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
sourcePath?: string;
targetPath?: string;
token?: string;
commitAction?: 'create' | 'delete' | 'update' | 'auto';
commitAction?: 'create' | 'delete' | 'update' | 'skip' | 'auto';
/** @deprecated projectID passed as query parameters in the repoUrl */
projectid?: string;
removeSourceBranch?: boolean;
@@ -78,7 +111,9 @@ export const createPublishGitlabMergeRequestAction = (options: {
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`,
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`,
},
/** @deprecated projectID is passed as query parameters in the repoUrl */
projectid: {
@@ -109,8 +144,11 @@ export const createPublishGitlabMergeRequestAction = (options: {
sourcePath: {
type: 'string',
title: 'Working Subdirectory',
description:
'Subdirectory of working directory to copy changes from',
description: `\
Subdirectory of working directory to copy changes from. \
For reasons of backward compatibility, any specified 'targetPath' input will \
be applied in place of an absent/falsy value for this input. \
Circumvent this behavior using '.'`,
},
targetPath: {
type: 'string',
@@ -126,8 +164,9 @@ export const createPublishGitlabMergeRequestAction = (options: {
title: 'Commit action',
type: 'string',
enum: ['create', 'update', 'delete', 'auto'],
description:
'The action to be used for git commit. Defaults to auto. "auto" is custom action provide by backstage, (automatic assign create or update action) /!\\ Use more api calls /!\\ *',
description: `\
The action to be used for git commit. Defaults to the custom 'auto' action provided by backstage,
which uses additional API calls in order to detect whether to 'create', 'update' or 'skip' each source file.`,
},
removeSourceBranch: {
title: 'Delete source branch',
@@ -224,7 +263,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
}
let remoteFiles: Types.RepositoryTreeSchema[] = [];
if (!ctx.input.commitAction || ctx.input.commitAction === 'auto') {
if ((ctx.input.commitAction ?? 'auto') === 'auto') {
try {
remoteFiles = await api.Repositories.tree(repoID, {
ref: targetBranch,
@@ -237,37 +276,70 @@ export const createPublishGitlabMergeRequestAction = (options: {
);
}
}
const actions: Types.CommitAction[] =
ctx.input.commitAction === 'skip'
? []
: (
(
await Promise.all(
fileContents.map(async file => {
const action = await getFileAction(
{ file, targetPath },
{ repoID, branch: targetBranch! },
api,
ctx.logger,
remoteFiles,
ctx.input.commitAction,
);
return { file, action };
}),
)
).filter(o => o.action !== 'skip') as {
file: SerializedFile;
action: Types.CommitAction['action'];
}[]
).map(({ file, action }) => ({
action,
filePath: targetPath
? path.posix.join(targetPath, file.path)
: file.path,
encoding: 'base64',
content: file.content.toString('base64'),
execute_filemode: file.executable,
}));
const actions: Types.CommitAction[] = fileContents.map(file => ({
action: getFileAction(
{ file, targetPath },
remoteFiles,
ctx.input.commitAction,
),
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.create(repoID, branchName, String(targetBranch));
} catch (e) {
throw new InputError(
`The branch creation failed. Please check that your repo does not already contain a branch named '${branchName}'. ${e}`,
);
let createBranch: boolean;
if (actions.length) {
createBranch = true;
} else {
try {
await api.Branches.show(repoID, branchName);
createBranch = false;
ctx.logger.info(
`Using existing branch ${branchName} without modification.`,
);
} catch (e) {
createBranch = true;
}
}
try {
await api.Commits.create(repoID, branchName, ctx.input.title, actions);
} 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}`,
);
if (createBranch) {
try {
await api.Branches.create(repoID, branchName, String(targetBranch));
} catch (e) {
throw new InputError(
`The branch creation failed. Please check that your repo does not already contain a branch named '${branchName}'. ${e}`,
);
}
}
if (actions.length) {
try {
await api.Commits.create(repoID, branchName, title, actions);
} 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}`,
);
}
}
try {
const mergeRequestUrl = await api.MergeRequests.create(
repoID,
+1 -1
View File
@@ -334,7 +334,7 @@ export const createPublishGitlabMergeRequestAction: (options: {
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined;
projectid?: string | undefined;
removeSourceBranch?: boolean | undefined;
assignee?: string | undefined;
+1
View File
@@ -7456,6 +7456,7 @@ __metadata:
"@gitbeaker/node": ^35.8.0
"@gitbeaker/rest": ^39.25.0
luxon: ^3.0.0
winston: ^3.2.1
yaml: ^2.0.0
zod: ^3.22.4
languageName: unknown