Merge pull request #6275 from backstage/mob/scaffolder-git-identity

This commit is contained in:
Himanshu Mishra
2021-07-07 11:19:44 +02:00
committed by GitHub
15 changed files with 368 additions and 68 deletions
+24
View File
@@ -0,0 +1,24 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Adding `config: Config` as a required argument to `createBuiltinActions` and downstream methods in order to support configuration of the default git author used for Scaffolder commits.
The affected methods are:
- `createBuiltinActions`
- `createPublishGithubAction`
- `createPublishGitlabAction`
- `createPublishBitbucketAction`
- `createPublishAzureAction`
Call sites to these methods will need to be migrated to include the new `config` argument. See `createRouter` in `plugins/scaffolder-backend/src/service/router.ts` for an example of adding this new argument.
To configure the default git author, use the `defaultAuthor` key under `scaffolder` in `app-config.yaml`:
```yaml
scaffolder:
defaultAuthor:
name: Example
email: example@example.com
```
+4
View File
@@ -250,6 +250,10 @@ catalog:
target: ../catalog-model/examples/acme-corp.yaml
scaffolder:
# Use to customize default commit author info used when new components are created
# defaultAuthor:
# name: Scaffolder
# email: scaffolder@backstage.io
github:
token: ${GITHUB_TOKEN}
visibility: public # or 'internal' or 'private'
+5
View File
@@ -116,6 +116,7 @@ export const createBuiltinActions: (options: {
integrations: ScmIntegrations;
catalogClient: CatalogApi;
templaters: TemplaterBuilder;
config: Config;
}) => TemplateAction<any>[];
// @public (undocumented)
@@ -155,11 +156,13 @@ export function createLegacyActions(options: Options): TemplateAction<any>[];
// @public (undocumented)
export function createPublishAzureAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public (undocumented)
export function createPublishBitbucketAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public
@@ -168,6 +171,7 @@ export function createPublishFileAction(): TemplateAction<any>;
// @public (undocumented)
export function createPublishGithubAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public (undocumented)
@@ -176,6 +180,7 @@ export const createPublishGithubPullRequestAction: ({ integrations, clientFactor
// @public (undocumented)
export function createPublishGitlabAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<any>;
// @public (undocumented)
+7
View File
@@ -17,6 +17,13 @@
export interface Config {
/** Configuration options for the scaffolder plugin */
scaffolder?: {
/**
* The commit author info used when new components are created.
*/
defaultAuthor?: {
name?: string;
email?: string;
};
github?: {
[key: string]: string;
/**
@@ -17,6 +17,7 @@
import { UrlReader } from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import { ScmIntegrations } from '@backstage/integration';
import { Config } from '@backstage/config';
import { TemplaterBuilder } from '../../stages';
import {
createCatalogRegisterAction,
@@ -41,8 +42,9 @@ export const createBuiltinActions = (options: {
integrations: ScmIntegrations;
catalogClient: CatalogApi;
templaters: TemplaterBuilder;
config: Config;
}) => {
const { reader, integrations, templaters, catalogClient } = options;
const { reader, integrations, templaters, catalogClient, config } = options;
return [
createFetchPlainAction({
@@ -56,18 +58,22 @@ export const createBuiltinActions = (options: {
}),
createPublishGithubAction({
integrations,
config,
}),
createPublishGithubPullRequestAction({
integrations,
}),
createPublishGitlabAction({
integrations,
config,
}),
createPublishBitbucketAction({
integrations,
config,
}),
createPublishAzureAction({
integrations,
config,
}),
createDebugLogAction(),
createCatalogRegisterAction({ catalogClient, integrations }),
@@ -28,17 +28,17 @@ import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
describe('publish:azure', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{ host: 'myazurehostnotoken.com' },
],
},
}),
);
const action = createPublishAzureAction({ integrations });
const config = new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{ host: 'myazurehostnotoken.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishAzureAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'dev.azure.com?repo=repo&owner=owner&organization=org',
@@ -165,6 +165,7 @@ describe('publish:azure', () => {
defaultBranch: 'master',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -187,6 +188,47 @@ describe('publish:azure', () => {
defaultBranch: 'master',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initRepoAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
azure: [
{ host: 'dev.azure.com', token: 'tokenlols' },
{ host: 'myazurehostnotoken.com' },
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations = ScmIntegrations.fromConfig(
customAuthorConfig,
);
const customAuthorAction = createPublishAzureAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
mockGitClient.createRepository.mockImplementation(() => ({
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
}));
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://dev.azure.com/organization/project/_git/repo',
auth: { username: 'notempty', password: 'tokenlols' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -21,11 +21,13 @@ import { GitRepositoryCreateOptions } from 'azure-devops-node-api/interfaces/Git
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
export function createPublishAzureAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
@@ -123,6 +125,11 @@ export function createPublishAzureAction(options: {
// so it's just the base path I think
const repoContentsUrl = remoteUrl;
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
@@ -132,6 +139,7 @@ export function createPublishAzureAction(options: {
password: integrationConfig.config.token,
},
logger: ctx.logger,
gitAuthorInfo,
});
ctx.output('remoteUrl', remoteUrl);
@@ -26,27 +26,27 @@ import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
describe('publish:bitbucket', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
}),
);
const action = createPublishBitbucketAction({ integrations });
const config = new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishBitbucketAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'bitbucket.org?repo=repo&owner=owner',
@@ -289,6 +289,7 @@ describe('publish:bitbucket', () => {
defaultBranch: 'master',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -331,6 +332,77 @@ describe('publish:bitbucket', () => {
defaultBranch: 'main',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
bitbucket: [
{
host: 'bitbucket.org',
token: 'tokenlols',
},
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations = ScmIntegrations.fromConfig(
customAuthorConfig,
);
const customAuthorAction = createPublishBitbucketAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/owner/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/owner/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/owner/cloneurl',
},
],
},
}),
),
),
);
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/owner/cloneurl',
auth: { username: 'x-token-auth', password: 'tokenlols' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -23,6 +23,7 @@ import fetch from 'cross-fetch';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { createTemplateAction } from '../../createTemplateAction';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { Config } from '@backstage/config';
const createBitbucketCloudRepository = async (opts: {
owner: string;
@@ -184,8 +185,9 @@ const performEnableLFS = async (opts: {
export function createPublishBitbucketAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
@@ -284,6 +286,11 @@ export function createPublishBitbucketAction(options: {
apiBaseUrl,
});
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
@@ -297,6 +304,7 @@ export function createPublishBitbucketAction(options: {
},
defaultBranch,
logger: ctx.logger,
gitAuthorInfo,
});
if (enableLFS && host !== 'bitbucket.org') {
@@ -25,17 +25,17 @@ import { initRepoAndPush } from '../../../stages/publish/helpers';
import { when } from 'jest-when';
describe('publish:github', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
}),
);
const action = createPublishGithubAction({ integrations });
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishGithubAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'github.com?repo=repo&owner=owner',
@@ -178,6 +178,7 @@ describe('publish:github', () => {
defaultBranch: 'master',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -207,6 +208,54 @@ describe('publish:github', () => {
defaultBranch: 'main',
auth: { username: 'x-access-token', password: 'tokenlols' },
logger: mockContext.logger,
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 = createPublishGithubAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
mockGithubClient.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
});
mockGithubClient.repos.createForAuthenticatedUser.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,
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -25,14 +25,16 @@ import {
} from '../../../stages/publish/helpers';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
type Permission = 'pull' | 'push' | 'admin' | 'maintain' | 'triage';
type Collaborator = { access: Permission; username: string };
export function createPublishGithubAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
const credentialsProviders = new Map(
integrations.github.list().map(integration => {
@@ -248,6 +250,11 @@ export function createPublishGithubAction(options: {
const remoteUrl = newRepo.clone_url;
const repoContentsUrl = `${newRepo.html_url}/blob/${defaultBranch}`;
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
@@ -257,6 +264,7 @@ export function createPublishGithubAction(options: {
password: token,
},
logger: ctx.logger,
gitAuthorInfo,
});
try {
@@ -24,24 +24,24 @@ import { PassThrough } from 'stream';
import { initRepoAndPush } from '../../../stages/publish/helpers';
describe('publish:gitlab', () => {
const integrations = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
{
host: 'hosted.gitlab.com',
apiBaseUrl: 'https://api.hosted.gitlab.com',
},
],
},
}),
);
const action = createPublishGitlabAction({ integrations });
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
{
host: 'hosted.gitlab.com',
apiBaseUrl: 'https://api.hosted.gitlab.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishGitlabAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
@@ -143,6 +143,7 @@ describe('publish:gitlab', () => {
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
@@ -166,6 +167,55 @@ describe('publish:gitlab', () => {
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initRepoAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
{
host: 'hosted.gitlab.com',
apiBaseUrl: 'https://api.hosted.gitlab.com',
},
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations = ScmIntegrations.fromConfig(
customAuthorConfig,
);
const customAuthorAction = createPublishGitlabAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 1234 });
mockGitlabClient.Projects.create.mockResolvedValue({
http_url_to_repo: 'http://mockurl.git',
});
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'http://mockurl.git',
auth: { username: 'oauth2', password: 'tokenlols' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
@@ -20,11 +20,13 @@ import { Gitlab } from '@gitbeaker/node';
import { initRepoAndPush } from '../../../stages/publish/helpers';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { createTemplateAction } from '../../createTemplateAction';
import { Config } from '@backstage/config';
export function createPublishGitlabAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
@@ -121,6 +123,11 @@ export function createPublishGitlabAction(options: {
const remoteUrl = (http_url_to_repo as string).replace(/\.git$/, '');
const repoContentsUrl = `${remoteUrl}/-/blob/master`;
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl: http_url_to_repo as string,
@@ -130,6 +137,7 @@ export function createPublishGitlabAction(options: {
password: integrationConfig.config.token,
},
logger: ctx.logger,
gitAuthorInfo,
});
ctx.output('remoteUrl', remoteUrl);
@@ -25,12 +25,14 @@ export async function initRepoAndPush({
auth,
logger,
defaultBranch = 'master',
gitAuthorInfo,
}: {
dir: string;
remoteUrl: string;
auth: { username: string; password: string };
logger: Logger;
defaultBranch?: string;
gitAuthorInfo?: { name?: string; email?: string };
}): Promise<void> {
const git = Git.fromAuth({
username: auth.username,
@@ -53,11 +55,17 @@ export async function initRepoAndPush({
await git.add({ dir, filepath });
}
// use provided info if possible, otherwise use fallbacks
const authorInfo = {
name: gitAuthorInfo?.name ?? 'Scaffolder',
email: gitAuthorInfo?.email ?? 'scaffolder@backstage.io',
};
await git.commit({
dir,
message: 'Initial commit',
author: { name: 'Scaffolder', email: 'scaffolder@backstage.io' },
committer: { name: 'Scaffolder', email: 'scaffolder@backstage.io' },
author: authorInfo,
committer: authorInfo,
});
await git.addRemote({
@@ -129,6 +129,7 @@ export async function createRouter(
catalogClient,
templaters,
reader,
config,
}),
];