Merge pull request #2282 from spotify/shmidt-i/circle-ci-plugin-new-route-api
CircleCI plugin new route api
This commit is contained in:
@@ -4,8 +4,6 @@ Website: [https://circleci.com/](https://circleci.com/)
|
||||
|
||||
<img src="./src/assets/screenshot-1.png" />
|
||||
<img src="./src/assets/screenshot-2.png" />
|
||||
<img src="./src/assets/screenshot-3.png" />
|
||||
<img src="./src/assets/screenshot-4.png" />
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -35,8 +33,39 @@ export default builder.build() as ApiHolder;
|
||||
export { plugin as Circleci } from '@backstage/plugin-circleci';
|
||||
```
|
||||
|
||||
3. Run app with `yarn start` and navigate to `/circleci/settings`
|
||||
4. Enter project settings and **project** token, acquired according to [https://circleci.com/docs/2.0/managing-api-tokens/](https://circleci.com/docs/2.0/managing-api-tokens/)
|
||||
3. Register the plugin router:
|
||||
|
||||
```jsx
|
||||
// packages/app/src/components/catalog/EntityPage.tsx
|
||||
|
||||
import { Router as CircleCIRouter } from '@backstage/plugin-circleci';
|
||||
|
||||
// Then somewhere inside <EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/ci-cd/*"
|
||||
title="CI/CD"
|
||||
element={<CircleCIRouter />}
|
||||
/>;
|
||||
```
|
||||
|
||||
4. Add proxy config:
|
||||
|
||||
```
|
||||
// app-config.yaml
|
||||
proxy:
|
||||
'/circleci/api':
|
||||
target: https://circleci.com/api/v1.1
|
||||
changeOrigin: true
|
||||
pathRewrite:
|
||||
'^/proxy/circleci/api/': '/'
|
||||
headers:
|
||||
Circle-Token:
|
||||
$secret:
|
||||
env: CIRCLECI_AUTH_TOKEN
|
||||
```
|
||||
|
||||
5. Get and provide `CIRCLECI_AUTH_TOKEN` as env variable (https://circleci.com/docs/api/#add-an-api-token)
|
||||
6. Add `circleci.com/project-slug` annotation to your component-info.yaml file in format <git-provider>/<owner>/<project> (https://backstage.io/docs/architecture-decisions/adrs-adr002#format)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -50,3 +79,4 @@ export { plugin as Circleci } from '@backstage/plugin-circleci';
|
||||
## Limitations
|
||||
|
||||
- CircleCI has pretty strict rate limits per token, be careful with opened tabs
|
||||
- CircelCI doesn't provide a way to auth by 3rd party (e.g. GitHub) token, nor by calling their OAuth endpoints, which currently stands in the way of better auth integration with Backstage (https://discuss.circleci.com/t/circleci-api-authorization-with-github-token/5356)
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.21",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
|
||||
"@backstage/theme": "^0.1.1-alpha.21",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
@@ -42,8 +42,8 @@ export class CircleCIApi {
|
||||
this.apiUrl = apiUrl;
|
||||
}
|
||||
|
||||
async retry(buildNumber: number, options: CircleCIOptions) {
|
||||
return postBuildActions(options.token, buildNumber, BuildAction.RETRY, {
|
||||
async retry(buildNumber: number, options: Partial<CircleCIOptions>) {
|
||||
return postBuildActions('', buildNumber, BuildAction.RETRY, {
|
||||
circleHost: this.apiUrl,
|
||||
...options.vcs,
|
||||
});
|
||||
@@ -51,9 +51,9 @@ export class CircleCIApi {
|
||||
|
||||
async getBuilds(
|
||||
{ limit = 10, offset = 0 }: { limit: number; offset: number },
|
||||
options: CircleCIOptions,
|
||||
options: Partial<CircleCIOptions>,
|
||||
) {
|
||||
return getBuildSummaries(options.token, {
|
||||
return getBuildSummaries('', {
|
||||
options: {
|
||||
limit,
|
||||
offset,
|
||||
@@ -64,12 +64,12 @@ export class CircleCIApi {
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(options: CircleCIOptions) {
|
||||
return getMe(options.token, { circleHost: this.apiUrl, ...options });
|
||||
async getUser(options: Partial<CircleCIOptions>) {
|
||||
return getMe('', { circleHost: this.apiUrl, ...options });
|
||||
}
|
||||
|
||||
async getBuild(buildNumber: number, options: CircleCIOptions) {
|
||||
return getFullBuild(options.token, buildNumber, {
|
||||
async getBuild(buildNumber: number, options: Partial<CircleCIOptions>) {
|
||||
return getFullBuild('', buildNumber, {
|
||||
circleHost: this.apiUrl,
|
||||
...options.vcs,
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 235 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 807 KiB After Width: | Height: | Size: 212 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 856 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 943 KiB |
+43
-54
@@ -15,20 +15,22 @@
|
||||
*/
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Content, InfoCard, Progress } from '@backstage/core';
|
||||
import { InfoCard, Progress, Link } from '@backstage/core';
|
||||
import { BuildWithSteps, BuildStepAction } from '../../api';
|
||||
import { Grid, Box, Link, IconButton } from '@material-ui/core';
|
||||
import {
|
||||
Grid,
|
||||
Box,
|
||||
IconButton,
|
||||
Breadcrumbs,
|
||||
Typography,
|
||||
Link as MaterialLink,
|
||||
} 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 { useSettings } from '../../state/useSettings';
|
||||
import { useBuildWithSteps } from '../../state/useBuildWithSteps';
|
||||
import { AppStateProvider } from '../../state';
|
||||
import { Settings } from '../../components/Settings';
|
||||
|
||||
const IconLink = IconButton as typeof Link;
|
||||
const IconLink = (IconButton as any) as typeof MaterialLink;
|
||||
const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
|
||||
<Box display="flex" alignItems="center">
|
||||
#{build?.build_num} - {build?.subject}
|
||||
@@ -94,56 +96,13 @@ const pickClassName = (
|
||||
return classes.neutral;
|
||||
};
|
||||
|
||||
const Page = () => (
|
||||
<AppStateProvider>
|
||||
<Layout>
|
||||
<Content>
|
||||
<BuildWithStepsView />
|
||||
<Settings />
|
||||
</Content>
|
||||
</Layout>
|
||||
</AppStateProvider>
|
||||
);
|
||||
|
||||
const BuildWithStepsView: FC<{}> = () => {
|
||||
const { buildId = '' } = useParams();
|
||||
const classes = useStyles();
|
||||
const [settings] = useSettings();
|
||||
const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps(
|
||||
parseInt(buildId, 10),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
return () => stopPolling();
|
||||
}, [buildId, settings, startPolling, stopPolling]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PluginHeader title="Build info" />
|
||||
|
||||
<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?: BuildWithSteps }> = ({ build }) => (
|
||||
<Box>
|
||||
{build &&
|
||||
build.steps &&
|
||||
build.steps.map(
|
||||
({ name, actions }: { name: string; actions: BuildStepAction[] }) => (
|
||||
<ActionsList name={name} actions={actions} />
|
||||
<ActionsList key={name} name={name} actions={actions} />
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
@@ -167,5 +126,35 @@ const ActionsList: FC<{ actions: BuildStepAction[]; name: string }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export { BuildWithStepsView as BuildWithSteps };
|
||||
export const BuildWithStepsPage = () => {
|
||||
const { buildId = '' } = useParams();
|
||||
const classes = useStyles();
|
||||
const [{ loading, value }, { startPolling, stopPolling }] = useBuildWithSteps(
|
||||
parseInt(buildId, 10),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
return () => stopPolling();
|
||||
}, [buildId, startPolling, stopPolling]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs aria-label="breadcrumb">
|
||||
<Link to="..">All builds</Link>
|
||||
<Typography>Build details</Typography>
|
||||
</Breadcrumbs>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './PluginHeader';
|
||||
export { BuildWithStepsPage } from './BuildWithStepsPage';
|
||||
+11
-4
@@ -13,7 +13,14 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export {
|
||||
default as DetailedViewPage,
|
||||
BuildWithSteps,
|
||||
} from './BuildWithStepsPage';
|
||||
import React from 'react';
|
||||
import { Builds } from './lib/Builds';
|
||||
import { Grid } from '@material-ui/core';
|
||||
|
||||
export const BuildsPage = () => (
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<Builds />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
+1
-1
@@ -13,4 +13,4 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './Layout';
|
||||
export { BuildsPage } from './BuildsPage';
|
||||
+6
-2
@@ -17,7 +17,7 @@ import React, { FC } from 'react';
|
||||
import { Link, Typography, Box, IconButton } 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 { Link as RouterLink, generatePath } from 'react-router-dom';
|
||||
import {
|
||||
StatusError,
|
||||
StatusWarning,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Table,
|
||||
TableColumn,
|
||||
} from '@backstage/core';
|
||||
import { circleCIBuildRouteRef } from '../../../../route-refs';
|
||||
|
||||
export type CITableBuildInfo = {
|
||||
id: string;
|
||||
@@ -80,7 +81,10 @@ const generatedColumns: TableColumn[] = [
|
||||
field: 'buildName',
|
||||
highlight: true,
|
||||
render: (row: Partial<CITableBuildInfo>) => (
|
||||
<Link component={RouterLink} to={`/circleci/build/${row.id}`}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`${generatePath(circleCIBuildRouteRef.path, { buildId: row.id! })}`}
|
||||
>
|
||||
{row.buildName}
|
||||
</Link>
|
||||
),
|
||||
@@ -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 { Route, MemoryRouter, Routes } from 'react-router';
|
||||
import { Builds } from '../pages/BuildsPage';
|
||||
import { BuildWithSteps } from '../pages/BuildWithStepsPage';
|
||||
import { AppStateProvider } from '../state';
|
||||
import { Settings } from './Settings';
|
||||
|
||||
// TODO: allow pass in settings as props
|
||||
// When some shared settings workflow
|
||||
// will be established
|
||||
export const CircleCIWidget = () => (
|
||||
<MemoryRouter initialEntries={['/circleci']}>
|
||||
<AppStateProvider>
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/circleci" element={<Builds />} />
|
||||
<Route path="/circleci/build/:buildId" element={<BuildWithSteps />} />
|
||||
</Routes>
|
||||
<Settings />
|
||||
</>
|
||||
</AppStateProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -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="CircleCI" subtitle="See recent builds and their status">
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
</Header>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +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 } from 'react';
|
||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||
import { ContentHeader, SupportButton } from '@backstage/core';
|
||||
import { Button, IconButton, Box, Typography } from '@material-ui/core';
|
||||
import ArrowBack from '@material-ui/icons/ArrowBack';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import { useSettings } from '../../state';
|
||||
|
||||
export type Props = { title?: string };
|
||||
export const PluginHeader: FC<Props> = ({ title = 'CircleCI' }) => {
|
||||
const [, { showSettings }] = useSettings();
|
||||
const location = useLocation();
|
||||
const notRoot = !location.pathname.match(/\/circleci\/?$/);
|
||||
const isSettingsPage = location.pathname.match(/\/circleci\/settings\/?/);
|
||||
return (
|
||||
<ContentHeader
|
||||
title={title}
|
||||
titleComponent={() => (
|
||||
<Box alignItems="center" display="flex">
|
||||
{notRoot && (
|
||||
<IconButton component={RouterLink} to="/circleci">
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h4">{title}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{!isSettingsPage && (
|
||||
<Button onClick={showSettings} startIcon={<SettingsIcon />}>
|
||||
Settings
|
||||
</Button>
|
||||
)}
|
||||
<SupportButton>
|
||||
This plugin allows you to view and interact with your builds within the
|
||||
Circle CI environment.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 { Routes, Route } from 'react-router';
|
||||
import { circleCIRouteRef, circleCIBuildRouteRef } from '../route-refs';
|
||||
import { BuildWithStepsPage } from './BuildWithStepsPage/';
|
||||
import { BuildsPage } from './BuildsPage';
|
||||
import { CIRCLECI_ANNOTATION } from '../constants';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { WarningPanel } from '@backstage/core';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[CIRCLECI_ANNOTATION]) &&
|
||||
entity.metadata.annotations?.[CIRCLECI_ANNOTATION] !== '';
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) =>
|
||||
!isPluginApplicableToEntity(entity) ? (
|
||||
<WarningPanel title="CircleCI plugin:">
|
||||
<pre>{CIRCLECI_ANNOTATION}</pre> annotation is missing on the entity.
|
||||
</WarningPanel>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path={`/${circleCIRouteRef.path}`} element={<BuildsPage />} />
|
||||
<Route
|
||||
path={`/${circleCIBuildRouteRef.path}`}
|
||||
element={<BuildWithStepsPage />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
@@ -1,129 +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, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
Snackbar,
|
||||
Box,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
} from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { useSettings } from '../../state';
|
||||
|
||||
const Settings = () => {
|
||||
const [
|
||||
{
|
||||
repo: repoFromStore,
|
||||
owner: ownerFromStore,
|
||||
token: tokenFromStore,
|
||||
showSettings,
|
||||
},
|
||||
{ saveSettings, hideSettings },
|
||||
] = useSettings();
|
||||
|
||||
const [token, setToken] = useState(() => tokenFromStore);
|
||||
const [owner, setOwner] = useState(() => ownerFromStore);
|
||||
const [repo, setRepo] = useState(() => repoFromStore);
|
||||
|
||||
useEffect(() => {
|
||||
if (tokenFromStore !== token) {
|
||||
setToken(token);
|
||||
}
|
||||
if (ownerFromStore !== owner) {
|
||||
setOwner(owner);
|
||||
}
|
||||
if (repoFromStore !== repo) {
|
||||
setRepo(repo);
|
||||
}
|
||||
}, [ownerFromStore, repoFromStore, tokenFromStore, token, owner, repo]);
|
||||
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Snackbar
|
||||
autoHideDuration={1000}
|
||||
open={saved}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
onClose={() => setSaved(false)}
|
||||
>
|
||||
<Alert severity="success">Credentials saved.</Alert>
|
||||
</Snackbar>
|
||||
<Dialog open={showSettings} onClose={hideSettings}>
|
||||
<DialogTitle>
|
||||
Project Credentials
|
||||
{/* {authed ? <StatusOK /> : <StatusFailed />} */}
|
||||
</DialogTitle>
|
||||
<Box minWidth="400px">
|
||||
<List>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="circleci-token"
|
||||
label="Token"
|
||||
value={token}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onChange={e => setToken(e.target.value)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="circleci-owner"
|
||||
fullWidth
|
||||
label="Owner"
|
||||
variant="outlined"
|
||||
value={owner}
|
||||
onChange={e => setOwner(e.target.value)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="circleci-repo"
|
||||
label="Repo"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={repo}
|
||||
onChange={e => setRepo(e.target.value)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Box mt={2} display="flex" width="100%" justifyContent="center">
|
||||
<Button
|
||||
data-testid="github-auth-button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setSaved(true);
|
||||
saveSettings({ repo, owner, token });
|
||||
hideSettings();
|
||||
}}
|
||||
>
|
||||
Save credentials
|
||||
</Button>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
+2
-1
@@ -13,4 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { default as Settings } from './Settings';
|
||||
|
||||
export const CIRCLECI_ANNOTATION = 'circleci.com/project-slug';
|
||||
@@ -17,4 +17,5 @@
|
||||
export { plugin } from './plugin';
|
||||
export * from './api';
|
||||
export * from './route-refs';
|
||||
export { CircleCIWidget } from './components/CircleCIWidget';
|
||||
export { Router, isPluginApplicableToEntity } from './components/Router';
|
||||
export { CIRCLECI_ANNOTATION } from './constants';
|
||||
|
||||
@@ -1,48 +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 } from 'react';
|
||||
import { Content } from '@backstage/core';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { Builds as BuildsComp } from './lib/Builds';
|
||||
import { Layout } from '../../components/Layout';
|
||||
import { PluginHeader } from '../../components/PluginHeader';
|
||||
import { AppStateProvider } from '../../state/AppState';
|
||||
import { Settings } from '../../components/Settings';
|
||||
|
||||
const BuildsPage: FC<{}> = () => (
|
||||
<AppStateProvider>
|
||||
<Layout>
|
||||
<Content>
|
||||
<Builds />
|
||||
<Settings />
|
||||
</Content>
|
||||
</Layout>
|
||||
</AppStateProvider>
|
||||
);
|
||||
|
||||
const Builds = () => (
|
||||
<>
|
||||
<PluginHeader title="All builds" />
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<BuildsComp />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
export default BuildsPage;
|
||||
export { Builds };
|
||||
@@ -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 { default as BuildsPage, Builds } from './BuildsPage';
|
||||
@@ -13,10 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin, createApiFactory, configApiRef } from '@backstage/core';
|
||||
import { circleCIRouteRef, circleCIBuildRouteRef } from './route-refs';
|
||||
import BuildsPage from './pages/BuildsPage/BuildsPage';
|
||||
import BuildWithStepsPage from './pages/BuildWithStepsPage/BuildWithStepsPage';
|
||||
import { circleCIApiRef, CircleCIApi } from './api';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
@@ -31,8 +29,4 @@ export const plugin = createPlugin({
|
||||
),
|
||||
}),
|
||||
],
|
||||
register({ router }) {
|
||||
router.addRoute(circleCIRouteRef, BuildsPage);
|
||||
router.addRoute(circleCIBuildRouteRef, BuildWithStepsPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,11 +33,11 @@ const CircleCIIcon: FC<SvgIconProps> = props => (
|
||||
|
||||
export const circleCIRouteRef = createRouteRef({
|
||||
icon: CircleCIIcon,
|
||||
path: '/circleci',
|
||||
path: '',
|
||||
title: 'CircleCI | All builds',
|
||||
});
|
||||
|
||||
export const circleCIBuildRouteRef = createRouteRef({
|
||||
path: '/circleci/build/:buildId',
|
||||
path: ':buildId',
|
||||
title: 'CircleCI | Build info',
|
||||
});
|
||||
|
||||
@@ -1,57 +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, useReducer, Dispatch, Reducer } from 'react';
|
||||
import { circleCIApiRef } from '../api';
|
||||
import type { State, Action, SettingsState } from './types';
|
||||
|
||||
export type { SettingsState };
|
||||
|
||||
export const AppContext = React.createContext<[State, Dispatch<Action>]>(
|
||||
[] as any,
|
||||
);
|
||||
export const STORAGE_KEY = `${circleCIApiRef.id}.settings`;
|
||||
|
||||
const initialState: State = {
|
||||
owner: '',
|
||||
repo: '',
|
||||
token: '',
|
||||
showSettings: false,
|
||||
};
|
||||
|
||||
const reducer: Reducer<State, Action> = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'setCredentials':
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
case 'showSettings':
|
||||
return { ...state, showSettings: true };
|
||||
case 'hideSettings':
|
||||
return { ...state, showSettings: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const AppStateProvider: FC = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
return (
|
||||
<AppContext.Provider value={[state, dispatch]}>
|
||||
<>{children}</>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,5 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './AppState';
|
||||
export * from './useSettings';
|
||||
export * from './useBuilds';
|
||||
export * from './useBuildWithSteps';
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export type Settings = { owner: string; repo: string; token: string };
|
||||
export type SettingsState = Settings & {
|
||||
showSettings: boolean;
|
||||
};
|
||||
|
||||
export type State = SettingsState;
|
||||
|
||||
type SettingsAction =
|
||||
| {
|
||||
type: 'setCredentials';
|
||||
payload: {
|
||||
repo: string;
|
||||
owner: string;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
| { type: 'showSettings' }
|
||||
| { type: 'hideSettings' };
|
||||
|
||||
export type Action = SettingsAction;
|
||||
@@ -14,31 +14,35 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { errorApiRef, useApi } from '@backstage/core';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useAsyncRetry } from 'react-use';
|
||||
import { circleCIApiRef, GitType } from '../api/index';
|
||||
import { circleCIApiRef } from '../api/index';
|
||||
import { useAsyncPolling } from './useAsyncPolling';
|
||||
import { useSettings } from './useSettings';
|
||||
import { useProjectSlugFromEntity, mapVcsType } from './useBuilds';
|
||||
|
||||
const INTERVAL_AMOUNT = 1500;
|
||||
export function useBuildWithSteps(buildId: number) {
|
||||
const [{ token, repo, owner }] = useSettings();
|
||||
const { vcs, repo, owner } = useProjectSlugFromEntity();
|
||||
const api = useApi(circleCIApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const vcsOption = useMemo(
|
||||
() => ({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
type: mapVcsType(vcs),
|
||||
}),
|
||||
[owner, repo, vcs],
|
||||
);
|
||||
|
||||
const getBuildWithSteps = useCallback(async () => {
|
||||
if (owner === '' || repo === '' || token === '') {
|
||||
if (owner === '' || repo === '' || vcs === '') {
|
||||
return Promise.reject('No credentials provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const options = {
|
||||
token: token,
|
||||
vcs: {
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
type: GitType.GITHUB,
|
||||
},
|
||||
vcs: vcsOption,
|
||||
};
|
||||
const build = await api.getBuild(buildId, options);
|
||||
return Promise.resolve(build);
|
||||
@@ -46,17 +50,12 @@ export function useBuildWithSteps(buildId: number) {
|
||||
errorApi.post(e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}, [token, owner, repo, buildId, api, errorApi]);
|
||||
}, [vcsOption, buildId, api, errorApi]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const restartBuild = async () => {
|
||||
try {
|
||||
await api.retry(buildId, {
|
||||
token: token,
|
||||
vcs: {
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
type: GitType.GITHUB,
|
||||
},
|
||||
vcs: vcsOption,
|
||||
});
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
|
||||
@@ -18,8 +18,9 @@ import { BuildSummary, GitType } from 'circleci-api';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAsyncRetry } from 'react-use';
|
||||
import { circleCIApiRef } from '../api/index';
|
||||
import type { CITableBuildInfo } from '../pages/BuildsPage/lib/CITable';
|
||||
import { useSettings } from './useSettings';
|
||||
import type { CITableBuildInfo } from '../components/BuildsPage/lib/CITable';
|
||||
import { useEntity } from '@backstage/plugin-catalog';
|
||||
import { CIRCLECI_ANNOTATION } from '../constants';
|
||||
|
||||
const makeReadableStatus = (status: string | undefined) => {
|
||||
if (!status) return '';
|
||||
@@ -68,8 +69,25 @@ export const transform = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useProjectSlugFromEntity = () => {
|
||||
const { entity } = useEntity();
|
||||
const [vcs, owner, repo] = (
|
||||
entity.metadata.annotations?.[CIRCLECI_ANNOTATION] ?? ''
|
||||
).split('/');
|
||||
return { vcs, owner, repo };
|
||||
};
|
||||
|
||||
export function mapVcsType(vcs: string): GitType {
|
||||
switch (vcs) {
|
||||
case 'github':
|
||||
return GitType.GITHUB;
|
||||
default:
|
||||
return GitType.BITBUCKET;
|
||||
}
|
||||
}
|
||||
|
||||
export function useBuilds() {
|
||||
const [{ repo, owner, token }] = useSettings();
|
||||
const { repo, owner, vcs } = useProjectSlugFromEntity();
|
||||
const api = useApi(circleCIApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
@@ -79,7 +97,7 @@ export function useBuilds() {
|
||||
|
||||
const getBuilds = useCallback(
|
||||
async ({ limit, offset }: { limit: number; offset: number }) => {
|
||||
if (owner === '' || repo === '' || token === '') {
|
||||
if (owner === '' || repo === '' || vcs === '') {
|
||||
return Promise.reject('No credentials provided');
|
||||
}
|
||||
|
||||
@@ -87,11 +105,10 @@ export function useBuilds() {
|
||||
return await api.getBuilds(
|
||||
{ limit, offset },
|
||||
{
|
||||
token: token,
|
||||
vcs: {
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
type: GitType.GITHUB,
|
||||
type: mapVcsType(vcs),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -100,13 +117,12 @@ export function useBuilds() {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
[repo, token, owner, api, errorApi],
|
||||
[repo, owner, vcs, api, errorApi],
|
||||
);
|
||||
|
||||
const restartBuild = async (buildId: number) => {
|
||||
try {
|
||||
await api.retry(buildId, {
|
||||
token: token,
|
||||
vcs: {
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
|
||||
@@ -1,68 +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 { errorApiRef, useApi } from '@backstage/core';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { AppContext, STORAGE_KEY } from './AppState';
|
||||
import { Settings } from './types';
|
||||
|
||||
export function useSettings() {
|
||||
const [settings, dispatch] = useContext(AppContext);
|
||||
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
useEffect(() => {
|
||||
const rehydrate = () => {
|
||||
try {
|
||||
const stateFromStorage = JSON.parse(
|
||||
sessionStorage.getItem(STORAGE_KEY)!,
|
||||
);
|
||||
if (
|
||||
stateFromStorage &&
|
||||
Object.keys(stateFromStorage).some(
|
||||
k => (settings as any)[k] !== stateFromStorage[k],
|
||||
)
|
||||
)
|
||||
dispatch({
|
||||
type: 'setCredentials',
|
||||
payload: stateFromStorage,
|
||||
});
|
||||
} catch (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
};
|
||||
|
||||
rehydrate();
|
||||
}, [dispatch, errorApi, settings]);
|
||||
|
||||
const persist = (state: Settings) => {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
};
|
||||
|
||||
return [
|
||||
settings,
|
||||
{
|
||||
saveSettings: (state: Settings) => {
|
||||
persist(state);
|
||||
dispatch({
|
||||
type: 'setCredentials',
|
||||
payload: state,
|
||||
});
|
||||
},
|
||||
showSettings: () => dispatch({ type: 'showSettings' }),
|
||||
hideSettings: () => dispatch({ type: 'hideSettings' }),
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { WorkflowRunsTable } from './WorkflowRunsTable';
|
||||
import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName';
|
||||
import { WarningPanel } from '@backstage/core';
|
||||
|
||||
const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]) &&
|
||||
entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== '';
|
||||
|
||||
@@ -30,8 +30,8 @@ export const Router = ({ entity }: { entity: Entity }) =>
|
||||
// TODO(shmidt-i): move warning to a separate standardized component
|
||||
!isPluginApplicableToEntity(entity) ? (
|
||||
<WarningPanel title=" GitHubActions plugin:">
|
||||
`entity.metadata.annotations['
|
||||
{GITHUB_ACTIONS_ANNOTATION}']` key is missing on the entity.{' '}
|
||||
<pre>{GITHUB_ACTIONS_ANNOTATION}</pre> annotation is missing on the
|
||||
entity.
|
||||
</WarningPanel>
|
||||
) : (
|
||||
<Routes>
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export * from './api';
|
||||
export { Router } from './components/Router';
|
||||
export { Router, isPluginApplicableToEntity } from './components/Router';
|
||||
export * from './components/Cards';
|
||||
export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName';
|
||||
|
||||
@@ -32,10 +32,7 @@ type Props = {
|
||||
export const Router = ({ entity }: Props) =>
|
||||
!isPluginApplicableToEntity(entity) ? (
|
||||
<WarningPanel title="Rollbar plugin:">
|
||||
<pre>
|
||||
entity.metadata.annotations['{ROLLBAR_ANNOTATION}']` key is missing on
|
||||
the entity.
|
||||
</pre>
|
||||
<pre>{ROLLBAR_ANNOTATION}</pre> annotation is missing on the entity.
|
||||
</WarningPanel>
|
||||
) : (
|
||||
<Routes>
|
||||
|
||||
Reference in New Issue
Block a user