feat: introduce github:repo:create github:repo:push scaffolder actions

Signed-off-by: Marco Crivellaro <marco.crive@gmail.com>
This commit is contained in:
Marco Crivellaro
2022-06-18 14:02:10 +01:00
parent 90ba9df791
commit ea07eb0bc8
6 changed files with 1405 additions and 15 deletions
@@ -15,25 +15,34 @@
*/
import { UrlReader } from '@backstage/backend-common';
import { JsonObject } from '@backstage/types';
import { CatalogApi } from '@backstage/catalog-client';
import {
GithubCredentialsProvider,
ScmIntegrations,
DefaultGithubCredentialsProvider,
} from '@backstage/integration';
import { Config } from '@backstage/config';
import {
createCatalogWriteAction,
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { JsonObject } from '@backstage/types';
import {
createCatalogRegisterAction,
createCatalogWriteAction,
} from './catalog';
import { TemplateFilter } from '../../../lib';
import { TemplateAction } from '../types';
import { createDebugLogAction } from './debug';
import { createFetchPlainAction, createFetchTemplateAction } from './fetch';
import {
createFilesystemDeleteAction,
createFilesystemRenameAction,
} from './filesystem';
import {
createGithubActionsDispatchAction,
createGithubIssuesLabelAction,
createGithubRepoCreateAction,
createGithubRepoPushAction,
createGithubWebhookAction,
} from './github';
import {
createPublishAzureAction,
createPublishBitbucketAction,
@@ -45,13 +54,6 @@ import {
createPublishGitlabAction,
createPublishGitlabMergeRequestAction,
} from './publish';
import {
createGithubActionsDispatchAction,
createGithubWebhookAction,
createGithubIssuesLabelAction,
} from './github';
import { TemplateFilter } from '../../../lib';
import { TemplateAction } from '../types';
/**
* The options passed to {@link createBuiltinActions}
@@ -165,6 +167,15 @@ export const createBuiltinActions = (
integrations,
githubCredentialsProvider,
}),
createGithubRepoCreateAction({
integrations,
githubCredentialsProvider,
}),
createGithubRepoPushAction({
integrations,
config,
githubCredentialsProvider,
}),
];
return actions as TemplateAction<JsonObject>[];
@@ -0,0 +1,416 @@
/*
* 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 { TemplateAction } from '../../types';
jest.mock('../helpers');
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import {
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { when } from 'jest-when';
import { PassThrough } from 'stream';
import { createGithubRepoCreateAction } from './githubRepoCreate';
const mockOctokit = {
rest: {
users: {
getByUsername: jest.fn(),
},
repos: {
addCollaborator: jest.fn(),
createInOrg: jest.fn(),
createForAuthenticatedUser: jest.fn(),
replaceAllTopics: jest.fn(),
},
teams: {
addOrUpdateRepoPermissionsInOrg: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
return mockOctokit;
}
},
}));
describe('github:repo:create', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
let githubCredentialsProvider: GithubCredentialsProvider;
let action: TemplateAction<any>;
const mockContext = {
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
description: 'description',
repoVisibility: 'private' as const,
access: 'owner/blam',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
githubCredentialsProvider =
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
action = createGithubRepoCreateAction({
integrations,
githubCredentialsProvider,
});
});
it('should call the githubApis with the correct values for createInOrg', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'Organization' },
});
mockOctokit.rest.repos.createInOrg.mockResolvedValue({ data: {} });
await action.handler(mockContext);
expect(mockOctokit.rest.repos.createInOrg).toHaveBeenCalledWith({
description: 'description',
name: 'repo',
org: 'owner',
private: true,
delete_branch_on_merge: false,
allow_squash_merge: true,
allow_merge_commit: true,
allow_rebase_merge: true,
visibility: 'private',
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoVisibility: 'public',
},
});
expect(mockOctokit.rest.repos.createInOrg).toHaveBeenCalledWith({
description: 'description',
name: 'repo',
org: 'owner',
private: false,
delete_branch_on_merge: false,
allow_squash_merge: true,
allow_merge_commit: true,
allow_rebase_merge: true,
visibility: 'public',
});
});
it('should call the githubApis with the correct values for createForAuthenticatedUser', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {},
});
await action.handler(mockContext);
expect(
mockOctokit.rest.repos.createForAuthenticatedUser,
).toHaveBeenCalledWith({
description: 'description',
name: 'repo',
private: true,
delete_branch_on_merge: false,
allow_squash_merge: true,
allow_merge_commit: true,
allow_rebase_merge: true,
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoVisibility: 'public',
},
});
expect(
mockOctokit.rest.repos.createForAuthenticatedUser,
).toHaveBeenCalledWith({
description: 'description',
name: 'repo',
private: false,
delete_branch_on_merge: false,
allow_squash_merge: true,
allow_merge_commit: true,
allow_rebase_merge: true,
});
});
it('should add access for the team when it starts with the owner', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler(mockContext);
expect(
mockOctokit.rest.teams.addOrUpdateRepoPermissionsInOrg,
).toHaveBeenCalledWith({
org: 'owner',
team_slug: 'blam',
owner: 'owner',
repo: 'repo',
permission: 'admin',
});
});
it('should add outside collaborators when provided', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
access: 'outsidecollaborator',
},
});
expect(mockOctokit.rest.repos.addCollaborator).toHaveBeenCalledWith({
username: 'outsidecollaborator',
owner: 'owner',
repo: 'repo',
permission: 'admin',
});
});
it('should add multiple collaborators when provided', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
collaborators: [
{
access: 'pull',
user: 'robot-1',
},
{
access: 'push',
team: 'robot-2',
},
],
},
});
const commonProperties = {
owner: 'owner',
repo: 'repo',
};
expect(mockOctokit.rest.repos.addCollaborator).toHaveBeenCalledWith({
...commonProperties,
username: 'robot-1',
permission: 'pull',
});
expect(
mockOctokit.rest.teams.addOrUpdateRepoPermissionsInOrg,
).toHaveBeenCalledWith({
...commonProperties,
org: 'owner',
team_slug: 'robot-2',
permission: 'push',
});
});
it('should ignore failures when adding multiple collaborators', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
when(mockOctokit.rest.teams.addOrUpdateRepoPermissionsInOrg)
.calledWith({
org: 'owner',
owner: 'owner',
repo: 'repo',
team_slug: 'robot-1',
permission: 'pull',
})
.mockRejectedValueOnce(new Error('Something bad happened') as never);
await action.handler({
...mockContext,
input: {
...mockContext.input,
collaborators: [
{
access: 'pull',
team: 'robot-1',
},
{
access: 'push',
team: 'robot-2',
},
],
},
});
expect(
mockOctokit.rest.teams.addOrUpdateRepoPermissionsInOrg.mock.calls[2],
).toEqual([
{
org: 'owner',
owner: 'owner',
repo: 'repo',
team_slug: 'robot-2',
permission: 'push',
},
]);
});
it('should add topics when provided', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
mockOctokit.rest.repos.replaceAllTopics.mockResolvedValue({
data: {
names: ['node.js'],
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
topics: ['node.js'],
},
});
expect(mockOctokit.rest.repos.replaceAllTopics).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
names: ['node.js'],
});
});
it('should lowercase topics when provided', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
mockOctokit.rest.repos.replaceAllTopics.mockResolvedValue({
data: {
names: ['backstage'],
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
topics: ['BACKSTAGE'],
},
});
expect(mockOctokit.rest.repos.replaceAllTopics).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
names: ['backstage'],
});
});
it('should call output with the remoteUrl', async () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/clone/url.git',
);
});
});
@@ -0,0 +1,337 @@
/*
* 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 { assertError, InputError } from '@backstage/errors';
import {
GithubCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { Octokit } from 'octokit';
import { createTemplateAction } from '../../createTemplateAction';
import { parseRepoUrl } from '../publish/util';
import { getOctokitOptions } from './helpers';
/**
* Creates a new action that initializes a git repository
*
* @public
*/
export function createGithubRepoCreateAction(options: {
integrations: ScmIntegrationRegistry;
githubCredentialsProvider?: GithubCredentialsProvider;
}) {
const { integrations, githubCredentialsProvider } = options;
return createTemplateAction<{
repoUrl: string;
description?: string;
access?: string;
deleteBranchOnMerge?: boolean;
gitAuthorName?: string;
gitAuthorEmail?: string;
allowRebaseMerge?: boolean;
allowSquashMerge?: boolean;
allowMergeCommit?: boolean;
requireCodeOwnerReviews?: boolean;
requiredStatusCheckContexts?: string[];
repoVisibility?: 'private' | 'internal' | 'public';
collaborators?: Array<
| {
user: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
| {
team: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
>;
token?: string;
topics?: string[];
}>({
id: 'github:repo:create',
description: 'Creates a GitHub repository.',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
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',
},
description: {
title: 'Repository Description',
type: 'string',
},
access: {
title: 'Repository Access',
description: `Sets an admin collaborator on the repository. Can either be a user reference different from 'owner' in 'repoUrl' or team reference, eg. 'org/team-name'`,
type: 'string',
},
requireCodeOwnerReviews: {
title: 'Require CODEOWNER Reviews?',
description:
'Require an approved review in PR including files with a designated Code Owner',
type: 'boolean',
},
requiredStatusCheckContexts: {
title: 'Required Status Check Contexts',
description:
'The list of status checks to require in order to merge into this branch',
type: 'array',
items: {
type: 'string',
},
},
repoVisibility: {
title: 'Repository Visibility',
type: 'string',
enum: ['private', 'public', 'internal'],
},
deleteBranchOnMerge: {
title: 'Delete Branch On Merge',
type: 'boolean',
description: `Delete the branch after merging the PR. The default value is 'false'`,
},
gitAuthorName: {
title: 'Default Author Name',
type: 'string',
description: `Sets the default author name for the commit. The default value is 'Scaffolder'`,
},
gitAuthorEmail: {
title: 'Default Author Email',
type: 'string',
description: `Sets the default author email for the commit.`,
},
allowMergeCommit: {
title: 'Allow Merge Commits',
type: 'boolean',
description: `Allow merge commits. The default value is 'true'`,
},
allowSquashMerge: {
title: 'Allow Squash Merges',
type: 'boolean',
description: `Allow squash merges. The default value is 'true'`,
},
allowRebaseMerge: {
title: 'Allow Rebase Merges',
type: 'boolean',
description: `Allow rebase merges. The default value is 'true'`,
},
collaborators: {
title: 'Collaborators',
description: 'Provide additional users or teams with permissions',
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['access'],
properties: {
access: {
type: 'string',
description: 'The type of access for the user',
enum: ['push', 'pull', 'admin', 'maintain', 'triage'],
},
user: {
type: 'string',
description:
'The name of the user that will be added as a collaborator',
},
team: {
type: 'string',
description:
'The name of the team that will be added as a collaborator',
},
},
oneOf: [{ required: ['user'] }, { required: ['team'] }],
},
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitHub',
},
topics: {
title: 'Topics',
type: 'array',
items: {
type: 'string',
},
},
},
},
output: {
type: 'object',
properties: {
remoteUrl: {
title: 'A URL to the repository with the provider',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
description,
access,
repoVisibility = 'private',
deleteBranchOnMerge = false,
allowMergeCommit = true,
allowSquashMerge = true,
allowRebaseMerge = true,
collaborators,
topics,
token: providedToken,
} = ctx.input;
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
if (!owner) {
throw new InputError('Invalid repository owner provided in repoUrl');
}
const octokitOptions = await getOctokitOptions({
integrations,
credentialsProvider: githubCredentialsProvider,
token: providedToken,
repoUrl,
});
const client = new Octokit(octokitOptions);
const user = await client.rest.users.getByUsername({
username: owner,
});
const repoCreationPromise =
user.data.type === 'Organization'
? client.rest.repos.createInOrg({
name: repo,
org: owner,
private: repoVisibility === 'private',
visibility: repoVisibility,
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
allow_merge_commit: allowMergeCommit,
allow_squash_merge: allowSquashMerge,
allow_rebase_merge: allowRebaseMerge,
})
: client.rest.repos.createForAuthenticatedUser({
name: repo,
private: repoVisibility === 'private',
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
allow_merge_commit: allowMergeCommit,
allow_squash_merge: allowSquashMerge,
allow_rebase_merge: allowRebaseMerge,
});
let newRepo;
try {
newRepo = (await repoCreationPromise).data;
} catch (e) {
assertError(e);
if (e.message === 'Resource not accessible by integration') {
ctx.logger.warn(
`The GitHub app or token provided may not have the required permissions to create the ${user.data.type} repository ${owner}/${repo}.`,
);
}
throw new Error(
`Failed to create the ${user.data.type} repository ${owner}/${repo}, ${e.message}`,
);
}
if (access?.startsWith(`${owner}/`)) {
const [, team] = access.split('/');
await client.rest.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: team,
owner,
repo,
permission: 'admin',
});
// No need to add access if it's the person who owns the personal account
} else if (access && access !== owner) {
await client.rest.repos.addCollaborator({
owner,
repo,
username: access,
permission: 'admin',
});
}
if (collaborators) {
for (const collaborator of collaborators) {
try {
if ('user' in collaborator) {
await client.rest.repos.addCollaborator({
owner,
repo,
username: collaborator.user,
permission: collaborator.access,
});
} else if ('team' in collaborator) {
await client.rest.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: collaborator.team,
owner,
repo,
permission: collaborator.access,
});
}
} catch (e) {
assertError(e);
const name = extractCollaboratorName(collaborator);
ctx.logger.warn(
`Skipping ${collaborator.access} access for ${name}, ${e.message}`,
);
}
}
}
if (topics) {
try {
await client.rest.repos.replaceAllTopics({
owner,
repo,
names: topics.map(t => t.toLowerCase()),
});
} catch (e) {
assertError(e);
ctx.logger.warn(`Skipping topics ${topics.join(' ')}, ${e.message}`);
}
}
const remoteUrl = newRepo.clone_url;
ctx.output('remoteUrl', remoteUrl);
},
});
}
function extractCollaboratorName(
collaborator: { user: string } | { team: string } | { username: string },
) {
if ('username' in collaborator) return collaborator.username;
if ('user' in collaborator) return collaborator.user;
return collaborator.team;
}
@@ -0,0 +1,400 @@
/*
* 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 { TemplateAction } from '../../types';
jest.mock('../helpers');
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import {
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { PassThrough } from 'stream';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from '../helpers';
import { createGithubRepoPushAction } from './githubRepoPush';
const mockOctokit = {
rest: {
repos: {
get: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
return mockOctokit;
}
},
}));
describe('github:repo:push', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
let githubCredentialsProvider: GithubCredentialsProvider;
let action: TemplateAction<any>;
const mockContext = {
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
description: 'description',
repoVisibility: 'private' as const,
access: 'owner/blam',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
githubCredentialsProvider =
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
action = createGithubRepoPushAction({
integrations,
config,
githubCredentialsProvider,
});
});
it('should call initRepoAndPush with the correct values', async () => {
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://github.com/clone/url.git',
defaultBranch: 'master',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
commitMessage: 'initial commit',
gitAuthorInfo: {},
});
});
it('should call initRepoAndPush with the correct defaultBranch main', async () => {
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://github.com/clone/url.git',
defaultBranch: 'main',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
commitMessage: 'initial commit',
gitAuthorInfo: {},
});
});
it('should call initRepoAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations =
ScmIntegrations.fromConfig(customAuthorConfig);
const customAuthorAction = createGithubRepoPushAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
githubCredentialsProvider,
});
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://github.com/clone/url.git',
defaultBranch: 'master',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
commitMessage: 'initial commit',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
it('should call initRepoAndPush with the configured defaultCommitMessage', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
scaffolder: {
defaultCommitMessage: 'Test commit message',
},
});
const customAuthorIntegrations =
ScmIntegrations.fromConfig(customAuthorConfig);
const customAuthorAction = createGithubRepoPushAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
githubCredentialsProvider,
});
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://github.com/clone/url.git',
defaultBranch: 'master',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
commitMessage: 'initial commit',
gitAuthorInfo: { email: undefined, name: undefined },
});
});
it('should call output with the remoteUrl and the repoContentsUrl', async () => {
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/clone/url.git',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://github.com/html/url/blob/master',
);
});
it('should use main as default branch', async () => {
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/clone/url.git',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://github.com/html/url/blob/main',
);
});
it('should call enableBranchProtectionOnDefaultRepoBranch with the correct values of requireCodeOwnerReviews', async () => {
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler(mockContext);
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
requiredStatusCheckContexts: [],
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
requireCodeOwnerReviews: true,
},
});
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: true,
requiredStatusCheckContexts: [],
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
requireCodeOwnerReviews: false,
},
});
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
requiredStatusCheckContexts: [],
});
});
it('should call enableBranchProtectionOnDefaultRepoBranch with the correct values of requiredStatusCheckContexts', async () => {
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler(mockContext);
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
requiredStatusCheckContexts: [],
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
requiredStatusCheckContexts: ['statusCheck'],
},
});
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
requiredStatusCheckContexts: ['statusCheck'],
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
requiredStatusCheckContexts: [],
},
});
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
requiredStatusCheckContexts: [],
});
});
it('should not call enableBranchProtectionOnDefaultRepoBranch with protectDefaultBranch disabled', async () => {
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
clone_url: 'https://github.com/clone/url.git',
html_url: 'https://github.com/html/url',
},
});
await action.handler({
...mockContext,
input: {
...mockContext.input,
protectDefaultBranch: false,
},
});
expect(enableBranchProtectionOnDefaultRepoBranch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,224 @@
/*
* 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 { Config } from '@backstage/config';
import { assertError, InputError } from '@backstage/errors';
import {
GithubCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { Octokit } from 'octokit';
import { createTemplateAction } from '../../createTemplateAction';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from '../helpers';
import { getRepoSourceDirectory, parseRepoUrl } from '../publish/util';
import { getOctokitOptions } from './helpers';
/**
* Creates a new action that initializes a git repository of the content in the workspace
* and publishes it to GitHub.
*
* @public
*/
export function createGithubRepoPushAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
githubCredentialsProvider?: GithubCredentialsProvider;
}) {
const { integrations, config, githubCredentialsProvider } = options;
return createTemplateAction<{
repoUrl: string;
description?: string;
defaultBranch?: string;
protectDefaultBranch?: boolean;
gitCommitMessage?: string;
gitAuthorName?: string;
gitAuthorEmail?: string;
requireCodeOwnerReviews?: boolean;
requiredStatusCheckContexts?: string[];
sourcePath?: string;
token?: string;
}>({
id: 'github:repo:push',
description:
'Initializes a git repository of contents in workspace and publishes it to GitHub.',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
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',
},
requireCodeOwnerReviews: {
title: 'Require CODEOWNER Reviews?',
description:
'Require an approved review in PR including files with a designated Code Owner',
type: 'boolean',
},
requiredStatusCheckContexts: {
title: 'Required Status Check Contexts',
description:
'The list of status checks to require in order to merge into this branch',
type: 'array',
items: {
type: 'string',
},
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
protectDefaultBranch: {
title: 'Protect Default Branch',
type: 'boolean',
description: `Protect the default branch after creating the repository. The default value is 'true'`,
},
gitCommitMessage: {
title: 'Git Commit Message',
type: 'string',
description: `Sets the commit message on the repository. The default value is 'initial commit'`,
},
gitAuthorName: {
title: 'Default Author Name',
type: 'string',
description: `Sets the default author name for the commit. The default value is 'Scaffolder'`,
},
gitAuthorEmail: {
title: 'Default Author Email',
type: 'string',
description: `Sets the default author email for the commit.`,
},
sourcePath: {
title: 'Source Path',
description:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
type: 'string',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitHub',
},
topics: {
title: 'Topics',
type: 'array',
items: {
type: 'string',
},
},
},
},
output: {
type: 'object',
properties: {
remoteUrl: {
title: 'A URL to the repository with the provider',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
defaultBranch = 'master',
protectDefaultBranch = true,
gitCommitMessage = 'initial commit',
gitAuthorName,
gitAuthorEmail,
requireCodeOwnerReviews = false,
requiredStatusCheckContexts = [],
token: providedToken,
} = ctx.input;
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
if (!owner) {
throw new InputError('Invalid repository owner provided in repoUrl');
}
const octokitOptions = await getOctokitOptions({
integrations,
credentialsProvider: githubCredentialsProvider,
token: providedToken,
repoUrl,
});
const client = new Octokit(octokitOptions);
const targetRepo = await client.rest.repos.get({ owner, repo });
const remoteUrl = targetRepo.data.clone_url;
const repoContentsUrl = `${targetRepo.data.html_url}/blob/${defaultBranch}`;
const gitAuthorInfo = {
name: gitAuthorName
? gitAuthorName
: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: gitAuthorEmail
? gitAuthorEmail
: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
defaultBranch,
auth: {
username: 'x-access-token',
password: octokitOptions.auth,
},
logger: ctx.logger,
commitMessage: gitCommitMessage
? gitCommitMessage
: config.getOptionalString('scaffolder.defaultCommitMessage'),
gitAuthorInfo,
});
if (protectDefaultBranch) {
try {
await enableBranchProtectionOnDefaultRepoBranch({
owner,
client,
repoName: repo,
logger: ctx.logger,
defaultBranch,
requireCodeOwnerReviews,
requiredStatusCheckContexts,
});
} catch (e) {
assertError(e);
ctx.logger.warn(
`Skipping: default branch protection on '${repo}', ${e.message}`,
);
}
}
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
@@ -15,5 +15,7 @@
*/
export { createGithubActionsDispatchAction } from './githubActionsDispatch';
export { createGithubWebhookAction } from './githubWebhook';
export { createGithubIssuesLabelAction } from './githubIssuesLabel';
export { createGithubRepoCreateAction } from './githubRepoCreate';
export { createGithubRepoPushAction } from './githubRepoPush';
export { createGithubWebhookAction } from './githubWebhook';