Merge branch 'master' into add-access-support
This commit is contained in:
@@ -7,3 +7,5 @@
|
||||
**/.git/**
|
||||
**/public/**
|
||||
**/microsite/**
|
||||
**/templates/**
|
||||
**/sample-templates/**
|
||||
|
||||
@@ -471,6 +471,7 @@ Describes the following entity kind:
|
||||
An API describes an interface that can be exposed by a component. The API can be
|
||||
defined in different formats, like [OpenAPI](https://swagger.io/specification/),
|
||||
[AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/),
|
||||
[GraphQL](https://graphql.org/learn/schema/),
|
||||
[gRPC](https://developers.google.com/protocol-buffers), or other formats.
|
||||
|
||||
Descriptor files for this kind may look as follows.
|
||||
|
||||
@@ -51,13 +51,13 @@ The value of this annotation is a location reference string (see above). If this
|
||||
annotation is specified, it is expected to point to a repository that the
|
||||
TechDocs system can read and generate docs from.
|
||||
|
||||
### backstage.io/jenkins-github-folder
|
||||
### jenkins.io/github-folder
|
||||
|
||||
```yaml
|
||||
# Example:
|
||||
metadata:
|
||||
annotations:
|
||||
backstage.io/jenkins-github-folder: folder-name/job-name
|
||||
jenkins.io/github-folder: folder-name/job-name
|
||||
```
|
||||
|
||||
The value of this annotation is the path to a job on Jenkins, that builds this
|
||||
|
||||
@@ -90,16 +90,19 @@ import {
|
||||
GithubPublisher,
|
||||
CreateReactAppTemplater,
|
||||
Templaters,
|
||||
RepoVisilityOptions,
|
||||
} from '@backstage/plugin-scaffolder-backend';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import type { PluginEnvironment } from '../types';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment) {
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
const templaters = new Templaters();
|
||||
|
||||
// Register default templaters
|
||||
templaters.register('cookiecutter', cookiecutterTemplater);
|
||||
templaters.register('cra', craTemplater);
|
||||
@@ -112,9 +115,17 @@ export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
preparers.register('file', filePreparer);
|
||||
preparers.register('github', githubPreparer);
|
||||
|
||||
// Create GitHub client with your access token from environment variables
|
||||
const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN });
|
||||
const publisher = new GithubPublisher({ client: githubClient });
|
||||
const githubToken = config.getString('scaffolder.github.token');
|
||||
const repoVisibility = config.getString(
|
||||
'scaffolder.github.visibility',
|
||||
) as RepoVisilityOptions;
|
||||
|
||||
const githubClient = new Octokit({ auth: githubToken });
|
||||
const publisher = new GithubPublisher({
|
||||
client: githubClient,
|
||||
token: githubToken,
|
||||
repoVisibility,
|
||||
});
|
||||
|
||||
const dockerClient = new Docker();
|
||||
return await createRouter({
|
||||
@@ -177,8 +188,23 @@ docs on creating private GitHub access tokens is available
|
||||
Note that the need for private GitHub access tokens will be replaced with GitHub
|
||||
Apps integration further down the line.
|
||||
|
||||
The GitHub access token is passed along using the `GITHUB_ACCESS_TOKEN`
|
||||
environment variable.
|
||||
The Github access token is retrieved from environment variables via the config.
|
||||
The config file needs to specify what environment variable the token is
|
||||
retrieved from. Your config should have the following objects.
|
||||
|
||||
```yaml
|
||||
scaffolder:
|
||||
github:
|
||||
token:
|
||||
$secret:
|
||||
env: GITHUB_ACCESS_TOKEN
|
||||
visibility: public # or 'internal' or 'private'
|
||||
```
|
||||
|
||||
You can configure who can see the new repositories that the scaffolder creates
|
||||
by specifying `visibility` option. Valid options are `public`, `private` and
|
||||
`internal`. `internal` options is for GitHub Enterprise clients, which means
|
||||
public within the organization.
|
||||
|
||||
### Running the Backend
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
Router as GitHubActionsRouter,
|
||||
isPluginApplicableToEntity as isGitHubActionsAvailable,
|
||||
} from '@backstage/plugin-github-actions';
|
||||
import {
|
||||
Router as JenkinsRouter,
|
||||
isPluginApplicableToEntity as isJenkinsAvailable,
|
||||
LatestRunCard as JenkinsLatestRunCard,
|
||||
} from '@backstage/plugin-jenkins';
|
||||
import {
|
||||
Router as CircleCIRouter,
|
||||
isPluginApplicableToEntity as isCircleCIAvailable,
|
||||
@@ -38,6 +43,8 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => {
|
||||
// This component is just an example of how you can implement your company's logic in entity page.
|
||||
// You can for example enforce that all components of type 'service' should use GitHubActions
|
||||
switch (true) {
|
||||
case isJenkinsAvailable(entity):
|
||||
return <JenkinsRouter entity={entity} />;
|
||||
case isGitHubActionsAvailable(entity):
|
||||
return <GitHubActionsRouter entity={entity} />;
|
||||
case isCircleCIAvailable(entity):
|
||||
@@ -57,6 +64,11 @@ const OverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
<Grid item>
|
||||
<AboutCard entity={entity} />
|
||||
</Grid>
|
||||
{isJenkinsAvailable(entity) && (
|
||||
<Grid item sm={4}>
|
||||
<JenkinsLatestRunCard branch="master" />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
|
||||
|
||||
@@ -23,12 +23,16 @@ import {
|
||||
GithubPublisher,
|
||||
CreateReactAppTemplater,
|
||||
Templaters,
|
||||
RepoVisilityOptions,
|
||||
} from '@backstage/plugin-scaffolder-backend';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import type { PluginEnvironment } from '../types';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment) {
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
const templaters = new Templaters();
|
||||
@@ -42,8 +46,17 @@ export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
preparers.register('file', filePreparer);
|
||||
preparers.register('github', githubPreparer);
|
||||
|
||||
const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN });
|
||||
const publisher = new GithubPublisher({ client: githubClient });
|
||||
const githubToken = config.getString('scaffolder.github.token');
|
||||
const repoVisibility = config.getString(
|
||||
'scaffolder.github.visibility',
|
||||
) as RepoVisilityOptions;
|
||||
|
||||
const githubClient = new Octokit({ auth: githubToken });
|
||||
const publisher = new GithubPublisher({
|
||||
client: githubClient,
|
||||
token: githubToken,
|
||||
repoVisibility,
|
||||
});
|
||||
|
||||
const dockerClient = new Docker();
|
||||
return await createRouter({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,13 @@ lighthouse:
|
||||
auth:
|
||||
providers: {}
|
||||
|
||||
scaffolder:
|
||||
github:
|
||||
token:
|
||||
$secret:
|
||||
env: GITHUB_ACCESS_TOKEN
|
||||
visibility: public # or 'internal' or 'private'
|
||||
|
||||
catalog:
|
||||
locations:
|
||||
# Backstage example components
|
||||
|
||||
+16
-3
@@ -7,12 +7,16 @@ import {
|
||||
GithubPublisher,
|
||||
CreateReactAppTemplater,
|
||||
Templaters,
|
||||
RepoVisilityOptions,
|
||||
} from '@backstage/plugin-scaffolder-backend';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import type { PluginEnvironment } from '../types';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
}: PluginEnvironment) {
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
const templaters = new Templaters();
|
||||
@@ -26,8 +30,17 @@ export default async function createPlugin({ logger }: PluginEnvironment) {
|
||||
preparers.register('file', filePreparer);
|
||||
preparers.register('github', githubPreparer);
|
||||
|
||||
const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN });
|
||||
const publisher = new GithubPublisher({ client: githubClient });
|
||||
const githubToken = config.getString('scaffolder.github.token');
|
||||
const repoVisibility = config.getString(
|
||||
'scaffolder.github.visibility',
|
||||
) as RepoVisilityOptions;
|
||||
|
||||
const githubClient = new Octokit({ auth: githubToken });
|
||||
const publisher = new GithubPublisher({
|
||||
client: githubClient,
|
||||
token: githubToken,
|
||||
repoVisibility,
|
||||
});
|
||||
|
||||
const dockerClient = new Docker();
|
||||
return await createRouter({
|
||||
|
||||
@@ -272,6 +272,10 @@ async function createPlugin(pluginName: string, appDir: string) {
|
||||
async function testAppServe(pluginName: string, appDir: string) {
|
||||
const startApp = spawnPiped(['yarn', 'start'], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_ACCESS_TOKEN: 'abc',
|
||||
},
|
||||
});
|
||||
Browser.localhost('localhost', 3000);
|
||||
|
||||
@@ -351,6 +355,10 @@ async function testBackendStart(appDir: string, isPostgres: boolean) {
|
||||
|
||||
const child = spawnPiped(['yarn', 'workspace', 'backend', 'start'], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_ACCESS_TOKEN: 'abc',
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
|
||||
@@ -14,8 +14,9 @@ The plugin provides a standalone list of APIs, as well as an integration into th
|
||||
|
||||
Right now, the following API formats are supported:
|
||||
|
||||
- [OpenAPI](https://swagger.io/specification/) 2 & 3,
|
||||
- [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/),
|
||||
- [OpenAPI](https://swagger.io/specification/) 2 & 3
|
||||
- [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/)
|
||||
- [GraphQL](https://graphql.org/learn/schema/)
|
||||
|
||||
Other formats are displayed as plain text, but this can easily be extented.
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"graphiql": "^1.0.0-alpha.10",
|
||||
"graphql": "^15.3.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { ComponentEntity, Entity } from '@backstage/catalog-model';
|
||||
import { Progress } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import {
|
||||
ApiDefinitionCard,
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
useComponentApiNames,
|
||||
} from '../../components';
|
||||
|
||||
export const EntityPageApi: FC<{ entity: Entity }> = ({ entity }) => {
|
||||
type Props = {
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export const EntityPageApi = ({ entity }: Props) => {
|
||||
const apiNames = useComponentApiNames(entity as ComponentEntity);
|
||||
|
||||
const { apiEntities, loading } = useComponentApiEntities({
|
||||
|
||||
@@ -15,12 +15,13 @@
|
||||
*/
|
||||
|
||||
import { ApiEntity } from '@backstage/catalog-model';
|
||||
import { TabbedCard, CardTab } from '@backstage/core';
|
||||
import { CardTab, TabbedCard } from '@backstage/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React from 'react';
|
||||
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget';
|
||||
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget';
|
||||
import { GraphQlDefinitionWidget } from '../GraphQlDefinitionWidget';
|
||||
|
||||
type ApiDefinitionWidget = {
|
||||
type: string;
|
||||
@@ -47,6 +48,14 @@ export function defaultDefinitionWidgets(): ApiDefinitionWidget[] {
|
||||
<AsyncApiDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'graphql',
|
||||
title: 'GraphQL',
|
||||
rawLanguage: 'graphql',
|
||||
component: definition => (
|
||||
<GraphQlDefinitionWidget definition={definition} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useAsync } from 'react-use';
|
||||
import { ApiDefinitionCard } from '../ApiDefinitionCard';
|
||||
|
||||
const REDIRECT_DELAY = 1000;
|
||||
|
||||
function headerProps(
|
||||
kind: string,
|
||||
namespace: string | undefined,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2020 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, { Suspense } from 'react';
|
||||
import { buildSchema } from 'graphql';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { Progress } from '@backstage/core';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
|
||||
const GraphiQL = React.lazy(() => import('graphiql'));
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(() => ({
|
||||
root: {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
},
|
||||
graphiQlWrapper: {
|
||||
flex: 1,
|
||||
'@global': {
|
||||
'.graphiql-container': {
|
||||
boxSizing: 'initial',
|
||||
height: '100%',
|
||||
minHeight: '600px',
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
definition: any;
|
||||
};
|
||||
|
||||
export const GraphQlDefinitionWidget = ({ definition }: Props) => {
|
||||
const classes = useStyles();
|
||||
const schema = buildSchema(definition);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Progress />}>
|
||||
<div className={classes.root}>
|
||||
<div className={classes.graphiQlWrapper}>
|
||||
<GraphiQL
|
||||
fetcher={() => Promise.resolve(null) as any}
|
||||
schema={schema}
|
||||
docExplorerOpen
|
||||
defaultSecondaryEditorOpen={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -13,5 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './useBuilds';
|
||||
export * from './useBuildWithSteps';
|
||||
|
||||
export { GraphQlDefinitionWidget } from './GraphQlDefinitionWidget';
|
||||
@@ -14,17 +14,7 @@ Website: [https://jenkins.io/](https://jenkins.io/)
|
||||
yarn add @backstage/plugin-jenkins
|
||||
```
|
||||
|
||||
2. Add plugin API to your Backstage instance:
|
||||
|
||||
```js
|
||||
// packages/app/src/api.ts
|
||||
import { JenkinsApi, jenkinsApiRef } from '@backstage/plugin-jenkins';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
builder.add(jenkinsApiRef, new JenkinsApi(`${backendUrl}/proxy/jenkins/api`));
|
||||
```
|
||||
|
||||
2. Add plugin itself:
|
||||
2. Add plugin:
|
||||
|
||||
```js
|
||||
// packages/app/src/plugins.ts
|
||||
@@ -63,7 +53,7 @@ metadata:
|
||||
name: 'your-component'
|
||||
description: 'a description'
|
||||
annotations:
|
||||
backstage.io/jenkins-github-folder: 'folder-name/job-name'
|
||||
jenkins.io/github-folder: 'folder-name/job-name'
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: experimental
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.21",
|
||||
"@backstage/core": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
|
||||
"@backstage/theme": "^0.1.1-alpha.21",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { CITableBuildInfo } from '../pages/BuildsPage/lib/CITable';
|
||||
import { CITableBuildInfo } from '../components/BuildsPage/lib/CITable';
|
||||
|
||||
const jenkins = require('jenkins');
|
||||
|
||||
@@ -65,6 +65,21 @@ export class JenkinsApi {
|
||||
})
|
||||
.pop();
|
||||
|
||||
const author = jobDetails.actions
|
||||
.filter(
|
||||
(action: any) =>
|
||||
action._class ===
|
||||
'jenkins.scm.api.metadata.ContributorMetadataAction',
|
||||
)
|
||||
.map((action: any) => {
|
||||
return action.contributorDisplayName;
|
||||
})
|
||||
.pop();
|
||||
|
||||
if (author) {
|
||||
scmInfo.author = author;
|
||||
}
|
||||
|
||||
return scmInfo;
|
||||
}
|
||||
|
||||
@@ -154,12 +169,15 @@ export class JenkinsApi {
|
||||
if (jobScmInfo) {
|
||||
source.url = jobScmInfo?.url;
|
||||
source.displayName = jobScmInfo?.displayName;
|
||||
source.author = jobScmInfo?.author;
|
||||
}
|
||||
|
||||
const path = new URL(jenkinsResult.url).pathname;
|
||||
|
||||
return {
|
||||
id: path,
|
||||
buildNumber: jenkinsResult.number,
|
||||
buildUrl: jenkinsResult.url,
|
||||
buildName: jenkinsResult.fullDisplayName,
|
||||
status: jenkinsResult.building ? 'running' : jenkinsResult.result,
|
||||
onRestartClick: () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2020 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 { useParams } from 'react-router-dom';
|
||||
import { Content, Link } from '@backstage/core';
|
||||
import {
|
||||
Typography,
|
||||
Breadcrumbs,
|
||||
Paper,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableBody,
|
||||
Link as MaterialLink,
|
||||
} from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { useBuildWithSteps } from '../useBuildWithSteps';
|
||||
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
|
||||
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
|
||||
import ExternalLinkIcon from '@material-ui/icons/Launch';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
maxWidth: 720,
|
||||
margin: theme.spacing(2),
|
||||
},
|
||||
table: {
|
||||
padding: theme.spacing(1),
|
||||
},
|
||||
externalLinkIcon: {
|
||||
fontSize: 'inherit',
|
||||
verticalAlign: 'bottom',
|
||||
},
|
||||
}));
|
||||
|
||||
const Page = () => (
|
||||
<Content>
|
||||
<BuildWithStepsView />
|
||||
</Content>
|
||||
);
|
||||
|
||||
const BuildWithStepsView = () => {
|
||||
const { owner, repo } = useProjectSlugFromEntity();
|
||||
const { branch, buildNumber } = useParams();
|
||||
const classes = useStyles();
|
||||
const buildPath = `${owner}/${repo}/${branch}/${buildNumber}`;
|
||||
const [{ value }] = useBuildWithSteps(buildPath);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
<Link to="../../..">Jobs</Link>
|
||||
<Typography>Run</Typography>
|
||||
</Breadcrumbs>
|
||||
<TableContainer component={Paper} className={classes.table}>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Branch</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{value?.source?.branchName}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Message</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{value?.source?.displayName}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Commit ID</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{value?.source?.commit?.hash}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Status</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<JenkinsRunStatus status={value?.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Author</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{value?.source?.author}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Jenkins</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaterialLink target="_blank" href={value?.buildUrl}>
|
||||
View on Jenkins{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</MaterialLink>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>GitHub</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MaterialLink target="_blank" href={value?.source.url}>
|
||||
View on GitHub{' '}
|
||||
<ExternalLinkIcon className={classes.externalLinkIcon} />
|
||||
</MaterialLink>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export { BuildWithStepsView as BuildWithSteps };
|
||||
+34
-7
@@ -14,21 +14,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { FC } from 'react';
|
||||
import { Link, Typography, Box, IconButton } from '@material-ui/core';
|
||||
import { Box, IconButton, Link, Typography } from '@material-ui/core';
|
||||
import RetryIcon from '@material-ui/icons/Replay';
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { JenkinsRunStatus } from '../Status';
|
||||
import { useBuilds } from '../../../useBuilds';
|
||||
import { useProjectSlugFromEntity } from '../../../useProjectSlugFromEntity';
|
||||
import { buildRouteRef } from '../../../../plugin';
|
||||
|
||||
export type CITableBuildInfo = {
|
||||
id: string;
|
||||
buildName: string;
|
||||
buildUrl?: string;
|
||||
buildNumber: number;
|
||||
buildUrl: string;
|
||||
source: {
|
||||
branchName: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
author?: string;
|
||||
commit: {
|
||||
hash: string;
|
||||
};
|
||||
@@ -105,7 +110,13 @@ const generatedColumns: TableColumn[] = [
|
||||
field: 'buildName',
|
||||
highlight: true,
|
||||
render: (row: Partial<CITableBuildInfo>) => (
|
||||
<Link component={RouterLink} to={`/jenkins/job?url=${row.id}`}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(buildRouteRef.path, {
|
||||
branch: row.source?.branchName!,
|
||||
buildNumber: row.buildNumber?.toString()!,
|
||||
})}
|
||||
>
|
||||
{row.buildName}
|
||||
</Link>
|
||||
),
|
||||
@@ -177,7 +188,8 @@ type Props = {
|
||||
pageSize: number;
|
||||
onChangePageSize: (pageSize: number) => void;
|
||||
};
|
||||
export const CITable: FC<Props> = ({
|
||||
|
||||
export const CITableView: FC<Props> = ({
|
||||
projectName,
|
||||
loading,
|
||||
pageSize,
|
||||
@@ -191,7 +203,7 @@ export const CITable: FC<Props> = ({
|
||||
return (
|
||||
<Table
|
||||
isLoading={loading}
|
||||
options={{ paging: true, pageSize }}
|
||||
options={{ paging: true, pageSize, padding: 'dense' }}
|
||||
totalCount={total}
|
||||
page={page}
|
||||
actions={[
|
||||
@@ -202,7 +214,7 @@ export const CITable: FC<Props> = ({
|
||||
onClick: () => retry(),
|
||||
},
|
||||
]}
|
||||
data={builds}
|
||||
data={builds ?? []}
|
||||
onChangePage={onChangePage}
|
||||
onChangeRowsPerPage={onChangePageSize}
|
||||
title={
|
||||
@@ -216,3 +228,18 @@ export const CITable: FC<Props> = ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CITable = () => {
|
||||
const { owner, repo } = useProjectSlugFromEntity();
|
||||
|
||||
const [tableProps, { setPage, retry, setPageSize }] = useBuilds(owner, repo);
|
||||
|
||||
return (
|
||||
<CITableView
|
||||
{...tableProps}
|
||||
retry={retry}
|
||||
onChangePageSize={setPageSize}
|
||||
onChangePage={setPage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+8
-17
@@ -14,12 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core';
|
||||
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
|
||||
import ExternalLinkIcon from '@material-ui/icons/Launch';
|
||||
import { useBuilds } from '../../state';
|
||||
import { JenkinsRunStatus } from '../../pages/BuildsPage/lib/Status';
|
||||
import { useBuilds } from '../useBuilds';
|
||||
import { JenkinsRunStatus } from '../BuildsPage/lib/Status';
|
||||
import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity';
|
||||
|
||||
const useStyles = makeStyles<Theme>({
|
||||
externalLinkIcon: {
|
||||
@@ -38,6 +38,7 @@ const WidgetContent = ({
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
if (loading || !lastRun) return <LinearProgress />;
|
||||
|
||||
return (
|
||||
<StructuredMetadataTable
|
||||
metadata={{
|
||||
@@ -60,20 +61,10 @@ const WidgetContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const JenkinsLastBuildWidget = ({
|
||||
entity,
|
||||
branch = 'master',
|
||||
}: {
|
||||
entity: Entity;
|
||||
branch: string;
|
||||
}) => {
|
||||
const [owner, repo] = (
|
||||
entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/'
|
||||
).split('/');
|
||||
const [{ loading, value }] = useBuilds(owner, repo, branch);
|
||||
|
||||
const lastRun = value ?? {};
|
||||
|
||||
export const LatestRunCard = ({ branch = 'master' }: { branch: string }) => {
|
||||
const { owner, repo } = useProjectSlugFromEntity();
|
||||
const [{ builds, loading }] = useBuilds(owner, repo, branch);
|
||||
const lastRun = builds ?? {};
|
||||
return (
|
||||
<InfoCard title={`Last ${branch} build`}>
|
||||
<WidgetContent loading={loading} branch={branch} lastRun={lastRun} />
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './PluginHeader';
|
||||
export { LatestRunCard } from './Cards';
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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 { Header, Page, pageTheme, HeaderLabel } from '@backstage/core';
|
||||
|
||||
export const Layout: React.FC = ({ children }) => {
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header title="Jenkins" subtitle="See recent builds and their status">
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
</Header>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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 * from './Layout';
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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 { ContentHeader, SupportButton } from '@backstage/core';
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
|
||||
export type Props = { title?: string };
|
||||
export const PluginHeader = ({ title = 'Jenkins' }) => {
|
||||
return (
|
||||
<ContentHeader
|
||||
title={title}
|
||||
titleComponent={() => (
|
||||
<Box alignItems="center" display="flex">
|
||||
<Typography variant="h4">{title}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
<SupportButton>
|
||||
This plugin allows you to view and interact with your builds in Jenkins.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 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 { Route, Routes } from 'react-router';
|
||||
import { buildRouteRef, rootRouteRef } from '../plugin';
|
||||
import { DetailedViewPage } from './BuildWithStepsPage/';
|
||||
import { JENKINS_ANNOTATION } from '../constants';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { WarningPanel } from '@backstage/core';
|
||||
import { CITable } from './BuildsPage/lib/CITable';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[JENKINS_ANNOTATION]) &&
|
||||
entity.metadata.annotations?.[JENKINS_ANNOTATION] !== '';
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) => {
|
||||
return !isPluginApplicableToEntity(entity) ? (
|
||||
<WarningPanel title="Jenkins plugin:">
|
||||
<pre>entity.metadata.annotations['{JENKINS_ANNOTATION}']</pre>
|
||||
key is missing on the entity.
|
||||
</WarningPanel>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path={`/${rootRouteRef.path}`} element={<CITable />} />
|
||||
<Route path={`/${buildRouteRef.path}`} element={<DetailedViewPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
import { errorApiRef, useApi } from '@backstage/core';
|
||||
import { useCallback } from 'react';
|
||||
import { useAsyncRetry } from 'react-use';
|
||||
import { jenkinsApiRef } from '../api/index';
|
||||
import { jenkinsApiRef } from '../api';
|
||||
import { useAsyncPolling } from './useAsyncPolling';
|
||||
|
||||
const INTERVAL_AMOUNT = 1500;
|
||||
+5
-4
@@ -56,8 +56,9 @@ export function useBuilds(owner: string, repo: string, branch?: string) {
|
||||
});
|
||||
}, [repo, getBuilds]);
|
||||
|
||||
const { loading, value, retry } = useAsyncRetry(
|
||||
() => getBuilds().then(builds => builds ?? [], restartBuild),
|
||||
const { loading, value: builds, retry } = useAsyncRetry(
|
||||
() =>
|
||||
getBuilds().then(retrievedBuilds => retrievedBuilds ?? [], restartBuild),
|
||||
[page, pageSize, getBuilds],
|
||||
);
|
||||
|
||||
@@ -67,12 +68,12 @@ export function useBuilds(owner: string, repo: string, branch?: string) {
|
||||
page,
|
||||
pageSize,
|
||||
loading,
|
||||
value,
|
||||
builds,
|
||||
projectName,
|
||||
total,
|
||||
},
|
||||
{
|
||||
getBuilds,
|
||||
builds,
|
||||
setPage,
|
||||
setPageSize,
|
||||
restartBuild,
|
||||
+6
-7
@@ -13,15 +13,14 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useEntity } from '@backstage/plugin-catalog';
|
||||
import { JENKINS_ANNOTATION } from '../constants';
|
||||
|
||||
import React from 'react';
|
||||
import { Builds } from '../../pages/BuildsPage/lib/Builds';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
export const useProjectSlugFromEntity = () => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
export const JenkinsBuildsWidget = ({ entity }: { entity: Entity }) => {
|
||||
const [owner, repo] = (
|
||||
entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/'
|
||||
entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? ''
|
||||
).split('/');
|
||||
|
||||
return <Builds owner={owner} repo={repo} />;
|
||||
return { owner, repo };
|
||||
};
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { Builds } from './Builds';
|
||||
export const JENKINS_ANNOTATION = 'jenkins.io/github-folder';
|
||||
@@ -14,5 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin, JenkinsBuildsWidget, JenkinsLastBuildWidget } from './plugin';
|
||||
export { plugin } from './plugin';
|
||||
export { LatestRunCard } from './components/Cards';
|
||||
export { Router, isPluginApplicableToEntity } from './components/Router';
|
||||
export { JENKINS_ANNOTATION } from './constants';
|
||||
export * from './api';
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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, { FC, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Content, InfoCard, Progress } from '@backstage/core';
|
||||
import { Grid, Box, Link, IconButton } from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { PluginHeader } from '../../components/PluginHeader';
|
||||
import { ActionOutput } from './lib/ActionOutput/ActionOutput';
|
||||
import { Layout } from '../../components/Layout';
|
||||
import LaunchIcon from '@material-ui/icons/Launch';
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
import { useBuildWithSteps } from '../../state/useBuildWithSteps';
|
||||
|
||||
const IconLink = IconButton as typeof Link;
|
||||
const BuildName: FC<{ build?: any }> = ({ build }) => (
|
||||
<Box display="flex" alignItems="center">
|
||||
{build?.buildName}
|
||||
<IconLink href={build?.url} target="_blank" title="View on Jenkins">
|
||||
<LaunchIcon /> {/* TODO use Jenkins logo*/}
|
||||
</IconLink>
|
||||
<IconLink href={build?.source.url} target="_blank" title="View on GitHub">
|
||||
<GitHubIcon />
|
||||
</IconLink>
|
||||
</Box>
|
||||
);
|
||||
const useStyles = makeStyles(theme => ({
|
||||
neutral: {},
|
||||
failed: {
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
pointerEvents: 'none',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
boxShadow: `inset 4px 0px 0px ${theme.palette.error.main}`,
|
||||
},
|
||||
},
|
||||
running: {
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
pointerEvents: 'none',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
boxShadow: `inset 4px 0px 0px ${theme.palette.info.main}`,
|
||||
},
|
||||
},
|
||||
cardContent: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
success: {
|
||||
position: 'relative',
|
||||
'&:after': {
|
||||
pointerEvents: 'none',
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
boxShadow: `inset 4px 0px 0px ${theme.palette.success.main}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const pickClassName = (
|
||||
classes: ReturnType<typeof useStyles>,
|
||||
build: any = {},
|
||||
) => {
|
||||
if (build.result === 'UNSTABLE') return classes.failed;
|
||||
if (build.result === 'FAILURE') return classes.failed;
|
||||
if (build.building) return classes.running;
|
||||
if (build.status === 'SUCCESS') return classes.success;
|
||||
|
||||
return classes.neutral;
|
||||
};
|
||||
|
||||
const Page = () => (
|
||||
<Layout>
|
||||
<Content>
|
||||
<BuildWithStepsView />
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
const BuildWithStepsView = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const buildPath = searchParams.get('url') || '';
|
||||
const classes = useStyles();
|
||||
const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps(
|
||||
buildPath,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
return () => stopPolling();
|
||||
}, [buildPath, startPolling, stopPolling]);
|
||||
return (
|
||||
<>
|
||||
<PluginHeader title={value?.source.displayName || 'Build details'} />
|
||||
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard
|
||||
className={pickClassName(classes, value)}
|
||||
title={<BuildName build={value} />}
|
||||
cardClassName={classes.cardContent}
|
||||
>
|
||||
{loading ? <Progress /> : <BuildsList build={value} />}
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const BuildsList: FC<{ build?: any }> = ({ build }) => (
|
||||
<Box>
|
||||
{build &&
|
||||
build.steps &&
|
||||
build.steps.map(({ name, actions }: { name: string; actions: any[] }) => (
|
||||
<ActionsList name={name} actions={actions} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ActionsList: FC<{ actions: any[]; name: string }> = ({ actions }) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<>
|
||||
{actions.map((action: any) => (
|
||||
<ActionOutput
|
||||
className={action.failed ? classes.failed : classes.success}
|
||||
action={action}
|
||||
name={action.name}
|
||||
url={action.output_url || ''}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export { BuildWithStepsView as BuildWithSteps };
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 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 { CITable } from '../CITable';
|
||||
import { useBuilds } from '../../../../state';
|
||||
|
||||
export const Builds = ({ owner, repo }: { owner: string; repo: string }) => {
|
||||
const [
|
||||
{ total, loading, value, projectName, page, pageSize },
|
||||
{ setPage, retry, setPageSize },
|
||||
] = useBuilds(owner, repo);
|
||||
return (
|
||||
<CITable
|
||||
total={total}
|
||||
loading={loading}
|
||||
retry={retry}
|
||||
builds={value ?? []}
|
||||
projectName={projectName}
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
pageSize={pageSize}
|
||||
onChangePageSize={setPageSize}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -20,11 +20,15 @@ import {
|
||||
createApiFactory,
|
||||
configApiRef,
|
||||
} from '@backstage/core';
|
||||
import { DetailedViewPage } from './pages/BuildWithStepsPage';
|
||||
import { jenkinsApiRef, JenkinsApi } from './api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '',
|
||||
title: 'Jenkins',
|
||||
});
|
||||
|
||||
export const buildRouteRef = createRouteRef({
|
||||
path: '/jenkins/job',
|
||||
path: 'run/:branch/:buildNumber',
|
||||
title: 'Jenkins run',
|
||||
});
|
||||
|
||||
@@ -40,10 +44,4 @@ export const plugin = createPlugin({
|
||||
),
|
||||
}),
|
||||
],
|
||||
register({ router }) {
|
||||
router.addRoute(buildRouteRef, DetailedViewPage);
|
||||
},
|
||||
});
|
||||
|
||||
export { JenkinsBuildsWidget } from './components/JenkinsPluginWidget/JenkinsBuildsWidget';
|
||||
export { JenkinsLastBuildWidget } from './components/JenkinsPluginWidget/JenkinsLastBuildWidget';
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"git-url-parse": "^11.1.2",
|
||||
"globby": "^11.0.0",
|
||||
"helmet": "^4.0.0",
|
||||
"jsonschema": "^1.2.6",
|
||||
"morgan": "^1.10.0",
|
||||
"nodegit": "0.26.5",
|
||||
"uuid": "^8.2.0",
|
||||
|
||||
@@ -54,50 +54,83 @@ const {
|
||||
};
|
||||
|
||||
describe('GitHub Publisher', () => {
|
||||
const publisher = new GithubPublisher({ client: new Octokit() });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('publish: createRemoteInGithub', () => {
|
||||
it('should use octokit to create a repo in an organisation if the organisation property is set', async () => {
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
describe('with public repo visibility', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'public',
|
||||
});
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam/team',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
describe('publish: createRemoteInGithub', () => {
|
||||
it('should use octokit to create a repo in an organisation if the organisation property is set', async () => {
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam/team',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
name: 'test',
|
||||
private: false,
|
||||
visibility: 'public',
|
||||
});
|
||||
expect(
|
||||
mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg,
|
||||
).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
team_slug: 'team',
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
permission: 'admin',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
name: 'test',
|
||||
});
|
||||
expect(
|
||||
mockGithubClient.teams.addOrUpdateRepoPermissionsInOrg,
|
||||
).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
team_slug: 'team',
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
permission: 'admin',
|
||||
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'User',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
private: false,
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use octokit to create a repo in the authed user if the organisation property is not set', async () => {
|
||||
it('should invite other user in the authed user', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
@@ -113,7 +146,7 @@ describe('GitHub Publisher', () => {
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'blam',
|
||||
access: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
@@ -122,151 +155,194 @@ describe('GitHub Publisher', () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
private: false,
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
username: 'bob',
|
||||
permission: 'admin',
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should invite other user in the authed user', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'User',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values: {
|
||||
describe('publish: createGitDirectory', () => {
|
||||
const values = {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
access: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
owner: 'lols',
|
||||
access: 'lols',
|
||||
};
|
||||
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
});
|
||||
expect(mockGithubClient.repos.addCollaborator).toHaveBeenCalledWith({
|
||||
owner: 'blam',
|
||||
repo: 'test',
|
||||
username: 'bob',
|
||||
permission: 'admin',
|
||||
const mockDir = '/tmp/test/dir';
|
||||
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
it('should call init on the repo with the directory', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Repository.init).toHaveBeenCalledWith(mockDir, 0);
|
||||
});
|
||||
|
||||
it('should call refresh index on the index and write the new files', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockRepo.refreshIndex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call add all files and write', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockIndex.addAll).toHaveBeenCalled();
|
||||
expect(mockIndex.write).toHaveBeenCalled();
|
||||
expect(mockIndex.writeTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a commit with on head with the right name and commiter', async () => {
|
||||
const mockSignature = { mockSignature: 'bloblly' };
|
||||
Signature.now.mockReturnValue(mockSignature);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Signature.now).toHaveBeenCalledTimes(2);
|
||||
expect(Signature.now).toHaveBeenCalledWith(
|
||||
'Scaffolder',
|
||||
'scaffolder@backstage.io',
|
||||
);
|
||||
|
||||
expect(mockRepo.createCommit).toHaveBeenCalledWith(
|
||||
'HEAD',
|
||||
mockSignature,
|
||||
mockSignature,
|
||||
'initial commit',
|
||||
'mockoid',
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a remote with the repo and remote', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Remote.create).toHaveBeenCalledWith(
|
||||
mockRepo,
|
||||
'origin',
|
||||
'mockclone',
|
||||
);
|
||||
});
|
||||
|
||||
it('shoud push to the remote repo', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
const [remotes, { callbacks }] = mockRemote.push.mock
|
||||
.calls[0] as NodeGit.PushOptions[];
|
||||
|
||||
expect(remotes).toEqual(['refs/heads/master:refs/heads/master']);
|
||||
|
||||
callbacks?.credentials?.();
|
||||
|
||||
expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith(
|
||||
'abc',
|
||||
'x-oauth-basic',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish: createGitDirectory', () => {
|
||||
const values = {
|
||||
storePath: 'blam/test',
|
||||
owner: 'lols',
|
||||
access: 'lols',
|
||||
};
|
||||
|
||||
const mockDir = '/tmp/test/dir';
|
||||
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
it('should call init on the repo with the directory', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(Repository.init).toHaveBeenCalledWith(mockDir, 0);
|
||||
describe('with internal repo visibility', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'internal',
|
||||
});
|
||||
|
||||
it('should call refresh index on the index and write the new files', async () => {
|
||||
it('creates a private repository in the organization with visibility set to internal', async () => {
|
||||
mockGithubClient.repos.createInOrg.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'Organization',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
values: {
|
||||
isOrg: true,
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(mockRepo.refreshIndex).toHaveBeenCalled();
|
||||
expect(mockGithubClient.repos.createInOrg).toHaveBeenCalledWith({
|
||||
org: 'blam',
|
||||
name: 'test',
|
||||
private: true,
|
||||
visibility: 'internal',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('private visibility in a user account', () => {
|
||||
const publisher = new GithubPublisher({
|
||||
client: new Octokit(),
|
||||
token: 'abc',
|
||||
repoVisibility: 'private',
|
||||
});
|
||||
|
||||
it('should call add all files and write', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
expect(mockIndex.addAll).toHaveBeenCalled();
|
||||
expect(mockIndex.write).toHaveBeenCalled();
|
||||
expect(mockIndex.writeTree).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a commit with on head with the right name and commiter', async () => {
|
||||
const mockSignature = { mockSignature: 'bloblly' };
|
||||
Signature.now.mockReturnValue(mockSignature);
|
||||
it('creates a private repository', async () => {
|
||||
mockGithubClient.repos.createForAuthenticatedUser.mockResolvedValue({
|
||||
data: {
|
||||
clone_url: 'mockclone',
|
||||
},
|
||||
} as OctokitResponse<ReposCreateInOrgResponseData>);
|
||||
mockGithubClient.users.getByUsername.mockResolvedValue({
|
||||
data: {
|
||||
type: 'User',
|
||||
},
|
||||
} as OctokitResponse<UsersGetByUsernameResponseData>);
|
||||
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
values: {
|
||||
storePath: 'blam/test',
|
||||
owner: 'bob',
|
||||
},
|
||||
directory: '/tmp/test',
|
||||
});
|
||||
|
||||
expect(Signature.now).toHaveBeenCalledTimes(2);
|
||||
expect(Signature.now).toHaveBeenCalledWith(
|
||||
'Scaffolder',
|
||||
'scaffolder@backstage.io',
|
||||
);
|
||||
|
||||
expect(mockRepo.createCommit).toHaveBeenCalledWith(
|
||||
'HEAD',
|
||||
mockSignature,
|
||||
mockSignature,
|
||||
'initial commit',
|
||||
'mockoid',
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a remote with the repo and remote', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
expect(
|
||||
mockGithubClient.repos.createForAuthenticatedUser,
|
||||
).toHaveBeenCalledWith({
|
||||
name: 'test',
|
||||
private: true,
|
||||
});
|
||||
|
||||
expect(Remote.create).toHaveBeenCalledWith(
|
||||
mockRepo,
|
||||
'origin',
|
||||
'mockclone',
|
||||
);
|
||||
});
|
||||
|
||||
it('shoud push to the remote repo', async () => {
|
||||
await publisher.publish({
|
||||
values,
|
||||
directory: mockDir,
|
||||
});
|
||||
|
||||
const [remotes, { callbacks }] = mockRemote.push.mock
|
||||
.calls[0] as NodeGit.PushOptions[];
|
||||
|
||||
expect(remotes).toEqual(['refs/heads/master:refs/heads/master']);
|
||||
|
||||
process.env.GITHUb_ACCESS_TOKEN = 'blob';
|
||||
|
||||
callbacks?.credentials?.();
|
||||
|
||||
expect(Cred.userpassPlaintextNew).toHaveBeenCalledWith(
|
||||
process.env.GITHUB_ACCESS_TOKEN,
|
||||
'x-oauth-basic',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,10 +21,27 @@ import { JsonValue } from '@backstage/config';
|
||||
import { RequiredTemplateValues } from '../templater';
|
||||
import { Repository, Remote, Signature, Cred } from 'nodegit';
|
||||
|
||||
export type RepoVisilityOptions = 'private' | 'internal' | 'public';
|
||||
|
||||
interface GithubPublisherParams {
|
||||
client: Octokit;
|
||||
token: string;
|
||||
repoVisibility: RepoVisilityOptions;
|
||||
}
|
||||
|
||||
export class GithubPublisher implements PublisherBase {
|
||||
private client: Octokit;
|
||||
constructor({ client }: { client: Octokit }) {
|
||||
private token: string;
|
||||
private repoVisibility: RepoVisilityOptions;
|
||||
|
||||
constructor({
|
||||
client,
|
||||
token,
|
||||
repoVisibility = 'public',
|
||||
}: GithubPublisherParams) {
|
||||
this.client = client;
|
||||
this.token = token;
|
||||
this.repoVisibility = repoVisibility;
|
||||
}
|
||||
|
||||
async publish({
|
||||
@@ -50,8 +67,16 @@ export class GithubPublisher implements PublisherBase {
|
||||
|
||||
const repoCreationPromise =
|
||||
user.data.type === 'Organization'
|
||||
? this.client.repos.createInOrg({ name, org: owner })
|
||||
: this.client.repos.createForAuthenticatedUser({ name });
|
||||
? this.client.repos.createInOrg({
|
||||
name,
|
||||
org: owner,
|
||||
private: this.repoVisibility !== 'public',
|
||||
visibility: this.repoVisibility,
|
||||
})
|
||||
: this.client.repos.createForAuthenticatedUser({
|
||||
name,
|
||||
private: this.repoVisibility === 'private',
|
||||
});
|
||||
|
||||
const { data } = await repoCreationPromise;
|
||||
|
||||
@@ -96,10 +121,7 @@ export class GithubPublisher implements PublisherBase {
|
||||
await remoteRepo.push(['refs/heads/master:refs/heads/master'], {
|
||||
callbacks: {
|
||||
credentials: () => {
|
||||
return Cred.userpassPlaintextNew(
|
||||
process.env.GITHUB_ACCESS_TOKEN as string,
|
||||
'x-oauth-basic',
|
||||
);
|
||||
return Cred.userpassPlaintextNew(this.token, 'x-oauth-basic');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2020 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 { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createRouter } from './router';
|
||||
import { Templaters, Preparers, PublisherBase } from '../scaffolder';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
jest.mock('dockerode');
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
const publisher: jest.Mocked<PublisherBase> = { publish: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
preparers: new Preparers(),
|
||||
templaters: new Templaters(),
|
||||
publisher: publisher,
|
||||
dockerClient: new Docker(),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /v1/jobs', () => {
|
||||
const template = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
description: 'Create a new CRA website project',
|
||||
name: 'create-react-app-template',
|
||||
tags: ['experimental', 'react', 'cra'],
|
||||
title: 'Create React App Template',
|
||||
},
|
||||
spec: {
|
||||
owner: 'web@example.com',
|
||||
path: '.',
|
||||
schema: {
|
||||
properties: {
|
||||
component_id: {
|
||||
description: 'Unique name of the component',
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
},
|
||||
description: {
|
||||
description: 'Description of the component',
|
||||
title: 'Description',
|
||||
type: 'string',
|
||||
},
|
||||
use_typescript: {
|
||||
default: true,
|
||||
description: 'Include typescript',
|
||||
title: 'Use Typescript',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['component_id', 'use_typescript'],
|
||||
},
|
||||
templater: 'cra',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
it('rejects template values which do not match the template schema definition', async () => {
|
||||
const response = await request(app).post('/v1/jobs').send({
|
||||
template,
|
||||
values: {},
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TemplaterBuilder,
|
||||
PublisherBase,
|
||||
} from '../scaffolder';
|
||||
import { validate, ValidatorResult } from 'jsonschema';
|
||||
|
||||
export interface RouterOptions {
|
||||
preparers: PreparerBuilder;
|
||||
@@ -84,6 +85,15 @@ export async function createRouter(
|
||||
const values: RequiredTemplateValues & Record<string, JsonValue> =
|
||||
req.body.values;
|
||||
|
||||
const validationResult: ValidatorResult = validate(
|
||||
values,
|
||||
template.spec.schema,
|
||||
);
|
||||
if (!validationResult.valid) {
|
||||
res.status(400).json({ errors: validationResult.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobProcessor.create({
|
||||
entity: template,
|
||||
values,
|
||||
|
||||
@@ -13288,6 +13288,11 @@ jsonpointer@^4.0.1:
|
||||
resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9"
|
||||
integrity sha1-T9kss04OnbPInIYi7PUfm5eMbLk=
|
||||
|
||||
jsonschema@^1.2.6:
|
||||
version "1.2.6"
|
||||
resolved "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.6.tgz#52b0a8e9dc06bbae7295249d03e4b9faee8a0c0b"
|
||||
integrity sha512-SqhURKZG07JyKKeo/ir24QnS4/BV7a6gQy93bUSe4lUdNp0QNpIz2c9elWJQ9dpc5cQYY6cvCzgRwy0MQCLyqA==
|
||||
|
||||
jspdf-autotable@3.5.3:
|
||||
version "3.5.3"
|
||||
resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.5.3.tgz#2f73adb07f340e7dbf22950e3e6c8bf853991479"
|
||||
|
||||
Reference in New Issue
Block a user