diff --git a/packages/app/package.json b/packages/app/package.json index 341953a204..e5dbbaa5ff 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 13b0daf7e3..ccc7e2a45c 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -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'; diff --git a/plugins/github-deployments/.eslintrc.js b/plugins/github-deployments/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/github-deployments/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/github-deployments/README.md b/plugins/github-deployments/README.md new file mode 100644 index 0000000000..174d1bc8c7 --- /dev/null +++ b/plugins/github-deployments/README.md @@ -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 = () => ( + + // ... + + + + // ... + +); +``` + +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 +``` diff --git a/plugins/github-deployments/dev/index.tsx b/plugins/github-deployments/dev/index.tsx new file mode 100644 index 0000000000..dbb737c3c8 --- /dev/null +++ b/plugins/github-deployments/dev/index.tsx @@ -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(); diff --git a/plugins/github-deployments/docs/github-deployments-card.png b/plugins/github-deployments/docs/github-deployments-card.png new file mode 100644 index 0000000000..f56301c0fb Binary files /dev/null and b/plugins/github-deployments/docs/github-deployments-card.png differ diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json new file mode 100644 index 0000000000..b8761c1385 --- /dev/null +++ b/plugins/github-deployments/package.json @@ -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" + ] +} diff --git a/plugins/github-deployments/src/Router.tsx b/plugins/github-deployments/src/Router.tsx new file mode 100644 index 0000000000..e48f17b75f --- /dev/null +++ b/plugins/github-deployments/src/Router.tsx @@ -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]); diff --git a/plugins/github-deployments/src/api/index.ts b/plugins/github-deployments/src/api/index.ts new file mode 100644 index 0000000000..ca69ac3853 --- /dev/null +++ b/plugins/github-deployments/src/api/index.ts @@ -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; +} + +export const githubDeploymentsApiRef = createApiRef({ + 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 { + 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() || []; + } +} diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx new file mode 100644 index 0000000000..678132bd8f --- /dev/null +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx @@ -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( + + + , + ); + + 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( + + + , + ); + + expect( + await rendered.findByText('No deployments found for this entity.'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx new file mode 100644 index 0000000000..6be4a5a6b3 --- /dev/null +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx @@ -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 ( + + + + ); + } + if (error) { + return ; + } + + return ; +}; + +export const GithubDeploymentsCard = ({ last }: { last?: number }) => { + const { entity } = useEntity(); + + return !isGithubDeploymentsAvailable(entity) ? ( + + ) : ( + + ); +}; diff --git a/plugins/github-deployments/src/components/GithubDeploymentsTable/GithubDeploymentsTable.tsx b/plugins/github-deployments/src/components/GithubDeploymentsTable/GithubDeploymentsTable.tsx new file mode 100644 index 0000000000..f5fae7f449 --- /dev/null +++ b/plugins/github-deployments/src/components/GithubDeploymentsTable/GithubDeploymentsTable.tsx @@ -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 ; + case 'IN_PROGRESS': + return ; + case 'ACTIVE': + return ; + case 'ERROR': + case 'FAILURE': + return ; + default: + return ; + } +}; + +const columns: TableColumn[] = [ + { + title: 'Environment', + field: 'environment', + highlight: true, + }, + { + title: 'Status', + render: (row: GithubDeployment): React.ReactNode => ( + + {statusIndicator(row.state)} + {row.state} + + ), + }, + { + title: 'Commit', + render: (row: GithubDeployment): React.ReactNode => ( + + {row.commit.abbreviatedOid} + + ), + }, + { + 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 ( + + + No deployments found for this entity. + + + } + /> + ); +}; + +export default GithubDeploymentsTable; diff --git a/plugins/github-deployments/src/index.ts b/plugins/github-deployments/src/index.ts new file mode 100644 index 0000000000..2ee681332a --- /dev/null +++ b/plugins/github-deployments/src/index.ts @@ -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'; diff --git a/plugins/github-deployments/src/mocks/mocks.ts b/plugins/github-deployments/src/mocks/mocks.ts new file mode 100644 index 0000000000..6a394b54bf --- /dev/null +++ b/plugins/github-deployments/src/mocks/mocks.ts @@ -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 = {}; diff --git a/plugins/github-deployments/src/plugin.test.ts b/plugins/github-deployments/src/plugin.test.ts new file mode 100644 index 0000000000..cac5d58c63 --- /dev/null +++ b/plugins/github-deployments/src/plugin.test.ts @@ -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(); + }); +}); diff --git a/plugins/github-deployments/src/plugin.ts b/plugins/github-deployments/src/plugin.ts new file mode 100644 index 0000000000..3707b1cf68 --- /dev/null +++ b/plugins/github-deployments/src/plugin.ts @@ -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, + ), + }, + }), +); diff --git a/plugins/github-deployments/src/setupTests.ts b/plugins/github-deployments/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/github-deployments/src/setupTests.ts @@ -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';