Merge pull request #5114 from anderoo/anderoo/deployments-plugin

[PLUGIN] Github Deployments Plugin
This commit is contained in:
Fredrik Adelöw
2021-03-31 17:06:41 +02:00
committed by GitHub
17 changed files with 725 additions and 0 deletions
+1
View File
@@ -19,6 +19,7 @@
"@backstage/plugin-explore": "^0.3.2",
"@backstage/plugin-gcp-projects": "^0.2.5",
"@backstage/plugin-github-actions": "^0.4.2",
"@backstage/plugin-github-deployments": "^0.1.1",
"@backstage/plugin-gitops-profiles": "^0.2.6",
"@backstage/plugin-graphiql": "^0.2.9",
"@backstage/plugin-jenkins": "^0.4.1",
+1
View File
@@ -45,3 +45,4 @@ export { plugin as Org } from '@backstage/plugin-org';
export { plugin as Kafka } from '@backstage/plugin-kafka';
export { todoPlugin } from '@backstage/plugin-todo';
export { badgesPlugin } from '@backstage/plugin-badges';
export { githubDeploymentsPlugin } from '@backstage/plugin-github-deployments';
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+62
View File
@@ -0,0 +1,62 @@
# GitHub Deployments Plugin
The GitHub Deployments Plugin displays recent deployments from GitHub.
![github-deployments-card](./docs/github-deployments-card.png)
## Prerequisites
- [GitHub Authentication Provider](https://backstage.io/docs/auth/github/provider)
## Getting Started
1. Install the GitHub Deployments Plugin.
```bash
# packages/app
yarn add @backstage/plugin-github-deployments
```
2. Add the plugin to the app
```typescript
// packages/app/src/plugins.ts
export { githubDeploymentsPlugin as GithubDeploymentsPlugin } from '@backstage/plugin-github-deployments';
```
3. Add the `EntityGithubDeploymentsCard` to the EntityPage:
```typescript
// packages/app/src/components/catalog/EntityPage.tsx
import { EntityGithubDeploymentsCard } from '@backstage/plugin-github-deployments';
const OverviewContent = () => (
<Grid container spacing={3} alignItems="stretch">
// ...
<Grid item xs={12} sm={6} md={4}>
<EntityGithubDeploymentsCard />
</Grid>
// ...
</Grid>
);
```
4. Add the `github.com/project-slug` annotation to your `catalog-info.yaml` file:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage
description: |
Backstage is an open-source developer portal that puts the developer experience first.
annotations:
github.com/project-slug: YOUR_PROJECT_SLUG
spec:
type: library
owner: CNCF
lifecycle: experimental
```
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createDevApp } from '@backstage/dev-utils';
import { githubDeploymentsPlugin } from '../src/plugin';
createDevApp().registerPlugin(githubDeploymentsPlugin).render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+51
View File
@@ -0,0 +1,51 @@
{
"name": "@backstage/plugin-github-deployments",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.7.4",
"@backstage/core": "^0.7.3",
"@backstage/plugin-catalog-react": "^0.1.3",
"@backstage/theme": "^0.2.5",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/graphql": "^4.5.8",
"luxon": "^1.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.6.6",
"@backstage/dev-utils": "^0.1.13",
"@backstage/test-utils": "^0.1.9",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
export const GITHUB_PROJECT_SLUG_ANNOTATION = 'github.com/project-slug';
export const isGithubDeploymentsAvailable = (entity: Entity) =>
Boolean(entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION]);
@@ -0,0 +1,98 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef, OAuthApi } from '@backstage/core';
import { graphql } from '@octokit/graphql';
export type GithubDeployment = {
environment: string;
state: string;
updatedAt: string;
commit: {
abbreviatedOid: string;
commitUrl: string;
};
};
export interface GithubDeploymentsApi {
listDeployments(options: {
owner: string;
repo: string;
last: number;
}): Promise<GithubDeployment[]>;
}
export const githubDeploymentsApiRef = createApiRef<GithubDeploymentsApi>({
id: 'plugin.github-deployments.service',
description: 'Used by the GitHub Deployments plugin to make requests',
});
export type Options = {
githubAuthApi: OAuthApi;
};
const deploymentsQuery = `
query deployments($owner: String!, $repo: String!, $last: Int) {
repository(owner: $owner, name: $repo) {
deployments(last: $last) {
nodes {
state
environment
updatedAt
commit {
abbreviatedOid
commitUrl
}
}
}
}
}
`;
export type QueryResponse = {
repository?: {
deployments?: {
nodes?: GithubDeployment[];
};
};
};
export class GithubDeploymentsApiClient implements GithubDeploymentsApi {
private readonly githubAuthApi: OAuthApi;
constructor(options: Options) {
this.githubAuthApi = options.githubAuthApi;
}
async listDeployments(options: {
owner: string;
repo: string;
last: number;
}): Promise<GithubDeployment[]> {
const token = await this.githubAuthApi.getAccessToken(['repo']);
const graphQLWithAuth = graphql.defaults({
headers: {
authorization: `token ${token}`,
},
});
const response: QueryResponse = await graphQLWithAuth(
deploymentsQuery,
options,
);
return response.repository?.deployments?.nodes?.reverse() || [];
}
}
@@ -0,0 +1,117 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
ApiProvider,
ApiRegistry,
errorApiRef,
configApiRef,
ConfigReader,
ConfigApi,
OAuthApi,
} from '@backstage/core';
import { msw, renderInTestApp } from '@backstage/test-utils';
import { GithubDeploymentsApiClient, githubDeploymentsApiRef } from '../api';
import { githubDeploymentsPlugin } from '../plugin';
import { GithubDeploymentsCard } from './GithubDeploymentsCard';
import { entityStub, noDataResponseStub, responseStub } from '../mocks/mocks';
import { setupServer } from 'msw/node';
import { graphql } from 'msw';
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: () => {
return entityStub;
},
}));
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
const configApi: ConfigApi = new ConfigReader({});
const githubAuthApi: OAuthApi = {
getAccessToken: async _ => 'access_token',
};
const apis = ApiRegistry.from([
[configApiRef, configApi],
[errorApiRef, errorApiMock],
[githubDeploymentsApiRef, new GithubDeploymentsApiClient({ githubAuthApi })],
]);
describe('github-deployments', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
beforeEach(() => {
jest.resetAllMocks();
});
describe('export-plugin', () => {
it('should export plugin', () => {
expect(githubDeploymentsPlugin).toBeDefined();
});
});
describe('GithubDeploymentsCard', () => {
it('should display fetched data', async () => {
worker.use(
graphql.query('deployments', (_, res, ctx) =>
res(ctx.data(responseStub)),
),
);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<GithubDeploymentsCard />
</ApiProvider>,
);
expect(await rendered.findByText('active')).toBeInTheDocument();
expect(await rendered.findByText('prd')).toBeInTheDocument();
expect(await rendered.findByText('12345')).toHaveAttribute(
'href',
'https://exampleapi.com/123456789',
);
expect(await rendered.findByText('pending')).toBeInTheDocument();
expect(await rendered.findByText('lab')).toBeInTheDocument();
expect(await rendered.findByText('54321')).toHaveAttribute(
'href',
'https://exampleapi.com/543212345',
);
});
it('should display empty state when no data', async () => {
worker.use(
graphql.query('deployments', (_, res, ctx) =>
res(ctx.data(noDataResponseStub)),
),
);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<GithubDeploymentsCard />
</ApiProvider>,
);
expect(
await rendered.findByText('No deployments found for this entity.'),
).toBeInTheDocument();
});
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
InfoCard,
MissingAnnotationEmptyState,
Progress,
ResponseErrorPanel,
useApi,
} from '@backstage/core';
import { useAsync } from 'react-use';
import { githubDeploymentsApiRef } from '../api';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
GITHUB_PROJECT_SLUG_ANNOTATION,
isGithubDeploymentsAvailable,
} from '../Router';
import GithubDeploymentsTable from './GithubDeploymentsTable/GithubDeploymentsTable';
const GithubDeploymentsComponent = ({
projectSlug,
last,
}: {
projectSlug: string;
last: number;
}) => {
const api = useApi(githubDeploymentsApiRef);
const [owner, repo] = projectSlug.split('/');
const { loading, value, error } = useAsync(
async () => await api.listDeployments({ owner, repo, last }),
);
if (loading) {
return (
<InfoCard title="GitHub Deployments">
<Progress />
</InfoCard>
);
}
if (error) {
return <ResponseErrorPanel error={error} />;
}
return <GithubDeploymentsTable deployments={value || []} />;
};
export const GithubDeploymentsCard = ({ last }: { last?: number }) => {
const { entity } = useEntity();
return !isGithubDeploymentsAvailable(entity) ? (
<MissingAnnotationEmptyState annotation={GITHUB_PROJECT_SLUG_ANNOTATION} />
) : (
<GithubDeploymentsComponent
projectSlug={
entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION] || ''
}
last={last || 10}
/>
);
};
@@ -0,0 +1,110 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
StatusPending,
StatusRunning,
StatusOK,
Table,
TableColumn,
StatusAborted,
StatusError,
} from '@backstage/core';
import { GithubDeployment } from '../../api';
import { DateTime } from 'luxon';
import { Box, Typography, Link, makeStyles } from '@material-ui/core';
const useStyles = makeStyles(theme => ({
empty: {
padding: theme.spacing(2),
display: 'flex',
justifyContent: 'center',
},
}));
const statusIndicator = (value: string): React.ReactNode => {
switch (value) {
case 'PENDING':
return <StatusPending />;
case 'IN_PROGRESS':
return <StatusRunning />;
case 'ACTIVE':
return <StatusOK />;
case 'ERROR':
case 'FAILURE':
return <StatusError />;
default:
return <StatusAborted />;
}
};
const columns: TableColumn<GithubDeployment>[] = [
{
title: 'Environment',
field: 'environment',
highlight: true,
},
{
title: 'Status',
render: (row: GithubDeployment): React.ReactNode => (
<Box display="flex" alignItems="center">
{statusIndicator(row.state)}
<Typography variant="caption">{row.state}</Typography>
</Box>
),
},
{
title: 'Commit',
render: (row: GithubDeployment): React.ReactNode => (
<Link href={row.commit.commitUrl} target="_blank" rel="noopener">
{row.commit.abbreviatedOid}
</Link>
),
},
{
title: 'Last Updated',
render: (row: GithubDeployment): React.ReactNode =>
DateTime.fromISO(row.updatedAt).toRelative({ locale: 'en' }),
},
];
type GithubDeploymentsTableProps = {
deployments: GithubDeployment[];
};
const GithubDeploymentsTable = ({
deployments,
}: GithubDeploymentsTableProps) => {
const classes = useStyles();
return (
<Table
columns={columns}
options={{ padding: 'dense', paging: true, search: false, pageSize: 5 }}
title="GitHub Deployments"
data={deployments}
emptyContent={
<div className={classes.empty}>
<Typography variant="body1">
No deployments found for this entity.
</Typography>
</div>
}
/>
);
};
export default GithubDeploymentsTable;
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { githubDeploymentsPlugin, EntityGithubDeploymentsCard } from './plugin';
export { isGithubDeploymentsAvailable } from './Router';
@@ -0,0 +1,67 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { QueryResponse } from '../api';
export const entityStub = {
entity: {
metadata: {
namespace: 'default',
annotations: {
'github.com/project-slug': 'org/repo',
},
name: 'sample-service',
description: 'Sample service',
uid: 'g0h33dd9-56h7-835b-b63v-7x5da3j64851',
generation: 1,
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
type: 'service',
lifecycle: 'experimental',
},
relations: [],
},
};
export const responseStub: QueryResponse = {
repository: {
deployments: {
nodes: [
{
state: 'active',
environment: 'prd',
updatedAt: '2021-03-25T12:08:45Z',
commit: {
commitUrl: 'https://exampleapi.com/123456789',
abbreviatedOid: '12345',
},
},
{
state: 'pending',
environment: 'lab',
updatedAt: '2021-03-25T12:08:47Z',
commit: {
commitUrl: 'https://exampleapi.com/543212345',
abbreviatedOid: '54321',
},
},
],
},
},
};
export const noDataResponseStub: QueryResponse = {};
@@ -0,0 +1,22 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { githubDeploymentsPlugin } from './plugin';
describe('graphiql', () => {
it('should export plugin', () => {
expect(githubDeploymentsPlugin).toBeDefined();
});
});
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createApiFactory,
createComponentExtension,
createPlugin,
githubAuthApiRef,
} from '@backstage/core';
import { githubDeploymentsApiRef, GithubDeploymentsApiClient } from './api';
export const githubDeploymentsPlugin = createPlugin({
id: 'github-deployments',
apis: [
createApiFactory({
api: githubDeploymentsApiRef,
deps: { githubAuthApi: githubAuthApiRef },
factory: ({ githubAuthApi }) =>
new GithubDeploymentsApiClient({ githubAuthApi }),
}),
],
});
export const EntityGithubDeploymentsCard = githubDeploymentsPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/GithubDeploymentsCard').then(
m => m.GithubDeploymentsCard,
),
},
}),
);
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';