Merge pull request #26688 from backstage/gh-envs-support-more-params
[Scaffolder] Github env create action to support more params from the Github env api
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-github': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Added support for all request parameters in the Github create/update environment API in the Github environment create scaffolder action.
|
||||
|
||||
Disable MultiEntityPicker when `maxItems` limit is reached defined in `JSONSchema`
|
||||
@@ -263,4 +263,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
'testing-library/custom-queries': 'off',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Config } from '@backstage/config';
|
||||
import { createPullRequest } from 'octokit-plugin-create-pull-request';
|
||||
import { GithubCredentialsProvider } from '@backstage/integration';
|
||||
@@ -66,6 +67,7 @@ export function createGithubDeployKeyAction(options: {
|
||||
// @public
|
||||
export function createGithubEnvironmentAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
catalogClient?: CatalogApi;
|
||||
}): TemplateAction<
|
||||
{
|
||||
repoUrl: string;
|
||||
@@ -89,6 +91,9 @@ export function createGithubEnvironmentAction(options: {
|
||||
}
|
||||
| undefined;
|
||||
token?: string | undefined;
|
||||
waitTimer?: number | undefined;
|
||||
preventSelfReview?: boolean | undefined;
|
||||
reviewers?: string[] | undefined;
|
||||
},
|
||||
JsonObject
|
||||
>;
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/integration": "workspace:^",
|
||||
|
||||
@@ -302,4 +302,52 @@ export const examples: TemplateExample[] = [
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Create a GitHub Environment with Wait Timer',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: 'github:environment:create',
|
||||
name: 'Create Environment',
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repository&owner=owner',
|
||||
name: 'envname',
|
||||
waitTimer: 1000,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Create a GitHub Environment with Prevent Self Review',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: 'github:environment:create',
|
||||
name: 'Create Environment',
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repository&owner=owner',
|
||||
name: 'envname',
|
||||
preventSelfReview: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
description: 'Create a GitHub Environment with Reviewers',
|
||||
example: yaml.stringify({
|
||||
steps: [
|
||||
{
|
||||
action: 'github:environment:create',
|
||||
name: 'Create Environment',
|
||||
input: {
|
||||
repoUrl: 'github.com?repo=repository&owner=owner',
|
||||
name: 'envname',
|
||||
reviewers: ['group:default/team-a', 'user:default/johndoe'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
+190
@@ -20,6 +20,7 @@ import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import yaml from 'yaml';
|
||||
import { examples } from './gitHubEnvironment.examples';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
@@ -33,8 +34,17 @@ const mockOctokit = {
|
||||
createOrUpdateEnvironment: jest.fn(),
|
||||
get: jest.fn(),
|
||||
},
|
||||
teams: {
|
||||
getByName: jest.fn(),
|
||||
},
|
||||
users: {
|
||||
getByUsername: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockCatalogClient: Partial<CatalogApi> = {
|
||||
getEntitiesByRefs: jest.fn(),
|
||||
};
|
||||
jest.mock('octokit', () => ({
|
||||
Octokit: class {
|
||||
constructor() {
|
||||
@@ -42,6 +52,9 @@ jest.mock('octokit', () => ({
|
||||
}
|
||||
},
|
||||
}));
|
||||
jest.mock('@backstage/catalog-client', () => ({
|
||||
CatalogClient: mockCatalogClient,
|
||||
}));
|
||||
|
||||
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
|
||||
|
||||
@@ -72,9 +85,36 @@ describe('github:environment:create examples', () => {
|
||||
id: 'repoid',
|
||||
},
|
||||
});
|
||||
mockOctokit.rest.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
mockOctokit.rest.teams.getByName.mockResolvedValue({
|
||||
data: {
|
||||
id: 2,
|
||||
},
|
||||
});
|
||||
(mockCatalogClient.getEntitiesByRefs as jest.Mock).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'johndoe',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-a',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
action = createGithubEnvironmentAction({
|
||||
integrations,
|
||||
catalogClient: mockCatalogClient as CatalogApi,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +135,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
expect(
|
||||
mockOctokit.rest.repos.createDeploymentBranchPolicy,
|
||||
@@ -128,6 +171,9 @@ describe('github:environment:create examples', () => {
|
||||
protected_branches: true,
|
||||
custom_branch_policies: false,
|
||||
},
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -162,6 +208,9 @@ describe('github:environment:create examples', () => {
|
||||
protected_branches: false,
|
||||
custom_branch_policies: true,
|
||||
},
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -208,6 +257,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -280,6 +332,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -331,6 +386,9 @@ describe('github:environment:create examples', () => {
|
||||
custom_branch_policies: true,
|
||||
protected_branches: false,
|
||||
},
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -382,6 +440,9 @@ describe('github:environment:create examples', () => {
|
||||
custom_branch_policies: true,
|
||||
protected_branches: false,
|
||||
},
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -473,6 +534,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -503,6 +567,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -536,6 +603,9 @@ describe('github:environment:create examples', () => {
|
||||
custom_branch_policies: true,
|
||||
protected_branches: false,
|
||||
},
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -593,6 +663,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -643,6 +716,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -697,6 +773,9 @@ describe('github:environment:create examples', () => {
|
||||
custom_branch_policies: false,
|
||||
protected_branches: true,
|
||||
},
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -726,6 +805,9 @@ describe('github:environment:create examples', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -782,4 +864,112 @@ describe('github:environment:create examples', () => {
|
||||
secret_name: 'SECRET2',
|
||||
});
|
||||
});
|
||||
|
||||
it(`should ${examples[14].description}`, async () => {
|
||||
const input = yaml.parse(examples[14].example).steps[0].input;
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 1000,
|
||||
reviewers: null,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
expect(
|
||||
mockOctokit.rest.repos.createDeploymentBranchPolicy,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.createEnvironmentVariable,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.getEnvironmentPublicKey,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should ${examples[15].description}`, async () => {
|
||||
const input = yaml.parse(examples[15].example).steps[0].input;
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: null,
|
||||
prevent_self_review: true,
|
||||
});
|
||||
expect(
|
||||
mockOctokit.rest.repos.createDeploymentBranchPolicy,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.createEnvironmentVariable,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.getEnvironmentPublicKey,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should ${examples[16].description}`, async () => {
|
||||
const input = yaml.parse(examples[16].example).steps[0].input;
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
reviewers: [
|
||||
{
|
||||
type: 'User',
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
type: 'Team',
|
||||
id: 2,
|
||||
},
|
||||
],
|
||||
prevent_self_review: false,
|
||||
});
|
||||
expect(
|
||||
mockOctokit.rest.repos.createDeploymentBranchPolicy,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.createEnvironmentVariable,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.getEnvironmentPublicKey,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
@@ -32,8 +33,19 @@ const mockOctokit = {
|
||||
createOrUpdateEnvironment: jest.fn(),
|
||||
get: jest.fn(),
|
||||
},
|
||||
teams: {
|
||||
getByName: jest.fn(),
|
||||
},
|
||||
users: {
|
||||
getByUsername: jest.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockCatalogClient: Partial<CatalogApi> = {
|
||||
getEntitiesByRefs: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('octokit', () => ({
|
||||
Octokit: class {
|
||||
constructor() {
|
||||
@@ -42,6 +54,10 @@ jest.mock('octokit', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@backstage/catalog-client', () => ({
|
||||
CatalogClient: mockCatalogClient,
|
||||
}));
|
||||
|
||||
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
|
||||
|
||||
describe('github:environment:create', () => {
|
||||
@@ -76,9 +92,36 @@ describe('github:environment:create', () => {
|
||||
id: 'repoid',
|
||||
},
|
||||
});
|
||||
mockOctokit.rest.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
mockOctokit.rest.teams.getByName.mockResolvedValue({
|
||||
data: {
|
||||
id: 2,
|
||||
},
|
||||
});
|
||||
(mockCatalogClient.getEntitiesByRefs as jest.Mock).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'johndoe',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Group',
|
||||
metadata: {
|
||||
name: 'team-a',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
action = createGithubEnvironmentAction({
|
||||
integrations,
|
||||
catalogClient: mockCatalogClient as CatalogApi,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +137,9 @@ describe('github:environment:create', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +165,9 @@ describe('github:environment:create', () => {
|
||||
protected_branches: true,
|
||||
custom_branch_policies: false,
|
||||
},
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +194,9 @@ describe('github:environment:create', () => {
|
||||
protected_branches: false,
|
||||
custom_branch_policies: true,
|
||||
},
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -190,6 +242,9 @@ describe('github:environment:create', () => {
|
||||
protected_branches: false,
|
||||
custom_branch_policies: true,
|
||||
},
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -231,6 +286,9 @@ describe('github:environment:create', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -277,6 +335,9 @@ describe('github:environment:create', () => {
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -316,4 +377,101 @@ describe('github:environment:create', () => {
|
||||
environment_name: 'envname',
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with wait_timer', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
waitTimer: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
reviewers: null,
|
||||
wait_timer: 1000,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with preventSelfReview set to true', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
preventSelfReview: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with preventSelfReview set to false', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
preventSelfReview: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
reviewers: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with reviewers', async () => {
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
...mockContext.input,
|
||||
reviewers: ['group:default/team-a', 'user:default/johndoe'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
mockOctokit.rest.repos.createOrUpdateEnvironment,
|
||||
).toHaveBeenCalledWith({
|
||||
owner: 'owner',
|
||||
repo: 'repository',
|
||||
environment_name: 'envname',
|
||||
deployment_branch_policy: null,
|
||||
wait_timer: 0,
|
||||
prevent_self_review: false,
|
||||
reviewers: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'User',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'Team',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ import { getOctokitOptions } from './helpers';
|
||||
import { Octokit } from 'octokit';
|
||||
import Sodium from 'libsodium-wrappers';
|
||||
import { examples } from './gitHubEnvironment.examples';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
/**
|
||||
* Creates an `github:environment:create` Scaffolder action that creates a Github Environment.
|
||||
@@ -32,8 +34,9 @@ import { examples } from './gitHubEnvironment.examples';
|
||||
*/
|
||||
export function createGithubEnvironmentAction(options: {
|
||||
integrations: ScmIntegrationRegistry;
|
||||
catalogClient?: CatalogApi;
|
||||
}) {
|
||||
const { integrations } = options;
|
||||
const { integrations, catalogClient } = options;
|
||||
// For more information on how to define custom actions, see
|
||||
// https://backstage.io/docs/features/software-templates/writing-custom-actions
|
||||
return createTemplateAction<{
|
||||
@@ -48,6 +51,9 @@ export function createGithubEnvironmentAction(options: {
|
||||
environmentVariables?: { [key: string]: string };
|
||||
secrets?: { [key: string]: string };
|
||||
token?: string;
|
||||
waitTimer?: number;
|
||||
preventSelfReview?: boolean;
|
||||
reviewers?: string[];
|
||||
}>({
|
||||
id: 'github:environment:create',
|
||||
description: 'Creates Deployment Environments',
|
||||
@@ -120,6 +126,25 @@ export function createGithubEnvironmentAction(options: {
|
||||
type: 'string',
|
||||
description: 'The token to use for authorization to GitHub',
|
||||
},
|
||||
waitTimer: {
|
||||
title: 'Wait Timer',
|
||||
type: 'integer',
|
||||
description:
|
||||
'The time to wait before creating or updating the environment (in milliseconds)',
|
||||
},
|
||||
preventSelfReview: {
|
||||
title: 'Prevent Self Review',
|
||||
type: 'boolean',
|
||||
description: 'Whether to prevent self-review for this environment',
|
||||
},
|
||||
reviewers: {
|
||||
title: 'Reviewers',
|
||||
type: 'array',
|
||||
description: 'Reviewers for this environment',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -133,8 +158,15 @@ export function createGithubEnvironmentAction(options: {
|
||||
environmentVariables,
|
||||
secrets,
|
||||
token: providedToken,
|
||||
waitTimer,
|
||||
preventSelfReview,
|
||||
reviewers,
|
||||
} = ctx.input;
|
||||
|
||||
// When environment creation step is executed right after a repo publish step, the repository might not be available immediately.
|
||||
// Add a 2-second delay before initiating the steps in this action.
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
const octokitOptions = await getOctokitOptions({
|
||||
integrations,
|
||||
token: providedToken,
|
||||
@@ -153,11 +185,56 @@ export function createGithubEnvironmentAction(options: {
|
||||
repo: repo,
|
||||
});
|
||||
|
||||
// convert reviewers from catalog entity to Github user or team
|
||||
const githubReviewers: { type: 'User' | 'Team'; id: number }[] = [];
|
||||
if (reviewers) {
|
||||
let reviewersEntityRefs: Array<Entity | undefined> = [];
|
||||
// Fetch reviewers from Catalog
|
||||
const catalogResponse = await catalogClient?.getEntitiesByRefs({
|
||||
entityRefs: reviewers,
|
||||
});
|
||||
if (catalogResponse?.items?.length) {
|
||||
reviewersEntityRefs = catalogResponse.items;
|
||||
}
|
||||
|
||||
for (const reviewerEntityRef of reviewersEntityRefs) {
|
||||
if (reviewerEntityRef?.kind === 'User') {
|
||||
try {
|
||||
const user = await client.rest.users.getByUsername({
|
||||
username: reviewerEntityRef.metadata.name,
|
||||
});
|
||||
githubReviewers.push({
|
||||
type: 'User',
|
||||
id: user.data.id,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.error('User not found:', error);
|
||||
}
|
||||
} else if (reviewerEntityRef?.kind === 'Group') {
|
||||
try {
|
||||
const team = await client.rest.teams.getByName({
|
||||
org: owner,
|
||||
team_slug: reviewerEntityRef.metadata.name,
|
||||
});
|
||||
githubReviewers.push({
|
||||
type: 'Team',
|
||||
id: team.data.id,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.error('Team not found:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.rest.repos.createOrUpdateEnvironment({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
environment_name: name,
|
||||
deployment_branch_policy: deploymentBranchPolicy ?? null,
|
||||
wait_timer: waitTimer ?? 0,
|
||||
prevent_self_review: preventSelfReview ?? false,
|
||||
reviewers: githubReviewers.length ? githubReviewers : null,
|
||||
});
|
||||
|
||||
if (customBranchPolicyNames) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
DefaultGithubCredentialsProvider,
|
||||
ScmIntegrations,
|
||||
} from '@backstage/integration';
|
||||
import { CatalogClient } from '@backstage/catalog-client';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -48,11 +49,15 @@ export const githubModule = createBackendModule({
|
||||
deps: {
|
||||
scaffolder: scaffolderActionsExtensionPoint,
|
||||
config: coreServices.rootConfig,
|
||||
discovery: coreServices.discovery,
|
||||
},
|
||||
async init({ scaffolder, config }) {
|
||||
async init({ scaffolder, config, discovery }) {
|
||||
const integrations = ScmIntegrations.fromConfig(config);
|
||||
const githubCredentialsProvider =
|
||||
DefaultGithubCredentialsProvider.fromIntegrations(integrations);
|
||||
const catalogClient = new CatalogClient({
|
||||
discoveryApi: discovery,
|
||||
});
|
||||
|
||||
scaffolder.addActions(
|
||||
createGithubActionsDispatchAction({
|
||||
@@ -68,6 +73,7 @@ export const githubModule = createBackendModule({
|
||||
}),
|
||||
createGithubEnvironmentAction({
|
||||
integrations,
|
||||
catalogClient,
|
||||
}),
|
||||
createGithubIssuesLabelAction({
|
||||
integrations,
|
||||
|
||||
@@ -241,6 +241,7 @@ export const createBuiltinActions = (
|
||||
}),
|
||||
createGithubEnvironmentAction({
|
||||
integrations,
|
||||
catalogClient,
|
||||
}),
|
||||
createGithubDeployKeyAction({
|
||||
integrations,
|
||||
|
||||
+120
@@ -24,6 +24,7 @@ import {
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { MultiEntityPicker } from './MultiEntityPicker';
|
||||
import { MultiEntityPickerProps } from './schema';
|
||||
@@ -704,4 +705,123 @@ describe('<MultiEntityPicker />', () => {
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiselect maxNoOfEntities option', () => {
|
||||
beforeEach(() => {
|
||||
const testEntities = [
|
||||
makeEntity('Group', 'default', 'team-a'),
|
||||
makeEntity('Group', 'default', 'squad-b'),
|
||||
makeEntity('User', 'default', 'user-a'),
|
||||
makeEntity('User', 'default', 'user-b'),
|
||||
];
|
||||
|
||||
uiSchema = {
|
||||
'ui:options': {
|
||||
catalogFilter: [
|
||||
{
|
||||
kind: ['Group'],
|
||||
'metadata.name': 'test-entity',
|
||||
},
|
||||
{
|
||||
kind: ['User'],
|
||||
'metadata.name': 'test-entity',
|
||||
},
|
||||
],
|
||||
},
|
||||
allowArbitraryValues: true,
|
||||
};
|
||||
props = {
|
||||
onChange,
|
||||
schema,
|
||||
required: false,
|
||||
uiSchema,
|
||||
rawErrors,
|
||||
formData,
|
||||
} as unknown as FieldProps<any>;
|
||||
|
||||
catalogApi.getEntities.mockResolvedValue({ items: testEntities });
|
||||
});
|
||||
|
||||
it('limit the number of selected entities when maxNoOfEntities is specified', async () => {
|
||||
props.schema.maxItems = 2;
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<MultiEntityPicker {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.mouseDown(input);
|
||||
const optionA = screen.getByText('team-a');
|
||||
await userEvent.click(optionA as HTMLElement);
|
||||
|
||||
fireEvent.mouseDown(input);
|
||||
const optionB = screen.getByText('user-b');
|
||||
await userEvent.click(optionB as HTMLElement);
|
||||
|
||||
fireEvent.mouseDown(input);
|
||||
const optionC = screen.getByText('user-a');
|
||||
await expect(() =>
|
||||
userEvent.click(optionC as HTMLElement),
|
||||
).rejects.toThrow(/pointer-events: none/);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(2);
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, ['group:default/team-a']);
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, [
|
||||
'group:default/team-a',
|
||||
'user:default/user-b',
|
||||
]);
|
||||
expect(onChange).not.toHaveBeenNthCalledWith(3, [
|
||||
'group:default/team-a',
|
||||
'user:default/user-b',
|
||||
'user:default/user-a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not limit the number of selected entities when maxItems is not specified', async () => {
|
||||
props.schema.maxItems = undefined;
|
||||
await renderInTestApp(
|
||||
<Wrapper>
|
||||
<MultiEntityPicker {...props} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.mouseDown(input);
|
||||
const optionA = screen.getByText('team-a');
|
||||
await userEvent.click(optionA as HTMLElement);
|
||||
|
||||
fireEvent.mouseDown(input);
|
||||
const optionB = screen.getByText('user-b');
|
||||
await userEvent.click(optionB as HTMLElement);
|
||||
|
||||
fireEvent.mouseDown(input);
|
||||
const optionC = screen.getByText('user-a');
|
||||
await userEvent.click(optionC as HTMLElement);
|
||||
|
||||
fireEvent.mouseDown(input);
|
||||
const optionD = screen.getByText('squad-b');
|
||||
await userEvent.click(optionD as HTMLElement);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(4);
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, ['group:default/team-a']);
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, [
|
||||
'group:default/team-a',
|
||||
'user:default/user-b',
|
||||
]);
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, [
|
||||
'group:default/team-a',
|
||||
'user:default/user-b',
|
||||
'user:default/user-a',
|
||||
]);
|
||||
expect(onChange).toHaveBeenNthCalledWith(4, [
|
||||
'group:default/team-a',
|
||||
'user:default/user-b',
|
||||
'user:default/user-a',
|
||||
'group:default/squad-b',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ import FormControl from '@material-ui/core/FormControl';
|
||||
import Autocomplete, {
|
||||
AutocompleteChangeReason,
|
||||
} from '@material-ui/lab/Autocomplete';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import useAsync from 'react-use/esm/useAsync';
|
||||
import { FieldValidation } from '@rjsf/utils';
|
||||
import {
|
||||
@@ -65,6 +65,7 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
|
||||
const defaultKind = uiSchema['ui:options']?.defaultKind;
|
||||
const defaultNamespace =
|
||||
uiSchema['ui:options']?.defaultNamespace || undefined;
|
||||
const [noOfItemsSelected, setNoOfItemsSelected] = useState(0);
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const entityPresentationApi = useApi(entityPresentationApiRef);
|
||||
@@ -92,6 +93,9 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
|
||||
const allowArbitraryValues =
|
||||
uiSchema['ui:options']?.allowArbitraryValues ?? true;
|
||||
|
||||
// if not specified, maxItems defaults to undefined
|
||||
const maxItems = props.schema.maxItems;
|
||||
|
||||
const onSelect = useCallback(
|
||||
(_: any, refs: (string | Entity)[], reason: AutocompleteChangeReason) => {
|
||||
const values = refs
|
||||
@@ -125,6 +129,7 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
|
||||
})
|
||||
.filter(ref => ref !== undefined) as string[];
|
||||
|
||||
setNoOfItemsSelected(values.length);
|
||||
onChange(values);
|
||||
},
|
||||
[onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues],
|
||||
@@ -158,6 +163,9 @@ export const MultiEntityPicker = (props: MultiEntityPickerProps) => {
|
||||
: entities?.entityRefToPresentation.get(stringifyEntityRef(option))
|
||||
?.entityRef!
|
||||
}
|
||||
getOptionDisabled={_options =>
|
||||
maxItems ? noOfItemsSelected >= maxItems : false
|
||||
}
|
||||
autoSelect
|
||||
freeSolo={allowArbitraryValues}
|
||||
renderInput={params => (
|
||||
|
||||
@@ -7109,6 +7109,8 @@ __metadata:
|
||||
"@backstage/backend-common": "workspace:^"
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/catalog-client": "workspace:^"
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user