Merge pull request #12127 from getndazn/feat/github-repo-create-push-actions

feat: scaffolder actions github:repo:create gitub:repo:push
This commit is contained in:
Ben Lambert
2022-06-29 15:29:03 +02:00
committed by GitHub
14 changed files with 1701 additions and 358 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Added two new scaffolder actions: `github:repo:create` and `github:repo:push`
+56
View File
@@ -168,6 +168,62 @@ export type CreateGithubPullRequestClientFactoryInput = {
token?: string;
};
// @public
export function createGithubRepoCreateAction(options: {
integrations: ScmIntegrationRegistry;
githubCredentialsProvider?: GithubCredentialsProvider;
}): TemplateAction<{
repoUrl: string;
description?: string | undefined;
access?: string | undefined;
deleteBranchOnMerge?: boolean | undefined;
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
allowRebaseMerge?: boolean | undefined;
allowSquashMerge?: boolean | undefined;
allowMergeCommit?: boolean | undefined;
requireCodeOwnerReviews?: boolean | undefined;
requiredStatusCheckContexts?: string[] | undefined;
repoVisibility?: 'internal' | 'private' | 'public' | undefined;
collaborators?:
| (
| {
user: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
| {
team: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
| {
username: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
)[]
| undefined;
token?: string | undefined;
topics?: string[] | undefined;
}>;
// @public
export function createGithubRepoPushAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
githubCredentialsProvider?: GithubCredentialsProvider;
}): TemplateAction<{
repoUrl: string;
description?: string | undefined;
defaultBranch?: string | undefined;
protectDefaultBranch?: boolean | undefined;
gitCommitMessage?: string | undefined;
gitAuthorName?: string | undefined;
gitAuthorEmail?: string | undefined;
requireCodeOwnerReviews?: boolean | undefined;
requiredStatusCheckContexts?: string[] | undefined;
sourcePath?: string | undefined;
token?: string | undefined;
}>;
// @public
export function createGithubWebhookAction(options: {
integrations: ScmIntegrationRegistry;
@@ -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,151 @@
/*
* 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 {
GithubCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { Octokit } from 'octokit';
import { createTemplateAction } from '../../createTemplateAction';
import { parseRepoUrl } from '../publish/util';
import {
createGithubRepoWithCollaboratorsAndTopics,
getOctokitOptions,
} from './helpers';
import * as inputProps from './inputProperties';
import * as outputProps from './outputProperties';
/**
* 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';
}
| {
/** @deprecated This field is deprecated in favor of team */
username: 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: inputProps.repoUrl,
description: inputProps.description,
access: inputProps.access,
requireCodeOwnerReviews: inputProps.requireCodeOwnerReviews,
requiredStatusCheckContexts: inputProps.requiredStatusCheckContexts,
repoVisibility: inputProps.repoVisibility,
deleteBranchOnMerge: inputProps.deleteBranchOnMerge,
allowMergeCommit: inputProps.allowMergeCommit,
allowSquashMerge: inputProps.allowSquashMerge,
allowRebaseMerge: inputProps.allowRebaseMerge,
collaborators: inputProps.collaborators,
token: inputProps.token,
topics: inputProps.topics,
},
},
output: {
type: 'object',
properties: {
remoteUrl: outputProps.remoteUrl,
repoContentsUrl: outputProps.repoContentsUrl,
},
},
},
async handler(ctx) {
const {
repoUrl,
description,
access,
repoVisibility = 'private',
deleteBranchOnMerge = false,
allowMergeCommit = true,
allowSquashMerge = true,
allowRebaseMerge = true,
collaborators,
topics,
token: providedToken,
} = ctx.input;
const octokitOptions = await getOctokitOptions({
integrations,
credentialsProvider: githubCredentialsProvider,
token: providedToken,
repoUrl: repoUrl,
});
const client = new Octokit(octokitOptions);
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
if (!owner) {
throw new InputError('Invalid repository owner provided in repoUrl');
}
const newRepo = await createGithubRepoWithCollaboratorsAndTopics(
client,
repo,
owner,
repoVisibility,
description,
deleteBranchOnMerge,
allowMergeCommit,
allowSquashMerge,
allowRebaseMerge,
access,
collaborators,
topics,
ctx.logger,
);
ctx.output('remoteUrl', newRepo.clone_url);
},
});
}
@@ -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,139 @@
/*
* 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 { 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, initRepoPushAndProtect } from './helpers';
import * as inputProps from './inputProperties';
import * as outputProps from './outputProperties';
/**
* 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: inputProps.repoUrl,
requireCodeOwnerReviews: inputProps.requireCodeOwnerReviews,
requiredStatusCheckContexts: inputProps.requiredStatusCheckContexts,
defaultBranch: inputProps.defaultBranch,
protectDefaultBranch: inputProps.protectDefaultBranch,
gitCommitMessage: inputProps.gitCommitMessage,
gitAuthorName: inputProps.gitAuthorName,
gitAuthorEmail: inputProps.gitAuthorEmail,
sourcePath: inputProps.sourcePath,
token: inputProps.token,
},
},
output: {
type: 'object',
properties: {
remoteUrl: outputProps.remoteUrl,
repoContentsUrl: outputProps.repoContentsUrl,
},
},
},
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}`;
await initRepoPushAndProtect(
remoteUrl,
octokitOptions.auth,
ctx.workspacePath,
ctx.input.sourcePath,
defaultBranch,
protectDefaultBranch,
owner,
client,
repo,
requireCodeOwnerReviews,
requiredStatusCheckContexts,
config,
ctx.logger,
gitCommitMessage,
gitAuthorName,
gitAuthorEmail,
);
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
@@ -13,14 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import { Config } from '@backstage/config';
import { assertError, InputError } from '@backstage/errors';
import {
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { OctokitOptions } from '@octokit/core/dist-types/types';
import { parseRepoUrl } from '../publish/util';
import { Octokit } from 'octokit';
import { Logger } from 'winston';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from '../helpers';
import { getRepoSourceDirectory, parseRepoUrl } from '../publish/util';
const DEFAULT_TIMEOUT_MS = 60_000;
@@ -83,3 +90,213 @@ export async function getOctokitOptions(options: {
previews: ['nebula-preview'],
};
}
export async function createGithubRepoWithCollaboratorsAndTopics(
client: Octokit,
repo: string,
owner: string,
repoVisibility: 'private' | 'internal' | 'public',
description: string | undefined,
deleteBranchOnMerge: boolean,
allowMergeCommit: boolean,
allowSquashMerge: boolean,
allowRebaseMerge: boolean,
access: string | undefined,
collaborators:
| (
| {
user: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
| {
team: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
| {
/** @deprecated This field is deprecated in favor of team */
username: string;
access: 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
}
)[]
| undefined,
topics: string[] | undefined,
logger: Logger,
) {
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') {
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);
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);
logger.warn(`Skipping topics ${topics.join(' ')}, ${e.message}`);
}
}
return newRepo;
}
export async function initRepoPushAndProtect(
remoteUrl: string,
password: string,
workspacePath: string,
sourcePath: string | undefined,
defaultBranch: string,
protectDefaultBranch: boolean,
owner: string,
client: Octokit,
repo: string,
requireCodeOwnerReviews: boolean,
requiredStatusCheckContexts: string[],
config: Config,
logger: any,
gitCommitMessage?: string,
gitAuthorName?: string,
gitAuthorEmail?: string,
) {
const gitAuthorInfo = {
name: gitAuthorName
? gitAuthorName
: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: gitAuthorEmail
? gitAuthorEmail
: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
const commitMessage = gitCommitMessage
? gitCommitMessage
: config.getOptionalString('scaffolder.defaultCommitMessage');
await initRepoAndPush({
dir: getRepoSourceDirectory(workspacePath, sourcePath),
remoteUrl,
defaultBranch,
auth: {
username: 'x-access-token',
password,
},
logger,
commitMessage,
gitAuthorInfo,
});
if (protectDefaultBranch) {
try {
await enableBranchProtectionOnDefaultRepoBranch({
owner,
client,
repoName: repo,
logger,
defaultBranch,
requireCodeOwnerReviews,
requiredStatusCheckContexts,
});
} catch (e) {
assertError(e);
logger.warn(
`Skipping: default branch protection on '${repo}', ${e.message}`,
);
}
}
}
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;
}
@@ -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';
@@ -0,0 +1,161 @@
/*
* 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.
*/
const 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',
};
const description = {
title: 'Repository Description',
type: 'string',
};
const 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',
};
const requireCodeOwnerReviews = {
title: 'Require CODEOWNER Reviews?',
description:
'Require an approved review in PR including files with a designated Code Owner',
type: 'boolean',
};
const 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',
},
};
const repoVisibility = {
title: 'Repository Visibility',
type: 'string',
enum: ['private', 'public', 'internal'],
};
const deleteBranchOnMerge = {
title: 'Delete Branch On Merge',
type: 'boolean',
description: `Delete the branch after merging the PR. The default value is 'false'`,
};
const gitAuthorName = {
title: 'Default Author Name',
type: 'string',
description: `Sets the default author name for the commit. The default value is 'Scaffolder'`,
};
const gitAuthorEmail = {
title: 'Default Author Email',
type: 'string',
description: `Sets the default author email for the commit.`,
};
const allowMergeCommit = {
title: 'Allow Merge Commits',
type: 'boolean',
description: `Allow merge commits. The default value is 'true'`,
};
const allowSquashMerge = {
title: 'Allow Squash Merges',
type: 'boolean',
description: `Allow squash merges. The default value is 'true'`,
};
const allowRebaseMerge = {
title: 'Allow Rebase Merges',
type: 'boolean',
description: `Allow rebase merges. The default value is 'true'`,
};
const 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'] }],
},
};
const token = {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitHub',
};
const topics = {
title: 'Topics',
type: 'array',
items: {
type: 'string',
},
};
const defaultBranch = {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
};
const protectDefaultBranch = {
title: 'Protect Default Branch',
type: 'boolean',
description: `Protect the default branch after creating the repository. The default value is 'true'`,
};
const gitCommitMessage = {
title: 'Git Commit Message',
type: 'string',
description: `Sets the commit message on the repository. The default value is 'initial commit'`,
};
const 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',
};
export { access };
export { allowMergeCommit };
export { allowRebaseMerge };
export { allowSquashMerge };
export { collaborators };
export { defaultBranch };
export { deleteBranchOnMerge };
export { description };
export { gitAuthorEmail };
export { gitAuthorName };
export { gitCommitMessage };
export { protectDefaultBranch };
export { repoUrl };
export { repoVisibility };
export { requireCodeOwnerReviews };
export { requiredStatusCheckContexts };
export { sourcePath };
export { token };
export { topics };
@@ -0,0 +1,27 @@
/*
* 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.
*/
const remoteUrl = {
title: 'A URL to the repository with the provider',
type: 'string',
};
const repoContentsUrl = {
title: 'A URL to the root of the repository',
type: 'string',
};
export { remoteUrl };
export { repoContentsUrl };
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import { SpawnOptionsWithoutStdio, spawn } from 'child_process';
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';
import { Git } from '@backstage/backend-common';
import { Octokit } from 'octokit';
import { assertError } from '@backstage/errors';
/** @public */
export type RunCommandOptions = {
@@ -200,3 +201,12 @@ export const enableBranchProtectionOnDefaultRepoBranch = async ({
await tryOnce();
}
};
export function getGitCommitMessage(
gitCommitMessage: string | undefined,
config: Config,
): string | undefined {
return gitCommitMessage
? gitCommitMessage
: config.getOptionalString('scaffolder.defaultCommitMessage');
}
@@ -18,20 +18,20 @@ import { TemplateAction } from '../../types';
jest.mock('../helpers');
import { createPublishGithubAction } from './github';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import {
ScmIntegrations,
DefaultGithubCredentialsProvider,
GithubCredentialsProvider,
ScmIntegrations,
} from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { when } from 'jest-when';
import { PassThrough } from 'stream';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from '../helpers';
import { when } from 'jest-when';
import { createPublishGithubAction } from './github';
const mockOctokit = {
rest: {
@@ -609,7 +609,7 @@ describe('publish:github', () => {
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
name: 'repository',
name: 'repo',
},
});
@@ -618,7 +618,7 @@ describe('publish:github', () => {
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
repoName: 'repo',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
@@ -636,7 +636,7 @@ describe('publish:github', () => {
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
repoName: 'repo',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: true,
@@ -654,7 +654,7 @@ describe('publish:github', () => {
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
repoName: 'repo',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
@@ -669,7 +669,7 @@ describe('publish:github', () => {
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
name: 'repository',
name: 'repo',
},
});
@@ -678,7 +678,7 @@ describe('publish:github', () => {
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
repoName: 'repo',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
@@ -696,7 +696,7 @@ describe('publish:github', () => {
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
repoName: 'repo',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
@@ -714,7 +714,7 @@ describe('publish:github', () => {
expect(enableBranchProtectionOnDefaultRepoBranch).toHaveBeenCalledWith({
owner: 'owner',
client: mockOctokit,
repoName: 'repository',
repoName: 'repo',
logger: mockContext.logger,
defaultBranch: 'master',
requireCodeOwnerReviews: false,
@@ -729,7 +729,7 @@ describe('publish:github', () => {
mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({
data: {
name: 'repository',
name: 'repo',
},
});
@@ -13,21 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import {
GithubCredentialsProvider,
ScmIntegrationRegistry,
} from '@backstage/integration';
import {
enableBranchProtectionOnDefaultRepoBranch,
initRepoAndPush,
} from '../helpers';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
import { assertError, InputError } from '@backstage/errors';
import { getOctokitOptions } from '../github/helpers';
import { Octokit } from 'octokit';
import { createTemplateAction } from '../../createTemplateAction';
import {
createGithubRepoWithCollaboratorsAndTopics,
getOctokitOptions,
initRepoPushAndProtect,
} from '../github/helpers';
import * as inputProps from '../github/inputProperties';
import * as outputProps from '../github/outputProperties';
import { parseRepoUrl } from './util';
/**
* Creates a new action that initializes a git repository of the content in the workspace
* and publishes it to GitHub.
@@ -84,153 +85,32 @@ export function createPublishGithubAction(options: {
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'],
},
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'`,
},
deleteBranchOnMerge: {
title: 'Delete Branch On Merge',
type: 'boolean',
description: `Delete the branch after merging the PR. The default value is 'false'`,
},
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.`,
},
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'`,
},
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',
},
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',
},
username: {
type: 'string',
description:
'Deprecated. Use the `team` or `user` field instead.',
},
team: {
type: 'string',
description:
'The name of the team that will be added as a collaborator',
},
},
oneOf: [
{ required: ['user'] },
{ required: ['username'] },
{ 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',
},
},
repoUrl: inputProps.repoUrl,
description: inputProps.description,
access: inputProps.access,
requireCodeOwnerReviews: inputProps.requireCodeOwnerReviews,
requiredStatusCheckContexts: inputProps.requiredStatusCheckContexts,
repoVisibility: inputProps.repoVisibility,
defaultBranch: inputProps.defaultBranch,
protectDefaultBranch: inputProps.protectDefaultBranch,
deleteBranchOnMerge: inputProps.deleteBranchOnMerge,
gitCommitMessage: inputProps.gitCommitMessage,
gitAuthorName: inputProps.gitAuthorName,
gitAuthorEmail: inputProps.gitAuthorEmail,
allowMergeCommit: inputProps.allowMergeCommit,
allowSquashMerge: inputProps.allowSquashMerge,
allowRebaseMerge: inputProps.allowRebaseMerge,
sourcePath: inputProps.sourcePath,
collaborators: inputProps.collaborators,
token: inputProps.token,
topics: inputProps.topics,
},
},
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',
},
remoteUrl: outputProps.remoteUrl,
repoContentsUrl: outputProps.repoContentsUrl,
},
},
},
@@ -256,192 +136,60 @@ export function createPublishGithubAction(options: {
token: providedToken,
} = ctx.input;
const octokitOptions = await getOctokitOptions({
integrations,
credentialsProvider: githubCredentialsProvider,
token: providedToken,
repoUrl: repoUrl,
});
const client = new Octokit(octokitOptions);
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 ('username' in collaborator) {
ctx.logger.warn(
'The field `username` is deprecated in favor of `team` and will be removed in the future.',
);
await client.rest.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: collaborator.username,
owner,
repo,
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 newRepo = await createGithubRepoWithCollaboratorsAndTopics(
client,
repo,
owner,
repoVisibility,
description,
deleteBranchOnMerge,
allowMergeCommit,
allowSquashMerge,
allowRebaseMerge,
access,
collaborators,
topics,
ctx.logger,
);
const remoteUrl = newRepo.clone_url;
const repoContentsUrl = `${newRepo.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),
await initRepoPushAndProtect(
remoteUrl,
octokitOptions.auth,
ctx.workspacePath,
ctx.input.sourcePath,
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: newRepo.name,
logger: ctx.logger,
defaultBranch,
requireCodeOwnerReviews,
requiredStatusCheckContexts,
});
} catch (e) {
assertError(e);
ctx.logger.warn(
`Skipping: default branch protection on '${newRepo.name}', ${e.message}`,
);
}
}
protectDefaultBranch,
owner,
client,
repo,
requireCodeOwnerReviews,
requiredStatusCheckContexts,
config,
ctx.logger,
gitCommitMessage,
gitAuthorName,
gitAuthorEmail,
);
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
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;
}