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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Added two new scaffolder actions: `github:repo:create` and `github:repo:push`
|
||||
@@ -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>[];
|
||||
|
||||
+416
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
+400
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user