Merge branch 'master' into scaffolder-each
This commit is contained in:
@@ -80,7 +80,7 @@ export const scaffolderPlugin = createBackendPlugin(
|
||||
env.registerInit({
|
||||
deps: {
|
||||
logger: coreServices.logger,
|
||||
config: coreServices.config,
|
||||
config: coreServices.rootConfig,
|
||||
reader: coreServices.urlReader,
|
||||
permissions: coreServices.permissions,
|
||||
database: coreServices.database,
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
createTemplateAction as createTemplateActionNode,
|
||||
TaskSecrets as TaskSecretsNode,
|
||||
TemplateAction as TemplateActionNode,
|
||||
executeShellCommand as executeShellCommandNode,
|
||||
ExecuteShellCommandOptions as ExecuteShellCommandOptionsNode,
|
||||
fetchContents as fetchContentsNode,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
|
||||
@@ -47,3 +50,28 @@ export type TaskSecrets = TaskSecretsNode;
|
||||
*/
|
||||
export type TemplateAction<TInput extends JsonObject> =
|
||||
TemplateActionNode<TInput>;
|
||||
|
||||
/**
|
||||
* Options for {@link executeShellCommand}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `ExecuteShellCommandOptions` from `@backstage/plugin-scaffolder-node` instead
|
||||
*/
|
||||
export type RunCommandOptions = ExecuteShellCommandOptionsNode;
|
||||
|
||||
/**
|
||||
* Run a command in a sub-process, normally a shell command.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `executeShellCommand` from `@backstage/plugin-scaffolder-node` instead
|
||||
*/
|
||||
export const executeShellCommand = executeShellCommandNode;
|
||||
|
||||
/**
|
||||
* A helper function that reads the contents of a directory from the given URL.
|
||||
* Can be used in your own actions, and also used behind fetch:template and fetch:plain
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `fetchContents` from `@backstage/plugin-scaffolder-node` instead
|
||||
*/
|
||||
export const fetchContents = fetchContentsNode;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { VM } from 'vm2';
|
||||
import { Isolate } from 'isolated-vm';
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import fs from 'fs-extra';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
@@ -45,20 +45,14 @@ const { render, renderCompat } = (() => {
|
||||
});
|
||||
compatEnv.addFilter('jsonify', compatEnv.getFilter('dump'));
|
||||
|
||||
if (typeof templateFilters !== 'undefined') {
|
||||
for (const [filterName, filterFn] of Object.entries(templateFilters)) {
|
||||
env.addFilter(filterName, (...args) => JSON.parse(filterFn(...args)));
|
||||
}
|
||||
for (const name of JSON.parse(availableTemplateFilters)) {
|
||||
env.addFilter(name, (...args) => JSON.parse(callFilter(name, args)));
|
||||
}
|
||||
|
||||
if (typeof templateGlobals !== 'undefined') {
|
||||
for (const [globalName, global] of Object.entries(templateGlobals)) {
|
||||
if (typeof global === 'function') {
|
||||
env.addGlobal(globalName, (...args) => JSON.parse(global(...args)));
|
||||
} else {
|
||||
env.addGlobal(globalName, JSON.parse(global));
|
||||
}
|
||||
}
|
||||
for (const [name, value] of Object.entries(JSON.parse(availableTemplateGlobals))) {
|
||||
env.addGlobal(name, value);
|
||||
}
|
||||
for (const name of JSON.parse(availableTemplateCallbacks)) {
|
||||
env.addGlobal(name, (...args) => JSON.parse(callGlobal(name, args)));
|
||||
}
|
||||
|
||||
let uninstallCompat = undefined;
|
||||
@@ -116,35 +110,15 @@ export type SecureTemplateRenderer = (
|
||||
|
||||
export class SecureTemplater {
|
||||
static async loadRenderer(options: SecureTemplaterOptions = {}) {
|
||||
const { cookiecutterCompat, templateFilters, templateGlobals } = options;
|
||||
const sandbox: Record<string, any> = {};
|
||||
const {
|
||||
cookiecutterCompat,
|
||||
templateFilters = {},
|
||||
templateGlobals = {},
|
||||
} = options;
|
||||
|
||||
if (templateFilters) {
|
||||
sandbox.templateFilters = Object.fromEntries(
|
||||
Object.entries(templateFilters)
|
||||
.filter(([_, filterFunction]) => !!filterFunction)
|
||||
.map(([filterName, filterFunction]) => [
|
||||
filterName,
|
||||
(...args: JsonValue[]) => JSON.stringify(filterFunction(...args)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
if (templateGlobals) {
|
||||
sandbox.templateGlobals = Object.fromEntries(
|
||||
Object.entries(templateGlobals)
|
||||
.filter(([_, global]) => !!global)
|
||||
.map(([globalName, global]) => {
|
||||
if (typeof global === 'function') {
|
||||
return [
|
||||
globalName,
|
||||
(...args: JsonValue[]) => JSON.stringify(global(...args)),
|
||||
];
|
||||
}
|
||||
return [globalName, JSON.stringify(global)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
const vm = new VM({ sandbox });
|
||||
const isolate = new Isolate({ memoryLimit: 128 });
|
||||
const context = await isolate.createContext();
|
||||
const contextGlobal = context.global;
|
||||
|
||||
const nunjucksSource = await fs.readFile(
|
||||
resolvePackagePath(
|
||||
@@ -154,20 +128,75 @@ export class SecureTemplater {
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
vm.run(mkScript(nunjucksSource));
|
||||
const nunjucksScript = await isolate.compileScript(
|
||||
mkScript(nunjucksSource),
|
||||
);
|
||||
|
||||
const availableFilters = Object.keys(templateFilters);
|
||||
|
||||
await contextGlobal.set(
|
||||
'availableTemplateFilters',
|
||||
JSON.stringify(availableFilters),
|
||||
);
|
||||
|
||||
const globalCallbacks = [];
|
||||
const globalValues: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(templateGlobals)) {
|
||||
if (typeof value === 'function') {
|
||||
globalCallbacks.push(name);
|
||||
} else {
|
||||
globalValues[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await contextGlobal.set(
|
||||
'availableTemplateGlobals',
|
||||
JSON.stringify(globalValues),
|
||||
);
|
||||
await contextGlobal.set(
|
||||
'availableTemplateCallbacks',
|
||||
JSON.stringify(globalCallbacks),
|
||||
);
|
||||
|
||||
await contextGlobal.set(
|
||||
'callFilter',
|
||||
(filterName: string, args: JsonValue[]) => {
|
||||
if (!Object.hasOwn(templateFilters, filterName)) {
|
||||
return '';
|
||||
}
|
||||
return JSON.stringify(templateFilters[filterName](...args));
|
||||
},
|
||||
);
|
||||
|
||||
await contextGlobal.set(
|
||||
'callGlobal',
|
||||
(globalName: string, args: JsonValue[]) => {
|
||||
if (!Object.hasOwn(templateGlobals, globalName)) {
|
||||
return '';
|
||||
}
|
||||
const global = templateGlobals[globalName];
|
||||
if (typeof global !== 'function') {
|
||||
return '';
|
||||
}
|
||||
return JSON.stringify(global(...args));
|
||||
},
|
||||
);
|
||||
|
||||
await nunjucksScript.run(context);
|
||||
|
||||
const render: SecureTemplateRenderer = (template, values) => {
|
||||
if (!vm) {
|
||||
if (!context) {
|
||||
throw new Error('SecureTemplater has not been initialized');
|
||||
}
|
||||
vm.setGlobal('templateStr', template);
|
||||
vm.setGlobal('templateValues', JSON.stringify(values));
|
||||
|
||||
contextGlobal.setSync('templateStr', String(template));
|
||||
contextGlobal.setSync('templateValues', JSON.stringify(values));
|
||||
|
||||
if (cookiecutterCompat) {
|
||||
return vm.run(`renderCompat(templateStr, templateValues)`);
|
||||
return context.evalSync(`renderCompat(templateStr, templateValues)`);
|
||||
}
|
||||
|
||||
return vm.run(`render(templateStr, templateValues)`);
|
||||
return context.evalSync(`render(templateStr, templateValues)`);
|
||||
};
|
||||
return render;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
} from './filesystem';
|
||||
import {
|
||||
createGithubActionsDispatchAction,
|
||||
createGithubDeployKeyAction,
|
||||
createGithubEnvironmentAction,
|
||||
createGithubIssuesLabelAction,
|
||||
createGithubRepoCreateAction,
|
||||
createGithubRepoPushAction,
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
createPublishBitbucketAction,
|
||||
createPublishBitbucketCloudAction,
|
||||
createPublishBitbucketServerAction,
|
||||
createPublishBitbucketServerPullRequestAction,
|
||||
createPublishGerritAction,
|
||||
createPublishGerritReviewAction,
|
||||
createPublishGithubAction,
|
||||
@@ -162,6 +165,10 @@ export const createBuiltinActions = (
|
||||
integrations,
|
||||
config,
|
||||
}),
|
||||
createPublishBitbucketServerPullRequestAction({
|
||||
integrations,
|
||||
config,
|
||||
}),
|
||||
createPublishAzureAction({
|
||||
integrations,
|
||||
config,
|
||||
@@ -194,6 +201,12 @@ export const createBuiltinActions = (
|
||||
config,
|
||||
githubCredentialsProvider,
|
||||
}),
|
||||
createGithubEnvironmentAction({
|
||||
integrations,
|
||||
}),
|
||||
createGithubDeployKeyAction({
|
||||
integrations,
|
||||
}),
|
||||
];
|
||||
|
||||
return actions as TemplateAction[];
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { UrlReader } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchContents, fetchFile } from './helpers';
|
||||
import os from 'os';
|
||||
|
||||
describe('fetchContent helper', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const integrations = ScmIntegrations.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [{ host: 'github.com', token: 'token' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const readUrl = jest.fn();
|
||||
const readTree = jest.fn();
|
||||
const reader: UrlReader = {
|
||||
readUrl,
|
||||
readTree,
|
||||
search: jest.fn(),
|
||||
};
|
||||
|
||||
const options = {
|
||||
reader,
|
||||
integrations,
|
||||
outputPath: os.tmpdir(),
|
||||
};
|
||||
|
||||
describe('fetch contents', () => {
|
||||
it('should reject absolute file locations', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '/etc/passwd',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject relative file locations that exit the baseUrl', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '../test',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy file to outputpath', async () => {
|
||||
await fetchContents({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: 'foo',
|
||||
outputPath: 'somepath',
|
||||
});
|
||||
expect(fs.copy).toHaveBeenCalledWith(
|
||||
resolvePath('/some/foo'),
|
||||
'somepath',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if no integration matches location', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
baseUrl: 'http://example.com/some/folder',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'No integration found for location http://example.com/some/folder',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if fetch url is relative and no base url is specified', async () => {
|
||||
await expect(
|
||||
fetchContents({
|
||||
...options,
|
||||
fetchUrl: 'foo',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch, template location could not be determined and the fetch URL is relative, foo',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch url contents', async () => {
|
||||
const dirFunction = jest.fn();
|
||||
readTree.mockResolvedValue({
|
||||
dir: dirFunction,
|
||||
});
|
||||
await fetchContents({
|
||||
...options,
|
||||
outputPath: 'foo',
|
||||
fetchUrl: 'https://github.com/backstage/foo',
|
||||
});
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith('foo');
|
||||
expect(dirFunction).toHaveBeenCalledWith({ targetDir: 'foo' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch file', () => {
|
||||
it('should reject absolute file locations', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '/etc/passwd',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject relative file locations that exit the baseUrl', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: '../test',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Relative path is not allowed to refer to a directory outside its parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy file to outputpath', async () => {
|
||||
await fetchFile({
|
||||
...options,
|
||||
baseUrl: 'file:///some/path',
|
||||
fetchUrl: 'foo',
|
||||
outputPath: 'somepath',
|
||||
});
|
||||
expect(fs.copyFile).toHaveBeenCalledWith(
|
||||
resolvePath('/some/foo'),
|
||||
'somepath',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if no integration matches location', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
baseUrl: 'http://example.com/some/folder',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'No integration found for location http://example.com/some/folder',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject if fetch url is relative and no base url is specified', async () => {
|
||||
await expect(
|
||||
fetchFile({
|
||||
...options,
|
||||
fetchUrl: 'foo',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'Failed to fetch, template location could not be determined and the fetch URL is relative, foo',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch content from url', async () => {
|
||||
readUrl.mockResolvedValue({
|
||||
buffer: () => Buffer.from('test', 'utf8'),
|
||||
});
|
||||
await fetchFile({
|
||||
...options,
|
||||
outputPath: 'foo',
|
||||
fetchUrl: 'https://github.com/backstage/foo',
|
||||
});
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith('.');
|
||||
expect(fs.outputFile).toHaveBeenCalledWith('foo', 'test');
|
||||
});
|
||||
|
||||
it('should fetch content from url into directory', async () => {
|
||||
readUrl.mockResolvedValue({
|
||||
buffer: () => Buffer.from('test', 'utf8'),
|
||||
});
|
||||
await fetchFile({
|
||||
...options,
|
||||
outputPath: 'mydir/foo',
|
||||
fetchUrl: 'https://github.com/backstage/foo',
|
||||
});
|
||||
expect(fs.ensureDir).toHaveBeenCalledWith('mydir');
|
||||
expect(fs.outputFile).toHaveBeenCalledWith('mydir/foo', 'test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
/*
|
||||
* 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 { resolveSafeChildPath, UrlReader } from '@backstage/backend-common';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* A helper function that reads the contents of a directory from the given URL.
|
||||
* Can be used in your own actions, and also used behind fetch:template and fetch:plain
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function fetchContents(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
baseUrl?: string;
|
||||
fetchUrl?: string;
|
||||
outputPath: string;
|
||||
}) {
|
||||
const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options;
|
||||
|
||||
const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
|
||||
|
||||
// We handle both file locations and url ones
|
||||
if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) {
|
||||
const basePath = baseUrl.slice('file://'.length);
|
||||
const srcDir = resolveSafeChildPath(path.dirname(basePath), fetchUrl);
|
||||
await fs.copy(srcDir, outputPath);
|
||||
} else {
|
||||
const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
|
||||
|
||||
const res = await reader.readTree(readUrl);
|
||||
await fs.ensureDir(outputPath);
|
||||
await res.dir({ targetDir: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function that reads the content of a single file from the given URL.
|
||||
* Can be used in your own actions, and also used behind `fetch:plain:file`
|
||||
*/
|
||||
export async function fetchFile(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
baseUrl?: string;
|
||||
fetchUrl?: string;
|
||||
outputPath: string;
|
||||
}) {
|
||||
const { reader, integrations, baseUrl, fetchUrl = '.', outputPath } = options;
|
||||
|
||||
const fetchUrlIsAbsolute = isFetchUrlAbsolute(fetchUrl);
|
||||
|
||||
// We handle both file locations and url ones
|
||||
if (!fetchUrlIsAbsolute && baseUrl?.startsWith('file://')) {
|
||||
const basePath = baseUrl.slice('file://'.length);
|
||||
const src = resolveSafeChildPath(path.dirname(basePath), fetchUrl);
|
||||
await fs.copyFile(src, outputPath);
|
||||
} else {
|
||||
const readUrl = getReadUrl(fetchUrl, baseUrl, integrations);
|
||||
|
||||
const res = await reader.readUrl(readUrl);
|
||||
await fs.ensureDir(path.dirname(outputPath));
|
||||
const buffer = await res.buffer();
|
||||
await fs.outputFile(outputPath, buffer.toString());
|
||||
}
|
||||
}
|
||||
|
||||
function isFetchUrlAbsolute(fetchUrl: string) {
|
||||
let fetchUrlIsAbsolute = false;
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(fetchUrl);
|
||||
fetchUrlIsAbsolute = true;
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
return fetchUrlIsAbsolute;
|
||||
}
|
||||
|
||||
function getReadUrl(
|
||||
fetchUrl: string,
|
||||
baseUrl: string | undefined,
|
||||
integrations: ScmIntegrations,
|
||||
) {
|
||||
if (isFetchUrlAbsolute(fetchUrl)) {
|
||||
return fetchUrl;
|
||||
} else if (baseUrl) {
|
||||
const integration = integrations.byUrl(baseUrl);
|
||||
if (!integration) {
|
||||
throw new InputError(`No integration found for location ${baseUrl}`);
|
||||
}
|
||||
|
||||
return integration.resolveUrl({
|
||||
url: fetchUrl,
|
||||
base: baseUrl,
|
||||
});
|
||||
}
|
||||
throw new InputError(
|
||||
`Failed to fetch, template location could not be determined and the fetch URL is relative, ${fetchUrl}`,
|
||||
);
|
||||
}
|
||||
@@ -17,4 +17,3 @@
|
||||
export { createFetchPlainAction } from './plain';
|
||||
export { createFetchPlainFileAction } from './plainFile';
|
||||
export { createFetchTemplateAction } from './template';
|
||||
export { fetchContents } from './helpers';
|
||||
|
||||
@@ -14,16 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
jest.mock('./helpers');
|
||||
jest.mock('@backstage/plugin-scaffolder-node', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-scaffolder-node');
|
||||
return { ...actual, fetchContents: jest.fn() };
|
||||
});
|
||||
|
||||
import os from 'os';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { getVoidLogger, UrlReader } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchContents } from '@backstage/plugin-scaffolder-node';
|
||||
import { createFetchPlainAction } from './plain';
|
||||
import { PassThrough } from 'stream';
|
||||
import { fetchContents } from './helpers';
|
||||
|
||||
describe('fetch:plain', () => {
|
||||
const integrations = ScmIntegrations.fromConfig(
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
|
||||
import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchContents } from './helpers';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import {
|
||||
createTemplateAction,
|
||||
fetchContents,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
|
||||
/**
|
||||
* Downloads content and places it in the workspace, or optionally
|
||||
|
||||
@@ -14,16 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
jest.mock('./helpers');
|
||||
jest.mock('@backstage/plugin-scaffolder-node', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-scaffolder-node');
|
||||
return { ...actual, fetchFile: jest.fn() };
|
||||
});
|
||||
|
||||
import os from 'os';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { getVoidLogger, UrlReader } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchFile } from '@backstage/plugin-scaffolder-node';
|
||||
import { createFetchPlainFileAction } from './plainFile';
|
||||
import { PassThrough } from 'stream';
|
||||
import { fetchFile } from './helpers';
|
||||
|
||||
describe('fetch:plain:file', () => {
|
||||
const integrations = ScmIntegrations.fromConfig(
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
|
||||
import { UrlReader, resolveSafeChildPath } from '@backstage/backend-common';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchFile } from './helpers';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import {
|
||||
createTemplateAction,
|
||||
fetchFile,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
|
||||
/**
|
||||
* Downloads content and places it in the workspace, or optionally
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
jest.mock('@backstage/plugin-scaffolder-node', () => {
|
||||
const actual = jest.requireActual('@backstage/plugin-scaffolder-node');
|
||||
return { ...actual, fetchContents: jest.fn() };
|
||||
});
|
||||
|
||||
import os from 'os';
|
||||
import { join as joinPath, sep as pathSep } from 'path';
|
||||
import fs from 'fs-extra';
|
||||
@@ -25,17 +30,13 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { PassThrough } from 'stream';
|
||||
import { fetchContents } from './helpers';
|
||||
import { createFetchTemplateAction } from './template';
|
||||
import {
|
||||
fetchContents,
|
||||
ActionContext,
|
||||
TemplateAction,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
|
||||
jest.mock('./helpers', () => ({
|
||||
fetchContents: jest.fn(),
|
||||
}));
|
||||
|
||||
type FetchTemplateInput = ReturnType<
|
||||
typeof createFetchTemplateAction
|
||||
> extends TemplateAction<infer U>
|
||||
|
||||
@@ -18,8 +18,10 @@ import { extname } from 'path';
|
||||
import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchContents } from './helpers';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import {
|
||||
createTemplateAction,
|
||||
fetchContents,
|
||||
} from '@backstage/plugin-scaffolder-node';
|
||||
import globby from 'globby';
|
||||
import fs from 'fs-extra';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
@@ -150,7 +152,7 @@ export function createFetchTemplateAction(options: {
|
||||
let renderFilename: boolean;
|
||||
if (ctx.input.copyWithoutRender) {
|
||||
ctx.logger.warn(
|
||||
'[Deprecated] Please use copyWithoutTemplating instead.',
|
||||
'[Deprecated] copyWithoutRender is deprecated Please use copyWithoutTemplating instead.',
|
||||
);
|
||||
copyOnlyPatterns = ctx.input.copyWithoutRender;
|
||||
renderFilename = false;
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 { createGithubDeployKeyAction } from './githubDeployKey';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
actions: {
|
||||
getRepoPublicKey: jest.fn(),
|
||||
createOrUpdateRepoSecret: jest.fn(),
|
||||
},
|
||||
repos: {
|
||||
createDeployKey: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mock('octokit', () => ({
|
||||
Octokit: class {
|
||||
constructor() {
|
||||
return mockOctokit;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
|
||||
|
||||
describe('github:deployKey:create', () => {
|
||||
const config = new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{ host: 'github.com', token: 'tokenlols' },
|
||||
{ host: 'ghe.github.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
let action: TemplateAction<any>;
|
||||
|
||||
const mockContext = {
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repository&owner=owner',
|
||||
publicKey: 'pubkey',
|
||||
privateKey: 'privkey',
|
||||
deployKeyName: 'Push Tags',
|
||||
},
|
||||
workspacePath: 'lol',
|
||||
logger: getVoidLogger(),
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
action = createGithubDeployKeyAction({
|
||||
integrations,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work happy path', async () => {
|
||||
mockOctokit.rest.actions.getRepoPublicKey.mockResolvedValue({
|
||||
data: {
|
||||
key: publicKey,
|
||||
key_id: 'keyid',
|
||||
},
|
||||
});
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
expect(mockOctokit.rest.repos.createDeployKey).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
title: 'Push Tags',
|
||||
key: 'pubkey',
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.actions.createOrUpdateRepoSecret,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
secret_name: 'PUSH_TAGS_PRIVATE_KEY',
|
||||
key_id: 'keyid',
|
||||
encrypted_value: expect.any(String),
|
||||
});
|
||||
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'privateKeySecretName',
|
||||
'PUSH_TAGS_PRIVATE_KEY',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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 { InputError } from '@backstage/errors';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { parseRepoUrl } from '../publish/util';
|
||||
import { getOctokitOptions } from './helpers';
|
||||
import { Octokit } from 'octokit';
|
||||
import Sodium from 'libsodium-wrappers';
|
||||
|
||||
/**
|
||||
* Creates an `github:deployKey:create` Scaffolder action that creates a Deploy Key
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createGithubDeployKeyAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
}) {
|
||||
const { integrations } = options;
|
||||
// For more information on how to define custom actions, see
|
||||
// https://backstage.io/docs/features/software-templates/writing-custom-actions
|
||||
return createTemplateAction<{
|
||||
repoUrl: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
deployKeyName: string;
|
||||
privateKeySecretName?: string;
|
||||
token?: string;
|
||||
}>({
|
||||
id: 'github:deployKey:create',
|
||||
description: 'Creates and stores Deploy Keys',
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['repoUrl', 'publicKey', 'privateKey', 'deployKeyName'],
|
||||
properties: {
|
||||
repoUrl: {
|
||||
title: 'Repository Location',
|
||||
description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`,
|
||||
type: 'string',
|
||||
},
|
||||
publicKey: {
|
||||
title: 'SSH Public Key',
|
||||
description: `Generated from ssh-keygen. Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'.`,
|
||||
type: 'string',
|
||||
},
|
||||
privateKey: {
|
||||
title: 'SSH Private Key',
|
||||
description: `SSH Private Key generated from ssh-keygen`,
|
||||
type: 'string',
|
||||
},
|
||||
deployKeyName: {
|
||||
title: 'Deploy Key Name',
|
||||
description: `Name of the Deploy Key`,
|
||||
type: 'string',
|
||||
},
|
||||
privateKeySecretName: {
|
||||
title: 'Private Key GitHub Secret Name',
|
||||
description: `Name of the GitHub Secret to store the private key related to the Deploy Key. Defaults to: 'KEY_NAME_PRIVATE_KEY' where 'KEY_NAME' is the name of the Deploy Key`,
|
||||
type: 'string',
|
||||
},
|
||||
token: {
|
||||
title: 'Authentication Token',
|
||||
type: 'string',
|
||||
description: 'The token to use for authorization to GitHub',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
privateKeySecretName: {
|
||||
title: 'The GitHub Action Repo Secret Name for the Private Key',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
const {
|
||||
repoUrl,
|
||||
publicKey,
|
||||
privateKey,
|
||||
deployKeyName,
|
||||
privateKeySecretName = `${deployKeyName
|
||||
.split(' ')
|
||||
.join('_')
|
||||
.toLocaleUpperCase('en-US')}_PRIVATE_KEY`,
|
||||
token: providedToken,
|
||||
} = ctx.input;
|
||||
|
||||
const octokitOptions = await getOctokitOptions({
|
||||
integrations,
|
||||
token: providedToken,
|
||||
repoUrl: repoUrl,
|
||||
});
|
||||
|
||||
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
|
||||
|
||||
if (!owner) {
|
||||
throw new InputError(`No owner provided for repo ${repoUrl}`);
|
||||
}
|
||||
|
||||
const client = new Octokit(octokitOptions);
|
||||
|
||||
await client.rest.repos.createDeployKey({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
title: deployKeyName,
|
||||
key: publicKey,
|
||||
});
|
||||
const publicKeyResponse = await client.rest.actions.getRepoPublicKey({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
});
|
||||
|
||||
await Sodium.ready;
|
||||
const binaryKey = Sodium.from_base64(
|
||||
publicKeyResponse.data.key,
|
||||
Sodium.base64_variants.ORIGINAL,
|
||||
);
|
||||
const binarySecret = Sodium.from_string(privateKey);
|
||||
const encryptedBinarySecret = Sodium.crypto_box_seal(
|
||||
binarySecret,
|
||||
binaryKey,
|
||||
);
|
||||
const encryptedBase64Secret = Sodium.to_base64(
|
||||
encryptedBinarySecret,
|
||||
Sodium.base64_variants.ORIGINAL,
|
||||
);
|
||||
|
||||
await client.rest.actions.createOrUpdateRepoSecret({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
secret_name: privateKeySecretName,
|
||||
encrypted_value: encryptedBase64Secret,
|
||||
key_id: publicKeyResponse.data.key_id,
|
||||
});
|
||||
|
||||
ctx.output('privateKeySecretName', privateKeySecretName);
|
||||
},
|
||||
});
|
||||
}
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* 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 { createGithubEnvironmentAction } from './githubEnvironment';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
actions: {
|
||||
getRepoPublicKey: jest.fn(),
|
||||
createEnvironmentVariable: jest.fn(),
|
||||
createOrUpdateEnvironmentSecret: jest.fn(),
|
||||
},
|
||||
repos: {
|
||||
createDeploymentBranchPolicy: jest.fn(),
|
||||
createOrUpdateEnvironment: jest.fn(),
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mock('octokit', () => ({
|
||||
Octokit: class {
|
||||
constructor() {
|
||||
return mockOctokit;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
|
||||
|
||||
describe('github:environment:create', () => {
|
||||
const config = new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{ host: 'github.com', token: 'tokenlols' },
|
||||
{ host: 'ghe.github.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
let action: TemplateAction<any>;
|
||||
|
||||
const mockContext = {
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repository&owner=owner',
|
||||
name: 'envname',
|
||||
},
|
||||
workspacePath: 'lol',
|
||||
logger: getVoidLogger(),
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockOctokit.rest.actions.getRepoPublicKey.mockResolvedValue({
|
||||
data: {
|
||||
key: publicKey,
|
||||
key_id: 'keyid',
|
||||
},
|
||||
});
|
||||
mockOctokit.rest.repos.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'repoid',
|
||||
},
|
||||
});
|
||||
|
||||
action = createGithubEnvironmentAction({
|
||||
integrations,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(jest.resetAllMocks);
|
||||
|
||||
it('should work happy path', async () => {
|
||||
await action.handler(mockContext);
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work specify deploymentBranchPolicy protected', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
deploymentBranchPolicy: {
|
||||
protected_branches: true,
|
||||
custom_branch_policies: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: {
|
||||
protected_branches: true,
|
||||
custom_branch_policies: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should work specify deploymentBranchPolicy custom', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
deploymentBranchPolicy: {
|
||||
protected_branches: false,
|
||||
custom_branch_policies: true,
|
||||
},
|
||||
customBranchPolicyNames: ['main', '*.*.*'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: {
|
||||
protected_branches: false,
|
||||
custom_branch_policies: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createDeploymentBranchPolicy,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
name: 'main',
|
||||
});
|
||||
expect(
|
||||
mockOctokit.rest.repos.createDeploymentBranchPolicy,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
name: '*.*.*',
|
||||
});
|
||||
});
|
||||
|
||||
it('should work specify environment variables', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
environmentVariables: {
|
||||
key1: 'val1',
|
||||
key2: 'val2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.actions.createEnvironmentVariable,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
mockOctokit.rest.actions.createEnvironmentVariable,
|
||||
).toHaveBeenCalledWith({
|
||||
repository_id: 'repoid',
|
||||
environment_name: 'envname',
|
||||
name: 'key1',
|
||||
value: 'val1',
|
||||
});
|
||||
expect(
|
||||
mockOctokit.rest.actions.createEnvironmentVariable,
|
||||
).toHaveBeenCalledWith({
|
||||
repository_id: 'repoid',
|
||||
environment_name: 'envname',
|
||||
name: 'key2',
|
||||
value: 'val2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should work specify secrets', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
secrets: {
|
||||
key1: 'val1',
|
||||
key2: 'val2',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
|
||||
).toHaveBeenCalledWith({
|
||||
repository_id: 'repoid',
|
||||
environment_name: 'envname',
|
||||
secret_name: 'key1',
|
||||
key_id: 'keyid',
|
||||
encrypted_value: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
|
||||
).toHaveBeenCalledWith({
|
||||
repository_id: 'repoid',
|
||||
environment_name: 'envname',
|
||||
secret_name: 'key2',
|
||||
key_id: 'keyid',
|
||||
encrypted_value: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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 { InputError } from '@backstage/errors';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import { parseRepoUrl } from '../publish/util';
|
||||
import { getOctokitOptions } from './helpers';
|
||||
import { Octokit } from 'octokit';
|
||||
import Sodium from 'libsodium-wrappers';
|
||||
|
||||
/**
|
||||
* Creates an `github:environment:create` Scaffolder action that creates a Github Environment.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createGithubEnvironmentAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
}) {
|
||||
const { integrations } = options;
|
||||
// For more information on how to define custom actions, see
|
||||
// https://backstage.io/docs/features/software-templates/writing-custom-actions
|
||||
return createTemplateAction<{
|
||||
repoUrl: string;
|
||||
name: string;
|
||||
deploymentBranchPolicy?: {
|
||||
protected_branches: boolean;
|
||||
custom_branch_policies: boolean;
|
||||
};
|
||||
customBranchPolicyNames?: string[];
|
||||
environmentVariables?: { [key: string]: string };
|
||||
secrets?: { [key: string]: string };
|
||||
token?: string;
|
||||
}>({
|
||||
id: 'github:environment:create',
|
||||
description: 'Creates Deployment Environments',
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['repoUrl', 'name'],
|
||||
properties: {
|
||||
repoUrl: {
|
||||
title: 'Repository Location',
|
||||
description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`,
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
title: 'Environment Name',
|
||||
description: `Name of the deployment environment to create`,
|
||||
type: 'string',
|
||||
},
|
||||
deploymentBranchPolicy: {
|
||||
title: 'Deployment Branch Policy',
|
||||
description: `The type of deployment branch policy for this environment. To allow all branches to deploy, set to null.`,
|
||||
type: 'object',
|
||||
required: ['protected_branches', 'custom_branch_policies'],
|
||||
properties: {
|
||||
protected_branches: {
|
||||
title: 'Protected Branches',
|
||||
description: `Whether only branches with branch protection rules can deploy to this environment. If protected_branches is true, custom_branch_policies must be false; if protected_branches is false, custom_branch_policies must be true.`,
|
||||
type: 'boolean',
|
||||
},
|
||||
custom_branch_policies: {
|
||||
title: 'Custom Branch Policies',
|
||||
description: `Whether only branches that match the specified name patterns can deploy to this environment. If custom_branch_policies is true, protected_branches must be false; if custom_branch_policies is false, protected_branches must be true.`,
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
customBranchPolicyNames: {
|
||||
title: 'Custom Branch Policy Name',
|
||||
description: `The name pattern that branches must match in order to deploy to the environment.
|
||||
|
||||
Wildcard characters will not match /. For example, to match branches that begin with release/ and contain an additional single slash, use release/*/*. For more information about pattern matching syntax, see the Ruby File.fnmatch documentation.`,
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
environmentVariables: {
|
||||
title: 'Environment Variables',
|
||||
description: `Environment variables attached to the deployment environment`,
|
||||
type: 'object',
|
||||
},
|
||||
secrets: {
|
||||
title: 'Deployment Secrets',
|
||||
description: `Secrets attached to the deployment environment`,
|
||||
type: 'object',
|
||||
},
|
||||
token: {
|
||||
title: 'Authentication Token',
|
||||
type: 'string',
|
||||
description: 'The token to use for authorization to GitHub',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
const {
|
||||
repoUrl,
|
||||
name,
|
||||
deploymentBranchPolicy,
|
||||
customBranchPolicyNames,
|
||||
environmentVariables,
|
||||
secrets,
|
||||
token: providedToken,
|
||||
} = ctx.input;
|
||||
|
||||
const octokitOptions = await getOctokitOptions({
|
||||
integrations,
|
||||
token: providedToken,
|
||||
repoUrl: repoUrl,
|
||||
});
|
||||
|
||||
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
|
||||
|
||||
if (!owner) {
|
||||
throw new InputError(`No owner provided for repo ${repoUrl}`);
|
||||
}
|
||||
|
||||
const client = new Octokit(octokitOptions);
|
||||
const repository = await client.rest.repos.get({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
});
|
||||
|
||||
await client.rest.repos.createOrUpdateEnvironment({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
environment_name: name,
|
||||
deployment_branch_policy: deploymentBranchPolicy ?? null,
|
||||
});
|
||||
|
||||
if (customBranchPolicyNames) {
|
||||
for (const item of customBranchPolicyNames) {
|
||||
await client.rest.repos.createDeploymentBranchPolicy({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
environment_name: name,
|
||||
name: item,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(environmentVariables ?? {})) {
|
||||
await client.rest.actions.createEnvironmentVariable({
|
||||
repository_id: repository.data.id,
|
||||
environment_name: name,
|
||||
name: key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
if (secrets) {
|
||||
const publicKeyResponse = await client.rest.actions.getRepoPublicKey({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
});
|
||||
|
||||
await Sodium.ready;
|
||||
const binaryKey = Sodium.from_base64(
|
||||
publicKeyResponse.data.key,
|
||||
Sodium.base64_variants.ORIGINAL,
|
||||
);
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
const binarySecret = Sodium.from_string(value);
|
||||
const encryptedBinarySecret = Sodium.crypto_box_seal(
|
||||
binarySecret,
|
||||
binaryKey,
|
||||
);
|
||||
const encryptedBase64Secret = Sodium.to_base64(
|
||||
encryptedBinarySecret,
|
||||
Sodium.base64_variants.ORIGINAL,
|
||||
);
|
||||
|
||||
await client.rest.actions.createOrUpdateEnvironmentSecret({
|
||||
repository_id: repository.data.id,
|
||||
environment_name: name,
|
||||
secret_name: key,
|
||||
encrypted_value: encryptedBase64Secret,
|
||||
key_id: publicKeyResponse.data.key_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
|
||||
client: Octokit,
|
||||
repo: string,
|
||||
owner: string,
|
||||
repoVisibility: 'private' | 'internal' | 'public',
|
||||
repoVisibility: 'private' | 'internal' | 'public' | undefined,
|
||||
description: string | undefined,
|
||||
homepage: string | undefined,
|
||||
deleteBranchOnMerge: boolean,
|
||||
@@ -149,7 +149,7 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
|
||||
name: repo,
|
||||
org: owner,
|
||||
private: repoVisibility === 'private',
|
||||
// @ts-ignore
|
||||
// @ts-ignore https://github.com/octokit/types.ts/issues/522
|
||||
visibility: repoVisibility,
|
||||
description: description,
|
||||
delete_branch_on_merge: deleteBranchOnMerge,
|
||||
|
||||
@@ -19,3 +19,5 @@ export { createGithubIssuesLabelAction } from './githubIssuesLabel';
|
||||
export { createGithubRepoCreateAction } from './githubRepoCreate';
|
||||
export { createGithubRepoPushAction } from './githubRepoPush';
|
||||
export { createGithubWebhookAction } from './githubWebhook';
|
||||
export { createGithubDeployKeyAction } from './githubDeployKey';
|
||||
export { createGithubEnvironmentAction } from './githubEnvironment';
|
||||
|
||||
@@ -17,61 +17,9 @@
|
||||
import { Git } from '@backstage/backend-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { spawn, SpawnOptionsWithoutStdio } from 'child_process';
|
||||
import { Octokit } from 'octokit';
|
||||
import { PassThrough, Writable } from 'stream';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
/** @public */
|
||||
export type RunCommandOptions = {
|
||||
/** command to run */
|
||||
command: string;
|
||||
/** arguments to pass the command */
|
||||
args: string[];
|
||||
/** options to pass to spawn */
|
||||
options?: SpawnOptionsWithoutStdio;
|
||||
/** stream to capture stdout and stderr output */
|
||||
logStream?: Writable;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a command in a sub-process, normally a shell command.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const executeShellCommand = async (options: RunCommandOptions) => {
|
||||
const {
|
||||
command,
|
||||
args,
|
||||
options: spawnOptions,
|
||||
logStream = new PassThrough(),
|
||||
} = options;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const process = spawn(command, args, spawnOptions);
|
||||
|
||||
process.stdout.on('data', stream => {
|
||||
logStream.write(stream);
|
||||
});
|
||||
|
||||
process.stderr.on('data', stream => {
|
||||
logStream.write(stream);
|
||||
});
|
||||
|
||||
process.on('error', error => {
|
||||
return reject(error);
|
||||
});
|
||||
|
||||
process.on('close', code => {
|
||||
if (code !== 0) {
|
||||
return reject(
|
||||
new Error(`Command ${command} failed, exit code: ${code}`),
|
||||
);
|
||||
}
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export async function initRepoAndPush({
|
||||
dir,
|
||||
remoteUrl,
|
||||
|
||||
@@ -21,6 +21,3 @@ export * from './fetch';
|
||||
export * from './filesystem';
|
||||
export * from './publish';
|
||||
export * from './github';
|
||||
|
||||
export { executeShellCommand } from './helpers';
|
||||
export type { RunCommandOptions } from './helpers';
|
||||
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
jest.mock('../helpers', () => {
|
||||
return {
|
||||
initRepoAndPush: jest.fn().mockResolvedValue({
|
||||
commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6',
|
||||
}),
|
||||
commitAndPushRepo: jest.fn().mockResolvedValue({
|
||||
commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { createPublishBitbucketServerPullRequestAction } from './bitbucketServerPullRequest';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
describe('publish:bitbucketServer:pull-request', () => {
|
||||
const config = new ConfigReader({
|
||||
integrations: {
|
||||
bitbucketServer: [
|
||||
{
|
||||
host: 'hosted.bitbucket.com',
|
||||
token: 'thing',
|
||||
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
|
||||
},
|
||||
{
|
||||
host: 'basic-auth.bitbucket.com',
|
||||
username: 'test-user',
|
||||
password: 'test-password',
|
||||
apiBaseUrl: 'https://basic-auth.bitbucket.com/rest/api/1.0',
|
||||
},
|
||||
{
|
||||
host: 'no-credentials.bitbucket.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
const action = createPublishBitbucketServerPullRequestAction({
|
||||
integrations,
|
||||
config,
|
||||
});
|
||||
const mockContext = {
|
||||
input: {
|
||||
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
|
||||
title: 'Add Scaffolder actions for Bitbucket Server',
|
||||
description:
|
||||
'I just made a Pull Request that Add Scaffolder actions for Bitbucket Server',
|
||||
targetBranch: 'master',
|
||||
sourceBranch: 'develop',
|
||||
},
|
||||
workspacePath: 'wsp',
|
||||
logger: getVoidLogger(),
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
};
|
||||
const responseOfBranches = {
|
||||
size: 3,
|
||||
limit: 25,
|
||||
isLastPage: true,
|
||||
values: [
|
||||
{
|
||||
id: 'refs/heads/master',
|
||||
displayId: 'master',
|
||||
type: 'BRANCH',
|
||||
latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
|
||||
latestChangeset: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'refs/heads/develop',
|
||||
displayId: 'develop',
|
||||
type: 'BRANCH',
|
||||
latestCommit: '98e21148205367aeb11c25a52eaca3c2945253fa',
|
||||
latestChangeset: '98e21148205367aeb11c25a52eaca3c2945253fa',
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
id: 'refs/heads/develop-2',
|
||||
displayId: 'develop-2',
|
||||
type: 'BRANCH',
|
||||
latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
|
||||
latestChangeset: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
start: 0,
|
||||
};
|
||||
const responseOfPullRequests = {
|
||||
id: 19,
|
||||
version: 0,
|
||||
title: 'Test for bitbucket server pull-requests',
|
||||
description: 'Test for bitbucket server pull-requests',
|
||||
state: 'OPEN',
|
||||
open: true,
|
||||
closed: false,
|
||||
createdDate: 1684200289521,
|
||||
updatedDate: 1684200289521,
|
||||
fromRef: {
|
||||
id: 'refs/heads/develop',
|
||||
displayId: 'develop',
|
||||
latestCommit: '98e21148205367aeb11c25a52eaca3c2945253fa',
|
||||
type: 'BRANCH',
|
||||
repository: {
|
||||
slug: 'repo',
|
||||
id: 1812,
|
||||
name: 'repo',
|
||||
description: 'This is a test repo',
|
||||
hierarchyId: '1da8822903a9b11a27b8',
|
||||
scmId: 'git',
|
||||
state: 'AVAILABLE',
|
||||
statusMessage: 'Available',
|
||||
forkable: true,
|
||||
project: {},
|
||||
public: false,
|
||||
links: {},
|
||||
},
|
||||
},
|
||||
toRef: {
|
||||
id: 'refs/heads/master',
|
||||
displayId: 'master',
|
||||
latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
|
||||
type: 'BRANCH',
|
||||
repository: {
|
||||
slug: 'repo',
|
||||
id: 1812,
|
||||
name: 'repo',
|
||||
description: 'This is a test repo',
|
||||
hierarchyId: '1da8822903a9b11a27b8',
|
||||
scmId: 'git',
|
||||
state: 'AVAILABLE',
|
||||
statusMessage: 'Available',
|
||||
forkable: true,
|
||||
project: {},
|
||||
public: false,
|
||||
links: {},
|
||||
},
|
||||
},
|
||||
locked: false,
|
||||
author: {
|
||||
user: {
|
||||
name: 'test-user',
|
||||
emailAddress: 'test-user@sample.com',
|
||||
id: 2944,
|
||||
displayName: 'test-user',
|
||||
active: true,
|
||||
slug: 'test-user',
|
||||
type: 'NORMAL',
|
||||
links: {},
|
||||
},
|
||||
role: 'AUTHOR',
|
||||
approved: false,
|
||||
status: 'UNAPPROVED',
|
||||
},
|
||||
reviewers: [],
|
||||
participants: [],
|
||||
links: {
|
||||
self: [
|
||||
{
|
||||
href: 'https://hosted.bitbucket.com/projects/project/repos/repo/pull-requests/1',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const handlers = [
|
||||
rest.get(
|
||||
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
|
||||
(_, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfBranches),
|
||||
);
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
|
||||
(_, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfPullRequests),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should throw an error when the repoUrl is not well formed', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
repoUrl: 'hosted.bitbucket.com?repo=repo',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/missing project/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
repoUrl: 'hosted.bitbucket.com?project=project',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/missing repo/);
|
||||
});
|
||||
|
||||
it('should throw if there is no integration config provided', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
repoUrl: 'missing.com?project=project&repo=repo',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/No matching integration configuration/);
|
||||
});
|
||||
|
||||
it('should throw if there no credentials in the integration config that is returned', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo',
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Authorization has not been provided for no-credentials.bitbucket.com/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call the correct APIs with token', async () => {
|
||||
expect.assertions(3);
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer thing');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfBranches),
|
||||
);
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer thing');
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfPullRequests),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the correct APIs with basic auth', async () => {
|
||||
expect.assertions(3);
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('Authorization')).toBe(
|
||||
'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=',
|
||||
);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfBranches),
|
||||
);
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('Authorization')).toBe(
|
||||
'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=',
|
||||
);
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfPullRequests),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
repoUrl: 'basic-auth.bitbucket.com?project=project&repo=repo',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should work if the token is provided through ctx.input', async () => {
|
||||
expect.assertions(3);
|
||||
const token = 'user-token';
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfBranches),
|
||||
);
|
||||
},
|
||||
),
|
||||
rest.post(
|
||||
'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||
return res(
|
||||
ctx.status(201),
|
||||
ctx.set('Content-Type', 'application/json'),
|
||||
ctx.json(responseOfPullRequests),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo',
|
||||
token: token,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call outputs with the correct urls', async () => {
|
||||
server.use(...handlers);
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
expect(mockContext.output).toHaveBeenCalledWith(
|
||||
'pullRequestUrl',
|
||||
'https://hosted.bitbucket.com/projects/project/repos/repo/pull-requests/1',
|
||||
);
|
||||
});
|
||||
});
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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 { InputError } from '@backstage/errors';
|
||||
import {
|
||||
getBitbucketServerRequestOptions,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { parseRepoUrl } from './util';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
const createPullRequest = async (opts: {
|
||||
project: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
toRef: {
|
||||
id: string;
|
||||
displayId: string;
|
||||
type: string;
|
||||
latestCommit: string;
|
||||
latestChangeset: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
fromRef: {
|
||||
id: string;
|
||||
displayId: string;
|
||||
type: string;
|
||||
latestCommit: string;
|
||||
latestChangeset: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
authorization: string;
|
||||
apiBaseUrl: string;
|
||||
}) => {
|
||||
const {
|
||||
project,
|
||||
repo,
|
||||
title,
|
||||
description,
|
||||
toRef,
|
||||
fromRef,
|
||||
authorization,
|
||||
apiBaseUrl,
|
||||
} = opts;
|
||||
|
||||
let response: Response;
|
||||
const data: RequestInit = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
description: description,
|
||||
state: 'OPEN',
|
||||
open: true,
|
||||
closed: false,
|
||||
locked: true,
|
||||
toRef: toRef,
|
||||
fromRef: fromRef,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
response = await fetch(
|
||||
`${apiBaseUrl}/projects/${encodeURIComponent(
|
||||
project,
|
||||
)}/repos/${encodeURIComponent(repo)}/pull-requests`,
|
||||
data,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to create pull-reqeusts, ${e}`);
|
||||
}
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error(
|
||||
`Unable to create pull requests, ${response.status} ${
|
||||
response.statusText
|
||||
}, ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const r = await response.json();
|
||||
return `${r.links.self[0].href}`;
|
||||
};
|
||||
const findBranches = async (opts: {
|
||||
project: string;
|
||||
repo: string;
|
||||
branchName: string;
|
||||
authorization: string;
|
||||
apiBaseUrl: string;
|
||||
}) => {
|
||||
const { project, repo, branchName, authorization, apiBaseUrl } = opts;
|
||||
|
||||
let response: Response;
|
||||
const options: RequestInit = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
response = await fetch(
|
||||
`${apiBaseUrl}/projects/${encodeURIComponent(
|
||||
project,
|
||||
)}/repos/${encodeURIComponent(
|
||||
repo,
|
||||
)}/branches?boostMatches=true&filterText=${encodeURIComponent(
|
||||
branchName,
|
||||
)}`,
|
||||
options,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to get branches, ${e}`);
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Unable to get branches, ${response.status} ${
|
||||
response.statusText
|
||||
}, ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const r = await response.json();
|
||||
for (const object of r.values) {
|
||||
if (object.displayId === branchName) {
|
||||
return object;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a BitbucketServer Pull Request action.
|
||||
* @public
|
||||
*/
|
||||
export function createPublishBitbucketServerPullRequestAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
config: Config;
|
||||
}) {
|
||||
const { integrations } = options;
|
||||
|
||||
return createTemplateAction<{
|
||||
repoUrl: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
targetBranch?: string;
|
||||
sourceBranch: string;
|
||||
token?: string;
|
||||
}>({
|
||||
id: 'publish:bitbucketServer:pull-request',
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['repoUrl', 'title', 'sourceBranch'],
|
||||
properties: {
|
||||
repoUrl: {
|
||||
title: 'Repository Location',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
title: 'Pull Request title',
|
||||
type: 'string',
|
||||
description: 'The title for the pull request',
|
||||
},
|
||||
description: {
|
||||
title: 'Pull Request Description',
|
||||
type: 'string',
|
||||
description: 'The description of the pull request',
|
||||
},
|
||||
targetBranch: {
|
||||
title: 'Target Branch',
|
||||
type: 'string',
|
||||
description: `Branch of repository to apply changes to. The default value is 'master'`,
|
||||
},
|
||||
sourceBranch: {
|
||||
title: 'Source Branch',
|
||||
type: 'string',
|
||||
description: 'Branch of repository to copy changes from',
|
||||
},
|
||||
token: {
|
||||
title: 'Authorization Token',
|
||||
type: 'string',
|
||||
description:
|
||||
'The token to use for authorization to BitBucket Server',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pullRequestUrl: {
|
||||
title: 'A URL to the pull request with the provider',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
const {
|
||||
repoUrl,
|
||||
title,
|
||||
description,
|
||||
targetBranch = 'master',
|
||||
sourceBranch,
|
||||
} = ctx.input;
|
||||
|
||||
const { project, repo, host } = parseRepoUrl(repoUrl, integrations);
|
||||
|
||||
if (!project) {
|
||||
throw new InputError(
|
||||
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`,
|
||||
);
|
||||
}
|
||||
|
||||
const integrationConfig = integrations.bitbucketServer.byHost(host);
|
||||
if (!integrationConfig) {
|
||||
throw new InputError(
|
||||
`No matching integration configuration for host ${host}, please check your integrations config`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = ctx.input.token ?? integrationConfig.config.token;
|
||||
|
||||
const authConfig = {
|
||||
...integrationConfig.config,
|
||||
...{ token },
|
||||
};
|
||||
|
||||
const reqOpts = getBitbucketServerRequestOptions(authConfig);
|
||||
const authorization = reqOpts.headers.Authorization;
|
||||
if (!authorization) {
|
||||
throw new Error(
|
||||
`Authorization has not been provided for ${integrationConfig.config.host}. Please add either (a) a user login auth token, or (b) a token input from the template or (c) username + password to the integration config.`,
|
||||
);
|
||||
}
|
||||
|
||||
const apiBaseUrl = integrationConfig.config.apiBaseUrl;
|
||||
|
||||
const toRef = await findBranches({
|
||||
project,
|
||||
repo,
|
||||
branchName: targetBranch,
|
||||
authorization,
|
||||
apiBaseUrl,
|
||||
});
|
||||
|
||||
const fromRef = await findBranches({
|
||||
project,
|
||||
repo,
|
||||
branchName: sourceBranch,
|
||||
authorization,
|
||||
apiBaseUrl,
|
||||
});
|
||||
|
||||
const pullRequestUrl = await createPullRequest({
|
||||
project,
|
||||
repo,
|
||||
title,
|
||||
description,
|
||||
toRef,
|
||||
fromRef,
|
||||
authorization,
|
||||
apiBaseUrl,
|
||||
});
|
||||
|
||||
ctx.output('pullRequestUrl', pullRequestUrl);
|
||||
},
|
||||
});
|
||||
}
|
||||
+92
@@ -62,6 +62,9 @@ describe('createPublishGithubPullRequestAction', () => {
|
||||
data: {
|
||||
html_url: 'https://github.com/myorg/myrepo/pull/123',
|
||||
number: 123,
|
||||
base: {
|
||||
ref: 'main',
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -90,6 +93,94 @@ describe('createPublishGithubPullRequestAction', () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('with targetBranchName', () => {
|
||||
let input: GithubPullRequestActionInput;
|
||||
let ctx: ActionContext<GithubPullRequestActionInput>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeClient = {
|
||||
createPullRequest: jest.fn(async (_: any) => {
|
||||
return {
|
||||
url: 'https://api.github.com/myorg/myrepo/pull/123',
|
||||
headers: {},
|
||||
status: 201,
|
||||
data: {
|
||||
html_url: 'https://github.com/myorg/myrepo/pull/123',
|
||||
number: 123,
|
||||
base: {
|
||||
ref: 'test',
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
rest: {
|
||||
pulls: {
|
||||
requestReviewers: jest.fn(async (_: any) => ({ data: {} })),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
input = {
|
||||
repoUrl: 'github.com?owner=myorg&repo=myrepo',
|
||||
title: 'Create my new app',
|
||||
branchName: 'new-app',
|
||||
targetBranchName: 'test',
|
||||
description: 'This PR is really good',
|
||||
draft: true,
|
||||
};
|
||||
|
||||
mockFs({
|
||||
[workspacePath]: { 'file.txt': 'Hello there!' },
|
||||
});
|
||||
|
||||
ctx = {
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
output: jest.fn(),
|
||||
logger: getRootLogger(),
|
||||
logStream: new Writable(),
|
||||
input,
|
||||
workspacePath,
|
||||
};
|
||||
});
|
||||
|
||||
it('creates a pull request', async () => {
|
||||
await instance.handler(ctx);
|
||||
|
||||
expect(fakeClient.createPullRequest).toHaveBeenCalledWith({
|
||||
owner: 'myorg',
|
||||
repo: 'myrepo',
|
||||
title: 'Create my new app',
|
||||
head: 'new-app',
|
||||
base: 'test',
|
||||
body: 'This PR is really good',
|
||||
draft: true,
|
||||
changes: [
|
||||
{
|
||||
commit: 'Create my new app',
|
||||
files: {
|
||||
'file.txt': {
|
||||
content: Buffer.from('Hello there!').toString('base64'),
|
||||
encoding: 'base64',
|
||||
mode: '100644',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates outputs for the pull request url and number', async () => {
|
||||
await instance.handler(ctx);
|
||||
|
||||
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'test');
|
||||
expect(ctx.output).toHaveBeenCalledWith(
|
||||
'remoteUrl',
|
||||
'https://github.com/myorg/myrepo/pull/123',
|
||||
);
|
||||
expect(ctx.output).toHaveBeenCalledWith('pullRequestNumber', 123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no sourcePath', () => {
|
||||
let input: GithubPullRequestActionInput;
|
||||
let ctx: ActionContext<GithubPullRequestActionInput>;
|
||||
@@ -145,6 +236,7 @@ describe('createPublishGithubPullRequestAction', () => {
|
||||
it('creates outputs for the pull request url and number', async () => {
|
||||
await instance.handler(ctx);
|
||||
|
||||
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'main');
|
||||
expect(ctx.output).toHaveBeenCalledWith(
|
||||
'remoteUrl',
|
||||
'https://github.com/myorg/myrepo/pull/123',
|
||||
|
||||
+22
-2
@@ -42,6 +42,9 @@ export type OctokitWithPullRequestPluginClient = Octokit & {
|
||||
data: {
|
||||
html_url: string;
|
||||
number: number;
|
||||
base: {
|
||||
ref: string;
|
||||
};
|
||||
};
|
||||
} | null>;
|
||||
};
|
||||
@@ -128,6 +131,7 @@ export const createPublishGithubPullRequestAction = (
|
||||
return createTemplateAction<{
|
||||
title: string;
|
||||
branchName: string;
|
||||
targetBranchName?: string;
|
||||
description: string;
|
||||
repoUrl: string;
|
||||
draft?: boolean;
|
||||
@@ -154,6 +158,11 @@ export const createPublishGithubPullRequestAction = (
|
||||
title: 'Branch Name',
|
||||
description: 'The name for the branch',
|
||||
},
|
||||
targetBranchName: {
|
||||
type: 'string',
|
||||
title: 'Target Branch Name',
|
||||
description: 'The target branch name of the merge request',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Pull Request Name',
|
||||
@@ -214,6 +223,10 @@ export const createPublishGithubPullRequestAction = (
|
||||
required: ['remoteUrl'],
|
||||
type: 'object',
|
||||
properties: {
|
||||
targetBranchName: {
|
||||
title: 'Target branch name of the merge request',
|
||||
type: 'string',
|
||||
},
|
||||
remoteUrl: {
|
||||
type: 'string',
|
||||
title: 'Pull Request URL',
|
||||
@@ -231,6 +244,7 @@ export const createPublishGithubPullRequestAction = (
|
||||
const {
|
||||
repoUrl,
|
||||
branchName,
|
||||
targetBranchName,
|
||||
title,
|
||||
description,
|
||||
draft,
|
||||
@@ -298,7 +312,7 @@ export const createPublishGithubPullRequestAction = (
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await client.createPullRequest({
|
||||
const createOptions: createPullRequest.Options = {
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
@@ -311,7 +325,11 @@ export const createPublishGithubPullRequestAction = (
|
||||
body: description,
|
||||
head: branchName,
|
||||
draft,
|
||||
});
|
||||
};
|
||||
if (targetBranchName) {
|
||||
createOptions.base = targetBranchName;
|
||||
}
|
||||
const response = await client.createPullRequest(createOptions);
|
||||
|
||||
if (!response) {
|
||||
throw new GithubResponseError('null response from Github');
|
||||
@@ -329,6 +347,8 @@ export const createPublishGithubPullRequestAction = (
|
||||
);
|
||||
}
|
||||
|
||||
const targetBranch = response.data.base.ref;
|
||||
ctx.output('targetBranchName', targetBranch);
|
||||
ctx.output('remoteUrl', response.data.html_url);
|
||||
ctx.output('pullRequestNumber', pullRequestNumber);
|
||||
} catch (e) {
|
||||
|
||||
+51
@@ -101,6 +101,49 @@ describe('createGitLabMergeRequest', () => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
describe('createGitLabMergeRequestWithSpecifiedTargetBranch', () => {
|
||||
it('removeSourceBranch is false by default when not passed in options', async () => {
|
||||
const input = {
|
||||
repoUrl: 'gitlab.com?repo=repo&owner=owner',
|
||||
title: 'Create my new MR',
|
||||
branchName: 'new-mr',
|
||||
targetBranchName: 'test',
|
||||
description: 'This MR is really good',
|
||||
targetPath: 'Subdirectory',
|
||||
};
|
||||
mockFs({
|
||||
[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.Projects.show).not.toHaveBeenCalled();
|
||||
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'new-mr',
|
||||
'test',
|
||||
);
|
||||
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'new-mr',
|
||||
'test',
|
||||
'Create my new MR',
|
||||
{ description: 'This MR is really good', removeSourceBranch: false },
|
||||
);
|
||||
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGitLabMergeRequestWithoutRemoveBranch', () => {
|
||||
it('removeSourceBranch is false by default when not passed in options', async () => {
|
||||
const input = {
|
||||
@@ -126,6 +169,12 @@ describe('createGitLabMergeRequest', () => {
|
||||
};
|
||||
await instance.handler(ctx);
|
||||
|
||||
expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith('owner/repo');
|
||||
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'new-mr',
|
||||
'main',
|
||||
);
|
||||
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
|
||||
'owner/repo',
|
||||
'new-mr',
|
||||
@@ -133,6 +182,8 @@ describe('createGitLabMergeRequest', () => {
|
||||
'Create my new MR',
|
||||
{ description: 'This MR is really good', removeSourceBranch: false },
|
||||
);
|
||||
|
||||
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'main');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+22
-6
@@ -39,6 +39,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
title: string;
|
||||
description: string;
|
||||
branchName: string;
|
||||
targetBranchName?: string;
|
||||
sourcePath?: string;
|
||||
targetPath?: string;
|
||||
token?: string;
|
||||
@@ -77,8 +78,13 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
},
|
||||
branchName: {
|
||||
type: 'string',
|
||||
title: 'Destination Branch name',
|
||||
description: 'The description of the merge request',
|
||||
title: 'Source Branch Name',
|
||||
description: 'The source branch name of the merge request',
|
||||
},
|
||||
targetBranchName: {
|
||||
type: 'string',
|
||||
title: 'Target Branch Name',
|
||||
description: 'The target branch name of the merge request',
|
||||
},
|
||||
sourcePath: {
|
||||
type: 'string',
|
||||
@@ -119,6 +125,10 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
targetBranchName: {
|
||||
title: 'Target branch name of the merge request',
|
||||
type: 'string',
|
||||
},
|
||||
projectid: {
|
||||
title: 'Gitlab Project id/Name(slug)',
|
||||
type: 'string',
|
||||
@@ -139,6 +149,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
const {
|
||||
assignee,
|
||||
branchName,
|
||||
targetBranchName,
|
||||
description,
|
||||
repoUrl,
|
||||
removeSourceBranch,
|
||||
@@ -211,12 +222,16 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
execute_filemode: file.executable,
|
||||
}));
|
||||
|
||||
const projects = await api.Projects.show(repoID);
|
||||
let targetBranch = targetBranchName;
|
||||
if (!targetBranch) {
|
||||
const projects = await api.Projects.show(repoID);
|
||||
|
||||
const { default_branch: defaultBranch } = projects;
|
||||
const { default_branch: defaultBranch } = projects;
|
||||
targetBranch = defaultBranch!;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.Branches.create(repoID, branchName, String(defaultBranch));
|
||||
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}`,
|
||||
@@ -235,7 +250,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
const mergeRequestUrl = await api.MergeRequests.create(
|
||||
repoID,
|
||||
branchName,
|
||||
String(defaultBranch),
|
||||
String(targetBranch),
|
||||
title,
|
||||
{
|
||||
description,
|
||||
@@ -246,6 +261,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
|
||||
return mergeRequest.web_url;
|
||||
});
|
||||
ctx.output('projectid', repoID);
|
||||
ctx.output('targetBranchName', targetBranch);
|
||||
ctx.output('projectPath', repoID);
|
||||
ctx.output('mergeRequestUrl', mergeRequestUrl);
|
||||
} catch (e) {
|
||||
|
||||
@@ -18,6 +18,7 @@ export { createPublishAzureAction } from './azure';
|
||||
export { createPublishBitbucketAction } from './bitbucket';
|
||||
export { createPublishBitbucketCloudAction } from './bitbucketCloud';
|
||||
export { createPublishBitbucketServerAction } from './bitbucketServer';
|
||||
export { createPublishBitbucketServerPullRequestAction } from './bitbucketServerPullRequest';
|
||||
export { createPublishGerritAction } from './gerrit';
|
||||
export { createPublishGerritReviewAction } from './gerritReview';
|
||||
export { createPublishGithubAction } from './github';
|
||||
|
||||
Reference in New Issue
Block a user