Merge remote-tracking branch 'origin/master' into feat/bitrise_pagination

This commit is contained in:
GregoireW
2021-03-10 17:07:42 +01:00
24 changed files with 509 additions and 148 deletions
@@ -38,4 +38,8 @@ export class TemplateActionRegistry {
}
return action;
}
list(): TemplateAction<any>[] {
return [...this.actions.values()];
}
}
@@ -31,6 +31,8 @@ export function createCatalogRegisterAction(options: {
| { repoContentsUrl: string; catalogInfoPath?: string }
>({
id: 'catalog:register',
description:
'Registers entities from a catalog descriptor file in the workspace into the software catalog.',
schema: {
input: {
oneOf: [
@@ -38,6 +38,8 @@ export function createFetchCookiecutterAction(options: {
values: JsonObject;
}>({
id: 'fetch:cookiecutter',
description:
'Downloads a template from the given URL into the workspace, and runs cookiecutter on it.',
schema: {
input: {
type: 'object',
@@ -28,6 +28,8 @@ export function createFetchPlainAction(options: {
return createTemplateAction<{ url: string; targetPath?: string }>({
id: 'fetch:plain',
description:
"Downloads content and places it in the workspace, or optionally in a subdirectory specified by the 'targetPath' input option.",
schema: {
input: {
type: 'object',
@@ -32,6 +32,8 @@ export function createPublishAzureAction(options: {
description?: string;
}>({
id: 'publish:azure',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Azure.',
schema: {
input: {
type: 'object',
@@ -167,6 +167,8 @@ export function createPublishBitbucketAction(options: {
repoVisibility: 'private' | 'public';
}>({
id: 'publish:bitbucket',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket.',
schema: {
input: {
type: 'object',
@@ -43,6 +43,8 @@ export function createPublishGithubAction(options: {
repoVisibility: 'private' | 'internal' | 'public';
}>({
id: 'publish:github',
description:
'Initializes a git repository of contents in workspace and publishes it to GitHub.',
schema: {
input: {
type: 'object',
@@ -31,6 +31,8 @@ export function createPublishGitlabAction(options: {
repoVisibility: 'private' | 'internal' | 'public';
}>({
id: 'publish:gitlab',
description:
'Initializes a git repository of the content in the workspace, and publishes it to GitLab.',
schema: {
input: {
type: 'object',
@@ -44,6 +44,7 @@ export type ActionContext<Input extends InputBase> = {
export type TemplateAction<Input extends InputBase> = {
id: string;
description?: string;
schema?: {
input?: Schema;
output?: Schema;
@@ -131,25 +131,30 @@ export class GithubPublisher implements PublisherBase {
const { data } = await repoCreationPromise;
if (access?.startsWith(`${owner}/`)) {
const [, team] = access.split('/');
await client.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: team,
owner,
repo: name,
permission: 'admin',
});
// no need to add access if it's the person who own's the personal account
} else if (access && access !== owner) {
await client.repos.addCollaborator({
owner,
repo: name,
username: access,
permission: 'admin',
});
try {
if (access?.startsWith(`${owner}/`)) {
const [, team] = access.split('/');
await client.teams.addOrUpdateRepoPermissionsInOrg({
org: owner,
team_slug: team,
owner,
repo: name,
permission: 'admin',
});
// no need to add access if it's the person who owns the personal account
} else if (access && access !== owner) {
await client.repos.addCollaborator({
owner,
repo: name,
username: access,
permission: 'admin',
});
}
} catch (e) {
throw new Error(
`Failed to add access to '${access}'. Status ${e.status} ${e.message}`,
);
}
return data?.clone_url;
}
}
@@ -261,6 +261,15 @@ describe('createRouter', () => {
});
});
describe('GET /v2/actions', () => {
it('lists available actions', async () => {
const response = await request(app).get('/v2/actions').send();
expect(response.status).toEqual(200);
expect(response.body[0].id).toBeDefined();
expect(response.body.length).toBeGreaterThan(8);
});
});
describe('POST /v2/tasks', () => {
it('rejects template values which do not match the template schema definition', async () => {
const response = await request(app)
@@ -347,6 +347,16 @@ export async function createRouter(
}
},
)
.get('/v2/actions', async (_req, res) => {
const actionsList = actionRegistry.list().map(action => {
return {
id: action.id,
description: action.description,
schema: action.schema,
};
});
res.json(actionsList);
})
.post('/v2/tasks', async (req, res) => {
const templateName: string = req.body.templateName;
const values: TemplaterValues = req.body.values;
+1
View File
@@ -43,6 +43,7 @@
"@rjsf/core": "^2.4.0",
"@rjsf/material-ui": "^2.4.0",
"classnames": "^2.2.6",
"json-schema": "^0.2.5",
"git-url-parse": "^11.4.4",
"humanize-duration": "^3.25.1",
"luxon": "^1.25.0",
+13 -1
View File
@@ -25,7 +25,7 @@ import {
} from '@backstage/core';
import { ScmIntegrations } from '@backstage/integration';
import ObservableImpl from 'zen-observable';
import { ScaffolderTask, Status } from './types';
import { ListActionsResponse, ScaffolderTask, Status } from './types';
export const scaffolderApiRef = createApiRef<ScaffolderApi>({
id: 'plugin.scaffolder.service',
@@ -72,6 +72,9 @@ export interface ScaffolderApi {
allowedHosts: string[];
}): Promise<{ type: string; title: string; host: string }[]>;
// Returns a list of all installed actions.
listActions(): Promise<ListActionsResponse>;
streamLogs({
taskId,
after,
@@ -227,4 +230,13 @@ export class ScaffolderClient implements ScaffolderApi {
);
});
}
/**
* @returns ListActionsResponse containing all registered actions.
*/
async listActions(): Promise<ListActionsResponse> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const response = await fetch(`${baseUrl}/v2/actions`);
return await response.json();
}
}
@@ -0,0 +1,167 @@
/*
* Copyright 2021 Spotify AB
*
* 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 { ApiRegistry, ApiProvider } from '@backstage/core';
import React from 'react';
import { ScaffolderApi, scaffolderApiRef } from '../../api';
import { ActionsPage } from './ActionsPage';
import { rootRouteRef } from '../../routes';
import { renderInTestApp } from '@backstage/test-utils';
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
};
const apis = ApiRegistry.from([[scaffolderApiRef, scaffolderApiMock]]);
describe('TemplatePage', () => {
beforeEach(() => jest.resetAllMocks());
it('renders action with input', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test title',
type: 'string',
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.queryByText('Test title')).toBeInTheDocument();
expect(rendered.queryByText('example description')).toBeInTheDocument();
expect(rendered.queryByText('foobar')).toBeInTheDocument();
expect(rendered.queryByText('output')).not.toBeInTheDocument();
});
it('renders action with input and output', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
type: 'object',
required: ['foobar'],
properties: {
foobar: {
title: 'Test title',
type: 'string',
},
},
},
output: {
type: 'object',
properties: {
buzz: {
title: 'Test output',
type: 'string',
},
},
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.queryByText('Test title')).toBeInTheDocument();
expect(rendered.queryByText('example description')).toBeInTheDocument();
expect(rendered.queryByText('foobar')).toBeInTheDocument();
expect(rendered.queryByText('Test output')).toBeInTheDocument();
});
it('renders action with oneOf input', async () => {
scaffolderApiMock.listActions.mockResolvedValue([
{
id: 'test',
description: 'example description',
schema: {
input: {
oneOf: [
{
type: 'object',
required: ['foo'],
properties: {
foo: {
title: 'Foo title',
description: 'Foo description',
type: 'string',
},
},
},
{
type: 'object',
required: ['bar'],
properties: {
bar: {
title: 'Bar title',
description: 'Bar description',
type: 'string',
},
},
},
],
},
},
},
]);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<ActionsPage />
</ApiProvider>,
{
mountedRoutes: {
'/create/actions': rootRouteRef,
},
},
);
expect(rendered.queryByText('oneOf')).toBeInTheDocument();
expect(rendered.queryByText('Foo title')).toBeInTheDocument();
expect(rendered.queryByText('Foo description')).toBeInTheDocument();
expect(rendered.queryByText('Bar title')).toBeInTheDocument();
expect(rendered.queryByText('Bar description')).toBeInTheDocument();
});
});
@@ -0,0 +1,192 @@
/*
* Copyright 2021 Spotify AB
*
* 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 React from 'react';
import { useAsync } from 'react-use';
import {
useApi,
Progress,
Content,
Header,
Page,
ErrorPage,
} from '@backstage/core';
import { scaffolderApiRef } from '../../api';
import {
Typography,
Paper,
Table,
TableBody,
Box,
TableCell,
TableContainer,
TableHead,
TableRow,
makeStyles,
} from '@material-ui/core';
import { JSONSchema } from '@backstage/catalog-model';
import { JSONSchema7Definition } from 'json-schema';
import classNames from 'classnames';
const useStyles = makeStyles(theme => ({
code: {
fontFamily: 'Menlo, monospace',
padding: theme.spacing(1),
backgroundColor:
theme.palette.type === 'dark'
? theme.palette.grey[700]
: theme.palette.grey[300],
display: 'inline-block',
borderRadius: 5,
border: `1px solid ${theme.palette.grey[500]}`,
position: 'relative',
},
codeRequired: {
'&::after': {
position: 'absolute',
content: '"*"',
top: 0,
right: theme.spacing(0.5),
fontWeight: 'bolder',
color: theme.palette.error.light,
},
},
}));
export const ActionsPage = () => {
const api = useApi(scaffolderApiRef);
const classes = useStyles();
const { loading, value, error } = useAsync(async () => {
return api.listActions();
});
if (loading) {
return <Progress />;
}
if (error) {
return (
<ErrorPage
statusMessage="Failed to load installed actions"
status="500"
/>
);
}
const formatRows = (input: JSONSchema) => {
const properties = input.properties;
if (!properties) {
return undefined;
}
return Object.entries(properties).map(entry => {
const [key] = entry;
const props = (entry[1] as unknown) as JSONSchema;
const codeClassname = classNames(classes.code, {
[classes.codeRequired]: input.required?.includes(key),
});
return (
<TableRow key={key}>
<TableCell>
<div className={codeClassname}>{key}</div>
</TableCell>
<TableCell>{props.title}</TableCell>
<TableCell>{props.description}</TableCell>
<TableCell>
<span className={classes.code}>{props.type}</span>
</TableCell>
</TableRow>
);
});
};
const renderTable = (input: JSONSchema) => {
if (!input.properties) {
return undefined;
}
return (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Title</TableCell>
<TableCell>Description</TableCell>
<TableCell>Type</TableCell>
</TableRow>
</TableHead>
<TableBody>{formatRows(input)}</TableBody>
</Table>
</TableContainer>
);
};
const renderTables = (name: string, input?: JSONSchema7Definition[]) => {
if (!input) {
return undefined;
}
return (
<>
<Typography variant="h6">{name}</Typography>
{input.map((i, index) => (
<div key={index}>{renderTable((i as unknown) as JSONSchema)}</div>
))}
</>
);
};
const items = value?.map(action => {
if (action.id.startsWith('legacy:')) {
return undefined;
}
const oneOf = renderTables('oneOf', action.schema?.input?.oneOf);
return (
<Box pb={4} key={action.id}>
<Typography variant="h4" className={classes.code}>
{action.id}
</Typography>
<Typography>{action.description}</Typography>
{action.schema?.input && (
<Box pb={2}>
<Typography variant="h5">Input</Typography>
{renderTable(action.schema.input)}
{oneOf}
</Box>
)}
{action.schema?.output && (
<Box pb={2}>
<Typography variant="h5">Output</Typography>
{renderTable(action.schema.output)}
</Box>
)}
</Box>
);
});
return (
<Page themeId="home">
<Header
pageTitleOverride="Create a New Component"
title="Installed actions"
subtitle="This is the collection of all installed actions"
/>
<Content>{items}</Content>
</Page>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* 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.
*/
export { ActionsPage } from './ActionsPage';
@@ -19,11 +19,13 @@ import { Routes, Route } from 'react-router';
import { ScaffolderPage } from './ScaffolderPage';
import { TemplatePage } from './TemplatePage';
import { TaskPage } from './TaskPage';
import { ActionsPage } from './ActionsPage';
export const Router = () => (
<Routes>
<Route path="/" element={<ScaffolderPage />} />
<Route path="/templates/:templateName" element={<TemplatePage />} />
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
</Routes>
);
@@ -39,6 +39,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
getIntegrationsList: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
};
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
+10
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JSONSchema } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
export type Status = 'open' | 'processing' | 'failed' | 'completed';
@@ -54,3 +55,12 @@ export type ScaffolderTask = {
lastHeartbeatAt: string;
createdAt: string;
};
export type ListActionsResponse = Array<{
id: string;
description?: string;
schema?: {
input?: JSONSchema;
output?: JSONSchema;
};
}>;