diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index d7a862806a..dc65b987a8 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -2,8 +2,8 @@ name: Frontend CI on: pull_request: - paths: - - '!microsite/**' + paths-ignore: + - 'microsite/**' jobs: build: runs-on: ubuntu-latest @@ -45,12 +45,12 @@ jobs: run: git diff --quiet origin/master HEAD -- yarn.lock continue-on-error: true - - name: verify doc links - run: node docs/verify-links.js - - name: yarn install run: yarn install --frozen-lockfile + - name: verify doc links + run: node docs/verify-links.js + - name: lint run: yarn lerna -- run lint --since origin/master diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c1db8fc47..40a8b0ce90 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,35 @@ Have you started using Backstage? Adding your company to [ADOPTERS](ADOPTERS.md) # Get Started! -So...feel ready to jump in? Let's do this. Head over to the [Getting Started guide](https://github.com/spotify/backstage#getting-started) 👏🏻💯 +So...feel ready to jump in? Let's do this. 👏🏻💯 + +To run a Backstage app, you will need to have the following installed: + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [NodeJS](https://nodejs.org/en/download/) - Active LTS Release, currently v12 +- [yarn](https://classic.yarnpkg.com/en/docs/install) + +After cloning this repo, open a terminal window and start the example app using the following commands from the project root: + +```bash +yarn install # Install dependencies + +yarn start # Start dev server, use --check to enable linting and type-checks +``` + +The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. + +Depending on the work you're doing, you often also want to run the example backend. Start the backend in a separate terminal session using the following: + +```bash +cd packages/backend + +yarn start + +yarn lerna run mock-data # Populate the backend with mock data +``` + +And that's it! You are good to go 👍 If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). diff --git a/README.md b/README.md index 3a4af2723f..9fb3745368 100644 --- a/README.md +++ b/README.md @@ -49,27 +49,33 @@ The Backstage platform consists of a number of different components: - [**proxy**](https://github.com/spotify/backstage/tree/master/plugins/proxy-backend) - Terminates HTTPS and exposes any RESTful API to Plugins. - **identity** - A backend service that holds your organisation's metadata. -## Getting started +## Getting Started -To run a Backstage app, you will need to have the following installed: +There are two different ways to get started with Backstage, either by creating a standalone app, or by cloning this repo. Which method you use depends on what you're planning to do. -- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -- [NodeJS](https://nodejs.org/en/download/) - Active LTS Release, currently v12 -- [yarn](https://classic.yarnpkg.com/en/docs/install) +Creating a standalone instance makes it simpler to customize the application for your needs whilst staying up to date with the project. You will also depend on `@backstage` packages from NPM, making the project much smaller. This is the recommended approach if you want to kick the tyres of Backstage or setup your own instance. -After cloning this repo, open a terminal window and start the example app using the following commands from the project root: +On the other hand, if you want to contribute plugins or to the project in general, it's easier to fork and clone this project. That will let you stay up to date with the latest changes, and gives you an easier path to make Pull Requests towards this repo. + +### Creating a Standalone App + +Backstage provides the `@backstage/create-app` package to scaffold standalone instances of Backstage. You will need to have +[NodeJS](https://nodejs.org/en/download/) Active LTS Release installed +(currently v12), and [yarn](https://classic.yarnpkg.com/en/docs/install). You will also need to have [Docker](https://docs.docker.com/engine/install/) installed to use some features like Software Templates and TechDocs. + +Using `npx` you can then run the following to create an app in a chosen subdirectory of your current working directory: ```bash -yarn install # Install dependencies - -yarn start # Start dev server, use --check to enable linting and type-checks +npx @backstage/create-app ``` -The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. +You will be taken through a wizard to create your app, and the output should look something like this. You can read more about this process [here](docs/getting-started/create-an-app.md) -And that's it! You are good to go 👍 +### Contributing to Backstage -### Next step +You can read more in our [CONTRIBUTING.md](./CONTRIBUTING.md#get-started) guide, which can help you get setup with a Backstage development environment. + +### Next steps Take a look at the [Getting Started](docs/getting-started/index.md) guide to learn how to set up Backstage, and how to develop on the platform. @@ -77,15 +83,10 @@ Take a look at the [Getting Started](docs/getting-started/index.md) guide to lea - [Main documentation](docs/README.md) - [Service Catalog](docs/features/software-catalog/index.md) -- [Create a Backstage App](docs/getting-started/create-an-app.md) - [Architecture](docs/overview/architecture-terminology.md) ([Decisions](docs/architecture-decisions/index.md)) - [Designing for Backstage](docs/dls/design.md) - [Storybook - UI components](http://storybook.backstage.io) -## Contributing - -We would love your help in building Backstage! See [CONTRIBUTING](CONTRIBUTING.md) for more information. - ## Community - [Discord chatroom](https://discord.gg/MUpMjP2) - Get support or discuss the project diff --git a/app-config.yaml b/app-config.yaml index 5a5fdc1cab..c5c7a3674c 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -16,6 +16,15 @@ proxy: changeOrigin: true pathRewrite: '^/proxy/circleci/api/': '/' + '/jenkins/api': + target: 'http://localhost:8080' + changeOrigin: true + headers: + Authorization: + $secret: + env: JENKINS_BASIC_AUTH_HEADER + pathRewrite: + '^/proxy/jenkins/api/': '/' organization: name: Spotify diff --git a/package.json b/package.json index 706fe7fc67..b24c0ed2fb 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ "@spotify/eslint-config-oss": "^1.0.1", "@spotify/prettier-config": "^8.0.0", "concurrently": "^5.2.0", + "fs-extra": "^9.0.0", "husky": "^4.2.3", "lerna": "^3.20.2", "lint-staged": "^10.1.0", - "prettier": "^2.0.5" + "prettier": "^2.0.5", + "recursive-readdir": "^2.2.2" }, "husky": { "hooks": { diff --git a/packages/app/package.json b/packages/app/package.json index 41fcc054a3..f42456709c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,6 +11,7 @@ "@backstage/plugin-github-actions": "^0.1.1-alpha.18", "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.18", "@backstage/plugin-graphiql": "^0.1.1-alpha.18", + "@backstage/plugin-jenkins": "^0.1.1-alpha.18", "@backstage/plugin-lighthouse": "^0.1.1-alpha.18", "@backstage/plugin-newrelic": "^0.1.1-alpha.18", "@backstage/plugin-register-component": "^0.1.1-alpha.18", @@ -25,6 +26,7 @@ "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.0", + "@roadiehq/backstage-plugin-travis-ci": "^0.1.3", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^16.12.0", diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index cac3d6e171..d9e3302146 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -62,6 +62,9 @@ import { GithubActionsClient, githubActionsApiRef, } from '@backstage/plugin-github-actions'; +import { jenkinsApiRef, JenkinsApi } from '@backstage/plugin-jenkins'; + +import { TravisCIApi, travisCIApiRef } from '@roadiehq/backstage-plugin-travis-ci'; export const apis = (config: ConfigApi) => { // eslint-disable-next-line no-console @@ -83,12 +86,16 @@ export const apis = (config: ConfigApi) => { new CircleCIApi(`${backendUrl}/proxy/circleci/api`), ); + builder.add(jenkinsApiRef, new JenkinsApi(`${backendUrl}/proxy/jenkins/api`)); + builder.add(githubActionsApiRef, new GithubActionsClient()); builder.add(featureFlagsApiRef, new FeatureFlags()); builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); + builder.add(travisCIApiRef, new TravisCIApi()); + const oauthRequestApi = builder.add( oauthRequestApiRef, new OAuthRequestManager(), diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 95d9e6c557..7e803698fe 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -28,3 +28,5 @@ export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; export { plugin as GithubActions } from '@backstage/plugin-github-actions'; export { plugin as Rollbar } from '@backstage/plugin-rollbar'; export { plugin as Newrelic } from '@backstage/plugin-newrelic'; +export { plugin as TravisCI } from '@roadiehq/backstage-plugin-travis-ci'; +export { plugin as Jenkins } from '@backstage/plugin-jenkins'; diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index 41e4226001..fdac6dd938 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -29,8 +29,9 @@ import { useHotCleanup } from '@backstage/backend-common'; export default async function createPlugin({ logger, database, + config, }: PluginEnvironment) { - const locationReader = new LocationReaders(logger); + const locationReader = new LocationReaders({ logger, config }); const db = await DatabaseManager.createDatabase(database, { logger }); const entitiesCatalog = new DatabaseEntitiesCatalog(db); diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index 3dc7deae7c..120de5152e 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -24,6 +24,7 @@ import { } from './entity'; import { ComponentEntityV1alpha1Policy, + GroupEntityV1alpha1Policy, LocationEntityV1alpha1Policy, TemplateEntityV1alpha1Policy, } from './kinds'; @@ -74,6 +75,7 @@ export class EntityPolicies implements EntityPolicy { ]), EntityPolicies.anyOf([ new ComponentEntityV1alpha1Policy(), + new GroupEntityV1alpha1Policy(), new LocationEntityV1alpha1Policy(), new TemplateEntityV1alpha1Policy(), ]), diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts new file mode 100644 index 0000000000..94dcc2f14f --- /dev/null +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { EntityPolicy } from '../types'; +import { + GroupEntityV1alpha1, + GroupEntityV1alpha1Policy, +} from './GroupEntityV1alpha1'; + +describe('GroupV1alpha1Policy', () => { + let entity: GroupEntityV1alpha1; + let policy: EntityPolicy; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'doe-squad', + title: 'Doe Squad', + description: 'A squad for John and Jane', + }, + spec: { + type: 'squad', + parent: 'group-a', + ancestors: ['group-a', 'global-synergies', 'acme-corp'], + children: ['child-a', 'child-b'], + descendants: ['desc-a', 'desc-b'], + }, + }; + policy = new GroupEntityV1alpha1Policy(); + }); + + it('happy path: accepts valid data', async () => { + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('silently accepts v1beta1 as well', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta1'; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('rejects unknown apiVersion', async () => { + (entity as any).apiVersion = 'backstage.io/v1beta0'; + await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/); + }); + + it('rejects unknown kind', async () => { + (entity as any).kind = 'Wizard'; + await expect(policy.enforce(entity)).rejects.toThrow(/kind/); + }); + + it('rejects missing type', async () => { + delete (entity as any).spec.type; + await expect(policy.enforce(entity)).rejects.toThrow(/type/); + }); + + it('rejects wrong type', async () => { + (entity as any).spec.type = 7; + await expect(policy.enforce(entity)).rejects.toThrow(/type/); + }); + + it('rejects empty type', async () => { + (entity as any).spec.type = ''; + await expect(policy.enforce(entity)).rejects.toThrow(/type/); + }); + + it('accepts missing parent', async () => { + delete (entity as any).spec.parent; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('rejects empty parent', async () => { + (entity as any).spec.parent = ''; + await expect(policy.enforce(entity)).rejects.toThrow(/parent/); + }); + + it('rejects missing ancestors', async () => { + delete (entity as any).spec.ancestors; + await expect(policy.enforce(entity)).rejects.toThrow(/ancestor/); + }); + + it('accepts empty ancestors', async () => { + (entity as any).spec.ancestors = ['']; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('rejects missing children', async () => { + delete (entity as any).spec.children; + await expect(policy.enforce(entity)).rejects.toThrow(/children/); + }); + + it('accepts empty children', async () => { + (entity as any).spec.children = ['']; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); + + it('rejects missing descendants', async () => { + delete (entity as any).spec.descendants; + await expect(policy.enforce(entity)).rejects.toThrow(/descendants/); + }); + + it('accepts empty descendants', async () => { + (entity as any).spec.descendants = ['']; + await expect(policy.enforce(entity)).resolves.toBe(entity); + }); +}); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts new file mode 100644 index 0000000000..c1d39d5b44 --- /dev/null +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -0,0 +1,58 @@ +/* + * 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 * as yup from 'yup'; +import type { Entity } from '../entity/Entity'; +import type { EntityPolicy } from '../types'; + +const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; +const KIND = 'Group' as const; + +export interface GroupEntityV1alpha1 extends Entity { + apiVersion: typeof API_VERSION[number]; + kind: typeof KIND; + spec: { + type: string; + parent?: string; + ancestors: string[]; + children: string[]; + descendants: string[]; + }; +} + +export class GroupEntityV1alpha1Policy implements EntityPolicy { + private schema: yup.Schema; + + constructor() { + this.schema = yup.object>({ + apiVersion: yup.string().required().oneOf(API_VERSION), + kind: yup.string().required().equals([KIND]), + spec: yup + .object({ + type: yup.string().required().min(1), + parent: yup.string().notRequired().min(1), + ancestors: yup.array(yup.string()).required(), + children: yup.array(yup.string()).required(), + descendants: yup.array(yup.string()).required(), + }) + .required(), + }); + } + + async enforce(envelope: Entity): Promise { + return await this.schema.validate(envelope, { strict: true }); + } +} diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index b5fad184d4..4526e99da4 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -19,6 +19,11 @@ export type { ComponentEntityV1alpha1 as ComponentEntity, ComponentEntityV1alpha1, } from './ComponentEntityV1alpha1'; +export { GroupEntityV1alpha1Policy } from './GroupEntityV1alpha1'; +export type { + GroupEntityV1alpha1 as GroupEntity, + GroupEntityV1alpha1, +} from './GroupEntityV1alpha1'; export { LocationEntityV1alpha1Policy } from './LocationEntityV1alpha1'; export type { LocationEntityV1alpha1 as LocationEntity, diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 1898285a22..b6b39b0f17 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -142,7 +142,12 @@ async function cleanUp(tempDir: string) { } async function buildPlugin(pluginFolder: string) { - const commands = ['yarn install', 'yarn tsc', 'yarn build']; + const commands = [ + 'yarn install', + 'yarn lint --fix', + 'yarn tsc', + 'yarn build', + ]; for (const command of commands) { await Task.forItem('executing', command, async () => { process.chdir(pluginFolder); diff --git a/packages/cli/templates/default-plugin/dev/index.tsx b/packages/cli/templates/default-plugin/dev/index.tsx index 812a5585d4..6fce113093 100644 --- a/packages/cli/templates/default-plugin/dev/index.tsx +++ b/packages/cli/templates/default-plugin/dev/index.tsx @@ -1,19 +1,3 @@ -/* - * 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 { createDevApp } from '@backstage/dev-utils'; import { plugin } from '../src/plugin'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs index dff57e66a4..37759313ee 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs @@ -1,19 +1,3 @@ -/* - * 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 { render } from '@testing-library/react'; import mockFetch from 'jest-fetch-mock'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs index 34e37f3e7b..79adaefaa8 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs @@ -1,19 +1,3 @@ -/* - * 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 { Typography, Grid } from '@material-ui/core'; import { diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts index e785d45082..520a3bf553 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts @@ -1,17 +1 @@ -/* - * 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 } from './ExampleComponent'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs index 7fecdc6f11..cc61c215cd 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs @@ -1,19 +1,3 @@ -/* - * 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 { render } from '@testing-library/react'; import mockFetch from 'jest-fetch-mock'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs index 2dd54f726e..0af27a5935 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs @@ -1,19 +1,3 @@ -/* - * 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 { makeStyles } from '@material-ui/core/styles'; import { Table, TableColumn, Progress } from '@backstage/core'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts index 28482f9fe1..3e53453948 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts @@ -1,17 +1 @@ -/* - * 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 } from './ExampleFetchComponent'; diff --git a/packages/cli/templates/default-plugin/src/index.ts b/packages/cli/templates/default-plugin/src/index.ts index 3a0a0fe2d3..99edba26c3 100644 --- a/packages/cli/templates/default-plugin/src/index.ts +++ b/packages/cli/templates/default-plugin/src/index.ts @@ -1,17 +1 @@ -/* - * 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 { plugin } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs index e34204c9ae..c2be0a5301 100644 --- a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs @@ -1,19 +1,3 @@ -/* - * 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 { plugin } from './plugin'; describe('{{ id }}', () => { diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index ad5db2e711..6a410a6a28 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -1,19 +1,3 @@ -/* - * 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 { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; diff --git a/packages/cli/templates/default-plugin/src/setupTests.ts b/packages/cli/templates/default-plugin/src/setupTests.ts index 8553642152..3fa8703ac4 100644 --- a/packages/cli/templates/default-plugin/src/setupTests.ts +++ b/packages/cli/templates/default-plugin/src/setupTests.ts @@ -1,19 +1,3 @@ -/* - * 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 '@testing-library/jest-dom'; require('jest-fetch-mock').enableMocks(); diff --git a/plugins/catalog-backend/migrations/20200809202832_add_bootstrap_location.js b/plugins/catalog-backend/migrations/20200809202832_add_bootstrap_location.js new file mode 100644 index 0000000000..3d13a4219a --- /dev/null +++ b/plugins/catalog-backend/migrations/20200809202832_add_bootstrap_location.js @@ -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. + */ + +// @ts-check + +/** + * @param {import('knex')} knex + */ +exports.up = async function up(knex) { + // Adds a single 'bootstrap' location that can be used to trigger work in processors. + // This is primarily here to fulfill foreign key constraints. + await knex('locations').insert({ + id: 'bootstrap', + type: 'bootstrap', + target: 'bootstrap', + }); +}; + +/** + * @param {import('knex')} knex + */ +exports.down = async function down(knex) { + await knex('locations') + .where({ + id: 'bootstrap', + }) + .del(); +}; diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 2445ee206e..4fbe982de4 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -23,6 +23,7 @@ "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.18", "@backstage/catalog-model": "^0.1.1-alpha.18", + "@backstage/config": "^0.1.1-alpha.18", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^3.0.3", diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts index 679e5b43c1..456f06b334 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts @@ -17,6 +17,12 @@ import { DatabaseManager } from '../database'; import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog'; +const bootstrapLocation = { + id: 'bootstrap', + type: 'bootstrap', + target: 'bootstrap', +}; + describe('DatabaseLocationsCatalog', () => { let catalog: DatabaseLocationsCatalog; @@ -35,9 +41,12 @@ describe('DatabaseLocationsCatalog', () => { await expect( catalog.location('dd12620d-0436-422f-93bd-929aa0788123'), ).resolves.toEqual(expect.objectContaining({ data: location })); - await expect(catalog.locations()).resolves.toEqual([ - expect.objectContaining({ data: location }), - ]); + await expect(catalog.locations()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ data: location }), + expect.objectContaining({ data: bootstrapLocation }), + ]), + ); }); it('does not return duplicates of rows because of logs', async () => { @@ -60,11 +69,12 @@ describe('DatabaseLocationsCatalog', () => { catalog.logUpdateSuccess(location1.id), ).resolves.toBeUndefined(); const locations = await catalog.locations(); - expect(locations.length).toBe(2); + expect(locations.length).toBe(3); expect(locations).toEqual( expect.arrayContaining([ expect.objectContaining({ data: location1 }), expect.objectContaining({ data: location2 }), + expect.objectContaining({ data: bootstrapLocation }), ]), ); }); diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index 21dde4ca74..94dfc25aa4 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -24,6 +24,15 @@ import type { DbLocationsRowWithStatus, } from './types'; +const bootstrapLocation = { + id: 'bootstrap', + type: 'bootstrap', + target: 'bootstrap', + message: null, + status: null, + timestamp: null, +}; + describe('CommonDatabase', () => { let db: Database; let entityRequest: DbEntityRequest; @@ -85,8 +94,12 @@ describe('CommonDatabase', () => { await db.addLocation(input); const locations = await db.locations(); - expect(locations).toEqual([output]); - const location = await db.location(locations[0].id); + expect(locations).toEqual( + expect.arrayContaining([output, bootstrapLocation]), + ); + const location = await db.location( + locations.find(l => l.id !== 'bootstrap')!.id, + ); expect(location).toEqual(output); // If we add 2 new update log events, @@ -105,20 +118,21 @@ describe('CommonDatabase', () => { DatabaseLocationUpdateLogStatus.FAIL, ); - expect(await db.locations()).toEqual([ - { - ...output, - status: DatabaseLocationUpdateLogStatus.FAIL, - timestamp: expect.any(String), - }, - ]); - - await db.transaction(tx => db.removeLocation(tx, locations[0].id)); - - await expect(db.locations()).resolves.toEqual([]); - await expect(db.location(locations[0].id)).rejects.toThrow( - /Found no location/, + await expect(db.locations()).resolves.toEqual( + expect.arrayContaining([ + bootstrapLocation, + { + ...output, + status: DatabaseLocationUpdateLogStatus.FAIL, + timestamp: expect.any(String), + }, + ]), ); + + await db.transaction(tx => db.removeLocation(tx, location.id)); + + await expect(db.locations()).resolves.toEqual([bootstrapLocation]); + await expect(db.location(location.id)).rejects.toThrow(/Found no location/); }); describe('addEntity', () => { diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index e5ef410456..7e91af7c59 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -15,6 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import { Config, ConfigReader } from '@backstage/config'; import { Entity, EntityPolicies, @@ -31,6 +32,7 @@ import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor' import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor'; import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; import { LocationRefProcessor } from './processors/LocationEntityProcessor'; +import { StaticLocationProcessor } from './processors/StaticLocationProcessor'; import * as result from './processors/results'; import { LocationProcessor, @@ -47,6 +49,12 @@ import { LocationReader, ReadLocationResult } from './types'; // The max amount of nesting depth of generated work items const MAX_DEPTH = 10; +type Options = { + logger?: Logger; + config?: Config; + processors?: LocationProcessor[]; +}; + /** * Implements the reading of a location through a series of processor tasks. */ @@ -54,10 +62,16 @@ export class LocationReaders implements LocationReader { private readonly logger: Logger; private readonly processors: LocationProcessor[]; - static defaultProcessors( - entityPolicy: EntityPolicy = new EntityPolicies(), - ): LocationProcessor[] { + static defaultProcessors(options: { + config?: Config; + entityPolicy?: EntityPolicy; + }): LocationProcessor[] { + const { + config = new ConfigReader({}, 'missing-config'), + entityPolicy = new EntityPolicies(), + } = options; return [ + StaticLocationProcessor.fromConfig(config), new FileReaderProcessor(), new GithubReaderProcessor(), new GithubApiReaderProcessor(), @@ -71,10 +85,11 @@ export class LocationReaders implements LocationReader { ]; } - constructor( - logger: Logger = getVoidLogger(), - processors: LocationProcessor[] = LocationReaders.defaultProcessors(), - ) { + constructor({ + logger = getVoidLogger(), + config, + processors = LocationReaders.defaultProcessors({ config }), + }: Options) { this.logger = logger; this.processors = processors; } diff --git a/plugins/catalog-backend/src/ingestion/processors/StaticLocationProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/StaticLocationProcessor.ts new file mode 100644 index 0000000000..6a2d1096cc --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/StaticLocationProcessor.ts @@ -0,0 +1,53 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import * as result from './results'; +import { Config } from '@backstage/config'; +import { LocationProcessorEmit } from './types'; + +export class StaticLocationProcessor implements StaticLocationProcessor { + static fromConfig(config: Config): StaticLocationProcessor { + const locations: LocationSpec[] = []; + + const lConfigs = config.getOptionalConfigArray('catalog.locations') ?? []; + for (const lConfig of lConfigs) { + const type = lConfig.getString('type'); + const target = lConfig.getString('target'); + locations.push({ type, target }); + } + + return new StaticLocationProcessor(locations); + } + + constructor(private readonly staticLocations: LocationSpec[]) {} + + async readLocation( + location: LocationSpec, + _optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'bootstrap') { + return false; + } + + for (const staticLocation of this.staticLocations) { + emit(result.location(staticLocation, false)); + } + + return true; + } +} diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 5c7ccb23f5..0b532d1664 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createServiceBuilder } from '@backstage/backend-common'; +import { + createServiceBuilder, + loadBackendConfig, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; import { Server } from 'http'; import { Logger } from 'winston'; import { HigherOrderOperations } from '..'; @@ -34,12 +38,13 @@ export async function startStandaloneServer( options: ServerOptions, ): Promise { const logger = options.logger.child({ service: 'catalog-backend' }); + const config = ConfigReader.fromConfigs(await loadBackendConfig()); logger.debug('Creating application...'); const db = await DatabaseManager.createInMemoryDatabase({ logger }); const entitiesCatalog = new DatabaseEntitiesCatalog(db); const locationsCatalog = new DatabaseLocationsCatalog(db); - const locationReader = new LocationReaders(); + const locationReader = new LocationReaders({ logger, config }); const higherOrderOperation = new HigherOrderOperations( entitiesCatalog, locationsCatalog, diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index a0e69a647d..4f0e5df322 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -24,6 +24,7 @@ "@backstage/catalog-model": "^0.1.1-alpha.18", "@backstage/core": "^0.1.1-alpha.18", "@backstage/plugin-github-actions": "^0.1.1-alpha.18", + "@backstage/plugin-jenkins": "^0.1.1-alpha.18", "@backstage/plugin-scaffolder": "^0.1.1-alpha.18", "@backstage/plugin-sentry": "^0.1.1-alpha.18", "@backstage/theme": "^0.1.1-alpha.18", diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx index c11844f722..5dd35bf8db 100644 --- a/plugins/catalog/src/components/EntityPage/EntityPage.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx @@ -29,6 +29,10 @@ import { } from '@backstage/core'; import { SentryIssuesWidget } from '@backstage/plugin-sentry'; import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions'; +import { + JenkinsBuildsWidget, + JenkinsLastBuildWidget, +} from '@backstage/plugin-jenkins'; import { Grid, Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { FC, useEffect, useState } from 'react'; @@ -187,12 +191,20 @@ export const EntityPage: FC<{}> = () => { - - - + {entity.metadata?.annotations?.[ + 'backstage.io/jenkins-github-folder' + ] && ( + + + + )} + {entity.metadata?.annotations?.[ + 'backstage.io/jenkins-github-folder' + ] && ( + + + + )} {entity.metadata?.annotations?.[ 'backstage.io/github-actions-id' ] && ( @@ -201,6 +213,12 @@ export const EntityPage: FC<{}> = () => { )} + + + +Folder results +Build detials + +## Setup + +1. If you have a standalone app (you didn't clone this repo), then do + +```bash +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: + +```js +// packages/app/src/plugins.ts +export { plugin as Jenkins } from '@backstage/plugin-jenkins'; +``` + +3. Add proxy configuration to `app-config.yaml` + +```yaml +proxy: + '/jenkins/api': + target: 'http://localhost:8080' # your Jenkins URL + changeOrigin: true + headers: + Authorization: + $secret: + env: JENKINS_BASIC_AUTH_HEADER + pathRewrite: + '^/proxy/jenkins/api/': '/' +``` + +4. Add an environment variable which contains the Jenkins credentials, (note: use an API token not your password) + +```shell +HEADER=$(echo -n user:api-token | base64) +export JENKINS_BASIC_AUTH_HEADER="Basic $HEADER" +``` + +5. Run app with `yarn start` +6. Add the Jenkins folder annotation to your `component-info.yaml`, (note: currently this plugin only supports folders and Git SCM) + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: 'your-component' + description: 'a description' + annotations: + backstage.io/jenkins-github-folder: 'folder-name/job-name' +spec: + type: service + lifecycle: experimental + owner: your-name +``` + +7. Register your component + +8. Click the component in the catalog you should now see Jenkins builds, and a last build result for your master build. + +## Features + +- View all runs inside a folder +- Last build status for specified branch +- View summary of a build + +## Limitations + +- Only works with projects that use the Git SCM +- It requires jobs to be organised into folders +- No pagination support currently - don't run this on a Jenkins with lots of builds diff --git a/plugins/jenkins/dev/index.tsx b/plugins/jenkins/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/jenkins/dev/index.tsx @@ -0,0 +1,20 @@ +/* + * 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 { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json new file mode 100644 index 0000000000..dce577801e --- /dev/null +++ b/plugins/jenkins/package.json @@ -0,0 +1,51 @@ +{ + "name": "@backstage/plugin-jenkins", + "version": "0.1.1-alpha.18", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "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.1.1-alpha.18", + "@backstage/core": "^0.1.1-alpha.18", + "@backstage/theme": "^0.1.1-alpha.18", + "@material-ui/core": "^4.9.1", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "jenkins": "^0.28.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.18", + "@backstage/dev-utils": "^0.1.1-alpha.18", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "@types/testing-library__jest-dom": "^5.9.1", + "jest-fetch-mock": "^3.0.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/jenkins/src/api/index.ts b/plugins/jenkins/src/api/index.ts new file mode 100644 index 0000000000..545a1da9cc --- /dev/null +++ b/plugins/jenkins/src/api/index.ts @@ -0,0 +1,198 @@ +/* + * 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 { createApiRef } from '@backstage/core'; +import { CITableBuildInfo } from '../pages/BuildsPage/lib/CITable'; + +const jenkins = require('jenkins'); + +export const jenkinsApiRef = createApiRef({ + id: 'plugin.jenkins.service', + description: 'Used by the Jenkins plugin to make requests', +}); + +export class JenkinsApi { + apiUrl: string; + jenkins: any; + + constructor(apiUrl: string) { + this.apiUrl = apiUrl; + this.jenkins = jenkins({ baseUrl: apiUrl, promisify: true }); + } + + async retry(buildName: string) { + // looks like the current SDK only supports triggering a new build + // can't see any support for replay (re-running the specific build with the same SCM info) + return await this.jenkins.job.build(buildName); + } + + async getLastBuild(jobName: string) { + const job = await this.jenkins.job.get(jobName); + + const lastBuild = await this.jenkins.build.get( + jobName, + job.lastBuild.number, + ); + return lastBuild; + } + + extractScmDetailsFromJob(jobDetails: any): any { + const scmInfo = jobDetails.actions + .filter( + (action: any) => + action._class === 'jenkins.scm.api.metadata.ObjectMetadataAction', + ) + .map((action: any) => { + return { + url: action?.objectUrl, + // https://javadoc.jenkins.io/plugin/scm-api/jenkins/scm/api/metadata/ObjectMetadataAction.html + // branch name for regular builds, pull request title on pull requests + displayName: action?.objectDisplayName, + }; + }) + .pop(); + + return scmInfo; + } + + async getJob(jobName: string) { + return this.jenkins.job.get({ + name: jobName, + depth: 1, + }); + } + + async getFolder(folderName: string) { + const folder = await this.jenkins.job.get(folderName); + const results = []; + for (const jobSummary of folder.jobs) { + const jobDetails = await this.jenkins.job.get({ + name: `${folderName}/${jobSummary.name}`, + depth: 1, + }); + + const jobScmInfo = this.extractScmDetailsFromJob(jobDetails); + if (jobDetails.jobs) { + // skipping folders inside folders for now + } else { + for (const buildDetails of jobDetails.builds) { + const build = await this.jenkins.build.get({ + name: `${folderName}/${jobSummary.name}`, + number: buildDetails.number, + depth: 1, + }); + + const ciTable = this.mapJenkinsBuildToCITable(build, jobScmInfo); + results.push(ciTable); + } + } + } + return results; + } + + private getTestReport( + jenkinsResult: any, + ): { + total: number; + passed: number; + skipped: number; + failed: number; + testUrl: string; + } { + return jenkinsResult.actions + .filter( + (action: any) => + action._class === 'hudson.tasks.junit.TestResultAction', + ) + .map((action: any) => { + return { + total: action.totalCount, + passed: action.totalCount - action.failCount - action.skipCount, + skipped: action.skipCount, + failed: action.failCount, + testUrl: `${jenkinsResult.url}${action.urlName}/`, + }; + }) + .pop(); + } + + mapJenkinsBuildToCITable( + jenkinsResult: any, + jobScmInfo?: any, + ): CITableBuildInfo { + const source = + jenkinsResult.actions + .filter( + (action: any) => + action._class === 'hudson.plugins.git.util.BuildData', + ) + .map((action: any) => { + const [first]: any = Object.values(action.buildsByBranchName); + const branch = first.revision.branch[0]; + return { + branchName: branch.name, + commit: { + hash: branch.SHA1.substring(0, 8), + }, + }; + }) + .pop() || {}; + + if (jobScmInfo) { + source.url = jobScmInfo?.url; + source.displayName = jobScmInfo?.displayName; + } + + const path = new URL(jenkinsResult.url).pathname; + + return { + id: path, + buildName: jenkinsResult.fullDisplayName, + status: jenkinsResult.building ? 'running' : jenkinsResult.result, + onRestartClick: () => { + // TODO: this won't handle non root context path, need a better way to get the job name + const { jobName } = this.extractJobDetailsFromBuildName(path); + return this.retry(jobName); + }, + source: source, + tests: this.getTestReport(jenkinsResult), + }; + } + + async getBuild(buildName: string) { + const { jobName, buildNumber } = this.extractJobDetailsFromBuildName( + buildName, + ); + const buildResult = await this.jenkins.build.get(jobName, buildNumber); + return buildResult; + } + + extractJobDetailsFromBuildName(buildName: string) { + const trimmedBuild = buildName.replace(/\/job/g, '').replace(/\/$/, ''); + + const split = trimmedBuild.split('/'); + const buildNumber = parseInt(split[split.length - 1], 10); + const jobName = trimmedBuild.slice( + 0, + trimmedBuild.length - buildNumber.toString(10).length - 1, + ); + + return { + jobName, + buildNumber, + }; + } +} diff --git a/plugins/jenkins/src/assets/build-details.png b/plugins/jenkins/src/assets/build-details.png new file mode 100644 index 0000000000..396af428af Binary files /dev/null and b/plugins/jenkins/src/assets/build-details.png differ diff --git a/plugins/jenkins/src/assets/folder-results.png b/plugins/jenkins/src/assets/folder-results.png new file mode 100644 index 0000000000..2e14f3551f Binary files /dev/null and b/plugins/jenkins/src/assets/folder-results.png differ diff --git a/plugins/jenkins/src/assets/last-master-build.png b/plugins/jenkins/src/assets/last-master-build.png new file mode 100644 index 0000000000..517e7a4410 Binary files /dev/null and b/plugins/jenkins/src/assets/last-master-build.png differ diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx new file mode 100644 index 0000000000..469869af69 --- /dev/null +++ b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsBuildsWidget.tsx @@ -0,0 +1,27 @@ +/* + * 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 { Builds } from '../../pages/BuildsPage/lib/Builds'; +import { Entity } from '@backstage/catalog-model'; + +export const JenkinsBuildsWidget = ({ entity }: { entity: Entity }) => { + const [owner, repo] = ( + entity?.metadata.annotations?.['backstage.io/jenkins-github-folder'] ?? '/' + ).split('/'); + + return ; +}; diff --git a/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx new file mode 100644 index 0000000000..9bd16bf9e6 --- /dev/null +++ b/plugins/jenkins/src/components/JenkinsPluginWidget/JenkinsLastBuildWidget.tsx @@ -0,0 +1,82 @@ +/* + * 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 { 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'; + +const useStyles = makeStyles({ + externalLinkIcon: { + fontSize: 'inherit', + verticalAlign: 'bottom', + }, +}); + +const WidgetContent = ({ + loading, + lastRun, +}: { + loading?: boolean; + lastRun: any; + branch: string; +}) => { + const classes = useStyles(); + if (loading || !lastRun) return ; + return ( + + + + ), + build: lastRun.fullDisplayName, + url: ( + + See more on Jenkins{' '} + + + ), + }} + /> + ); +}; + +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 ?? {}; + + return ( + + + + ); +}; diff --git a/plugins/jenkins/src/components/Layout/Layout.tsx b/plugins/jenkins/src/components/Layout/Layout.tsx new file mode 100644 index 0000000000..c6000437a3 --- /dev/null +++ b/plugins/jenkins/src/components/Layout/Layout.tsx @@ -0,0 +1,29 @@ +/* + * 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 ( + +
+ + +
+ {children} +
+ ); +}; diff --git a/plugins/jenkins/src/components/Layout/index.ts b/plugins/jenkins/src/components/Layout/index.ts new file mode 100644 index 0000000000..236fc98851 --- /dev/null +++ b/plugins/jenkins/src/components/Layout/index.ts @@ -0,0 +1,16 @@ +/* + * 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'; diff --git a/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx b/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx new file mode 100644 index 0000000000..501649e49a --- /dev/null +++ b/plugins/jenkins/src/components/PluginHeader/PluginHeader.tsx @@ -0,0 +1,36 @@ +/* + * 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 ( + ( + + {title} + + )} + > + + This plugin allows you to view and interact with your builds in Jenkins. + + + ); +}; diff --git a/plugins/jenkins/src/components/PluginHeader/index.ts b/plugins/jenkins/src/components/PluginHeader/index.ts new file mode 100644 index 0000000000..4de972f6f2 --- /dev/null +++ b/plugins/jenkins/src/components/PluginHeader/index.ts @@ -0,0 +1,16 @@ +/* + * 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 './PluginHeader'; diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts new file mode 100644 index 0000000000..c7f2d8dc28 --- /dev/null +++ b/plugins/jenkins/src/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { plugin, JenkinsBuildsWidget, JenkinsLastBuildWidget } from './plugin'; +export * from './api'; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx new file mode 100644 index 0000000000..ab4fc3f34f --- /dev/null +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -0,0 +1,164 @@ +/* + * 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 }) => ( + + {build?.buildName} + + {/* TODO use Jenkins logo*/} + + + + + +); +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, + 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 = () => ( + + + + + +); + +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 ( + <> + + + + + } + cardClassName={classes.cardContent} + > + {loading ? : } + + + + + ); +}; + +const BuildsList: FC<{ build?: any }> = ({ build }) => ( + + {build && + build.steps && + build.steps.map(({ name, actions }: { name: string; actions: any[] }) => ( + + ))} + +); + +const ActionsList: FC<{ actions: any[]; name: string }> = ({ actions }) => { + const classes = useStyles(); + return ( + <> + {actions.map((action: any) => ( + + ))} + + ); +}; + +export default Page; +export { BuildWithStepsView as BuildWithSteps }; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts b/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts new file mode 100644 index 0000000000..fddff7088c --- /dev/null +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/index.ts @@ -0,0 +1,19 @@ +/* + * 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 DetailedViewPage, + BuildWithSteps, +} from './BuildWithStepsPage'; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx new file mode 100644 index 0000000000..1143121a98 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -0,0 +1,67 @@ +/* + * 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, { useEffect, FC } from 'react'; +import { + ExpansionPanel, + ExpansionPanelSummary, + Typography, + ExpansionPanelDetails, +} from '@material-ui/core'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles({ + expansionPanelDetails: { + padding: 0, + }, + button: { + order: -1, + marginRight: 0, + marginLeft: '-20px', + }, +}); + +export const ActionOutput: FC<{ + url: string; + name: string; + className?: string; + action: any; +}> = ({ url, name, className }) => { + const classes = useStyles(); + + useEffect(() => {}, [url]); + + return ( + + } + aria-controls={`panel-${name}-content`} + id={`panel-${name}-header`} + IconButtonProps={{ + className: classes.button, + }} + > + {name} + + + Nothing here... + + + ); +}; diff --git a/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts new file mode 100644 index 0000000000..7cf74c73f8 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildWithStepsPage/lib/ActionOutput/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { ActionOutput } from './ActionOutput'; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx new file mode 100644 index 0000000000..79f081c82d --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/Builds.tsx @@ -0,0 +1,38 @@ +/* + * 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 ( + + ); +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts new file mode 100644 index 0000000000..e91a9496b7 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/Builds/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { Builds } from './Builds'; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx new file mode 100644 index 0000000000..7f17107374 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/CITable.tsx @@ -0,0 +1,218 @@ +/* + * 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, 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 { Table, TableColumn } from '@backstage/core'; +import { JenkinsRunStatus } from '../Status'; + +export type CITableBuildInfo = { + id: string; + buildName: string; + buildUrl?: string; + source: { + branchName: string; + url: string; + displayName: string; + commit: { + hash: string; + }; + }; + status: string; + tests?: { + total: number; + passed: number; + skipped: number; + failed: number; + testUrl: string; + }; + onRestartClick: () => void; +}; + +const FailCount = ({ count }: { count: number }): JSX.Element | null => { + if (count !== 0) { + return <>{count} failed; + } + return null; +}; + +const SkippedCount = ({ count }: { count: number }): JSX.Element | null => { + if (count !== 0) { + return <>{count} skipped; + } + return null; +}; + +const FailSkippedWidget = ({ + skipped, + failed, +}: { + skipped: number; + failed: number; +}): JSX.Element | null => { + if (skipped === 0 && failed === 0) { + return null; + } + + if (skipped !== 0 && failed !== 0) { + return ( + <> + {' '} + (, ) + + ); + } + + if (failed !== 0) { + return ( + <> + {' '} + () + + ); + } + + if (skipped !== 0) { + return ( + <> + {' '} + () + + ); + } + + return null; +}; + +const generatedColumns: TableColumn[] = [ + { + title: 'Build', + field: 'buildName', + highlight: true, + render: (row: Partial) => ( + + {row.buildName} + + ), + }, + { + title: 'Source', + render: (row: Partial) => ( + <> +

+ + {row.source?.branchName} + +

+

{row.source?.commit?.hash}

+ + ), + }, + { + title: 'Status', + render: (row: Partial) => { + return ( + + + + ); + }, + }, + { + title: 'Tests', + render: (row: Partial) => { + return ( + <> +

+ {row.tests && ( + + {row.tests.passed} / {row.tests.total} passed + + + )} + + {!row.tests && 'n/a'} +

+ + ); + }, + }, + { + title: 'Actions', + render: (row: Partial) => ( + + + + ), + width: '10%', + }, +]; + +type Props = { + loading: boolean; + retry: () => void; + builds: CITableBuildInfo[]; + projectName: string; + page: number; + onChangePage: (page: number) => void; + total: number; + pageSize: number; + onChangePageSize: (pageSize: number) => void; +}; +export const CITable: FC = ({ + projectName, + loading, + pageSize, + page, + retry, + builds, + onChangePage, + onChangePageSize, + total, +}) => { + return ( + , + tooltip: 'Refresh Data', + isFreeAction: true, + onClick: () => retry(), + }, + ]} + data={builds} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangePageSize} + title={ + + + + {projectName} + + } + columns={generatedColumns} + /> + ); +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts new file mode 100644 index 0000000000..358939e69f --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/CITable/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { CITable } from './CITable'; +export type { CITableBuildInfo } from './CITable'; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx b/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx new file mode 100644 index 0000000000..66a58abe76 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/Status/JenkinsRunStatus.tsx @@ -0,0 +1,78 @@ +/* + * 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 { + StatusPending, + StatusRunning, + StatusOK, + StatusWarning, + StatusAborted, + StatusError, +} from '@backstage/core'; +import React from 'react'; + +export const JenkinsRunStatus = ({ + status, +}: { + status: string | undefined; +}) => { + if (status === undefined) return null; + switch (status.toLowerCase()) { + case 'queued': + case 'scheduled': + return ( + <> + Queued + + ); + case 'running': + return ( + <> + In progress + + ); + case 'unstable': + return ( + <> + Unstable + + ); + case 'failure': + return ( + <> + Failed + + ); + case 'success': + return ( + <> + Completed + + ); + case 'aborted': + return ( + <> + Aborted + + ); + default: + return ( + <> + {status} + + ); + } +}; diff --git a/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts b/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts new file mode 100644 index 0000000000..f5dd4a55e5 --- /dev/null +++ b/plugins/jenkins/src/pages/BuildsPage/lib/Status/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { JenkinsRunStatus } from './JenkinsRunStatus'; diff --git a/plugins/jenkins/src/plugin.test.ts b/plugins/jenkins/src/plugin.test.ts new file mode 100644 index 0000000000..8c3f057ecb --- /dev/null +++ b/plugins/jenkins/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * 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 { plugin } from './plugin'; + +describe('jenkins', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts new file mode 100644 index 0000000000..979b0a31cf --- /dev/null +++ b/plugins/jenkins/src/plugin.ts @@ -0,0 +1,33 @@ +/* + * 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 { createPlugin, createRouteRef } from '@backstage/core'; +import { DetailedViewPage } from './pages/BuildWithStepsPage'; + +export const buildRouteRef = createRouteRef({ + path: '/jenkins/job', + title: 'Jenkins run', +}); + +export const plugin = createPlugin({ + id: 'jenkins', + register({ router }) { + router.addRoute(buildRouteRef, DetailedViewPage); + }, +}); + +export { JenkinsBuildsWidget } from './components/JenkinsPluginWidget/JenkinsBuildsWidget'; +export { JenkinsLastBuildWidget } from './components/JenkinsPluginWidget/JenkinsLastBuildWidget'; diff --git a/plugins/jenkins/src/setupTests.ts b/plugins/jenkins/src/setupTests.ts new file mode 100644 index 0000000000..8553642152 --- /dev/null +++ b/plugins/jenkins/src/setupTests.ts @@ -0,0 +1,19 @@ +/* + * 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 '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/jenkins/src/state/index.ts b/plugins/jenkins/src/state/index.ts new file mode 100644 index 0000000000..d21a380c2a --- /dev/null +++ b/plugins/jenkins/src/state/index.ts @@ -0,0 +1,17 @@ +/* + * 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 './useBuilds'; +export * from './useBuildWithSteps'; diff --git a/plugins/jenkins/src/state/useAsyncPolling.ts b/plugins/jenkins/src/state/useAsyncPolling.ts new file mode 100644 index 0000000000..7ea0755368 --- /dev/null +++ b/plugins/jenkins/src/state/useAsyncPolling.ts @@ -0,0 +1,37 @@ +/* + * 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 { useRef } from 'react'; + +export const useAsyncPolling = ( + pollingFn: () => Promise, + interval: number, +) => { + const isPolling = useRef(false); + const startPolling = async () => { + if (isPolling.current === true) return; + isPolling.current = true; + + while (isPolling.current === true) { + await pollingFn(); + await new Promise(resolve => setTimeout(resolve, interval)); + } + }; + + const stopPolling = () => { + isPolling.current = false; + }; + return { startPolling, stopPolling }; +}; diff --git a/plugins/jenkins/src/state/useBuildWithSteps.ts b/plugins/jenkins/src/state/useBuildWithSteps.ts new file mode 100644 index 0000000000..617b2b1f4f --- /dev/null +++ b/plugins/jenkins/src/state/useBuildWithSteps.ts @@ -0,0 +1,68 @@ +/* + * 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 { useCallback } from 'react'; +import { useAsyncRetry } from 'react-use'; +import { jenkinsApiRef } from '../api/index'; +import { useAsyncPolling } from './useAsyncPolling'; + +const INTERVAL_AMOUNT = 1500; +export function useBuildWithSteps(buildName: string) { + const api = useApi(jenkinsApiRef); + const errorApi = useApi(errorApiRef); + + const getBuildWithSteps = useCallback(async () => { + try { + const build = await api.getBuild(buildName); + + const { jobName } = api.extractJobDetailsFromBuildName(buildName); + const job = await api.getJob(jobName); + const jobInfo = api.extractScmDetailsFromJob(job); + + return Promise.resolve(api.mapJenkinsBuildToCITable(build, jobInfo)); + } catch (e) { + errorApi.post(e); + return Promise.reject(e); + } + }, [buildName, api, errorApi]); + + const restartBuild = async () => { + try { + await api.retry(buildName); + } catch (e) { + errorApi.post(e); + } + }; + + const { loading, value, retry } = useAsyncRetry(() => getBuildWithSteps(), [ + getBuildWithSteps, + ]); + + const { startPolling, stopPolling } = useAsyncPolling( + getBuildWithSteps, + INTERVAL_AMOUNT, + ); + + return [ + { loading, value, retry }, + { + restartBuild, + getBuildWithSteps, + startPolling, + stopPolling, + }, + ] as const; +} diff --git a/plugins/jenkins/src/state/useBuilds.ts b/plugins/jenkins/src/state/useBuilds.ts new file mode 100644 index 0000000000..deb3125637 --- /dev/null +++ b/plugins/jenkins/src/state/useBuilds.ts @@ -0,0 +1,82 @@ +/* + * 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 { useCallback, useEffect, useState } from 'react'; +import { useAsyncRetry } from 'react-use'; +import { jenkinsApiRef } from '../api'; + +export function useBuilds(owner: string, repo: string, branch?: string) { + const api = useApi(jenkinsApiRef); + const errorApi = useApi(errorApiRef); + + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(5); + + const getBuilds = useCallback(async () => { + try { + let build; + if (branch) { + build = await api.getLastBuild(`${owner}/${repo}/${branch}`); + } else { + build = await api.getFolder(`${owner}/${repo}`); + } + return build; + } catch (e) { + errorApi.post(e); + return Promise.reject(e); + } + }, [api, branch, errorApi, owner, repo]); + + const restartBuild = async (buildName: string) => { + try { + await api.retry(buildName); + } catch (e) { + errorApi.post(e); + } + }; + + useEffect(() => { + getBuilds().then(b => { + const size = Array.isArray(b) ? b?.[0].build_num! : 1; + setTotal(size); + }); + }, [repo, getBuilds]); + + const { loading, value, retry } = useAsyncRetry( + () => getBuilds().then(builds => builds ?? [], restartBuild), + [page, pageSize, getBuilds], + ); + + const projectName = `${owner}/${repo}`; + return [ + { + page, + pageSize, + loading, + value, + projectName, + total, + }, + { + getBuilds, + setPage, + setPageSize, + restartBuild, + retry, + }, + ] as const; +} diff --git a/yarn.lock b/yarn.lock index 16e13985c0..ff1e0a3e65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3183,6 +3183,26 @@ resolved "https://registry.npmjs.org/@rjsf/material-ui/-/material-ui-2.1.0.tgz#a361b125af3a383b7f671634b8d254345f9f9311" integrity sha512-9mMttnPNP6GiP7BtZGimYcYsbWwjyviqg/PD8oxrkEtZykULXOIdC3WDMJ3nPSym8RvZsgSnB8bajCpa5iSYQQ== +"@roadiehq/backstage-plugin-travis-ci@^0.1.3": + version "0.1.3" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-travis-ci/-/backstage-plugin-travis-ci-0.1.3.tgz#597882bb6db119b9c8970f15cc506c5d70ba2c41" + integrity sha512-xTnc71VtQSp7qfiE/p92U2/FEAvsG32BHIELebYZGCIt+ZqY/fSiXZSGsOXFDlg9adTu0e8ro6HWnknXCdo2pw== + dependencies: + "@backstage/core" "0.1.1-alpha.18" + "@backstage/theme" "0.1.1-alpha.18" + "@material-ui/core" "^4.9.1" + "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "4.0.0-alpha.45" + date-fns "^2.15.0" + history "^5.0.0" + moment "^2.27.0" + react "^16.13.1" + react-dom "^16.13.1" + react-lazylog "^4.5.2" + react-router "6.0.0-beta.0" + react-router-dom "6.0.0-beta.0" + react-use "^15.3.3" + "@rollup/plugin-commonjs@^13.0.0": version "13.0.0" resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-13.0.0.tgz#8a1d684ba6848afe8b9e3d85649d4b2f6f7217ec" @@ -8676,7 +8696,7 @@ date-fns@^1.27.2: resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -date-fns@^2.0.0-alpha.27, date-fns@^2.0.1: +date-fns@^2.0.0-alpha.27, date-fns@^2.0.1, date-fns@^2.15.0: version "2.15.0" resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.15.0.tgz#424de6b3778e4e69d3ff27046ec136af58ae5d5f" integrity sha512-ZCPzAMJZn3rNUvvQIMlXhDr4A+Ar07eLeGsGREoWU19a3Pqf5oYa+ccd+B3F6XVtQY6HANMFdOQ8A+ipFnvJdQ== @@ -13286,6 +13306,13 @@ iterate-value@^1.0.0: es-get-iterator "^1.0.2" iterate-iterator "^1.0.1" +jenkins@^0.28.0: + version "0.28.0" + resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.0.tgz#72d6fcc452145403b34f6d4ecbd877ee1ab77fca" + integrity sha512-EGzzZcyFwXBCPZZoDNvZTPmZOqaHALfAStNfCF37oh+Mc6G/e0MwIuQjx5kMEynTXR9bF5EwLiuMTiTf5kHk5g== + dependencies: + papi "^0.29.0" + jest-changed-files@^26.0.1: version "26.0.1" resolved "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.0.1.tgz#1334630c6a1ad75784120f39c3aa9278e59f349f" @@ -15412,12 +15439,12 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@2.26.0, moment@^2.25.3, moment@^2.26.0: +moment@2.26.0: version "2.26.0" resolved "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw== -moment@^2.27.0: +moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: version "2.27.0" resolved "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d" integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ== @@ -16553,6 +16580,11 @@ pako@~1.0.5: resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +papi@^0.29.0: + version "0.29.1" + resolved "https://registry.npmjs.org/papi/-/papi-0.29.1.tgz#7373e2c527f5117d61fd2a0e6c6b1dd72bf7f180" + integrity sha512-Y9ipSMfWuuVFO3zY9PlxOmEg+bQ7CeJ28sa9/a0veYNynLf9fwjR3+3fld5otEy7okUaEOUuCHVH62MyTmACXQ== + parallel-transform@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"