Merge branch 'master' into add-access-support

This commit is contained in:
Tim Jacomb
2020-09-16 16:02:01 +01:00
51 changed files with 1999 additions and 533 deletions
+2
View File
@@ -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>
);
+16 -3
View File
@@ -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
@@ -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({
+8
View File
@@ -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 = '';
+3 -2
View File
@@ -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.
+2
View File
@@ -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>
);
};
@@ -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';
+2 -12
View File
@@ -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
+1
View File
@@ -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",
+19 -1
View File
@@ -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 };
@@ -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}
/>
);
};
@@ -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} />
@@ -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>
);
};
+41
View File
@@ -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>
);
};
@@ -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;
@@ -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,
@@ -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 };
};
@@ -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';
+4 -1
View File
@@ -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}
/>
);
};
+6 -8
View File
@@ -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';
+1
View File
@@ -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,
+5
View File
@@ -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"