diff --git a/.changeset/breezy-carrots-hunt.md b/.changeset/breezy-carrots-hunt.md deleted file mode 100644 index 0075009a2d..0000000000 --- a/.changeset/breezy-carrots-hunt.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-explore': patch ---- - -Add "Organization" tab with a diagram diff --git a/.changeset/calm-insects-melt.md b/.changeset/calm-insects-melt.md deleted file mode 100644 index 4b17764c33..0000000000 --- a/.changeset/calm-insects-melt.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@backstage/cli': patch -'@backstage/create-app': patch -'@backstage/plugin-catalog-graphql': patch ---- - -chore: bump `ts-node` versions to 9.1.1 diff --git a/.changeset/cyan-beans-marry.md b/.changeset/cyan-beans-marry.md deleted file mode 100644 index 99d7ad47ce..0000000000 --- a/.changeset/cyan-beans-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-model': patch ---- - -Renamed parameters to input in template schema diff --git a/.changeset/grumpy-goats-refuse.md b/.changeset/grumpy-goats-refuse.md deleted file mode 100644 index 8dc2e390fc..0000000000 --- a/.changeset/grumpy-goats-refuse.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Show error on task page if task does not exist. diff --git a/.changeset/ninety-pots-guess.md b/.changeset/ninety-pots-guess.md deleted file mode 100644 index 4b9a770c29..0000000000 --- a/.changeset/ninety-pots-guess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -update plugins created to use react-use 17.2.4 diff --git a/.changeset/ten-paws-ring.md b/.changeset/ten-paws-ring.md deleted file mode 100644 index 8ed2158f6c..0000000000 --- a/.changeset/ten-paws-ring.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/core-api': patch -'@backstage/integration-react': patch -'@backstage/plugin-api-docs': patch -'@backstage/plugin-badges': patch -'@backstage/plugin-bitrise': patch -'@backstage/plugin-catalog': patch -'@backstage/plugin-catalog-import': patch -'@backstage/plugin-catalog-react': patch -'@backstage/plugin-circleci': patch -'@backstage/plugin-cloudbuild': patch -'@backstage/plugin-code-coverage': patch -'@backstage/plugin-config-schema': patch -'@backstage/plugin-cost-insights': patch -'@backstage/plugin-explore': patch -'@backstage/plugin-fossa': patch -'@backstage/plugin-gcp-projects': patch -'@backstage/plugin-github-actions': patch -'@backstage/plugin-github-deployments': patch -'@backstage/plugin-gitops-profiles': patch -'@backstage/plugin-graphiql': patch -'@backstage/plugin-jenkins': patch -'@backstage/plugin-kafka': patch -'@backstage/plugin-kubernetes': patch -'@backstage/plugin-lighthouse': patch -'@backstage/plugin-newrelic': patch -'@backstage/plugin-org': patch -'@backstage/plugin-pagerduty': patch -'@backstage/plugin-register-component': patch -'@backstage/plugin-rollbar': patch -'@backstage/plugin-scaffolder': patch -'@backstage/plugin-search': patch -'@backstage/plugin-sentry': patch -'@backstage/plugin-sonarqube': patch -'@backstage/plugin-splunk-on-call': patch -'@backstage/plugin-tech-radar': patch -'@backstage/plugin-techdocs': patch -'@backstage/plugin-todo': patch -'@backstage/plugin-user-settings': patch -'@backstage/plugin-welcome': patch ---- - -chore: bump `react-use` dependency in all packages diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 694226b8cc..4e3c6d9a88 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -167,6 +167,7 @@ mailto maintainership makefile md +memcache microsite middleware minikube @@ -177,6 +178,7 @@ mkdocs monorepo monorepos msw +mysql namespace namespaced namespaces diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b7433888c..2caa8c9c9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ Which will look something like the following in the repo; ``` Awesome commit message -Signed-off-by: Jane Smith jane.smith@example.com +Signed-off-by: Jane Smith ``` - In case you forgot to add it to the most recent commit, use `git commit --amend --signoff` diff --git a/OWNERS.md b/OWNERS.md index a79328dc20..cdbc234183 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -27,5 +27,5 @@ People that have made significant contributions to the project and earned write - Andrew Thauer - Wealthsimple (GitHub: [andrewthauer](https://github.com/andrewthauer)) - Oliver Sand - SDA SE (GitHub: [Fox32](https://github.com/Fox32)) - David Tuite - Roadie (GitHub: [dtuite](https://github.com/dtuite)) -- Adam Harvey - DXC Technology (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) +- Adam Harvey - Cisco (GitHub: [adamdmharvey](https://github.com/adamdmharvey)) - Dominik Henneke - SDA SE (GitHub: [dhenneke](https://github.com/dhenneke)) diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 5dbad9cf4a..45ef28ea2f 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -33,6 +33,7 @@ kubernetes: - type: 'gke' projectId: 'gke-clusters' region: 'europe-west1' + skipTLSVerify: true ``` ### `serviceLocatorMethod` @@ -129,6 +130,11 @@ The Google Cloud project to look for Kubernetes clusters in. The Google Cloud region to look for Kubernetes clusters in. Defaults to all regions. +##### `skipTLSVerify` + +This determines whether or not the Kubernetes client verifies the TLS +certificate presented by the API server. Defaults to `false`. + ### `customResources` (optional) Configures which [custom resources][3] to look for when returning an entity's diff --git a/microsite/data/plugins/git-release-manager.yaml b/microsite/data/plugins/git-release-manager.yaml new file mode 100644 index 0000000000..7eec6040ac --- /dev/null +++ b/microsite/data/plugins/git-release-manager.yaml @@ -0,0 +1,9 @@ +--- +title: GitHub Release Manager +author: '@Spotify' +authorUrl: https://github.com/spotify +category: Release management +description: Manage releases without having to juggle git commands +documentation: https://github.com/backstage/backstage/tree/master/plugins/git-release-manager +iconUrl: img/git-release-manager-logo.svg +npmPackageName: '@backstage/plugin-git-release-manager' diff --git a/microsite/package.json b/microsite/package.json index 07899fa703..595debcc65 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -18,7 +18,7 @@ "@spotify/prettier-config": "^10.0.0", "docusaurus": "^2.0.0-alpha.70", "js-yaml": "^4.1.0", - "prettier": "^2.2.1" + "prettier": "^2.3.0" }, "prettier": "@spotify/prettier-config" } diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index a761d6b6e2..45d87daa1d 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -431,7 +431,7 @@ class Index extends React.Component { Pick a cloud, any cloud Since Backstage uses the Kubernetes API, it's cloud agnostic — - so it works no matter which cloud provide or managed Kubernetes + so it works no matter which cloud provider or managed Kubernetes service you use, and even works in multi-cloud orgs diff --git a/microsite/static/img/git-release-manager-logo.svg b/microsite/static/img/git-release-manager-logo.svg new file mode 100644 index 0000000000..b505d21560 --- /dev/null +++ b/microsite/static/img/git-release-manager-logo.svg @@ -0,0 +1,13 @@ + + + Export + + + + + + + + + + \ No newline at end of file diff --git a/microsite/yarn.lock b/microsite/yarn.lock index bed7897abc..4033968a2e 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -5197,10 +5197,10 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.1.tgz#795a1a78dd52f073da0cd42b21f9c91381923ff5" - integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== +prettier@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18" + integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w== prismjs@^1.22.0: version "1.23.0" diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 33ef204f63..1cc00e5ff9 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,57 @@ # example-app +## 0.2.28 + +### Patch Changes + +- Updated dependencies [062bbf90f] +- Updated dependencies [2cd70e164] +- Updated dependencies [0b033d07b] +- Updated dependencies [3be844496] +- Updated dependencies [5542de095] +- Updated dependencies [10c008a3a] +- Updated dependencies [81ef1d57b] +- Updated dependencies [ea21d46f0] +- Updated dependencies [e3fc89df6] +- Updated dependencies [f59a945b7] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/plugin-api-docs@0.4.13 + - @backstage/plugin-badges@0.2.1 + - @backstage/plugin-catalog@0.5.7 + - @backstage/plugin-catalog-import@0.5.6 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/plugin-circleci@0.2.14 + - @backstage/plugin-cloudbuild@0.2.14 + - @backstage/plugin-code-coverage@0.1.3 + - @backstage/plugin-cost-insights@0.9.1 + - @backstage/plugin-explore@0.3.5 + - @backstage/plugin-gcp-projects@0.2.6 + - @backstage/plugin-github-actions@0.4.6 + - @backstage/plugin-graphiql@0.2.11 + - @backstage/plugin-jenkins@0.4.3 + - @backstage/plugin-kafka@0.2.7 + - @backstage/plugin-kubernetes@0.4.4 + - @backstage/plugin-lighthouse@0.2.16 + - @backstage/plugin-newrelic@0.2.7 + - @backstage/plugin-org@0.3.13 + - @backstage/plugin-pagerduty@0.3.4 + - @backstage/plugin-rollbar@0.3.5 + - @backstage/plugin-scaffolder@0.9.4 + - @backstage/plugin-search@0.3.6 + - @backstage/plugin-sentry@0.3.10 + - @backstage/plugin-shortcuts@0.1.2 + - @backstage/plugin-tech-radar@0.3.11 + - @backstage/plugin-techdocs@0.9.2 + - @backstage/plugin-todo@0.1.1 + - @backstage/plugin-user-settings@0.2.10 + - @backstage/cli@0.6.11 + - @backstage/catalog-model@0.7.9 + ## 0.2.27 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 996fa578bb..b412de762d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,41 +1,42 @@ { "name": "example-app", - "version": "0.2.27", + "version": "0.2.28", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.7.8", - "@backstage/cli": "^0.6.10", - "@backstage/core": "^0.7.8", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-api-docs": "^0.4.12", - "@backstage/plugin-badges": "^0.2.0", - "@backstage/plugin-catalog": "^0.5.6", - "@backstage/plugin-catalog-import": "^0.5.5", - "@backstage/plugin-catalog-react": "^0.1.5", - "@backstage/plugin-circleci": "^0.2.13", - "@backstage/plugin-cloudbuild": "^0.2.13", - "@backstage/plugin-code-coverage": "^0.1.2", - "@backstage/plugin-cost-insights": "^0.9.0", - "@backstage/plugin-explore": "^0.3.4", - "@backstage/plugin-gcp-projects": "^0.2.5", - "@backstage/plugin-github-actions": "^0.4.5", - "@backstage/plugin-graphiql": "^0.2.10", - "@backstage/plugin-jenkins": "^0.4.2", - "@backstage/plugin-kafka": "^0.2.6", - "@backstage/plugin-kubernetes": "^0.4.3", - "@backstage/plugin-lighthouse": "^0.2.15", - "@backstage/plugin-newrelic": "^0.2.6", - "@backstage/plugin-org": "^0.3.12", - "@backstage/plugin-pagerduty": "0.3.3", - "@backstage/plugin-rollbar": "^0.3.4", - "@backstage/plugin-scaffolder": "^0.9.3", - "@backstage/plugin-search": "^0.3.5", - "@backstage/plugin-sentry": "^0.3.9", - "@backstage/plugin-tech-radar": "^0.3.10", - "@backstage/plugin-techdocs": "^0.9.1", - "@backstage/plugin-todo": "^0.1.0", - "@backstage/plugin-user-settings": "^0.2.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/cli": "^0.6.11", + "@backstage/core": "^0.7.9", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-api-docs": "^0.4.13", + "@backstage/plugin-badges": "^0.2.1", + "@backstage/plugin-catalog": "^0.5.7", + "@backstage/plugin-catalog-import": "^0.5.6", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/plugin-circleci": "^0.2.14", + "@backstage/plugin-cloudbuild": "^0.2.14", + "@backstage/plugin-code-coverage": "^0.1.3", + "@backstage/plugin-cost-insights": "^0.9.1", + "@backstage/plugin-explore": "^0.3.5", + "@backstage/plugin-gcp-projects": "^0.2.6", + "@backstage/plugin-github-actions": "^0.4.6", + "@backstage/plugin-graphiql": "^0.2.11", + "@backstage/plugin-jenkins": "^0.4.3", + "@backstage/plugin-kafka": "^0.2.7", + "@backstage/plugin-kubernetes": "^0.4.4", + "@backstage/plugin-lighthouse": "^0.2.16", + "@backstage/plugin-newrelic": "^0.2.7", + "@backstage/plugin-org": "^0.3.13", + "@backstage/plugin-pagerduty": "0.3.4", + "@backstage/plugin-rollbar": "^0.3.5", + "@backstage/plugin-scaffolder": "^0.9.4", + "@backstage/plugin-search": "^0.3.6", + "@backstage/plugin-sentry": "^0.3.10", + "@backstage/plugin-shortcuts": "^0.1.2", + "@backstage/plugin-tech-radar": "^0.3.11", + "@backstage/plugin-techdocs": "^0.9.2", + "@backstage/plugin-todo": "^0.1.1", + "@backstage/plugin-user-settings": "^0.2.10", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -55,11 +56,11 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.11", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/jquery": "^3.3.34", "@types/node": "^14.14.32", diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 454c0d2e84..9c62ec0b4d 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -39,6 +39,7 @@ import { NavLink } from 'react-router-dom'; import { GraphiQLIcon } from '@backstage/plugin-graphiql'; import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; import { SidebarSearch } from '@backstage/plugin-search'; +import { Shortcuts } from '@backstage/plugin-shortcuts'; const useSidebarLogoStyles = makeStyles({ root: { @@ -91,6 +92,8 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( + + diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 7dbea5b995..e02e30e5d9 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -17,3 +17,4 @@ // TODO(Rugvip): This plugin is currently not part of the app element tree, // ideally we have an API for the context menu that permits that. export { badgesPlugin } from '@backstage/plugin-badges'; +export { shortcutsPlugin } from '@backstage/plugin-shortcuts'; diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 3568adf099..9c66f387d9 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,62 @@ # @backstage/backend-common +## 0.8.0 + +### Minor Changes + +- 22fd8ce2a: Introducing: a standard API for App Integrators to configure cache stores and Plugin Developers to interact with them. + + Two cache stores are currently supported. + + - `memory`, which is a very simple in-memory key/value store, intended for local development. + - `memcache`, which can be used to connect to a memcache host. + + Configuring and working with cache stores is very similar to the process for database connections. + + ```yaml + backend: + cache: + store: memcache + connection: user:pass@cache.example.com:11211 + ``` + + ```typescript + import { CacheManager } from '@backstage/backend-common'; + + // Instantiating a cache client for a plugin. + const cacheManager = CacheManager.fromConfig(config); + const somePluginCache = cacheManager.forPlugin('somePlugin'); + const cacheClient = somePluginCache.getClient(); + + // Using the cache client: + const cachedValue = await cacheClient.get('someKey'); + if (cachedValue) { + return cachedValue; + } else { + const someValue = await someExpensiveProcess(); + await cacheClient.set('someKey', someValue); + } + await cacheClient.delete('someKey'); + ``` + + Cache clients deal with TTLs in milliseconds. A TTL can be provided as a defaultTtl when getting a client, or may be passed when setting specific objects. If no TTL is provided, data will be persisted indefinitely. + + ```typescript + // Getting a client with a default TTL + const cacheClient = somePluginCache.getClient({ + defaultTtl: 3600000, + }); + + // Setting a TTL on a per-object basis. + cacheClient.set('someKey', data, { ttl: 3600000 }); + ``` + + Configuring a cache store is optional. Even when no cache store is configured, the cache manager will dutifully pass plugins a manager that resolves a cache client that does not actually write or read any data. + +### Patch Changes + +- f9fb4a205: Prep work for mysql support in backend-common + ## 0.7.0 ### Minor Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index f0cb066dc0..23e22fe34b 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -16,6 +16,7 @@ import { GithubCredentialsProvider } from '@backstage/integration'; import { GitHubIntegration } from '@backstage/integration'; import { GitLabIntegration } from '@backstage/integration'; import * as http from 'http'; +import { JsonValue } from '@backstage/config'; import { Knex } from 'knex'; import { Logger } from 'winston'; import { MergeResult } from 'isomorphic-git'; @@ -62,6 +63,19 @@ export class BitbucketUrlReader implements UrlReader { toString(): string; } +// @public +export interface CacheClient { + delete(key: string): Promise; + get(key: string): Promise; + set(key: string, value: JsonValue, options?: CacheSetOptions): Promise; +} + +// @public +export class CacheManager { + forPlugin(pluginId: string): PluginCacheManager; + static fromConfig(config: Config): CacheManager; + } + // @public (undocumented) export const coloredFormat: winston.Logform.Format; @@ -238,6 +252,11 @@ export function loadBackendConfig(options: Options): Promise; // @public export function notFoundHandler(): RequestHandler; +// @public +export type PluginCacheManager = { + getClient: (options?: ClientOptions) => CacheClient; +}; + // @public export interface PluginDatabaseManager { getClient(): Promise; diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 41845f952e..875660a594 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -68,6 +68,20 @@ export interface Config { connection: string | object; }; + /** Cache connection configuration, select cache type using the `store` field */ + cache?: + | { + store: 'memory'; + } + | { + store: 'memcache'; + /** + * A memcache connection string in the form `user:pass@host:port`. + * @secret + */ + connection: string; + }; + cors?: { origin?: string | string[]; methods?: string | string[]; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index a144f1c23f..7e647e57d0 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.7.0", + "version": "0.8.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -51,6 +51,8 @@ "git-url-parse": "^11.4.4", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", + "keyv": "^4.0.3", + "keyv-memcache": "^1.2.5", "knex": "^0.95.1", "lodash": "^4.17.15", "logform": "^2.1.1", @@ -62,7 +64,8 @@ "stoppable": "^1.1.0", "tar": "^6.0.5", "unzipper": "^0.10.11", - "winston": "^3.2.1" + "winston": "^3.2.1", + "yn": "^4.0.0" }, "peerDependencies": { "pg-connection-string": "^2.3.0" @@ -73,8 +76,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/test-utils": "^0.1.11", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", @@ -94,6 +97,7 @@ "jest": "^26.0.1", "mock-fs": "^4.13.0", "msw": "^0.21.2", + "mysql2": "^2.2.5", "recursive-readdir": "^2.2.2", "supertest": "^6.1.3" }, diff --git a/packages/backend-common/src/cache/CacheClient.test.ts b/packages/backend-common/src/cache/CacheClient.test.ts new file mode 100644 index 0000000000..81e8351b58 --- /dev/null +++ b/packages/backend-common/src/cache/CacheClient.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DefaultCacheClient } from './CacheClient'; +import Keyv from 'keyv'; + +describe('CacheClient', () => { + let client: Keyv; + const b64 = (k: string) => Buffer.from(k).toString('base64'); + + beforeEach(() => { + client = new Keyv(); + client.get = jest.fn(); + client.set = jest.fn(); + client.delete = jest.fn(); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('CacheClient.get', () => { + it('calls client with normalized key', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'somekey'; + + await sut.get(key); + + expect(client.get).toHaveBeenCalledWith(b64(key)); + }); + + it('calls client with normalized key (very long key)', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'x'.repeat(251); + + await sut.get(key); + + const spy = client.get as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).not.toEqual(b64(key)); + expect(actualKey.length).toBeLessThan(250); + }); + + it('rejects on underlying error', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedError = new Error('Some runtime error'); + client.get = jest.fn().mockRejectedValue(expectedError); + + return expect(sut.get('someKey')).rejects.toEqual(expectedError); + }); + + it('resolves what underlying client resolves', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedValue = 'some value'; + client.get = jest.fn().mockResolvedValue(expectedValue); + + const actualValue = await sut.get('someKey'); + + return expect(actualValue).toEqual(expectedValue); + }); + }); + + describe('CacheClient.set', () => { + it('calls client with normalized key', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'somekey'; + + await sut.set(key, {}); + + const spy = client.set as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).toEqual(b64(key)); + }); + + it('passes ttl to client when given', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedTtl = 3600; + + await sut.set('someKey', {}, { ttl: expectedTtl }); + + const spy = client.set as jest.Mock; + const actualTtl = spy.mock.calls[0][2]; + expect(actualTtl).toEqual(expectedTtl); + }); + + it('rejects on underlying error if configured', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedError = new Error('Some runtime error'); + client.set = jest.fn().mockRejectedValue(expectedError); + + return expect(sut.set('someKey', {})).rejects.toEqual(expectedError); + }); + }); + + describe('CacheClient.delete', () => { + it('calls client with normalized key', async () => { + const sut = new DefaultCacheClient({ client }); + const key = 'somekey'; + + await sut.delete(key); + + const spy = client.delete as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).toEqual(b64(key)); + }); + + it('rejects on underlying error if configured', async () => { + const sut = new DefaultCacheClient({ client }); + const expectedError = new Error('Some runtime error'); + client.delete = jest.fn().mockRejectedValue(expectedError); + + return expect(sut.delete('someKey')).rejects.toEqual(expectedError); + }); + }); +}); diff --git a/packages/backend-common/src/cache/CacheClient.ts b/packages/backend-common/src/cache/CacheClient.ts new file mode 100644 index 0000000000..49b5c367dd --- /dev/null +++ b/packages/backend-common/src/cache/CacheClient.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonValue } from '@backstage/config'; +import { createHash } from 'crypto'; +import Keyv from 'keyv'; + +type CacheClientArgs = { + client: Keyv; +}; + +type CacheSetOptions = { + /** + * Optional TTL in milliseconds. Defaults to the TTL provided when the client + * was set up (or no TTL if none are provided). + */ + ttl?: number; +}; + +/** + * A pre-configured, storage agnostic cache client suitable for use by + * Backstage plugins. + */ +export interface CacheClient { + /** + * Reads data from a cache store for the given key. If no data was found, + * returns undefined. + */ + get(key: string): Promise; + + /** + * Writes the given data to a cache store, associated with the given key. An + * optional TTL may also be provided, otherwise it defaults to the TTL that + * was provided when the client was instantiated. + */ + set(key: string, value: JsonValue, options?: CacheSetOptions): Promise; + + /** + * Removes the given key from the cache store. + */ + delete(key: string): Promise; +} + +/** + * A basic, concrete implementation of the CacheClient, suitable for almost + * all uses in Backstage. + */ +export class DefaultCacheClient implements CacheClient { + private readonly client: Keyv; + + constructor({ client }: CacheClientArgs) { + this.client = client; + } + + async get(key: string): Promise { + const k = this.getNormalizedKey(key); + return await this.client.get(k); + } + + async set( + key: string, + value: JsonValue, + opts: CacheSetOptions = {}, + ): Promise { + const k = this.getNormalizedKey(key); + await this.client.set(k, value, opts.ttl); + } + + async delete(key: string): Promise { + const k = this.getNormalizedKey(key); + await this.client.delete(k); + } + + /** + * Ensures keys are well-formed for any/all cache stores. + */ + private getNormalizedKey(candidateKey: string): string { + // Remove potentially invalid characters. + const wellFormedKey = Buffer.from(candidateKey).toString('base64'); + + // Memcache in particular doesn't do well with keys > 250 bytes. + if (wellFormedKey.length < 250) { + return wellFormedKey; + } + + return createHash('md5').update(candidateKey).digest('base64'); + } +} diff --git a/packages/backend-common/src/cache/CacheManager.test.ts b/packages/backend-common/src/cache/CacheManager.test.ts new file mode 100644 index 0000000000..ece4687ee6 --- /dev/null +++ b/packages/backend-common/src/cache/CacheManager.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import Keyv from 'keyv'; +/* @ts-expect-error */ +import KeyvMemcache from 'keyv-memcache'; +import { DefaultCacheClient } from './CacheClient'; +import { CacheManager } from './CacheManager'; +import { NoStore } from './NoStore'; + +jest.createMockFromModule('keyv'); +jest.mock('keyv'); +jest.createMockFromModule('keyv-memcache'); +jest.mock('keyv-memcache'); +jest.mock('./CacheClient', () => { + return { + DefaultCacheClient: jest.fn(), + }; +}); + +describe('CacheManager', () => { + const defaultConfigOptions = { + backend: { + cache: { + store: 'memory', + }, + }, + }; + const defaultConfig = () => new ConfigReader(defaultConfigOptions); + + afterEach(() => jest.resetAllMocks()); + + describe('CacheManager.fromConfig', () => { + it('accesses the backend.cache key', () => { + const getOptionalString = jest.fn(); + const config = defaultConfig(); + config.getOptionalString = getOptionalString; + + CacheManager.fromConfig(config); + + expect(getOptionalString.mock.calls[0][0]).toEqual('backend.cache.store'); + expect(getOptionalString.mock.calls[1][0]).toEqual( + 'backend.cache.connection', + ); + }); + + it('does not require the backend.cache key', () => { + const config = new ConfigReader({ backend: {} }); + expect(() => { + CacheManager.fromConfig(config); + }).not.toThrowError(); + }); + + it('throws on unknown cache store', () => { + const config = new ConfigReader({ + backend: { cache: { store: 'notreal' } }, + }); + expect(() => { + CacheManager.fromConfig(config); + }).toThrowError(); + }); + }); + + describe('CacheManager.forPlugin', () => { + const manager = CacheManager.fromConfig(defaultConfig()); + + it('connects to a cache store scoped to the plugin', async () => { + const pluginId = 'test1'; + manager.forPlugin(pluginId).getClient(); + + const client = DefaultCacheClient as jest.Mock; + expect(client).toHaveBeenCalledTimes(1); + }); + + it('provides different plugins different cache clients', async () => { + const plugin1Id = 'test1'; + const plugin2Id = 'test2'; + const expectedTtl = 3600; + manager.forPlugin(plugin1Id).getClient({ defaultTtl: expectedTtl }); + manager.forPlugin(plugin2Id).getClient({ defaultTtl: expectedTtl }); + + const client = DefaultCacheClient as jest.Mock; + const cache = (Keyv as unknown) as jest.Mock; + expect(cache).toHaveBeenCalledTimes(2); + expect(client).toHaveBeenCalledTimes(2); + + const plugin1CallArgs = cache.mock.calls[0]; + const plugin2CallArgs = cache.mock.calls[1]; + expect(plugin1CallArgs[0].namespace).not.toEqual( + plugin2CallArgs[0].namespace, + ); + }); + }); + + describe('CacheManager.forPlugin stores', () => { + it('returns none client when no cache is configured', () => { + const manager = CacheManager.fromConfig( + new ConfigReader({ backend: {} }), + ); + const expectedNamespace = 'test-plugin'; + manager.forPlugin(expectedNamespace).getClient(); + + const cache = (Keyv as unknown) as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0].store).toBeInstanceOf(NoStore); + }); + + it('returns memory client when explicitly configured', () => { + const manager = CacheManager.fromConfig(defaultConfig()); + const expectedTtl = 3600; + const expectedNamespace = 'test-plugin'; + manager + .forPlugin(expectedNamespace) + .getClient({ defaultTtl: expectedTtl }); + + const cache = (Keyv as unknown) as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0]).toMatchObject({ + ttl: expectedTtl, + namespace: expectedNamespace, + }); + }); + + it('returns a memcache client when configured', () => { + const expectedHost = '127.0.0.1:11211'; + const manager = CacheManager.fromConfig( + new ConfigReader({ + backend: { + cache: { + store: 'memcache', + connection: expectedHost, + }, + }, + }), + ); + const expectedTtl = 3600; + manager.forPlugin('test').getClient({ defaultTtl: expectedTtl }); + + const cache = (Keyv as unknown) as jest.Mock; + const mockCacheCalls = cache.mock.calls.splice(-1); + expect(mockCacheCalls[0][0]).toMatchObject({ + ttl: expectedTtl, + }); + expect(mockCacheCalls[0][0].store).toBeInstanceOf(KeyvMemcache); + const memcache = KeyvMemcache as jest.Mock; + const mockMemcacheCalls = memcache.mock.calls.splice(-1); + expect(mockMemcacheCalls[0][0]).toEqual(expectedHost); + }); + }); +}); diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts new file mode 100644 index 0000000000..fbd7e3ba0a --- /dev/null +++ b/packages/backend-common/src/cache/CacheManager.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import Keyv from 'keyv'; +// @ts-expect-error +import KeyvMemcache from 'keyv-memcache'; +import { DefaultCacheClient, CacheClient } from './CacheClient'; +import { NoStore } from './NoStore'; +import { PluginCacheManager } from './types'; + +/** + * Implements a Cache Manager which will automatically create new cache clients + * for plugins when requested. All requested cache clients are created with the + * connection details provided. + */ +export class CacheManager { + /** + * Keys represent supported `backend.cache.store` values, mapped to factories + * that return Keyv instances appropriate to the store. + */ + private readonly storeFactories = { + memcache: this.getMemcacheClient, + memory: this.getMemoryClient, + none: this.getNoneClient, + }; + + private readonly store: keyof CacheManager['storeFactories']; + private readonly connection: string; + + /** + * Creates a new CacheManager instance by reading from the `backend` config + * section, specifically the `.cache` key. + * + * @param config The loaded application configuration. + */ + static fromConfig(config: Config): CacheManager { + // If no `backend.cache` config is provided, instantiate the CacheManager + // with a "NoStore" cache client. + const store = config.getOptionalString('backend.cache.store') || 'none'; + const connectionString = + config.getOptionalString('backend.cache.connection') || ''; + return new CacheManager(store, connectionString); + } + + private constructor(store: string, connectionString: string) { + if (!this.storeFactories.hasOwnProperty(store)) { + throw new Error(`Unknown cache store: ${store}`); + } + this.store = store as keyof CacheManager['storeFactories']; + this.connection = connectionString; + } + + /** + * Generates a CacheManagerInstance for consumption by plugins. + * + * @param pluginId The plugin that the cache manager should be created for. Plugin names should be unique. + */ + forPlugin(pluginId: string): PluginCacheManager { + return { + getClient: (opts = {}): CacheClient => { + const concreteClient = this.getClientWithTtl(pluginId, opts.defaultTtl); + return new DefaultCacheClient({ + client: concreteClient, + }); + }, + }; + } + + private getClientWithTtl(pluginId: string, ttl: number | undefined): Keyv { + return this.storeFactories[this.store].call(this, pluginId, ttl); + } + + private getMemcacheClient( + pluginId: string, + defaultTtl: number | undefined, + ): Keyv { + return new Keyv({ + namespace: pluginId, + ttl: defaultTtl, + store: new KeyvMemcache(this.connection), + }); + } + + private getMemoryClient( + pluginId: string, + defaultTtl: number | undefined, + ): Keyv { + return new Keyv({ + namespace: pluginId, + ttl: defaultTtl, + }); + } + + private getNoneClient(pluginId: string): Keyv { + return new Keyv({ + namespace: pluginId, + store: new NoStore(), + }); + } +} diff --git a/packages/backend-common/src/cache/NoStore.ts b/packages/backend-common/src/cache/NoStore.ts new file mode 100644 index 0000000000..c89e814c93 --- /dev/null +++ b/packages/backend-common/src/cache/NoStore.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Storage class compatible with Keyv which always results in a no-op. This is + * used when no cache store is configured in a Backstage backend instance. + */ +export class NoStore extends Map { + clear(): void { + return; + } + + delete(_key: string): boolean { + return false; + } + + get(_key: string) { + return; + } + + has(_key: string): boolean { + return false; + } + + set(_key: string, _value: any): this { + return this; + } +} diff --git a/packages/backend-common/src/cache/index.ts b/packages/backend-common/src/cache/index.ts new file mode 100644 index 0000000000..0cc8178b1a --- /dev/null +++ b/packages/backend-common/src/cache/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { CacheClient } from './CacheClient'; +export { CacheManager } from './CacheManager'; +export type { PluginCacheManager } from './types'; diff --git a/packages/backend-common/src/cache/types.ts b/packages/backend-common/src/cache/types.ts new file mode 100644 index 0000000000..4dd3cf7d1b --- /dev/null +++ b/packages/backend-common/src/cache/types.ts @@ -0,0 +1,40 @@ +/* + * 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 { CacheClient } from './CacheClient'; + +type ClientOptions = { + /** + * An optional default TTL (in milliseconds) to be set when getting a client + * instance. If not provided, data will persist indefinitely by default (or + * can be configured per entry at set-time). + */ + defaultTtl?: number; +}; + +/** + * The PluginCacheManager manages access to cache stores that Plugins get. + */ +export type PluginCacheManager = { + /** + * getClient provides backend plugins cache connections for itself. + * + * The purpose of this method is to allow plugins to get isolated data + * stores so that plugins are discouraged from cache-level integration + * and/or cache key collisions. + */ + getClient: (options?: ClientOptions) => CacheClient; +}; diff --git a/packages/backend-common/src/database/connection.test.ts b/packages/backend-common/src/database/connection.test.ts index 9810ff9bef..6ab163ff7f 100644 --- a/packages/backend-common/src/database/connection.test.ts +++ b/packages/backend-common/src/database/connection.test.ts @@ -46,11 +46,11 @@ describe('database connection', () => { ).toBeTruthy(); }); - it('tries to create a mysql connection as a passthrough', () => { + it('returns a mysql connection', () => { expect(() => createDatabaseClient( new ConfigReader({ - client: 'mysql', + client: 'mysql2', connection: { host: '127.0.0.1', user: 'foo', @@ -59,7 +59,7 @@ describe('database connection', () => { }, }), ), - ).toThrowError(/Cannot find module 'mysql'/); + ).toBeTruthy(); }); it('accepts overrides', () => { diff --git a/packages/backend-common/src/database/connection.ts b/packages/backend-common/src/database/connection.ts index 3502e21674..fb16915cf4 100644 --- a/packages/backend-common/src/database/connection.ts +++ b/packages/backend-common/src/database/connection.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import knexFactory, { Knex } from 'knex'; import { Config } from '@backstage/config'; +import knexFactory, { Knex } from 'knex'; import { mergeDatabaseConfig } from './config'; +import { createMysqlDatabaseClient, ensureMysqlDatabaseExists } from './mysql'; import { createPgDatabaseClient, ensurePgDatabaseExists } from './postgres'; import { createSqliteDatabaseClient } from './sqlite3'; @@ -36,6 +37,8 @@ export function createDatabaseClient( if (client === 'pg') { return createPgDatabaseClient(dbConfig, overrides); + } else if (client === 'mysql' || client === 'mysql2') { + return createMysqlDatabaseClient(dbConfig, overrides); } else if (client === 'sqlite3') { return createSqliteDatabaseClient(dbConfig, overrides); } @@ -60,6 +63,8 @@ export async function ensureDatabaseExists( if (client === 'pg') { return ensurePgDatabaseExists(dbConfig, ...databases); + } else if (client === 'mysql' || client === 'mysql2') { + return ensureMysqlDatabaseExists(dbConfig, ...databases); } return undefined; diff --git a/packages/backend-common/src/database/mysql.test.ts b/packages/backend-common/src/database/mysql.test.ts new file mode 100644 index 0000000000..9e23585d5b --- /dev/null +++ b/packages/backend-common/src/database/mysql.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { Config, ConfigReader } from '@backstage/config'; +import { + buildMysqlDatabaseConfig, + createMysqlDatabaseClient, + getMysqlConnectionConfig, + parseMysqlConnectionString, +} from './mysql'; + +describe('mysql', () => { + const createMockConnection = () => ({ + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }); + + const createMockConnectionString = () => 'mysql://foo:bar@acme:3306/foodb'; + + const createConfig = (connection: any): Config => + new ConfigReader({ client: 'mysql2', connection }); + + describe('buildMysqlDatabaseConfig', () => { + it('builds a mysql config', () => { + const mockConnection = createMockConnection(); + + expect(buildMysqlDatabaseConfig(createConfig(mockConnection))).toEqual({ + client: 'mysql2', + connection: mockConnection, + useNullAsDefault: true, + }); + }); + + it('builds a connection string config', () => { + const mockConnectionString = createMockConnectionString(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnectionString)), + ).toEqual({ + client: 'mysql2', + connection: mockConnectionString, + useNullAsDefault: true, + }); + }); + + it('overrides the database name', () => { + const mockConnection = createMockConnection(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnection), { + connection: { database: 'other_db' }, + }), + ).toEqual({ + client: 'mysql2', + connection: { + ...mockConnection, + database: 'other_db', + }, + useNullAsDefault: true, + }); + }); + + it('adds additional config settings', () => { + const mockConnection = createMockConnection(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnection), { + connection: { database: 'other_db' }, + pool: { min: 0, max: 7 }, + debug: true, + }), + ).toEqual({ + client: 'mysql2', + connection: { + ...mockConnection, + database: 'other_db', + }, + useNullAsDefault: true, + pool: { min: 0, max: 7 }, + debug: true, + }); + }); + + it('overrides the database from connection string', () => { + const mockConnectionString = createMockConnectionString(); + const mockConnection = createMockConnection(); + + expect( + buildMysqlDatabaseConfig(createConfig(mockConnectionString), { + connection: { database: 'other_db' }, + }), + ).toEqual({ + client: 'mysql2', + connection: { + ...mockConnection, + port: 3306, + database: 'other_db', + }, + useNullAsDefault: true, + }); + }); + }); + + describe('getMysqlConnectionConfig', () => { + it('returns the connection object back', () => { + const mockConnection = createMockConnection(); + const config = createConfig(mockConnection); + + expect(getMysqlConnectionConfig(config)).toEqual(mockConnection); + }); + + it('does not parse the connection string', () => { + const mockConnection = createMockConnection(); + const config = createConfig(mockConnection); + + expect(getMysqlConnectionConfig(config, true)).toEqual(mockConnection); + }); + + it('automatically parses the connection string', () => { + const mockConnection = createMockConnection(); + const mockConnectionString = createMockConnectionString(); + const config = createConfig(mockConnectionString); + + expect(getMysqlConnectionConfig(config)).toEqual({ + ...mockConnection, + port: 3306, + }); + }); + + it('parses the connection string', () => { + const mockConnection = createMockConnection(); + const mockConnectionString = createMockConnectionString(); + const config = createConfig(mockConnectionString); + + expect(getMysqlConnectionConfig(config, true)).toEqual({ + ...mockConnection, + port: 3306, + }); + }); + }); + + describe('createMysqlDatabaseClient', () => { + it('creates a mysql knex instance', () => { + expect( + createMysqlDatabaseClient( + createConfig({ + host: 'acme', + user: 'foo', + password: 'bar', + database: 'foodb', + }), + ), + ).toBeTruthy(); + }); + }); + + describe('parseMysqlConnectionString', () => { + it('parses a connection string uri', () => { + expect( + parseMysqlConnectionString( + 'mysql://u:pass@foobar:3307/dbname?ssl=require', + ), + ).toEqual({ + host: 'foobar', + user: 'u', + password: 'pass', + port: 3307, + database: 'dbname', + ssl: 'require', + }); + }); + }); +}); diff --git a/packages/backend-common/src/database/mysql.ts b/packages/backend-common/src/database/mysql.ts new file mode 100644 index 0000000000..be4632baf6 --- /dev/null +++ b/packages/backend-common/src/database/mysql.ts @@ -0,0 +1,161 @@ +/* + * 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 { Config } from '@backstage/config'; +import { InputError } from '@backstage/errors'; +import knexFactory, { Knex } from 'knex'; +import { mergeDatabaseConfig } from './config'; +import yn from 'yn'; + +/** + * Creates a knex mysql database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function createMysqlDatabaseClient( + dbConfig: Config, + overrides?: Knex.Config, +) { + const knexConfig = buildMysqlDatabaseConfig(dbConfig, overrides); + const database = knexFactory(knexConfig); + return database; +} + +/** + * Builds a knex mysql database connection + * + * @param dbConfig The database config + * @param overrides Additional options to merge with the config + */ +export function buildMysqlDatabaseConfig( + dbConfig: Config, + overrides?: Knex.Config, +) { + return mergeDatabaseConfig( + dbConfig.get(), + { + connection: getMysqlConnectionConfig(dbConfig, !!overrides), + useNullAsDefault: true, + }, + overrides, + ); +} + +/** + * Gets the mysql connection config + * + * @param dbConfig The database config + * @param parseConnectionString Flag to explicitly control connection string parsing + */ +export function getMysqlConnectionConfig( + dbConfig: Config, + parseConnectionString?: boolean, +): Knex.MySqlConnectionConfig | string { + const connection = dbConfig.get('connection') as any; + const isConnectionString = + typeof connection === 'string' || connection instanceof String; + const autoParse = typeof parseConnectionString !== 'boolean'; + + const shouldParseConnectionString = autoParse + ? isConnectionString + : parseConnectionString && isConnectionString; + + return shouldParseConnectionString + ? parseMysqlConnectionString(connection as string) + : connection; +} + +/** + * Parses a mysql connection string. + * + * e.g. mysql://examplename:somepassword@examplehost:3306/dbname + * @param connectionString The mysql connection string + */ +export function parseMysqlConnectionString( + connectionString: string, +): Knex.MySqlConnectionConfig { + try { + const { + protocol, + username, + password, + port, + hostname, + pathname, + searchParams, + } = new URL(connectionString); + + if (protocol !== 'mysql:') { + throw new Error(`Unknown protocol ${protocol}`); + } else if (!username || !password) { + throw new Error(`Missing username/password`); + } else if (!pathname.match(/^\/[^/]+$/)) { + throw new Error(`Expected single path segment`); + } + + const result: Knex.MySqlConnectionConfig = { + user: username, + password, + host: hostname, + port: Number(port || 3306), + database: decodeURIComponent(pathname.substr(1)), + }; + + const ssl = searchParams.get('ssl'); + if (ssl) { + result.ssl = ssl; + } + + const debug = searchParams.get('debug'); + if (debug) { + result.debug = yn(debug); + } + + return result; + } catch (e) { + throw new InputError( + `Error while parsing MySQL connection string, ${e}`, + e, + ); + } +} + +/** + * Creates the missing mysql database if it does not exist + * + * @param dbConfig The database config + * @param databases The names of the databases to create + */ +export async function ensureMysqlDatabaseExists( + dbConfig: Config, + ...databases: Array +) { + const admin = createMysqlDatabaseClient(dbConfig, { + connection: { + database: (null as unknown) as string, + }, + }); + + try { + const ensureDatabase = async (database: string) => { + await admin.raw(`CREATE DATABASE IF NOT EXISTS ??`, [database]); + }; + await Promise.all(databases.map(ensureDatabase)); + } finally { + await admin.destroy(); + } +} diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index 29b1062750..dab981a824 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './cache'; export * from './config'; export * from './database'; export * from './discovery'; diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index ac65632142..977588b555 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,36 @@ # example-backend +## 0.2.28 + +### Patch Changes + +- Updated dependencies [062bbf90f] +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [82ca1ac22] +- Updated dependencies [f9fb4a205] +- Updated dependencies [9a207f052] +- Updated dependencies [16be1d093] +- Updated dependencies [fd39d4662] +- Updated dependencies [f9f9d633d] + - @backstage/plugin-scaffolder-backend@0.11.1 + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + - @backstage/plugin-catalog-backend@0.9.0 + - @backstage/plugin-kubernetes-backend@0.3.7 + - example-app@0.2.28 + - @backstage/plugin-app-backend@0.3.13 + - @backstage/plugin-auth-backend@0.3.10 + - @backstage/plugin-badges-backend@0.1.4 + - @backstage/plugin-code-coverage-backend@0.1.5 + - @backstage/plugin-graphql-backend@0.1.8 + - @backstage/plugin-kafka-backend@0.2.5 + - @backstage/plugin-proxy-backend@0.2.8 + - @backstage/plugin-rollbar-backend@0.1.11 + - @backstage/plugin-search-backend@0.1.5 + - @backstage/plugin-techdocs-backend@0.8.1 + - @backstage/plugin-todo-backend@0.1.5 + ## 0.2.27 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 6f70db7b19..4044a6632f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.27", + "version": "0.2.28", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,30 +27,30 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", - "@backstage/plugin-app-backend": "^0.3.12", - "@backstage/plugin-auth-backend": "^0.3.9", - "@backstage/plugin-badges-backend": "^0.1.3", - "@backstage/plugin-catalog-backend": "^0.8.2", - "@backstage/plugin-code-coverage-backend": "^0.1.4", - "@backstage/plugin-graphql-backend": "^0.1.7", - "@backstage/plugin-kubernetes-backend": "^0.3.6", - "@backstage/plugin-kafka-backend": "^0.2.4", - "@backstage/plugin-proxy-backend": "^0.2.7", - "@backstage/plugin-rollbar-backend": "^0.1.10", - "@backstage/plugin-scaffolder-backend": "^0.11.0", - "@backstage/plugin-search-backend": "^0.1.4", + "@backstage/plugin-app-backend": "^0.3.13", + "@backstage/plugin-auth-backend": "^0.3.10", + "@backstage/plugin-badges-backend": "^0.1.4", + "@backstage/plugin-catalog-backend": "^0.9.0", + "@backstage/plugin-code-coverage-backend": "^0.1.5", + "@backstage/plugin-graphql-backend": "^0.1.8", + "@backstage/plugin-kubernetes-backend": "^0.3.7", + "@backstage/plugin-kafka-backend": "^0.2.5", + "@backstage/plugin-proxy-backend": "^0.2.8", + "@backstage/plugin-rollbar-backend": "^0.1.11", + "@backstage/plugin-scaffolder-backend": "^0.11.1", + "@backstage/plugin-search-backend": "^0.1.5", "@backstage/plugin-search-backend-node": "^0.1.3", - "@backstage/plugin-techdocs-backend": "^0.8.0", - "@backstage/plugin-todo-backend": "^0.1.4", + "@backstage/plugin-techdocs-backend": "^0.8.1", + "@backstage/plugin-todo-backend": "^0.1.5", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.5.3", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.27", + "example-app": "^0.2.28", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^0.95.1", @@ -60,7 +60,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 00ed248df5..d43c8f0101 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -24,6 +24,7 @@ import Router from 'express-promise-router'; import { + CacheManager, createServiceBuilder, getRootLogger, loadBackendConfig, @@ -59,11 +60,13 @@ function makeCreateEnv(config: Config) { root.info(`Created UrlReader ${reader}`); const databaseManager = SingleConnectionDatabaseManager.fromConfig(config); + const cacheManager = CacheManager.fromConfig(config); return (plugin: string): PluginEnvironment => { const logger = root.child({ type: 'plugin', plugin }); const database = databaseManager.forPlugin(plugin); - return { logger, database, config, reader, discovery }; + const cache = cacheManager.forPlugin(plugin); + return { logger, cache, database, config, reader, discovery }; }; } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index f63b9dd780..356dd08d5f 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -17,6 +17,7 @@ import { Logger } from 'winston'; import { Config } from '@backstage/config'; import { + PluginCacheManager, PluginDatabaseManager, PluginEndpointDiscovery, UrlReader, @@ -24,6 +25,7 @@ import { export type PluginEnvironment = { logger: Logger; + cache: PluginCacheManager; database: PluginDatabaseManager; config: Config; reader: UrlReader; diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a246b3abab..063db5a6a3 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -134,6 +134,7 @@ export class CatalogClient implements CatalogApi { throw new Error(`Location wasn't added: ${target}`); } + // TODO(jhaals): This will throw using the experimental catalog since all discovered entities are deferred. if (entities.length === 0) { throw new Error( `Location was added but has no entities specified yet: ${target}`, diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index e289c5a0b3..312a329649 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/catalog-model +## 0.7.9 + +### Patch Changes + +- 10c008a3a: Renamed parameters to input in template schema +- 16be1d093: Improve error messages when schema validation fails + ## 0.7.8 ### Patch Changes diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index ac3b046a32..6b5b096cb6 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.7.8", + "version": "0.7.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -39,7 +39,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts index a4d7d904cd..249243bed3 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.test.ts @@ -168,4 +168,16 @@ components: (entity as any).spec.system = ''; await expect(validator.check(entity)).rejects.toThrow(/system/); }); + + it('rejects additional properties', async () => { + (entity as any).annotations = 'Test'; + await expect(validator.check(entity)).rejects.toThrow( + /additional properties/, + ); + }); + + it('rejects with useful error message', async () => { + (entity as any).annotations = 'Test'; + await expect(validator.check(entity)).rejects.toThrow(/annotations/); + }); }); diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts index 4002f2806c..1e9e7b7521 100644 --- a/packages/catalog-model/src/kinds/util.ts +++ b/packages/catalog-model/src/kinds/util.ts @@ -66,7 +66,13 @@ export function ajvCompiledJsonSchemaValidator( } throw new TypeError( - `Malformed ${kind}, ${error.dataPath || ''} ${error.message}`, + `Malformed ${kind}, ${error.dataPath || ''} ${error.message}${ + error.params + ? ` - ${Object.entries(error.params) + .map(([key, val]) => `${key}: ${val}`) + .join(', ')}` + : '' + }`, ); }, }; diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 996513f60e..cf28960f13 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli +## 0.6.11 + +### Patch Changes + +- 2cd70e164: Add context for versions:bump on what version it was bumped to. Updated tests for the same. +- 3be844496: chore: bump `ts-node` versions to 9.1.1 +- e3fc89df6: update plugins created to use react-use 17.2.4 + ## 0.6.10 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index cd0385196d..b17bc8cbda 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.6.10", + "version": "0.6.11", "private": false, "publishConfig": { "access": "public" @@ -117,11 +117,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/config": "^0.1.5", - "@backstage/core": "^0.7.8", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/core": "^0.7.9", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@backstage/theme": "^0.2.7", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 31803d4827..e5128fc995 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -136,7 +136,7 @@ describe('bump', () => { 'bumping @backstage/theme in b to ^2.0.0', 'Running yarn install to install new versions', '⚠️ The following packages may have breaking changes:', - ' @backstage/theme', + ' @backstage/theme : 1.0.0 ~> 2.0.0', ' https://github.com/backstage/backstage/blob/master/packages/theme/CHANGELOG.md', 'Version bump complete!', ]); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index ceb2f7f988..d046283aab 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -195,8 +195,14 @@ export default async () => { ); console.log(); - for (const name of Array.from(breakingUpdates.keys()).sort()) { - console.log(` ${chalk.yellow(name)}`); + for (const [name, { from, to }] of Array.from( + breakingUpdates.entries(), + ).sort()) { + console.log( + ` ${chalk.yellow(name)} : ${chalk.yellow(from)} ~> ${chalk.yellow( + to, + )}`, + ); let path; if (name.startsWith('@backstage/plugin-')) { diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 1b4f8ea6ab..1d2766eab4 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -39,7 +39,7 @@ "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "msw": "^0.21.2", diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index fab82a77e5..3652cf6bc0 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/core-api +## 0.2.18 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages + ## 0.2.17 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 13d7efe77b..b5cd8cba69 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core-api", "description": "Internal Core API used by Backstage plugins and apps", - "version": "0.2.17", + "version": "0.2.18", "private": false, "publishConfig": { "access": "public", @@ -42,13 +42,13 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/test-utils": "^0.1.11", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/zen-observable": "^0.8.0", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index a215c7cb8e..51c6b3f935 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/core +## 0.7.9 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 889d89b6e: Fix state persisted in the URL make search input in the table toolbar lose their + focus. +- 3f988cb63: Add count of older messages when multiple messages exist in AlertDisplay +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [675a569a9] + - @backstage/core-api@0.2.18 + ## 0.7.8 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index f8425c3289..e418a969ce 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.7.8", + "version": "0.7.9", "private": false, "publishConfig": { "access": "public", @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core-api": "^0.2.17", + "@backstage/core-api": "^0.2.18", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -52,6 +52,7 @@ "immer": "^9.0.1", "lodash": "^4.17.15", "material-table": "^1.69.1", + "pluralize": "^8.0.0", "prop-types": "^15.7.2", "qs": "^6.9.4", "rc-progress": "^3.0.0", @@ -70,11 +71,11 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/classnames": "^2.2.9", "@types/d3-selection": "^2.0.0", "@types/d3-shape": "^2.0.0", diff --git a/packages/core/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/core/src/components/AlertDisplay/AlertDisplay.test.tsx index 3f85dab9f3..d9e264bb32 100644 --- a/packages/core/src/components/AlertDisplay/AlertDisplay.test.tsx +++ b/packages/core/src/components/AlertDisplay/AlertDisplay.test.tsx @@ -62,4 +62,46 @@ describe('', () => { expect(queryByText(TEST_MESSAGE)).toBeInTheDocument(); }); + + describe('with multiple messages', () => { + let apiRegistry: ApiRegistry; + + beforeEach(() => { + apiRegistry = ApiRegistry.from([ + [ + alertApiRef, + { + post() {}, + alert$() { + return Observable.of( + { message: 'message one' }, + { message: 'message two' }, + { message: 'message three' }, + ); + }, + }, + ], + ]); + }); + + it('renders first message', async () => { + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText('message one')).toBeInTheDocument(); + }); + + it('renders a count of remaining messages', async () => { + const { queryByText } = await renderInTestApp( + + + , + ); + + expect(queryByText('(2 older messages)')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/core/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx index 6d6646fa18..76e21f909c 100644 --- a/packages/core/src/components/AlertDisplay/AlertDisplay.tsx +++ b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx @@ -19,6 +19,7 @@ import { Snackbar, IconButton } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import { Alert } from '@material-ui/lab'; import { AlertMessage, useApi, alertApiRef } from '@backstage/core-api'; +import pluralize from 'pluralize'; // TODO: improve on this and promote to a shared component for use by all apps. export const AlertDisplay = () => { @@ -46,11 +47,7 @@ export const AlertDisplay = () => { }; return ( - + { } severity={firstMessage.severity} > - {firstMessage.message.toString()} + + {firstMessage.message.toString()} + {messages.length > 1 && ( + {` (${messages.length - 1} older ${pluralize( + 'message', + messages.length - 1, + )})`} + )} + ); diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index 2251b8304e..1dd09e7ab8 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -20,6 +20,7 @@ import { makeStyles, Typography, useTheme, + withStyles, } from '@material-ui/core'; // Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51 import AddBox from '@material-ui/icons/AddBox'; @@ -49,6 +50,7 @@ import MTable, { } from 'material-table'; import React, { forwardRef, + MutableRefObject, ReactNode, useCallback, useEffect, @@ -100,19 +102,20 @@ function extractValueByField(data: any, field: string): any | undefined { return value; } -const useHeaderStyles = makeStyles(theme => ({ +const StyledMTableHeader = withStyles(theme => ({ header: { padding: theme.spacing(1, 2, 1, 2.5), borderTop: `1px solid ${theme.palette.grey.A100}`, borderBottom: `1px solid ${theme.palette.grey.A100}`, - color: theme.palette.textSubtle, + // withStyles hasn't a generic overload for theme + color: (theme as BackstageTheme).palette.textSubtle, fontWeight: theme.typography.fontWeightBold, position: 'static', wordBreak: 'normal', }, -})); +}))(MTableHeader); -const useToolbarStyles = makeStyles(theme => ({ +const StyledMTableToolbar = withStyles(theme => ({ root: { padding: theme.spacing(3, 0, 2.5, 2.5), }, @@ -124,7 +127,7 @@ const useToolbarStyles = makeStyles(theme => ({ searchField: { paddingRight: theme.spacing(2), }, -})); +}))(MTableToolbar); const useFilterStyles = makeStyles(() => ({ root: { @@ -208,6 +211,59 @@ export interface TableProps onStateChange?: (state: TableState) => any; } +export function TableToolbar(toolbarProps: { + toolbarRef: MutableRefObject; + setSearch: (value: string) => void; + onSearchChanged: (value: string) => void; + toggleFilters: () => void; + hasFilters: boolean; + selectedFiltersLength: number; +}) { + const { + toolbarRef, + setSearch, + hasFilters, + selectedFiltersLength, + toggleFilters, + } = toolbarProps; + const filtersClasses = useFilterStyles(); + const onSearchChanged = useCallback( + (searchText: string) => { + toolbarProps.onSearchChanged(searchText); + setSearch(searchText); + }, + [toolbarProps, setSearch], + ); + + if (hasFilters) { + return ( +
+
+ + + + + Filters ({selectedFiltersLength}) + +
+ +
+ ); + } + + return ( + + ); +} + export function Table({ columns, options, @@ -219,10 +275,7 @@ export function Table({ onStateChange, ...props }: TableProps) { - const headerClasses = useHeaderStyles(); - const toolbarClasses = useToolbarStyles(); const tableClasses = useTableStyles(); - const filtersClasses = useFilterStyles(); const { data, ...propsWithoutData } = props; @@ -230,9 +283,12 @@ export function Table({ const calculatedInitialState = { ...defaultInitialState, ...initialState }; - const [filtersOpen, toggleFilters] = useState( + const [filtersOpen, setFiltersOpen] = useState( calculatedInitialState.filtersOpen, ); + const toggleFilters = useCallback(() => setFiltersOpen(v => !v), [ + setFiltersOpen, + ]); const [selectedFiltersLength, setSelectedFiltersLength] = useState(0); const [tableData, setTableData] = useState(data as any[]); const [selectedFilters, setSelectedFilters] = useState( @@ -379,64 +435,32 @@ export function Table({ })); }; + const hasFilters = !!filters?.length; const Toolbar = useCallback( toolbarProps => { - const onSearchChanged = (searchText: string) => { - toolbarProps.onSearchChanged(searchText); - setSearch(searchText); - }; - - if (filters?.length) { - return ( -
-
- toggleFilters(el => !el)} - aria-label="filter list" - > - - - - Filters ({selectedFiltersLength}) - -
- -
- ); - } - return ( - ); }, - [ - filters?.length, - selectedFiltersLength, - toggleFilters, - toolbarClasses, - filtersClasses, - setSearch, - toolbarRef, - ], + [toggleFilters, hasFilters, selectedFiltersLength, setSearch, toolbarRef], ); + const hasNoRows = typeof data !== 'function' && data.length === 0; + const columnCount = columns.length; const Body = useCallback( bodyProps => { - if (emptyContent && typeof data !== 'function' && data.length === 0) { + if (emptyContent && hasNoRows) { return ( - {emptyContent} + {emptyContent} ); @@ -444,7 +468,7 @@ export function Table({ return ; }, - [data, emptyContent, columns], + [hasNoRows, emptyContent, columnCount], ); return ( @@ -458,9 +482,7 @@ export function Table({ )} components={{ - Header: headerProps => ( - - ), + Header: StyledMTableHeader, Toolbar, Body, }} diff --git a/packages/core/src/hooks/useQueryParamState.ts b/packages/core/src/hooks/useQueryParamState.ts index da303234f5..173993a466 100644 --- a/packages/core/src/hooks/useQueryParamState.ts +++ b/packages/core/src/hooks/useQueryParamState.ts @@ -17,7 +17,7 @@ import { isEqual } from 'lodash'; import qs from 'qs'; import { useEffect, useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { useDebounce } from 'react-use'; function stringify(queryParams: any): string { @@ -58,36 +58,37 @@ type SetQueryParams = (params: T) => void; export function useQueryParamState( stateName: string, - debounceTime: number = 100, + /** @deprecated Don't configure a custom debouceTime */ + debounceTime: number = 250, ): [T | undefined, SetQueryParams] { - const navigate = useNavigate(); - const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const searchParamsString = searchParams.toString(); const [queryParamState, setQueryParamState] = useState( - extractState(location.search, stateName), + extractState(searchParamsString, stateName), ); useEffect(() => { - const newState = extractState(location.search, stateName); + const newState = extractState(searchParamsString, stateName); setQueryParamState(oldState => isEqual(newState, oldState) ? oldState : newState, ); - }, [location, stateName]); + }, [searchParamsString, setQueryParamState, stateName]); useDebounce( () => { const queryString = joinQueryString( - location.search, + searchParamsString, stateName, queryParamState, ); - if (location.search !== queryString) { - navigate({ ...location, search: `?${queryString}` }, { replace: true }); + if (searchParamsString !== queryString) { + setSearchParams(queryString, { replace: true }); } }, debounceTime, - [queryParamState], + [setSearchParams, queryParamState, searchParamsString, stateName], ); return [queryParamState, setQueryParamState]; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 968c798625..2e62d43436 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,52 @@ # @backstage/create-app +## 0.3.22 + +### Patch Changes + +- 3be844496: chore: bump `ts-node` versions to 9.1.1 +- Updated dependencies [062bbf90f] +- Updated dependencies [2cd70e164] +- Updated dependencies [0b033d07b] +- Updated dependencies [3be844496] +- Updated dependencies [5542de095] +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [82ca1ac22] +- Updated dependencies [81ef1d57b] +- Updated dependencies [f9fb4a205] +- Updated dependencies [e3fc89df6] +- Updated dependencies [9a207f052] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [fd39d4662] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/test-utils@0.1.11 + - @backstage/plugin-api-docs@0.4.13 + - @backstage/plugin-catalog@0.5.7 + - @backstage/plugin-catalog-import@0.5.6 + - @backstage/plugin-explore@0.3.5 + - @backstage/plugin-github-actions@0.4.6 + - @backstage/plugin-lighthouse@0.2.16 + - @backstage/plugin-scaffolder@0.9.4 + - @backstage/plugin-scaffolder-backend@0.11.1 + - @backstage/plugin-search@0.3.6 + - @backstage/plugin-tech-radar@0.3.11 + - @backstage/plugin-techdocs@0.9.2 + - @backstage/plugin-user-settings@0.2.10 + - @backstage/cli@0.6.11 + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + - @backstage/plugin-catalog-backend@0.9.0 + - @backstage/plugin-app-backend@0.3.13 + - @backstage/plugin-auth-backend@0.3.10 + - @backstage/plugin-proxy-backend@0.2.8 + - @backstage/plugin-rollbar-backend@0.1.11 + - @backstage/plugin-techdocs-backend@0.8.1 + ## 0.3.21 ### Patch Changes @@ -15,8 +62,6 @@ + import { DockerContainerRunner } from '@backstage/backend-common'; - // ... - export default async function createPlugin({ logger, config, @@ -76,7 +121,6 @@ + SingleHostDiscovery, + } from '@backstage/backend-common'; - export default async function createPlugin({ logger, config, @@ -933,26 +977,25 @@ Update imports and remove the usage of the deprecated `app.getRoutes()`. - ```diff - -import { Router as DocsRouter } from '@backstage/plugin-techdocs'; - +import { TechdocsPage } from '@backstage/plugin-techdocs'; - import { CatalogImportPage } from '@backstage/plugin-catalog-import'; - -import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; - -import { SearchPage as SearchRouter } from '@backstage/plugin-search'; - -import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; - +import { TechRadarPage } from '@backstage/plugin-tech-radar'; - +import { SearchPage } from '@backstage/plugin-search'; - +import { UserSettingsPage } from '@backstage/plugin-user-settings'; - +import { ApiExplorerPage } from '@backstage/plugin-api-docs'; - import { EntityPage } from './components/catalog/EntityPage'; - import { scaffolderPlugin, ScaffolderPage } from '@backstage/plugin-scaffolder'; - ``` +```diff +- import { Router as DocsRouter } from '@backstage/plugin-techdocs'; ++ import { TechdocsPage } from '@backstage/plugin-techdocs'; + import { CatalogImportPage } from '@backstage/plugin-catalog-import'; +- import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar'; +- import { SearchPage as SearchRouter } from '@backstage/plugin-search'; +- import { Router as SettingsRouter } from '@backstage/plugin-user-settings'; ++ import { TechRadarPage } from '@backstage/plugin-tech-radar'; ++ import { SearchPage } from '@backstage/plugin-search'; ++ import { UserSettingsPage } from '@backstage/plugin-user-settings'; ++ import { ApiExplorerPage } from '@backstage/plugin-api-docs'; + import { EntityPage } from './components/catalog/EntityPage'; + import { scaffolderPlugin, ScaffolderPage } from '@backstage/plugin-scaffolder'; -const AppProvider = app.getProvider(); -const AppRouter = app.getRouter(); --const deprecatedAppRoutes = app.getRoutes(); -```` + const AppProvider = app.getProvider(); + const AppRouter = app.getRouter(); +- const deprecatedAppRoutes = app.getRoutes(); +``` As well as update or add the following routes: @@ -975,7 +1018,7 @@ As well as update or add the following routes: - {deprecatedAppRoutes} + } /> + } /> -```` +``` If you have added additional plugins with registered routes or are using `Router` components from other plugins, these should be migrated to use the `*Page` components as well. See [this commit](https://github.com/backstage/backstage/commit/abd655e42d4ed416b70848ffdb1c4b99d189f13b) for more examples of how to migrate. diff --git a/packages/create-app/package.json b/packages/create-app/package.json index b96b4e2065..b850893f9c 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.21", + "version": "0.3.22", "private": false, "publishConfig": { "access": "public" diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 3e8b8a96a0..ac88847489 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/dev-utils +## 0.1.14 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/test-utils@0.1.11 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.1.13 ### Patch Changes diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 3845da7e9b..fb1f8a26dc 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/dev-utils", "description": "Utilities for developing Backstage plugins.", - "version": "0.1.13", + "version": "0.1.14", "private": false, "publishConfig": { "access": "public", @@ -29,17 +29,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.0", - "@backstage/catalog-model": "^0.7.3", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/test-utils": "^0.1.8", + "@backstage/core": "^0.7.9", + "@backstage/catalog-model": "^0.7.9", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/test-utils": "^0.1.11", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/react": "^16.9", "react": "^16.12.0", "react-dom": "^16.12.0", @@ -48,7 +48,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.6.3", + "@backstage/cli": "^0.6.11", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/integration-react/CHANGELOG.md b/packages/integration-react/CHANGELOG.md new file mode 100644 index 0000000000..54aa3b3ab1 --- /dev/null +++ b/packages/integration-react/CHANGELOG.md @@ -0,0 +1,13 @@ +# @backstage/integration-react + +## 0.1.2 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 diff --git a/packages/integration-react/package.json b/packages/integration-react/package.json index 7833952e8e..fe831ca24f 100644 --- a/packages/integration-react/package.json +++ b/packages/integration-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration-react", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/config": "^0.1.2", - "@backstage/core": "^0.7.0", + "@backstage/core": "^0.7.9", "@backstage/integration": "^0.5.0", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -32,12 +32,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.3", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "msw": "^0.21.2", diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 644a3ebb5f..ec47f22475 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/techdocs-common +## 0.6.1 + +### Patch Changes + +- e04f1ccfb: Fixed a bug that prevented loading static assets from GCS, S3, Azure, and OpenStackSwift whose keys contain spaces or other special characters. +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.6.0 ### Minor Changes diff --git a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts index 0d480a7fa9..2fad447788 100644 --- a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -71,9 +71,9 @@ export class BlockBlobClient { download() { const filePath = path.join(rootDir, this.blobName); const emitter = new EventEmitter(); - process.nextTick(() => { + setTimeout(() => { if (fs.existsSync(filePath)) { - emitter.emit('data', Buffer.from(fs.readFileSync(filePath))); + emitter.emit('data', fs.readFileSync(filePath)); emitter.emit('end'); } else { emitter.emit( @@ -81,7 +81,7 @@ export class BlockBlobClient { new Error(`The file ${filePath} does not exist !`), ); } - }); + }, 0); return Promise.resolve({ readableStreamBody: emitter, }); diff --git a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts index f8d0368f27..5e9890cd98 100644 --- a/packages/techdocs-common/__mocks__/@google-cloud/storage.ts +++ b/packages/techdocs-common/__mocks__/@google-cloud/storage.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EventEmitter } from 'events'; +import { Readable } from 'stream'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; @@ -58,19 +58,21 @@ class GCSFile { } createReadStream() { - const emitter = new EventEmitter(); + const readable = new Readable(); + readable._read = () => {}; + process.nextTick(() => { if (fs.existsSync(this.localFilePath)) { - emitter.emit('data', Buffer.from(fs.readFileSync(this.localFilePath))); - emitter.emit('end'); + readable.emit('data', fs.readFileSync(this.localFilePath)); + readable.emit('end'); } else { - emitter.emit( + readable.emit( 'error', new Error(`The file ${this.localFilePath} does not exist !`), ); } }); - return emitter; + return readable; } } diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index f2d19ba8c5..e6debbb817 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.6.0", + "version": "0.6.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,8 +38,8 @@ "dependencies": { "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", - "@backstage/backend-common": "^0.7.0", - "@backstage/catalog-model": "^0.7.8", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.2", @@ -60,14 +60,16 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^4.0.0", "@types/mime-types": "^2.1.0", "@types/mock-fs": "^4.13.0", "@types/pkgcloud": "^1.7.4", - "@types/recursive-readdir": "^2.2.0" + "@types/recursive-readdir": "^2.2.0", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.3" }, "jest": { "roots": [ diff --git a/packages/techdocs-common/src/stages/publish/awsS3.test.ts b/packages/techdocs-common/src/stages/publish/awsS3.test.ts index f094457346..b2954f7b2e 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.test.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.test.ts @@ -21,6 +21,8 @@ import { ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; @@ -272,4 +274,49 @@ describe('AwsS3Publish', () => { }); }); }); + + describe('docsRouter', () => { + let app: express.Express; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + beforeEach(() => { + app = express().use(publisher.docsRouter()); + + mockFs.restore(); + mockFs({ + [entityRootDir]: { + img: { + 'with spaces.png': 'found it', + }, + 'some folder': { + 'also with spaces.js': 'found it too', + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should pass expected object path to bucket', async () => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + // Ensures leading slash is trimmed and encoded path is decoded. + const pngResponse = await request(app).get( + `/${namespace}/${kind}/${name}/img/with%20spaces.png`, + ); + expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( + 'found it', + ); + const jsResponse = await request(app).get( + `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`, + ); + expect(jsResponse.text).toEqual('found it too'); + }); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/awsS3.ts b/packages/techdocs-common/src/stages/publish/awsS3.ts index d628c860fd..e1ffb107cc 100644 --- a/packages/techdocs-common/src/stages/publish/awsS3.ts +++ b/packages/techdocs-common/src/stages/publish/awsS3.ts @@ -263,9 +263,9 @@ export class AwsS3Publish implements PublisherBase { */ docsRouter(): express.Handler { return async (req, res) => { - // Trim the leading forward slash + // Decode and trim the leading forward slash // filePath example - /default/Component/documented-component/index.html - const filePath = req.path.replace(/^\//, ''); + const filePath = decodeURI(req.path.replace(/^\//, '')); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = path.extname(filePath); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 1160ffe49d..568393662b 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -21,6 +21,8 @@ import { ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; @@ -329,4 +331,49 @@ describe('publishing with valid credentials', () => { ); }); }); + + describe('docsRouter', () => { + let app: express.Express; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + beforeEach(() => { + app = express().use(publisher.docsRouter()); + + mockFs.restore(); + mockFs({ + [entityRootDir]: { + img: { + 'with spaces.png': 'found it', + }, + 'some folder': { + 'also with spaces.js': 'found it too', + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should pass expected object path to bucket', async () => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + // Ensures leading slash is trimmed and encoded path is decoded. + const pngResponse = await request(app).get( + `/${namespace}/${kind}/${name}/img/with%20spaces.png`, + ); + expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( + 'found it', + ); + const jsResponse = await request(app).get( + `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`, + ); + expect(jsResponse.text).toEqual('found it too'); + }); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index f6567abe49..60640c374d 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -254,9 +254,9 @@ export class AzureBlobStoragePublish implements PublisherBase { */ docsRouter(): express.Handler { return (req, res) => { - // Trim the leading forward slash + // Decode and trim the leading forward slash // filePath example - /default/Component/documented-component/index.html - const filePath = req.path.replace(/^\//, ''); + const filePath = decodeURI(req.path.replace(/^\//, '')); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = platformPath.extname(filePath); const responseHeaders = getHeadersForFileExtension(fileExtension); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts index 19e4938763..f29e1f10be 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.test.ts @@ -21,6 +21,8 @@ import { EntityName, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; @@ -271,4 +273,47 @@ describe('GoogleGCSPublish', () => { }); }); }); + + describe('docsRouter', () => { + let app: express.Express; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + beforeEach(() => { + app = express().use(publisher.docsRouter()); + + mockFs.restore(); + mockFs({ + [entityRootDir]: { + img: { + 'with spaces.png': 'found it', + }, + 'some folder': { + 'also with spaces.js': 'found it too', + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should pass expected object path to bucket', async () => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + // Ensures leading slash is trimmed and encoded path is decoded. + const pngResponse = await request(app).get( + `/${namespace}/${kind}/${name}/img/with%20spaces.png`, + ); + expect(pngResponse.text).toEqual('found it'); + const jsResponse = await request(app).get( + `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`, + ); + expect(jsResponse.text).toEqual('found it too'); + }); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/googleStorage.ts b/packages/techdocs-common/src/stages/publish/googleStorage.ts index 8adafc361a..e5e3d6c25a 100644 --- a/packages/techdocs-common/src/stages/publish/googleStorage.ts +++ b/packages/techdocs-common/src/stages/publish/googleStorage.ts @@ -187,9 +187,9 @@ export class GoogleGCSPublish implements PublisherBase { */ docsRouter(): express.Handler { return (req, res) => { - // Trim the leading forward slash + // Decode and trim the leading forward slash // filePath example - /default/Component/documented-component/index.html - const filePath = req.path.replace(/^\//, ''); + const filePath = decodeURI(req.path.replace(/^\//, '')); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = path.extname(filePath); diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts index e179aeb9a7..aeaceeaaa6 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.test.ts @@ -21,6 +21,8 @@ import { ENTITY_DEFAULT_NAMESPACE, } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; +import express from 'express'; +import request from 'supertest'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; @@ -277,4 +279,49 @@ describe('OpenStackSwiftPublish', () => { }); }); }); + + describe('docsRouter', () => { + let app: express.Express; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + beforeEach(() => { + app = express().use(publisher.docsRouter()); + + mockFs.restore(); + mockFs({ + [entityRootDir]: { + img: { + 'with spaces.png': 'found it', + }, + 'some folder': { + 'also with spaces.js': 'found it too', + }, + }, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('should pass expected object path to bucket', async () => { + const { + kind, + metadata: { namespace, name }, + } = entity; + + // Ensures leading slash is trimmed and encoded path is decoded. + const pngResponse = await request(app).get( + `/${namespace}/${kind}/${name}/img/with%20spaces.png`, + ); + expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual( + 'found it', + ); + const jsResponse = await request(app).get( + `/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`, + ); + expect(jsResponse.text).toEqual('found it too'); + }); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/openStackSwift.ts b/packages/techdocs-common/src/stages/publish/openStackSwift.ts index bff0055402..2321aa33bd 100644 --- a/packages/techdocs-common/src/stages/publish/openStackSwift.ts +++ b/packages/techdocs-common/src/stages/publish/openStackSwift.ts @@ -221,10 +221,9 @@ export class OpenStackSwiftPublish implements PublisherBase { */ docsRouter(): express.Handler { return async (req, res) => { - // Trim the leading forward slash + // Decode and trim the leading forward slash // filePath example - /default/Component/documented-component/index.html - - const filePath = req.path.replace(/^\//, ''); + const filePath = decodeURI(req.path.replace(/^\//, '')); // Files with different extensions (CSS, HTML) need to be served with different headers const fileExtension = path.extname(filePath); diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index d2c2748449..9a77665e28 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/test-utils +## 0.1.11 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- Updated dependencies [062bbf90f] +- Updated dependencies [675a569a9] + - @backstage/core-api@0.2.18 + ## 0.1.10 ### Patch Changes diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index a81455df01..7b7b73a64a 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.10", + "version": "0.1.11", "private": false, "publishConfig": { "access": "public", @@ -29,13 +29,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-api": "^0.2.16", + "@backstage/core-api": "^0.2.18", "@backstage/test-utils-core": "^0.1.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/react": "^16.9", "msw": "^0.21.3", "react": "^16.12.0", @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.7", + "@backstage/cli": "^0.6.11", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/mockApis.ts b/packages/test-utils/src/testUtils/mockApis.ts index e05a8e6cac..0fe9a30926 100644 --- a/packages/test-utils/src/testUtils/mockApis.ts +++ b/packages/test-utils/src/testUtils/mockApis.ts @@ -18,10 +18,13 @@ import { storageApiRef, errorApiRef, createApiFactory, + AlertApiForwarder, + alertApiRef, } from '@backstage/core-api'; import { MockErrorApi, MockStorageApi } from './apis'; export const mockApis = [ createApiFactory(errorApiRef, new MockErrorApi()), createApiFactory(storageApiRef, MockStorageApi.create()), + createApiFactory(alertApiRef, new AlertApiForwarder()), ]; diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index f268ba1c4d..94fd3fe74a 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-api-docs +## 0.4.13 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 889d89b6e: Fix state persisted in the URL make search input in the table toolbar lose their + focus. +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.4.12 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 9cccb07d56..9610ae4e10 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-api-docs", - "version": "0.4.12", + "version": "0.4.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ }, "dependencies": { "@asyncapi/react-component": "^0.23.0", - "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", @@ -49,12 +49,12 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/swagger-ui-react": "^3.23.3", diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 39c2caaf1b..5b9808e26d 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -156,7 +156,6 @@ export const ApiExplorerTable = ({ }: ExplorerTableProps) => { const [queryParamState, setQueryParamState] = useQueryParamState( 'apiTable', - 500, ); if (error) { diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 73fa9fc3b1..ede16b6a5e 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-app-backend +## 0.3.13 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [f9fb4a205] + - @backstage/backend-common@0.8.0 + ## 0.3.12 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 84fe743550..60fa678dd0 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.12", + "version": "0.3.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/config-loader": "^0.6.1", "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^6.1.3" diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index e51942f742..1003140dec 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-auth-backend +## 0.3.10 + +### Patch Changes + +- Updated dependencies [062bbf90f] +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/test-utils@0.1.11 + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.3.9 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 41a47c7788..1b0ad25a84 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.3.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,12 +29,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/test-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.11", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", "compression": "^1.7.4", @@ -68,7 +68,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/badges-backend/CHANGELOG.md b/plugins/badges-backend/CHANGELOG.md index abbc4e70bd..d171d0d590 100644 --- a/plugins/badges-backend/CHANGELOG.md +++ b/plugins/badges-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-badges-backend +## 0.1.4 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.1.3 ### Patch Changes diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index 347f8b9219..d7e0df88b5 100644 --- a/plugins/badges-backend/package.json +++ b/plugins/badges-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-badges-backend", - "version": "0.1.3", + "version": "0.1.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@types/express": "^4.17.6", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges/CHANGELOG.md b/plugins/badges/CHANGELOG.md index a0efd43ccc..c5ac19b4ed 100644 --- a/plugins/badges/CHANGELOG.md +++ b/plugins/badges/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-badges +## 0.2.1 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.0 ### Minor Changes diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 24660899e2..72aa2df787 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-badges", - "version": "0.2.0", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.3", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -34,12 +34,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/bitrise/CHANGELOG.md b/plugins/bitrise/CHANGELOG.md index ecb26efd50..61a0af36ed 100644 --- a/plugins/bitrise/CHANGELOG.md +++ b/plugins/bitrise/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-bitrise +## 0.1.3 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.1.2 ### Patch Changes diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 45b32a11d1..1414f729fd 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bitrise", - "version": "0.1.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.2", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -37,12 +37,12 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/recharts": "^1.8.15", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 8d40d7c4bb..0018d83399 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,29 @@ # @backstage/plugin-catalog-backend +## 0.9.0 + +### Minor Changes + +- 9a207f052: Port `GithubOrgReaderProcessor` to support configuration via + [`integrations`](https://backstage.io/docs/integrations/github/locations) in + addition to [`catalog.processors.githubOrg.providers`](https://backstage.io/docs/integrations/github/org#configuration). + The `integrations` package supports authentication with both personal access + tokens and GitHub apps. + + This deprecates the `catalog.processors.githubOrg.providers` configuration. + A [`integrations` configuration](https://backstage.io/docs/integrations/github/locations) + for the same host takes precedence over the provider configuration. + You might need to add additional scopes for the credentials. + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.8.2 ### Patch Changes diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index 564dd7511e..20fcd975cf 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -114,6 +114,8 @@ export interface Config { processors?: { /** * GithubOrgReaderProcessor configuration + * + * @deprecated Configure a GitHub integration instead. */ githubOrg?: { /** diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index c8e17bb943..dc9397de82 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.8.2", + "version": "0.9.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.7.0", - "@backstage/catalog-model": "^0.7.8", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.2", @@ -63,8 +63,8 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.11", + "@backstage/test-utils": "^0.1.11", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", "@types/lodash": "^4.14.151", diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts index 19a08d219f..9b724def28 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts @@ -16,6 +16,11 @@ import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; +import { + GitHubIntegration, + ScmIntegrations, + ScmIntegrationsGroup, +} from '@backstage/integration'; import { GithubOrgReaderProcessor, parseUrl } from './GithubOrgReaderProcessor'; describe('GithubOrgReaderProcessor', () => { @@ -29,6 +34,20 @@ describe('GithubOrgReaderProcessor', () => { }); describe('implementation', () => { + let integrations: ScmIntegrations; + let github: jest.Mocked>; + + beforeEach(() => { + github = { + byHost: jest.fn(), + byUrl: jest.fn(), + list: jest.fn(), + }; + integrations = ({ + github, + } as Partial) as ScmIntegrations; + }); + it('rejects unknown types', async () => { const processor = new GithubOrgReaderProcessor({ providers: [ @@ -37,6 +56,7 @@ describe('GithubOrgReaderProcessor', () => { apiBaseUrl: 'https://api.github.com', }, ], + integrations, logger: getVoidLogger(), }); const location: LocationSpec = { @@ -48,7 +68,7 @@ describe('GithubOrgReaderProcessor', () => { ).resolves.toBeFalsy(); }); - it('rejects unknown targets', async () => { + it('rejects unknown targets from providers', async () => { const processor = new GithubOrgReaderProcessor({ providers: [ { @@ -56,6 +76,24 @@ describe('GithubOrgReaderProcessor', () => { apiBaseUrl: 'https://api.github.com', }, ], + integrations, + logger: getVoidLogger(), + }); + const location: LocationSpec = { + type: 'github-org', + target: 'https://not.github.com/apa', + }; + await expect( + processor.readLocation(location, false, () => {}), + ).rejects.toThrow( + /There is no GitHub Org provider that matches https:\/\/not.github.com\/apa/, + ); + }); + + it('rejects unknown targets from integrations', async () => { + const processor = new GithubOrgReaderProcessor({ + providers: [], + integrations, logger: getVoidLogger(), }); const location: LocationSpec = { diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts index 6a83d705f6..14204adde7 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -16,6 +16,10 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { + GithubCredentialsProvider, + ScmIntegrations, +} from '@backstage/integration'; import { graphql } from '@octokit/graphql'; import { Logger } from 'winston'; import { @@ -28,22 +32,33 @@ import * as results from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; import { buildOrgHierarchy } from './util/org'; +type GraphQL = typeof graphql; + /** * Extracts teams and users out of a GitHub org. */ export class GithubOrgReaderProcessor implements CatalogProcessor { private readonly providers: ProviderConfig[]; + private readonly integrations: ScmIntegrations; private readonly logger: Logger; static fromConfig(config: Config, options: { logger: Logger }) { + const integrations = ScmIntegrations.fromConfig(config); + return new GithubOrgReaderProcessor({ ...options, providers: readGithubConfig(config), + integrations, }); } - constructor(options: { providers: ProviderConfig[]; logger: Logger }) { + constructor(options: { + providers: ProviderConfig[]; + integrations: ScmIntegrations; + logger: Logger; + }) { this.providers = options.providers; + this.integrations = options.integrations; this.logger = options.logger; } @@ -56,24 +71,8 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { return false; } - const provider = this.providers.find(p => - location.target.startsWith(`${p.target}/`), - ); - if (!provider) { - throw new Error( - `There is no GitHub Org provider that matches ${location.target}. Please add a configuration entry for it under catalog.processors.githubOrg.providers.`, - ); - } - + const client = await this.createClient(location.target); const { org } = parseUrl(location.target); - const client = !provider.token - ? graphql - : graphql.defaults({ - baseUrl: provider.apiBaseUrl, - headers: { - authorization: `token ${provider.token}`, - }, - }); // Read out all of the raw data const startTimestamp = Date.now(); @@ -112,6 +111,67 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { return true; } + + private async createClient(orgUrl: string): Promise { + let client = await this.createClientFromIntegrations(orgUrl); + + if (!client) { + client = await this.createClientFromProvider(orgUrl); + } + + if (!client) { + throw new Error( + `There is no GitHub Org provider that matches ${orgUrl}. Please add a configuration for an integration or add an entry for it under catalog.processors.githubOrg.providers.`, + ); + } + + return client; + } + + private async createClientFromProvider( + orgUrl: string, + ): Promise { + const provider = this.providers.find(p => + orgUrl.startsWith(`${p.target}/`), + ); + + if (!provider) { + return undefined; + } + + this.logger.warn( + 'GithubOrgReaderProcessor uses provider defined in catalog.processors.githubOrg.providers, migrate to integrations instead. See https://backstage.io/docs/integrations/github/locations', + ); + + return !provider.token + ? graphql + : graphql.defaults({ + baseUrl: provider.apiBaseUrl, + headers: { + authorization: `token ${provider.token}`, + }, + }); + } + + private async createClientFromIntegrations( + orgUrl: string, + ): Promise { + const gitHubConfig = this.integrations.github.byUrl(orgUrl)?.config; + if (!gitHubConfig) { + return undefined; + } + + const credentialsProvider = GithubCredentialsProvider.create(gitHubConfig); + + const { headers } = await credentialsProvider.getCredentials({ + url: orgUrl, + }); + + return graphql.defaults({ + baseUrl: gitHubConfig.apiBaseUrl, + headers, + }); + } } /* diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.test.ts b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts new file mode 100644 index 0000000000..662be5d3a6 --- /dev/null +++ b/plugins/catalog-backend/src/next/DefaultLocationService.test.ts @@ -0,0 +1,159 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DefaultLocationService } from './DefaultLocationService'; +import { CatalogProcessingOrchestrator, LocationStore } from './types'; + +describe('DefaultLocationServiceTest', () => { + const orchestrator: jest.Mocked = { + process: jest.fn(), + }; + const store: jest.Mocked = { + deleteLocation: jest.fn(), + createLocation: jest.fn(), + listLocations: jest.fn(), + getLocation: jest.fn(), + }; + + beforeEach(() => jest.resetAllMocks()); + const locationService = new DefaultLocationService(store, orchestrator); + describe('createLocation', () => { + it('should support dry run', async () => { + orchestrator.process.mockResolvedValueOnce({ + ok: true, + state: new Map(), + completedEntity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + name: 'foo', + }, + }, + deferredEntities: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'bar', + }, + }, + ], + relations: [], + errors: [], + }); + + orchestrator.process.mockResolvedValueOnce({ + ok: true, + state: new Map(), + completedEntity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'bar', + }, + }, + deferredEntities: [], + relations: [], + errors: [], + }); + + await locationService.createLocation( + { type: 'url', target: 'https://backstage.io/catalog-info.yaml' }, + true, + ); + + expect(orchestrator.process).toBeCalledWith({ + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': + 'url:https://backstage.io/catalog-info.yaml', + 'backstage.io/managed-by-origin-location': + 'url:https://backstage.io/catalog-info.yaml', + }, + name: 'generated-bbad4f61e08f24e25d5c5e68e13e164f760aff06', + namespace: 'default', + }, + spec: { + target: 'https://backstage.io/catalog-info.yaml', + type: 'url', + }, + }, + state: expect.anything(), + }); + + expect(orchestrator.process).toBeCalledWith({ + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'bar' }, + }, + state: expect.anything(), + }); + expect(orchestrator.process).toBeCalledTimes(2); + expect(store.createLocation).not.toBeCalled(); + }); + + it('should create location', async () => { + const locationSpec = { + type: 'url', + target: 'https://backstage.io/catalog-info.yaml', + }; + + store.createLocation.mockResolvedValue({ + ...locationSpec, + id: '123', + }); + + await expect( + locationService.createLocation(locationSpec, false), + ).resolves.toEqual({ + entities: [], + location: { + id: '123', + target: 'https://backstage.io/catalog-info.yaml', + type: 'url', + }, + }); + expect(store.createLocation).toBeCalledWith({ + target: 'https://backstage.io/catalog-info.yaml', + type: 'url', + }); + }); + }); + describe('listLocations', () => { + it('should call locationStore.deleteLocation', async () => { + await locationService.listLocations(); + expect(store.listLocations).toBeCalled(); + }); + }); + + describe('deleteLocation', () => { + it('should call locationStore.deleteLocation', async () => { + await locationService.deleteLocation('123'); + expect(store.deleteLocation).toBeCalledWith('123'); + }); + }); + + describe('getLocation', () => { + it('should call locationStore.getLocation', async () => { + await locationService.getLocation('123'); + expect(store.getLocation).toBeCalledWith('123'); + }); + }); +}); diff --git a/plugins/catalog-backend/src/next/DefaultLocationService.ts b/plugins/catalog-backend/src/next/DefaultLocationService.ts index 3935f6e4d0..e628b1d77d 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationService.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationService.ts @@ -25,6 +25,7 @@ import { LocationStore, CatalogProcessingOrchestrator, } from './types'; +import { locationSpecToMetadataName } from './util'; export class DefaultLocationService implements LocationService { constructor( @@ -37,36 +38,8 @@ export class DefaultLocationService implements LocationService { dryRun: boolean, ): Promise<{ location: Location; entities: Entity[] }> { if (dryRun) { - const entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Location', - metadata: { - name: `${spec.type}:${spec.target}`, - namespace: 'default', - annotations: { - [LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`, - [ORIGIN_LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`, - }, - }, - spec: { - location: { type: spec.type, target: spec.target }, - }, - }; - const processed = await this.orchestrator.process({ - entity, - eager: true, - state: new Map(), - }); - if (processed.ok) { - return { - location: { ...spec, id: `${spec.type}:${spec.target}` }, - entities: [processed.completedEntity], - }; - } - - throw Error('error handling not implemented.'); + return this.dryRunCreateLocation(spec); } - const location = await this.store.createLocation(spec); return { location, entities: [] }; } @@ -80,4 +53,53 @@ export class DefaultLocationService implements LocationService { deleteLocation(id: string): Promise { return this.store.deleteLocation(id); } + + private async dryRunCreateLocation( + spec: LocationSpec, + ): Promise<{ location: Location; entities: Entity[] }> { + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + name: locationSpecToMetadataName({ + type: spec.type, + target: spec.target, + }), + namespace: 'default', + annotations: { + [LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`, + [ORIGIN_LOCATION_ANNOTATION]: `${spec.type}:${spec.target}`, + }, + }, + spec: { + type: spec.type, + target: spec.target, + }, + }; + const unprocessedEntities: Entity[] = [entity]; + const entities: Entity[] = []; + const state = new Map(); // ignored + while (unprocessedEntities.length) { + const currentEntity = unprocessedEntities.pop(); + if (!currentEntity) { + continue; + } + const processed = await this.orchestrator.process({ + entity: currentEntity, + state, + }); + + if (processed.ok) { + unprocessedEntities.push(...processed.deferredEntities); + entities.push(processed.completedEntity); + } else { + throw Error(processed.errors.map(String).join(', ')); + } + } + + return { + location: { ...spec, id: `${spec.type}:${spec.target}` }, + entities, + }; + } } diff --git a/plugins/catalog-backend/src/next/NextRouter.ts b/plugins/catalog-backend/src/next/NextRouter.ts index 433abd97e9..b4660edea0 100644 --- a/plugins/catalog-backend/src/next/NextRouter.ts +++ b/plugins/catalog-backend/src/next/NextRouter.ts @@ -133,8 +133,8 @@ export async function createNextRouter( res.status(201).json(output); }) .get('/locations', async (_req, res) => { - const output = await locationService.listLocations(); - res.status(200).json(output); + const locations = await locationService.listLocations(); + res.status(200).json(locations.map(l => ({ data: l }))); }) .get('/locations/:id', async (req, res) => { diff --git a/plugins/catalog-backend/src/next/types.ts b/plugins/catalog-backend/src/next/types.ts index 410066eafd..fbc243ce69 100644 --- a/plugins/catalog-backend/src/next/types.ts +++ b/plugins/catalog-backend/src/next/types.ts @@ -59,7 +59,6 @@ export interface EntityProvider { export type EntityProcessingRequest = { entity: Entity; - eager?: boolean; state: Map; // Versions for multiple deployments etc }; diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index 8140724840..c04cc8e3d2 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-catalog-graphql +## 0.2.9 + +### Patch Changes + +- 3be844496: chore: bump `ts-node` versions to 9.1.1 +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.2.8 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 983604ba03..5e0b8e49c2 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-graphql", - "version": "0.2.8", + "version": "0.2.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", - "@backstage/catalog-model": "^0.7.8", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@graphql-modules/core": "^0.7.17", "apollo-server": "^2.16.1", @@ -42,8 +42,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.11", + "@backstage/test-utils": "^0.1.11", "@graphql-codegen/cli": "^1.21.3", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 59baabfbc8..e41b135014 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-catalog-import +## 0.5.6 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.5.5 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index c5fcdd4d37..196b3d6e83 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.5.5", + "version": "0.5.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/catalog-client": "^0.3.11", - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/integration": "^0.5.2", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.5", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -53,13 +53,13 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx index 3e918db419..03882d48ce 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.test.tsx @@ -98,7 +98,11 @@ describe('', () => { ); await act(async () => { - userEvent.click(getByRole('button', { name: /Analyze/i })); + try { + userEvent.click(getByRole('button', { name: /Analyze/i })); + } catch { + return; + } }); expect(catalogImportApi.analyzeUrl).toBeCalledTimes(0); diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index e5a857eca0..c50b72d059 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,20 @@ # @backstage/plugin-catalog-react +## 0.1.6 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/catalog-model@0.7.9 + ## 0.1.5 ### Patch Changes diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index a9092ef75a..abce5a1996 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-react", - "version": "0.1.5", + "version": "0.1.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ }, "dependencies": { "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", "@material-ui/core": "^4.11.0", "@types/react": "^16.9", "lodash": "^4.17.15", @@ -40,13 +40,13 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index d6254a347a..160440c69f 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-catalog +## 0.5.7 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 5542de095: This makes the CatalogTable configurable with custom columns, passed through the CatalogPage component rendered on the home page. +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.5.6 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index f1a0e6979d..6354f00c5d 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.5.6", + "version": "0.5.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,12 +31,12 @@ }, "dependencies": { "@backstage/catalog-client": "^0.3.10", - "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -52,14 +52,14 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index fd3d63edd7..bbb26d7be7 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -20,6 +20,7 @@ import { ContentHeader, errorApiRef, SupportButton, + TableColumn, useApi, useRouteRef, } from '@backstage/core'; @@ -42,6 +43,7 @@ import { CatalogFilterType, } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; +import { EntityRow } from '../CatalogTable/types'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import { useOwnUser } from '../useOwnUser'; import CatalogLayout from './CatalogLayout'; @@ -61,6 +63,7 @@ const useStyles = makeStyles(theme => ({ export type CatalogPageProps = { initiallySelectedFilter?: string; + columns?: TableColumn[]; }; const CatalogPageContents = (props: CatalogPageProps) => { @@ -210,6 +213,7 @@ const CatalogPageContents = (props: CatalogPageProps) => { [] = [ - { - title: 'Name', - field: 'resolved.name', - highlight: true, - render: ({ entity }) => ( - - ), - }, - { - title: 'System', - field: 'resolved.partOfSystemRelationTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Owner', - field: 'resolved.ownedByRelationsTitle', - render: ({ resolved }) => ( - - ), - }, - { - title: 'Type', - field: 'entity.spec.type', - hidden: true, - }, - { - title: 'Lifecycle', - field: 'entity.spec.lifecycle', - }, - { - title: 'Description', - field: 'entity.metadata.description', - render: ({ entity }) => ( - - ), - width: 'auto', - }, - { - title: 'Tags', - field: 'entity.metadata.tags', - cellStyle: { - padding: '0px 16px 0px 20px', - }, - render: ({ entity }) => ( - <> - {entity.metadata.tags && - entity.metadata.tags.map(t => ( - - ))} - - ), - }, +const defaultColumns: TableColumn[] = [ + columnFactories.createNameColumn(), + columnFactories.createSystemColumn(), + columnFactories.createOwnerColumn(), + columnFactories.createSpecTypeColumn(), + columnFactories.createSpecLifecycleColumn(), + columnFactories.createMetadataDescriptionColumn(), + columnFactories.createTagsColumn(), ]; type CatalogTableProps = { @@ -136,6 +60,7 @@ type CatalogTableProps = { loading: boolean; error?: any; view?: string; + columns?: TableColumn[]; }; export const CatalogTable = ({ @@ -144,6 +69,7 @@ export const CatalogTable = ({ error, titlePreamble, view, + columns, }: CatalogTableProps) => { const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); @@ -224,7 +150,7 @@ export const CatalogTable = ({ }; }); - const typeColumn = columns.find(c => c.title === 'Type'); + const typeColumn = (columns || defaultColumns).find(c => c.title === 'Type'); if (typeColumn) { typeColumn.hidden = view !== 'Other'; } @@ -232,7 +158,7 @@ export const CatalogTable = ({ return ( isLoading={loading} - columns={columns} + columns={columns || defaultColumns} options={{ paging: true, pageSize: 20, @@ -248,3 +174,5 @@ export const CatalogTable = ({ /> ); }; + +CatalogTable.columns = columnFactories; diff --git a/plugins/catalog/src/components/CatalogTable/columns.tsx b/plugins/catalog/src/components/CatalogTable/columns.tsx new file mode 100644 index 0000000000..4f65e4b0c7 --- /dev/null +++ b/plugins/catalog/src/components/CatalogTable/columns.tsx @@ -0,0 +1,110 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { EntityRefLink, EntityRefLinks } from '@backstage/plugin-catalog-react'; +import { OverflowTooltip, TableColumn } from '@backstage/core'; +import { Chip } from '@material-ui/core'; +import { EntityRow } from './types'; + +export function createNameColumn(): TableColumn { + return { + title: 'Name', + field: 'resolved.name', + highlight: true, + render: ({ entity }) => ( + + ), + }; +} + +export function createSystemColumn(): TableColumn { + return { + title: 'System', + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( + + ), + }; +} + +export function createOwnerColumn(): TableColumn { + return { + title: 'Owner', + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( + + ), + }; +} + +export function createSpecTypeColumn(): TableColumn { + return { + title: 'Type', + field: 'entity.spec.type', + hidden: true, + }; +} + +export function createSpecLifecycleColumn(): TableColumn { + return { + title: 'Lifecycle', + field: 'entity.spec.lifecycle', + }; +} + +export function createMetadataDescriptionColumn(): TableColumn { + return { + title: 'Description', + field: 'entity.metadata.description', + render: ({ entity }) => ( + + ), + width: 'auto', + }; +} + +export function createTagsColumn(): TableColumn { + return { + title: 'Tags', + field: 'entity.metadata.tags', + cellStyle: { + padding: '0px 16px 0px 20px', + }, + render: ({ entity }) => ( + <> + {entity.metadata.tags && + entity.metadata.tags.map(t => ( + + ))} + + ), + }; +} diff --git a/plugins/catalog/src/components/CatalogTable/index.ts b/plugins/catalog/src/components/CatalogTable/index.ts new file mode 100644 index 0000000000..9148a22a41 --- /dev/null +++ b/plugins/catalog/src/components/CatalogTable/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 { CatalogTable } from './CatalogTable'; diff --git a/plugins/catalog/src/components/CatalogTable/types.ts b/plugins/catalog/src/components/CatalogTable/types.ts new file mode 100644 index 0000000000..b1a0ce2739 --- /dev/null +++ b/plugins/catalog/src/components/CatalogTable/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Entity, EntityName } from '@backstage/catalog-model'; + +export type EntityRow = { + entity: Entity; + resolved: { + name: string; + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 7ab7b95dd7..fb269fc484 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -17,6 +17,7 @@ export { AboutCard } from './components/AboutCard'; export { EntityLayout } from './components/EntityLayout'; export { EntityPageLayout } from './components/EntityPageLayout'; +export { CatalogTable } from './components/CatalogTable'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; export { diff --git a/plugins/circleci/CHANGELOG.md b/plugins/circleci/CHANGELOG.md index 8c6d6e094d..d1347fde2a 100644 --- a/plugins/circleci/CHANGELOG.md +++ b/plugins/circleci/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-circleci +## 0.2.14 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.13 ### Patch Changes diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index 98af012a49..e9b18c43d0 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-circleci", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,12 +50,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/react-lazylog": "^4.5.0", diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index dea818c97d..a7540f2e72 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-cloudbuild +## 0.2.14 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.13 ### Patch Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 325dfb4e06..510f417bc2 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cloudbuild", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,12 +47,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/code-coverage-backend/CHANGELOG.md b/plugins/code-coverage-backend/CHANGELOG.md index 18e78c7454..3322186cbe 100644 --- a/plugins/code-coverage-backend/CHANGELOG.md +++ b/plugins/code-coverage-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-code-coverage-backend +## 0.1.5 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.1.4 ### Patch Changes diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index db53096932..5b73fe45f8 100644 --- a/plugins/code-coverage-backend/package.json +++ b/plugins/code-coverage-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-code-coverage-backend", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,9 +19,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.2", @@ -36,7 +36,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/express-xml-bodyparser": "^0.3.2", "@types/supertest": "^2.0.8", "msw": "^0.21.2", diff --git a/plugins/code-coverage/CHANGELOG.md b/plugins/code-coverage/CHANGELOG.md index 4a55b7f0f5..6d1ea253a0 100644 --- a/plugins/code-coverage/CHANGELOG.md +++ b/plugins/code-coverage/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-code-coverage +## 0.1.3 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.1.2 ### Patch Changes diff --git a/plugins/code-coverage/package.json b/plugins/code-coverage/package.json index 9b227c1df1..f90985145f 100644 --- a/plugins/code-coverage/package.json +++ b/plugins/code-coverage/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-code-coverage", - "version": "0.1.2", + "version": "0.1.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,11 +20,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.7", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -39,12 +39,12 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/highlightjs": "^10.1.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", diff --git a/plugins/config-schema/CHANGELOG.md b/plugins/config-schema/CHANGELOG.md new file mode 100644 index 0000000000..e54fc1a802 --- /dev/null +++ b/plugins/config-schema/CHANGELOG.md @@ -0,0 +1,13 @@ +# @backstage/plugin-config-schema + +## 0.1.2 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 diff --git a/plugins/config-schema/package.json b/plugins/config-schema/package.json index caa03b9ffb..c878f418d0 100644 --- a/plugins/config-schema/package.json +++ b/plugins/config-schema/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-config-schema", - "version": "0.1.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -34,12 +34,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "msw": "^0.21.2", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index e6a351f2a4..a94910cfb2 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-cost-insights +## 0.9.1 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.9.0 ### Minor Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 3a5601041c..a467fc0542 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.9.0", + "version": "0.9.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -55,12 +55,12 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/pluralize": "^0.0.29", diff --git a/plugins/explore-react/CHANGELOG.md b/plugins/explore-react/CHANGELOG.md index 4141daf2b6..17e3775c6d 100644 --- a/plugins/explore-react/CHANGELOG.md +++ b/plugins/explore-react/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-explore-react +## 0.0.5 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.0.4 ### Patch Changes diff --git a/plugins/explore-react/package.json b/plugins/explore-react/package.json index b6852ea0f0..5a8d082abe 100644 --- a/plugins/explore-react/package.json +++ b/plugins/explore-react/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore-react", - "version": "0.0.4", + "version": "0.0.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,15 +28,15 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/core": "^0.7.1" + "@backstage/core": "^0.7.9" }, "devDependencies": { - "@backstage/cli": "^0.6.4", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.6", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index 78275577f5..c3a756f928 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-explore +## 0.3.5 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 0b033d07b: Add "Organization" tab with a diagram +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/plugin-explore-react@0.0.5 + - @backstage/catalog-model@0.7.9 + ## 0.3.4 ### Patch Changes diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 2a25a9db41..d96c2f4d18 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/plugin-explore-react": "^0.0.4", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/plugin-explore-react": "^0.0.5", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,12 +45,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/fossa/CHANGELOG.md b/plugins/fossa/CHANGELOG.md index bd7af91dca..1c5de31c0c 100644 --- a/plugins/fossa/CHANGELOG.md +++ b/plugins/fossa/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-fossa +## 0.2.7 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.6 ### Patch Changes diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 95762e52d7..51a6c5cf55 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-fossa", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,12 +47,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/gcp-projects/CHANGELOG.md b/plugins/gcp-projects/CHANGELOG.md index 8445cbb02b..3843a9519a 100644 --- a/plugins/gcp-projects/CHANGELOG.md +++ b/plugins/gcp-projects/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-gcp-projects +## 0.2.6 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.2.5 ### Patch Changes diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index fd6048ab2a..6b0c625501 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gcp-projects", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,12 +41,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/git-release-manager/.eslintrc.js b/plugins/git-release-manager/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/git-release-manager/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/git-release-manager/README.md b/plugins/git-release-manager/README.md new file mode 100644 index 0000000000..d9e1703781 --- /dev/null +++ b/plugins/git-release-manager/README.md @@ -0,0 +1,59 @@ +# Git Release Manager (GRM) + +## Overview + +`GRM` enables developers to manage their releases without having to juggle git commands. + +Does it build and ship your code? **No**. + +What `GRM` does is manage your Git **[releases](https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository)**, building and shipping is entirely up to you as a developer to handle in your CI. + +`GRM` is built with industry standards in mind and the flow is as follows: + +![](./src/features/Info/flow.png) + +> **Git**: The source control system where releases reside in a practical sense. Read more about [Git releases](https://docs.github.com/en/github/administering-a-repository/managing-releases-in-a-repository). (Note that this plugin works just as well with any system implementing `Git`.) +> +> **Release Candidate (RC)**: A Git pre-release intended primarily for internal testing +> +> **Release Version**: A Git release intended for end users + +Looking at the flow above, a common release lifecycle could be: + +- User presses **Create Release Candidate** + - `GRM` + 1. Creates a release branch `rc/` + 1. Creates Release Candidate tag `rc-` + 1. Creates a Git prerelease with Release Candidate tag + - Your CI + 1. Detects the new tag by matching the git reference `refs/tags/rc-.*` + 1. Builds and deploys to staging environment for testing +- User presses **Patch** + - `GRM` + 1. The selected commit is cherry-picked to the release branch + 1. The release tag is bumped + 1. Updates Git release's tag and description with the patch's details + - Your CI + 1. Detects the new tag by matching the git reference `refs/tags/(rc|version)-.*` (Release Versions are patchable as well) + 1. Builds and deploys to staging (or production if Release Version) for testing +- User presses **Promote Release Candidate to Release Version** + - `GRM` + 1. Creates Release Version tag `version-` + 1. Promotes the Git release by removing the prerelease flag + - Your CI + 1. Detects the new tag by matching the git reference `refs/tags/version-.*` + 1. Builds and deploys to production for testing + +## Usage + +### Importing + +The plugin exports a single full-page extension `GitReleaseManagerPage`, which one can add to an app like a usual top-level tool on a dedicated route. + +### Configuration + +The plugin is configurable either via props or the select elements on the page. + +If project configuration is provided via props, the select elements are disabled. It is also possible to omit features from the page via props, as well as attaching callbacks for successful executions. + +See the plugin's dev folder (`dev/index.tsx`) to see some examples. diff --git a/plugins/git-release-manager/dev/index.tsx b/plugins/git-release-manager/dev/index.tsx new file mode 100644 index 0000000000..cad8aaa9dc --- /dev/null +++ b/plugins/git-release-manager/dev/index.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { Box, Typography } from '@material-ui/core'; + +import { gitReleaseManagerPlugin, GitReleaseManagerPage } from '../src/plugin'; +import { InfoCardPlus } from '../src/components/InfoCardPlus'; + +createDevApp() + .registerPlugin(gitReleaseManagerPlugin) + .addPage({ + title: 'Dynamic', + path: '/dynamic', + element: ( + + + Dev notes + Configure plugin via select inputs + + + + + ), + }) + .addPage({ + title: 'Static', + path: '/static', + element: ( + + + Dev notes + + Configure plugin statically by passing props to the + `GitHubReleaseManagerPage` component + + + + + + ), + }) + .addPage({ + title: 'Omit', + path: '/omit', + element: ( + + + Dev notes + Each feature can be omitted + Success callbacks can also be added + + + { + // eslint-disable-next-line no-console + console.log( + 'Custom success callback for Create RC', + comparisonUrl, + createdTag, + gitReleaseName, + gitReleaseUrl, + previousTag, + ); + }, + }, + promoteRc: { + omit: true, + }, + patch: { + omit: true, + }, + }} + /> + + ), + }) + .render(); diff --git a/plugins/git-release-manager/package.json b/plugins/git-release-manager/package.json new file mode 100644 index 0000000000..cccb452b34 --- /dev/null +++ b/plugins/git-release-manager/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-git-release-manager", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core": "^0.7.9", + "@backstage/integration": "^0.5.1", + "@backstage/theme": "^0.2.7", + "recharts": "^1.8.5", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@octokit/rest": "^18.5.3", + "luxon": "^1.26.0", + "qs": "^6.10.1", + "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4", + "react": "^16.13.1" + }, + "devDependencies": { + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", + "@testing-library/jest-dom": "^5.10.1", + "@types/recharts": "^1.8.15", + "@testing-library/react-hooks": "^3.4.2", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/git-release-manager/src/GitReleaseManager.tsx b/plugins/git-release-manager/src/GitReleaseManager.tsx new file mode 100644 index 0000000000..1b388ffc82 --- /dev/null +++ b/plugins/git-release-manager/src/GitReleaseManager.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useAsync } from 'react-use'; +import { Alert } from '@material-ui/lab'; +import { useApi, ContentHeader, Progress } from '@backstage/core'; +import { Box } from '@material-ui/core'; + +import { + ComponentConfig, + ComponentConfigCreateRc, + ComponentConfigPatch, + ComponentConfigPromoteRc, +} from './types/types'; +import { Features } from './features/Features'; +import { gitReleaseManagerApiRef } from './api/serviceApiRef'; +import { InfoCardPlus } from './components/InfoCardPlus'; +import { isProjectValid } from './helpers/isProjectValid'; +import { ProjectContext, Project } from './contexts/ProjectContext'; +import { RepoDetailsForm } from './features/RepoDetailsForm/RepoDetailsForm'; +import { useQueryHandler } from './hooks/useQueryHandler'; +import { UserContext } from './contexts/UserContext'; + +interface GitReleaseManagerProps { + project?: Omit; + features?: { + info?: Pick, 'omit'>; + stats?: Pick, 'omit'>; + createRc?: ComponentConfigCreateRc; + promoteRc?: ComponentConfigPromoteRc; + patch?: ComponentConfigPatch; + }; +} + +export function GitReleaseManager(props: GitReleaseManagerProps) { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + + const { getParsedQuery } = useQueryHandler(); + const { parsedQuery } = getParsedQuery(); + const project: Project = isProjectValid(props.project) + ? { + ...props.project, + isProvidedViaProps: true, + } + : { + owner: parsedQuery.owner ?? '', + repo: parsedQuery.repo ?? '', + versioningStrategy: parsedQuery.versioningStrategy ?? 'semver', + isProvidedViaProps: false, + }; + + const userResponse = useAsync(() => + pluginApiClient.getUser({ owner: project.owner, repo: project.repo }), + ); + + if (userResponse.error) { + return {userResponse.error.message}; + } + + if (userResponse.loading) { + return ; + } + + if (!userResponse.value?.user.username) { + return Unable to retrieve username; + } + + const user = userResponse.value.user; + + return ( + + + + + + + + + + {isProjectValid(project) && } + + + + ); +} diff --git a/plugins/git-release-manager/src/api/GitReleaseClient.test.ts b/plugins/git-release-manager/src/api/GitReleaseClient.test.ts new file mode 100644 index 0000000000..1b7cc7876a --- /dev/null +++ b/plugins/git-release-manager/src/api/GitReleaseClient.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader, OAuthApi } from '@backstage/core'; + +import { GitReleaseClient } from './GitReleaseClient'; + +describe('GitReleaseClient', () => { + it('should return the default plugin api client', () => { + const configApi = new ConfigReader({}); + const githubAuthApi: OAuthApi = { + getAccessToken: jest.fn(), + }; + const gitReleaseClient = new GitReleaseClient({ + configApi, + githubAuthApi, + }); + + expect(gitReleaseClient).toMatchInlineSnapshot(` + GitReleaseClient { + "apiBaseUrl": "https://api.github.com", + "createCommit": [Function], + "createRef": [Function], + "createRelease": [Function], + "createTagObject": [Function], + "getAllReleases": [Function], + "getAllTags": [Function], + "getBranch": [Function], + "getCommit": [Function], + "getComparison": [Function], + "getHost": [Function], + "getLatestRelease": [Function], + "getOwners": [Function], + "getRecentCommits": [Function], + "getRepoPath": [Function], + "getRepositories": [Function], + "getRepository": [Function], + "getTag": [Function], + "getUser": [Function], + "githubAuthApi": Object { + "getAccessToken": [MockFunction], + }, + "host": "github.com", + "merge": [Function], + "updateRef": [Function], + "updateRelease": [Function], + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/api/GitReleaseClient.ts b/plugins/git-release-manager/src/api/GitReleaseClient.ts new file mode 100644 index 0000000000..cd178f292a --- /dev/null +++ b/plugins/git-release-manager/src/api/GitReleaseClient.ts @@ -0,0 +1,843 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigApi, OAuthApi } from '@backstage/core'; +import { Octokit } from '@octokit/rest'; +import { GitHubIntegration, ScmIntegrations } from '@backstage/integration'; + +import { DISABLE_CACHE } from '../constants/constants'; +import { Project } from '../contexts/ProjectContext'; +import { UnboxArray, UnboxReturnedPromise } from '../types/helpers'; +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export class GitReleaseClient implements GitReleaseApi { + private readonly githubAuthApi: OAuthApi; + private readonly apiBaseUrl: string; + readonly host: string; + + constructor({ + configApi, + githubAuthApi, + }: { + configApi: ConfigApi; + githubAuthApi: OAuthApi; + }) { + this.githubAuthApi = githubAuthApi; + + const gitHubIntegrations = ScmIntegrations.fromConfig( + configApi, + ).github.list(); + const { host, apiBaseUrl } = this.getGithubIntegrationConfig({ + gitHubIntegrations, + }); + + this.host = host; + this.apiBaseUrl = apiBaseUrl; + } + + private getGithubIntegrationConfig({ + gitHubIntegrations, + }: { + gitHubIntegrations: GitHubIntegration[]; + }) { + const defaultIntegration = gitHubIntegrations.find( + ({ config: { host } }) => host === 'github.com', + ); + const enterpriseIntegration = gitHubIntegrations.find( + ({ config: { host } }) => host !== 'github.com', + ); + + const host = + enterpriseIntegration?.config.host ?? defaultIntegration?.config.host; + const apiBaseUrl = + enterpriseIntegration?.config.apiBaseUrl ?? + defaultIntegration?.config.apiBaseUrl; + + if (!host) { + throw new GitReleaseManagerError( + 'Invalid API configuration: missing host', + ); + } + + if (!apiBaseUrl) { + throw new GitReleaseManagerError( + 'Invalid API configuration: missing apiBaseUrl', + ); + } + + return { + host, + apiBaseUrl, + }; + } + + private async getOctokit() { + const token = await this.githubAuthApi.getAccessToken(['repo']); + + return { + octokit: new Octokit({ + auth: token, + baseUrl: this.apiBaseUrl, + }), + }; + } + + public getHost: GitReleaseApi['getHost'] = () => { + return this.host; + }; + + public getRepoPath: GitReleaseApi['getRepoPath'] = ({ owner, repo }) => { + return `${owner}/${repo}`; + }; + + getOwners: GitReleaseApi['getOwners'] = async () => { + const { octokit } = await this.getOctokit(); + const orgListResponse = await octokit.paginate( + octokit.orgs.listForAuthenticatedUser, + { per_page: 100 }, + ); + + return { + owners: orgListResponse.map(organization => organization.login), + }; + }; + + getRepositories: GitReleaseApi['getRepositories'] = async ({ owner }) => { + const { octokit } = await this.getOctokit(); + + const repositoryResponse = await octokit + .paginate(octokit.repos.listForOrg, { org: owner, per_page: 100 }) + .catch(async error => { + // `owner` is not an org, try listing a user's repositories instead + if (error.status === 404) { + const userRepositoryResponse = await octokit.paginate( + octokit.repos.listForUser, + { username: owner, per_page: 100 }, + ); + return userRepositoryResponse; + } + + throw error; + }); + + return { + repositories: repositoryResponse.map(repository => repository.name), + }; + }; + + getUser: GitReleaseApi['getUser'] = async () => { + const { octokit } = await this.getOctokit(); + const userResponse = await octokit.users.getAuthenticated(); + + return { + user: { + username: userResponse.data.login, + email: userResponse.data.email ?? undefined, + }, + }; + }; + + getRecentCommits: GitReleaseApi['getRecentCommits'] = async ({ + owner, + repo, + releaseBranchName, + }) => { + const { octokit } = await this.getOctokit(); + const recentCommitsResponse = await octokit.repos.listCommits({ + owner, + repo, + ...(releaseBranchName ? { sha: releaseBranchName } : {}), + ...DISABLE_CACHE, + }); + + return { + recentCommits: recentCommitsResponse.data.map(commit => ({ + htmlUrl: commit.html_url, + sha: commit.sha, + author: { + htmlUrl: commit.author?.html_url, + login: commit.author?.login, + }, + commit: { + message: commit.commit.message, + }, + firstParentSha: commit.parents?.[0]?.sha, + })), + }; + }; + + getLatestRelease: GitReleaseApi['getLatestRelease'] = async ({ + owner, + repo, + }) => { + const { octokit } = await this.getOctokit(); + const { data: latestReleases } = await octokit.repos.listReleases({ + owner, + repo, + per_page: 1, + ...DISABLE_CACHE, + }); + + if (latestReleases.length === 0) { + return { + latestRelease: null, + }; + } + + const latestRelease = latestReleases[0]; + + return { + latestRelease: { + targetCommitish: latestRelease.target_commitish, + tagName: latestRelease.tag_name, + prerelease: latestRelease.prerelease, + id: latestRelease.id, + htmlUrl: latestRelease.html_url, + body: latestRelease.body, + }, + }; + }; + + getRepository: GitReleaseApi['getRepository'] = async ({ owner, repo }) => { + const { octokit } = await this.getOctokit(); + const { data: repository } = await octokit.repos.get({ + owner, + repo, + ...DISABLE_CACHE, + }); + + return { + repository: { + pushPermissions: repository.permissions?.push, + defaultBranch: repository.default_branch, + name: repository.name, + }, + }; + }; + + getCommit: GitReleaseApi['getCommit'] = async ({ owner, repo, ref }) => { + const { octokit } = await this.getOctokit(); + const { data: commit } = await octokit.repos.getCommit({ + owner, + repo, + ref, + ...DISABLE_CACHE, + }); + + return { + commit: { + sha: commit.sha, + htmlUrl: commit.html_url, + commit: { + message: commit.commit.message, + }, + createdAt: commit.commit.committer?.date, + }, + }; + }; + + getBranch: GitReleaseApi['getBranch'] = async ({ owner, repo, branch }) => { + const { octokit } = await this.getOctokit(); + + const { data: branchData } = await octokit.repos.getBranch({ + owner, + repo, + branch, + ...DISABLE_CACHE, + }); + + return { + branch: { + name: branchData.name, + links: { + html: branchData._links.html, + }, + commit: { + sha: branchData.commit.sha, + commit: { + tree: { + sha: branchData.commit.commit.tree.sha, + }, + }, + }, + }, + }; + }; + + createRef: GitReleaseApi['createRef'] = async ({ owner, repo, sha, ref }) => { + const { octokit } = await this.getOctokit(); + const createRefResponse = await octokit.git.createRef({ + owner, + repo, + ref, + sha, + }); + + return { + reference: { + ref: createRefResponse.data.ref, + objectSha: createRefResponse.data.object.sha, + }, + }; + }; + + getComparison: GitReleaseApi['getComparison'] = async ({ + owner, + repo, + base, + head, + }) => { + const { octokit } = await this.getOctokit(); + const compareCommitsResponse = await octokit.repos.compareCommits({ + owner, + repo, + base, + head, + }); + + return { + comparison: { + htmlUrl: compareCommitsResponse.data.html_url, + aheadBy: compareCommitsResponse.data.ahead_by, + }, + }; + }; + + createRelease: GitReleaseApi['createRelease'] = async ({ + owner, + repo, + tagName, + name, + targetCommitish, + body, + }) => { + const { octokit } = await this.getOctokit(); + const createReleaseResponse = await octokit.repos.createRelease({ + owner, + repo, + tag_name: tagName, + name: name, + target_commitish: targetCommitish, + body, + prerelease: true, + }); + + return { + release: { + name: createReleaseResponse.data.name, + htmlUrl: createReleaseResponse.data.html_url, + tagName: createReleaseResponse.data.tag_name, + }, + }; + }; + + createTagObject: GitReleaseApi['createTagObject'] = async ({ + owner, + repo, + tag, + object, + taggerName, + taggerEmail, + message, + }) => { + const { octokit } = await this.getOctokit(); + const { data: createdTagObject } = await octokit.git.createTag({ + owner, + repo, + message, + tag, + object, + type: 'commit', + ...(taggerEmail + ? { + tagger: { + date: new Date().toISOString(), + email: taggerEmail, + name: taggerName, + }, + } + : {}), + }); + + return { + tagObject: { + tagName: createdTagObject.tag, + tagSha: createdTagObject.sha, + }, + }; + }; + + createCommit: GitReleaseApi['createCommit'] = async ({ + owner, + repo, + message, + tree, + parents, + }) => { + const { octokit } = await this.getOctokit(); + const { data: commit } = await octokit.git.createCommit({ + owner, + repo, + message, + tree, + parents, + }); + + return { + commit: { + message: commit.message, + sha: commit.sha, + }, + }; + }; + + updateRef: GitReleaseApi['updateRef'] = async ({ + owner, + repo, + ref, + sha, + force, + }) => { + const { octokit } = await this.getOctokit(); + const { data: updatedRef } = await octokit.git.updateRef({ + owner, + repo, + ref, + sha, + force, + }); + + return { + reference: { + ref: updatedRef.ref, + object: { + sha: updatedRef.object.sha, + }, + }, + }; + }; + + merge: GitReleaseApi['merge'] = async ({ owner, repo, base, head }) => { + const { octokit } = await this.getOctokit(); + const { data: merge } = await octokit.repos.merge({ + owner, + repo, + base, + head, + }); + + return { + merge: { + htmlUrl: merge.html_url, + commit: { + message: merge.commit.message, + tree: { + sha: merge.commit.tree.sha, + }, + }, + }, + }; + }; + + updateRelease: GitReleaseApi['updateRelease'] = async ({ + owner, + repo, + releaseId, + tagName, + body, + prerelease, + }) => { + const { octokit } = await this.getOctokit(); + const { data: updatedRelease } = await octokit.repos.updateRelease({ + owner, + repo, + release_id: releaseId, + tag_name: tagName, + body, + prerelease, + }); + + return { + release: { + name: updatedRelease.name, + tagName: updatedRelease.tag_name, + htmlUrl: updatedRelease.html_url, + }, + }; + }; + + getAllTags: GitReleaseApi['getAllTags'] = async ({ owner, repo }) => { + const { octokit } = await this.getOctokit(); + + const tags = await octokit.paginate(octokit.git.listMatchingRefs, { + owner, + repo, + ref: 'tags', + per_page: 100, + ...DISABLE_CACHE, + }); + + return { + tags: tags + .map(tag => ({ + tagName: tag.ref.replace('refs/tags/', ''), + tagSha: tag.object.sha, + tagType: tag.object.type as 'tag' | 'commit', + })) + .reverse(), + }; + }; + + getAllReleases: GitReleaseApi['getAllReleases'] = async ({ owner, repo }) => { + const { octokit } = await this.getOctokit(); + + const releases = await octokit.paginate(octokit.repos.listReleases, { + owner, + repo, + per_page: 100, + ...DISABLE_CACHE, + }); + + return { + releases: releases.map(release => ({ + id: release.id, + name: release.name, + tagName: release.tag_name, + createdAt: release.published_at, + htmlUrl: release.html_url, + })), + }; + }; + + getTag: GitReleaseApi['getTag'] = async ({ owner, repo, tagSha }) => { + const { octokit } = await this.getOctokit(); + const singleTag = await octokit.git.getTag({ + owner, + repo, + tag_sha: tagSha, + }); + + return { + tag: { + date: singleTag.data.tagger.date, + username: singleTag.data.tagger.name, + userEmail: singleTag.data.tagger.email, + objectSha: singleTag.data.object.sha, + }, + }; + }; +} + +type OwnerRepo = { + owner: Project['owner']; + repo: Project['repo']; +}; + +export interface GitReleaseApi { + getHost: () => string; + + getRepoPath: (args: OwnerRepo) => string; + + getOwners: () => Promise<{ + owners: string[]; + }>; + + getRepositories: (args: { + owner: OwnerRepo['owner']; + }) => Promise<{ + repositories: string[]; + }>; + + getUser: ( + args: OwnerRepo, + ) => Promise<{ + user: { + username: string; + email?: string; + }; + }>; + + getRecentCommits: ( + args: { + releaseBranchName?: string; + } & OwnerRepo, + ) => Promise<{ + recentCommits: { + htmlUrl: string; + sha: string; + author: { + htmlUrl?: string; + login?: string; + }; + commit: { + message: string; + }; + firstParentSha?: string; + }[]; + }>; + + getLatestRelease: ( + args: OwnerRepo, + ) => Promise<{ + latestRelease: { + targetCommitish: string; + tagName: string; + prerelease: boolean; + id: number; + htmlUrl: string; + body?: string | null; + } | null; + }>; + + getRepository: ( + args: OwnerRepo, + ) => Promise<{ + repository: { + pushPermissions: boolean | undefined; + defaultBranch: string; + name: string; + }; + }>; + + getCommit: ( + args: { + ref: string; + } & OwnerRepo, + ) => Promise<{ + commit: { + sha: string; + htmlUrl: string; + commit: { + message: string; + }; + createdAt?: string; + }; + }>; + + getBranch: ( + args: { + branch: string; + } & OwnerRepo, + ) => Promise<{ + branch: { + name: string; + links: { + html: string; + }; + commit: { + sha: string; + commit: { + tree: { + sha: string; + }; + }; + }; + }; + }>; + + createRef: ( + args: { + ref: string; + sha: string; + } & OwnerRepo, + ) => Promise<{ + reference: { + ref: string; + objectSha: string; + }; + }>; + + getComparison: ( + args: { + base: string; + head: string; + } & OwnerRepo, + ) => Promise<{ + comparison: { + htmlUrl: string; + aheadBy: number; + }; + }>; + + createRelease: ( + args: { + tagName: string; + name: string; + targetCommitish: string; + body: string; + } & OwnerRepo, + ) => Promise<{ + release: { + name: string | null; + htmlUrl: string; + tagName: string; + }; + }>; + + createTagObject: ( + args: { + tag: string; + taggerEmail?: string; + message: string; + object: string; + taggerName: string; + } & OwnerRepo, + ) => Promise<{ + tagObject: { + tagName: string; + tagSha: string; + }; + }>; + + createCommit: ( + args: { + message: string; + tree: string; + parents: string[]; + } & OwnerRepo, + ) => Promise<{ + commit: { + message: string; + sha: string; + }; + }>; + + updateRef: ( + args: { + sha: string; + ref: string; + force: boolean; + } & OwnerRepo, + ) => Promise<{ + reference: { + ref: string; + object: { + sha: string; + }; + }; + }>; + + merge: ( + args: { + base: string; + head: string; + } & OwnerRepo, + ) => Promise<{ + merge: { + htmlUrl: string; + commit: { + message: string; + tree: { + sha: string; + }; + }; + }; + }>; + + updateRelease: ( + args: { + releaseId: number; + tagName: string; + body?: string; + prerelease?: boolean; + } & OwnerRepo, + ) => Promise<{ + release: { + name: string | null; + tagName: string; + htmlUrl: string; + }; + }>; + + /** + * Get all tags in descending order + */ + getAllTags: ( + args: OwnerRepo, + ) => Promise<{ + tags: Array<{ + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }>; + }>; + + getAllReleases: ( + args: OwnerRepo, + ) => Promise<{ + releases: Array<{ + id: number; + name: string | null; + tagName: string; + createdAt: string | null; + htmlUrl: string; + }>; + }>; + + getTag: ( + args: { + tagSha: string; + } & OwnerRepo, + ) => Promise<{ + tag: { + date: string; + username: string; + userEmail: string; + objectSha: string; + }; + }>; +} + +export type GetOwnersResult = UnboxReturnedPromise; +export type GetRepositoriesResult = UnboxReturnedPromise< + GitReleaseApi['getRepositories'] +>; +export type GetUserResult = UnboxReturnedPromise; +export type GetRecentCommitsResult = UnboxReturnedPromise< + GitReleaseApi['getRecentCommits'] +>; +export type GetRecentCommitsResultSingle = UnboxArray< + GetRecentCommitsResult['recentCommits'] +>; +export type GetLatestReleaseResult = UnboxReturnedPromise< + GitReleaseApi['getLatestRelease'] +>; +export type GetRepositoryResult = UnboxReturnedPromise< + GitReleaseApi['getRepository'] +>; +export type GetCommitResult = UnboxReturnedPromise; +export type GetBranchResult = UnboxReturnedPromise; +export type CreateRefResult = UnboxReturnedPromise; +export type GetComparisonResult = UnboxReturnedPromise< + GitReleaseApi['getComparison'] +>; +export type CreateReleaseResult = UnboxReturnedPromise< + GitReleaseApi['createRelease'] +>; +export type MergeResult = UnboxReturnedPromise; +export type CreateTagObjectResult = UnboxReturnedPromise< + GitReleaseApi['createTagObject'] +>; +export type UpdateReleaseResult = UnboxReturnedPromise< + GitReleaseApi['updateRelease'] +>; +export type GetAllTagsResult = UnboxReturnedPromise< + GitReleaseApi['getAllTags'] +>; +export type GetAllReleasesResult = UnboxReturnedPromise< + GitReleaseApi['getAllReleases'] +>; +export type GetTagResult = UnboxReturnedPromise; diff --git a/plugins/git-release-manager/src/api/serviceApiRef.test.ts b/plugins/git-release-manager/src/api/serviceApiRef.test.ts new file mode 100644 index 0000000000..8313294683 --- /dev/null +++ b/plugins/git-release-manager/src/api/serviceApiRef.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { gitReleaseManagerApiRef } from './serviceApiRef'; + +describe('gitReleaseManagerApiRef', () => { + it('should work', () => { + const result = gitReleaseManagerApiRef; + + expect(result).toMatchInlineSnapshot(` + ApiRefImpl { + "config": Object { + "description": "Used by the Git Release Manager plugin to make requests", + "id": "plugin.git-release-manager.service", + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/api/serviceApiRef.ts b/plugins/git-release-manager/src/api/serviceApiRef.ts new file mode 100644 index 0000000000..7a5c045d40 --- /dev/null +++ b/plugins/git-release-manager/src/api/serviceApiRef.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core'; + +import { GitReleaseApi } from './GitReleaseClient'; + +export const gitReleaseManagerApiRef = createApiRef({ + id: 'plugin.git-release-manager.service', + description: 'Used by the Git Release Manager plugin to make requests', +}); diff --git a/plugins/git-release-manager/src/components/Differ.test.tsx b/plugins/git-release-manager/src/components/Differ.test.tsx new file mode 100644 index 0000000000..b35773f1f5 --- /dev/null +++ b/plugins/git-release-manager/src/components/Differ.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Differ } from './Differ'; +import { TEST_IDS } from '../test-helpers/test-ids'; +import { + mockReleaseCandidateCalver, + mockReleaseVersionCalver, + mockReleaseVersionSemver, +} from '../test-helpers/test-helpers'; + +describe('Differ', () => { + it('should render icon and `none` for missing current & next', () => { + const { getByTestId, queryByTestId } = render(); + + const icon = getByTestId(TEST_IDS.components.differ.icons.branch); + const current = queryByTestId(TEST_IDS.components.differ.current); + const next = queryByTestId(TEST_IDS.components.differ.next); + + expect(icon).toBeInTheDocument(); + expect(current).toMatchInlineSnapshot(`null`); + expect(next).not.toBeInTheDocument(); + }); + + it('should render icon & current for missing next', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + const icon = getByTestId(TEST_IDS.components.differ.icons.branch); + const current = getByTestId(TEST_IDS.components.differ.current); + const next = queryByTestId(TEST_IDS.components.differ.next); + + expect(icon).toBeInTheDocument(); + expect(current.innerHTML).toMatchInlineSnapshot(`"version-1.2.3"`); + expect(next).not.toBeInTheDocument(); + }); + + it('should render icon & current & next (with seperator)', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + const icon = getByTestId(TEST_IDS.components.differ.icons.branch); + const current = getByTestId(TEST_IDS.components.differ.current); + const next = queryByTestId(TEST_IDS.components.differ.next); + + expect(icon).toBeInTheDocument(); + expect(current.innerHTML).toMatchInlineSnapshot(`"rc-2020.01.01_1"`); + expect(next?.innerHTML).toMatchInlineSnapshot(`"version-2020.01.01_1"`); + }); +}); diff --git a/plugins/git-release-manager/src/components/Differ.tsx b/plugins/git-release-manager/src/components/Differ.tsx new file mode 100644 index 0000000000..e5d64cc914 --- /dev/null +++ b/plugins/git-release-manager/src/components/Differ.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ReactNode } from 'react'; +import { grey } from '@material-ui/core/colors'; +import CallSplitIcon from '@material-ui/icons/CallSplit'; +import ChatIcon from '@material-ui/icons/Chat'; +import DynamicFeedIcon from '@material-ui/icons/DynamicFeed'; +import GitHubIcon from '@material-ui/icons/GitHub'; +import LocalOfferIcon from '@material-ui/icons/LocalOffer'; + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; +import { TEST_IDS } from '../test-helpers/test-ids'; + +interface DifferProps { + icon: 'tag' | 'branch' | 'github' | 'slack' | 'versioning'; + current?: string; + next?: string | ReactNode; +} + +export const Differ = ({ current, next, icon }: DifferProps) => { + return ( + <> + {icon && ( + + {' '} + + )} + + {!!current && ( + + {current ?? 'None'} + + )} + + {current && next && {' → '}} + + {next && ( + + {next} + + )} + + ); +}; + +interface IconProps { + icon: DifferProps['icon']; +} + +function Icon({ icon }: IconProps) { + switch (icon) { + case 'tag': + return ( + + ); + + case 'branch': + return ( + + ); + + case 'github': + return ( + + ); + + case 'slack': + return ( + + ); + + case 'versioning': + return ( + + ); + + default: + throw new GitReleaseManagerError('Invalid Differ icon'); + } +} diff --git a/plugins/git-release-manager/src/components/Divider.test.tsx b/plugins/git-release-manager/src/components/Divider.test.tsx new file mode 100644 index 0000000000..35e725d2ee --- /dev/null +++ b/plugins/git-release-manager/src/components/Divider.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { TEST_IDS } from '../test-helpers/test-ids'; +import { Divider } from './Divider'; + +describe('Divider', () => { + it('render Divider', () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.components.divider)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/Divider.tsx b/plugins/git-release-manager/src/components/Divider.tsx new file mode 100644 index 0000000000..6be0f4eb3c --- /dev/null +++ b/plugins/git-release-manager/src/components/Divider.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Divider as MaterialDivider } from '@material-ui/core'; + +import { TEST_IDS } from '../test-helpers/test-ids'; + +export const Divider = () => { + return ( + + + + ); +}; diff --git a/plugins/git-release-manager/src/components/InfoCardPlus.test.tsx b/plugins/git-release-manager/src/components/InfoCardPlus.test.tsx new file mode 100644 index 0000000000..01ed7aa4e8 --- /dev/null +++ b/plugins/git-release-manager/src/components/InfoCardPlus.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { TEST_IDS } from '../test-helpers/test-ids'; +import { InfoCardPlus } from './InfoCardPlus'; + +describe('InfoCardPlus', () => { + it('render InfoCardPlus', () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.info.infoFeaturePlus)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/InfoCardPlus.tsx b/plugins/git-release-manager/src/components/InfoCardPlus.tsx new file mode 100644 index 0000000000..fe2da57402 --- /dev/null +++ b/plugins/git-release-manager/src/components/InfoCardPlus.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { InfoCard } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; + +import { TEST_IDS } from '../test-helpers/test-ids'; + +const useStyles = makeStyles(() => ({ + feature: { + marginBottom: '3em', + }, +})); + +export const InfoCardPlus = ({ children }: { children?: React.ReactNode }) => { + const classes = useStyles(); + + return ( +
+ {children} +
+ ); +}; diff --git a/plugins/git-release-manager/src/components/NoLatestRelease.test.tsx b/plugins/git-release-manager/src/components/NoLatestRelease.test.tsx new file mode 100644 index 0000000000..6863109448 --- /dev/null +++ b/plugins/git-release-manager/src/components/NoLatestRelease.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { NoLatestRelease } from './NoLatestRelease'; + +describe('NoLatestRelease', () => { + it('render NoLatestRelease', () => { + const { container } = render(); + + expect(container).toMatchInlineSnapshot(` +
+
+ +
+
+ `); + }); +}); diff --git a/plugins/git-release-manager/src/components/NoLatestRelease.tsx b/plugins/git-release-manager/src/components/NoLatestRelease.tsx new file mode 100644 index 0000000000..e4121b1f4c --- /dev/null +++ b/plugins/git-release-manager/src/components/NoLatestRelease.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert } from '@material-ui/lab'; +import { Box } from '@material-ui/core'; + +import { TEST_IDS } from '../test-helpers/test-ids'; + +export const NoLatestRelease = () => { + return ( + + + Unable to find any Release + + + ); +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.test.tsx new file mode 100644 index 0000000000..a043758209 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.test.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { LinearProgressWithLabel, testables } from './LinearProgressWithLabel'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +const { ICONS } = testables; + +describe('LinearProgressWithLabel', () => { + it('should render 50% progress without CompletionEmoji', () => { + const progress = 50; + + const { container, getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.linearProgressWithLabel).getAttribute( + 'style', + ), + ).toContain(`font-size: 141%`); + expect(container.innerHTML).toContain(`${progress}%`); + expect(container.innerHTML).not.toContain(ICONS.SUCCESS); + expect(container.innerHTML).not.toContain(ICONS.FAILURE); + }); + + it('should render 100% progress with CompletionEmoji for success', () => { + const progress = 100; + + const { container, getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.linearProgressWithLabel).getAttribute( + 'style', + ), + ).toContain(`font-size: 157%`); + expect(container.innerHTML).toContain(`${progress}%`); + expect(container.innerHTML).toContain(ICONS.SUCCESS); + expect(container.innerHTML).not.toContain(ICONS.FAILURE); + }); + + it('should render 100% progress with CompletionEmoji for failure if at least one failed response step is present', () => { + const progress = 100; + + const { container } = render( + , + ); + + expect(container.innerHTML).toContain(`${progress}%`); + expect(container.innerHTML).toContain(ICONS.FAILURE); + expect(container.innerHTML).not.toContain(ICONS.SUCCESS); + }); + + it('should round > 100 progress to 100', () => { + const progress = 101; + + const { container } = render( + , + ); + + expect(container.innerHTML).toContain('100%'); + expect(container.innerHTML).toContain(ICONS.SUCCESS); + expect(container.innerHTML).not.toContain(ICONS.FAILURE); + expect(container.innerHTML).not.toContain(`${progress}%`); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.tsx new file mode 100644 index 0000000000..62d14e771e --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/LinearProgressWithLabel.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, LinearProgress, Typography } from '@material-ui/core'; + +import { ResponseStep } from '../../types/types'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +const STATUSES = { + FAILURE: 'FAILURE', + ONGOING: 'ONGOING', + SUCCESS: 'SUCCESS', +} as const; + +const ICONS = { + SUCCESS: '🚀', + FAILURE: '🔥', +}; + +const getFontSize = (progress: number) => 125 + Math.ceil(progress / Math.PI); + +export function LinearProgressWithLabel(props: { + progress: number; + responseSteps: ResponseStep[]; +}) { + const roundedValue = Math.ceil(props.progress); + const progress = roundedValue < 100 ? roundedValue : 100; + + const failure = props.responseSteps.some( + responseStep => responseStep.icon === 'failure', + ); + + let status: keyof typeof STATUSES = STATUSES.ONGOING; + if (!failure && progress === 100) status = STATUSES.SUCCESS; + if (failure) status = STATUSES.FAILURE; + + const CompletionEmoji = () => { + if (status === STATUSES.ONGOING) return null; + if (status === STATUSES.FAILURE) return {` ${ICONS.FAILURE} `}; + return {` ${ICONS.SUCCESS} `}; + }; + + return ( + + + + + + + + + {`${progress}%`} + + + + + ); +} + +export const testables = { + ICONS, +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.test.tsx new file mode 100644 index 0000000000..5f1c90c8cf --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ResponseStepDialog } from './ResponseStepDialog'; + +jest.mock('../../contexts/RefetchContext', () => ({ + useRefetchContext: () => jest.fn(), +})); + +describe('ResponseStepDialog', () => { + it('should render ResponseStepDialog', () => { + const mockTitle = 'mock_dialog_title'; + const mockResponseStepMessage = 'banana'; + + const { baseElement } = render( + , + ); + + expect(baseElement.innerHTML).toMatch(mockTitle); + expect(baseElement.innerHTML).toMatch(mockResponseStepMessage); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.tsx new file mode 100644 index 0000000000..52ae3e68c0 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepDialog.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Button, + Dialog as MaterialDialog, + DialogActions, + DialogTitle, +} from '@material-ui/core'; +import RefreshIcon from '@material-ui/icons/Refresh'; + +import { LinearProgressWithLabel } from './LinearProgressWithLabel'; +import { ResponseStep } from '../../types/types'; +import { ResponseStepList } from './ResponseStepList'; +import { Transition } from '../Transition'; +import { useRefetchContext } from '../../contexts/RefetchContext'; + +interface DialogProps { + progress: number; + responseSteps: ResponseStep[]; + title: string; +} + +export const ResponseStepDialog = ({ + progress, + responseSteps, + title, +}: DialogProps) => { + const { fetchGitBatchInfo } = useRefetchContext(); + + return ( + + {title} + + + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.test.tsx new file mode 100644 index 0000000000..67fcf634a3 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ResponseStepList } from './ResponseStepList'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +describe('ResponseStepList', () => { + it('should render loading state when loading', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.circularProgress), + ).toBeInTheDocument(); + }); + + it('should render loading state when no responseSteps', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.circularProgress), + ).toBeInTheDocument(); + }); + + it('should render dialog content when loading is completed', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListDialogContent), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.tsx new file mode 100644 index 0000000000..77bcd22957 --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepList.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { PropsWithChildren } from 'react'; +import { DialogContent, List } from '@material-ui/core'; +import { Progress } from '@backstage/core'; + +import { ResponseStep } from '../../types/types'; +import { ResponseStepListItem } from './ResponseStepListItem'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +interface ResponseStepListProps { + responseSteps: (ResponseStep | undefined)[]; + animationDelay?: number; + loading?: boolean; + closeable?: boolean; + denseList?: boolean; +} + +export const ResponseStepList = ({ + responseSteps, + animationDelay, + loading = false, + denseList = false, + children, +}: PropsWithChildren) => { + return ( + <> + {loading || responseSteps.length === 0 ? ( +
+ +
+ ) : ( + <> + + + {responseSteps.map((responseStep, index) => { + if (!responseStep) { + return null; + } + + return ( + + ); + })} + + + {children} + + + )} + + ); +}; diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.test.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.test.tsx new file mode 100644 index 0000000000..f0f185073a --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { ResponseStepListItem } from './ResponseStepListItem'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +describe('ResponseStepListItem', () => { + it('should render', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItem), + ).toBeInTheDocument(); + }); + + it('should render success icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconSuccess), + ).toBeInTheDocument(); + }); + + it('should render failure icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconFailure), + ).toBeInTheDocument(); + }); + + it('should render link icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconLink), + ).toBeInTheDocument(); + }); + + it('should render default icon', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.responseStepListItemIconDefault), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.tsx b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.tsx new file mode 100644 index 0000000000..e1c80691ed --- /dev/null +++ b/plugins/git-release-manager/src/components/ResponseStepDialog/ResponseStepListItem.tsx @@ -0,0 +1,126 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + colors, + IconButton, + ListItem, + ListItemIcon, + ListItemText, + makeStyles, +} from '@material-ui/core'; +import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline'; +import ErrorOutlineIcon from '@material-ui/icons/ErrorOutline'; +import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; + +import { ResponseStep } from '../../types/types'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +interface ResponseStepListItemProps { + responseStep: ResponseStep; + index: number; + animationDelay?: number; +} + +const useStyles = makeStyles({ + item: { + transition: `opacity ${(props: any) => + props.animationDelay <= 0 + ? 0 + : Math.ceil(props.animationDelay / 2)}ms ease-in`, + overflow: 'hidden', + '&:before': { + flex: 'none', + }, + }, + hidden: { + opacity: 0, + height: 0, + minHeight: 0, + }, + shown: { + opacity: 1, + }, +}); + +export const ResponseStepListItem = ({ + responseStep, + animationDelay = 300, +}: ResponseStepListItemProps) => { + const classes = useStyles({ animationDelay }); + + function ItemIcon() { + if (responseStep.icon === 'success') { + return ( + + ); + } + + if (responseStep.icon === 'failure') { + return ( + + ); + } + + if (responseStep.link) { + return ( + { + const newTab = window.open(responseStep.link, '_blank'); + newTab?.focus(); + }} + > + + + ); + } + + return ( + + ); + } + + return ( + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/components/Transition.tsx b/plugins/git-release-manager/src/components/Transition.tsx new file mode 100644 index 0000000000..0c54e0f851 --- /dev/null +++ b/plugins/git-release-manager/src/components/Transition.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { forwardRef, Ref } from 'react'; +import { Slide } from '@material-ui/core'; +import { TransitionProps } from '@material-ui/core/transitions'; + +export const Transition = forwardRef(function Transition( + props: { children?: React.ReactElement } & TransitionProps, + ref: Ref, +) { + return ; +}); diff --git a/plugins/git-release-manager/src/constants/constants.test.ts b/plugins/git-release-manager/src/constants/constants.test.ts new file mode 100644 index 0000000000..759c2c7ab2 --- /dev/null +++ b/plugins/git-release-manager/src/constants/constants.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as constants from './constants'; + +describe('constants', () => { + it('should match snapshot', () => { + expect(constants).toMatchInlineSnapshot(` + Object { + "DISABLE_CACHE": Object { + "headers": Object { + "If-None-Match": "", + }, + }, + "SEMVER_PARTS": Object { + "major": "major", + "minor": "minor", + "patch": "patch", + }, + "TAG_OBJECT_MESSAGE": "Tag generated by your friendly neighborhood Backstage Release Manager", + "VERSIONING_STRATEGIES": Object { + "calver": "calver", + "semver": "semver", + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/constants/constants.ts b/plugins/git-release-manager/src/constants/constants.ts new file mode 100644 index 0000000000..099a5913ca --- /dev/null +++ b/plugins/git-release-manager/src/constants/constants.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const SEMVER_PARTS: { + major: 'major'; + minor: 'minor'; + patch: 'patch'; +} = { + major: 'major', + minor: 'minor', + patch: 'patch', +} as const; + +export const DISABLE_CACHE = { + headers: { + 'If-None-Match': '', + }, +} as const; + +export const VERSIONING_STRATEGIES: { + semver: 'semver'; + calver: 'calver'; +} = { + semver: 'semver', + calver: 'calver', +} as const; + +export const TAG_OBJECT_MESSAGE = + 'Tag generated by your friendly neighborhood Backstage Release Manager'; diff --git a/plugins/git-release-manager/src/contexts/ProjectContext.ts b/plugins/git-release-manager/src/contexts/ProjectContext.ts new file mode 100644 index 0000000000..cc13c5ac77 --- /dev/null +++ b/plugins/git-release-manager/src/contexts/ProjectContext.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { VERSIONING_STRATEGIES } from '../constants/constants'; +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export interface Project { + /** + * Repository's owner (user or organisation) + * + * @example erikengervall + */ + owner: string; + /** + * Repository's name + * + * @example dockest + */ + repo: string; + /** + * Declares the versioning strategy of the project + * + * semver: `1.2.3` (major.minor.patch) + * calver: `2020.01.01_0` (YYYY.0M.0D_patch) + * + * Default: false + */ + versioningStrategy: keyof typeof VERSIONING_STRATEGIES; + /** + * Project props was provided via props + * + * If true, this means select inputs will be disabled + */ + isProvidedViaProps: boolean; +} + +export const ProjectContext = createContext<{ project: Project } | undefined>( + undefined, +); + +export const useProjectContext = () => { + const { project } = useContext(ProjectContext) ?? {}; + + if (!project) { + throw new GitReleaseManagerError('project not found'); + } + + return { + project, + }; +}; diff --git a/plugins/git-release-manager/src/contexts/RefetchContext.ts b/plugins/git-release-manager/src/contexts/RefetchContext.ts new file mode 100644 index 0000000000..9545944a30 --- /dev/null +++ b/plugins/git-release-manager/src/contexts/RefetchContext.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export const RefetchContext = createContext< + { fetchGitBatchInfo: () => any } | undefined +>(undefined); + +export const useRefetchContext = () => { + const refetch = useContext(RefetchContext); + + if (!refetch) { + throw new GitReleaseManagerError('refetch not found'); + } + + return { + fetchGitBatchInfo: refetch.fetchGitBatchInfo, + }; +}; diff --git a/plugins/git-release-manager/src/contexts/UserContext.ts b/plugins/git-release-manager/src/contexts/UserContext.ts new file mode 100644 index 0000000000..664c621d3c --- /dev/null +++ b/plugins/git-release-manager/src/contexts/UserContext.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +interface User { + username: string; + email?: string; +} + +export const UserContext = createContext<{ user: User } | undefined>(undefined); + +export const useUserContext = () => { + const { user } = useContext(UserContext) ?? {}; + + if (!user) { + throw new GitReleaseManagerError('user not found'); + } + + return { + user, + }; +}; diff --git a/plugins/git-release-manager/src/errors/GitReleaseManagerError.ts b/plugins/git-release-manager/src/errors/GitReleaseManagerError.ts new file mode 100644 index 0000000000..3c2b516202 --- /dev/null +++ b/plugins/git-release-manager/src/errors/GitReleaseManagerError.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class GitReleaseManagerError extends Error { + constructor(message: string) { + super(message); + + this.name = 'GitReleaseManagerError'; + } +} diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.test.tsx b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.test.tsx new file mode 100644 index 0000000000..b7509cd27b --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockCalverProject, + mockNextGitInfoSemver, + mockReleaseBranch, + mockReleaseCandidateCalver, + mockReleaseVersionCalver, + mockSemverProject, +} from '../../test-helpers/test-helpers'; +import { CreateReleaseCandidate } from './CreateReleaseCandidate'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useCreateReleaseCandidate } from './hooks/useCreateReleaseCandidate'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: jest.fn(() => ({ + project: mockCalverProject, + })), +})); +jest.mock('../../helpers/getReleaseCandidateGitInfo', () => ({ + getReleaseCandidateGitInfo: () => mockNextGitInfoSemver, +})); +jest.mock('./hooks/useCreateReleaseCandidate', () => ({ + useCreateReleaseCandidate: () => + ({ + run: jest.fn(), + responseSteps: [], + progress: 0, + runInvoked: false, + } as ReturnType), +})); + +describe('CreateReleaseCandidate', () => { + it('should display CTA', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.createRc.cta)).toBeInTheDocument(); + }); + + it('should display select element for semver', () => { + (useProjectContext as jest.Mock).mockReturnValue({ + project: mockSemverProject, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.createRc.semverSelect)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.tsx b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.tsx new file mode 100644 index 0000000000..ea074b5fe6 --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/CreateReleaseCandidate.tsx @@ -0,0 +1,201 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Typography, +} from '@material-ui/core'; + +import { + GetBranchResult, + GetLatestReleaseResult, + GetRepositoryResult, +} from '../../api/GitReleaseClient'; +import { ComponentConfigCreateRc } from '../../types/types'; +import { Differ } from '../../components/Differ'; +import { getReleaseCandidateGitInfo } from '../../helpers/getReleaseCandidateGitInfo'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog'; +import { SEMVER_PARTS } from '../../constants/constants'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useCreateReleaseCandidate } from './hooks/useCreateReleaseCandidate'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +interface CreateReleaseCandidateProps { + defaultBranch: GetRepositoryResult['repository']['defaultBranch']; + latestRelease: GetLatestReleaseResult['latestRelease']; + releaseBranch: GetBranchResult['branch'] | null; + onSuccess?: ComponentConfigCreateRc['onSuccess']; +} + +const InfoCardPlusWrapper = ({ children }: { children: React.ReactNode }) => { + return ( + + + Create Release Candidate + + {children} + + ); +}; + +export const CreateReleaseCandidate = ({ + defaultBranch, + latestRelease, + releaseBranch, + onSuccess, +}: CreateReleaseCandidateProps) => { + const { project } = useProjectContext(); + + const [semverBumpLevel, setSemverBumpLevel] = useState<'major' | 'minor'>( + SEMVER_PARTS.minor, + ); + const [releaseCandidateGitInfo, setReleaseCandidateGitInfo] = useState( + getReleaseCandidateGitInfo({ latestRelease, project, semverBumpLevel }), + ); + + useEffect(() => { + setReleaseCandidateGitInfo( + getReleaseCandidateGitInfo({ latestRelease, project, semverBumpLevel }), + ); + }, [semverBumpLevel, setReleaseCandidateGitInfo, latestRelease, project]); + + const { + progress, + responseSteps, + run, + runInvoked, + } = useCreateReleaseCandidate({ + defaultBranch, + latestRelease, + releaseCandidateGitInfo, + project, + onSuccess, + }); + if (responseSteps.length > 0) { + return ( + + ); + } + + if (releaseCandidateGitInfo.error !== undefined) { + return ( + + + {releaseCandidateGitInfo.error.title && ( + {releaseCandidateGitInfo.error.title} + )} + + {releaseCandidateGitInfo.error.subtitle} + + + ); + } + + const tagAlreadyExists = + latestRelease !== null && + latestRelease.tagName === releaseCandidateGitInfo.rcReleaseTag; + const conflictingPreRelease = + latestRelease !== null && latestRelease.prerelease; + + return ( + + {project.versioningStrategy === 'semver' && + latestRelease && + !conflictingPreRelease && ( + + + Select bump severity + + + + + )} + + {conflictingPreRelease || tagAlreadyExists ? ( + <> + {conflictingPreRelease && ( + + + The most recent release is already a Release Candidate + + + )} + + {tagAlreadyExists && ( + + + There's already a tag named{' '} + {releaseCandidateGitInfo.rcReleaseTag} + + + )} + + ) : ( + + + + + + + + + + )} + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx new file mode 100644 index 0000000000..d8703e0278 --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockDefaultBranch, + mockNextGitInfoCalver, + mockReleaseVersionCalver, + mockUser, +} from '../../../test-helpers/test-helpers'; +import { useCreateReleaseCandidate } from './useCreateReleaseCandidate'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); + +describe('useCreateReleaseCandidate', () => { + beforeEach(jest.clearAllMocks); + + it('should return the expected responseSteps and progress', async () => { + const { result } = renderHook(() => + useCreateReleaseCandidate({ + defaultBranch: mockDefaultBranch, + latestRelease: mockReleaseVersionCalver, + releaseCandidateGitInfo: mockNextGitInfoCalver, + project: mockCalverProject, + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(6); + }); + + it('should return the expected responseSteps and progress (with onSuccess)', async () => { + const { result } = renderHook(() => + useCreateReleaseCandidate({ + defaultBranch: mockDefaultBranch, + latestRelease: mockReleaseVersionCalver, + releaseCandidateGitInfo: mockNextGitInfoCalver, + project: mockCalverProject, + onSuccess: jest.fn(), + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.current.responseSteps).toHaveLength(7); + expect(result.current).toMatchInlineSnapshot(` + Object { + "progress": 100, + "responseSteps": Array [ + Object { + "link": "https://latestCommit.html_url", + "message": "Fetched latest commit from \\"mock_defaultBranch\\"", + "secondaryMessage": "with message \\"latestCommit.commit.message\\"", + }, + Object { + "message": "Created Release Branch", + "secondaryMessage": "with ref \\"mock_createRef_ref\\"", + }, + Object { + "message": "Created Tag Object", + "secondaryMessage": "with sha \\"mock_tag_object_sha\\"", + }, + Object { + "message": "Cut Tag Reference", + "secondaryMessage": "with ref \\"mock_createRef_ref\\"", + }, + Object { + "link": "https://mock_compareCommits_html_url", + "message": "Fetched commit comparison", + "secondaryMessage": "rc/2020.01.01_1...rc/2020.01.01_1", + }, + Object { + "link": "https://mock_createRelease_html_url", + "message": "Created Release Candidate \\"mock_createRelease_name\\"", + "secondaryMessage": "with tag \\"rc-2020.01.01_1\\"", + }, + Object { + "icon": "success", + "message": "Success callback successfully called 🚀", + }, + ], + "run": [Function], + "runInvoked": true, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts new file mode 100644 index 0000000000..477e404d78 --- /dev/null +++ b/plugins/git-release-manager/src/features/CreateReleaseCandidate/hooks/useCreateReleaseCandidate.ts @@ -0,0 +1,300 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { useApi } from '@backstage/core'; + +import { + GetLatestReleaseResult, + GetRepositoryResult, +} from '../../../api/GitReleaseClient'; +import { CardHook, ComponentConfigCreateRc } from '../../../types/types'; +import { getReleaseCandidateGitInfo } from '../../../helpers/getReleaseCandidateGitInfo'; +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; +import { Project } from '../../../contexts/ProjectContext'; +import { TAG_OBJECT_MESSAGE } from '../../../constants/constants'; +import { useResponseSteps } from '../../../hooks/useResponseSteps'; +import { useUserContext } from '../../../contexts/UserContext'; + +interface UseCreateReleaseCandidate { + defaultBranch: GetRepositoryResult['repository']['defaultBranch']; + latestRelease: GetLatestReleaseResult['latestRelease']; + releaseCandidateGitInfo: ReturnType; + project: Project; + onSuccess?: ComponentConfigCreateRc['onSuccess']; +} + +export function useCreateReleaseCandidate({ + defaultBranch, + latestRelease, + releaseCandidateGitInfo, + project, + onSuccess, +}: UseCreateReleaseCandidate): CardHook { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); + + if (releaseCandidateGitInfo.error) { + throw new GitReleaseManagerError( + `Unexpected error: ${ + releaseCandidateGitInfo.error.title + ? `${releaseCandidateGitInfo.error.title} (${releaseCandidateGitInfo.error.subtitle})` + : releaseCandidateGitInfo.error.subtitle + }`, + ); + } + + const { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + } = useResponseSteps(); + + /** + * (1) Get the default branch's most recent commit + */ + const [latestCommitRes, run] = useAsyncFn(async () => { + const { commit: latestCommit } = await pluginApiClient + .getCommit({ + owner: project.owner, + repo: project.repo, + ref: defaultBranch, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Fetched latest commit from "${defaultBranch}"`, + secondaryMessage: `with message "${latestCommit.commit.message}"`, + link: latestCommit.htmlUrl, + }); + + return { + latestCommit, + }; + }); + + /** + * (2) Create release branch based on default branch's most recent sha + */ + const releaseBranchRes = useAsync(async () => { + abortIfError(latestCommitRes.error); + if (!latestCommitRes.value) return undefined; + + const { reference: createdReleaseBranch } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + sha: latestCommitRes.value.latestCommit.sha, + ref: `refs/heads/${releaseCandidateGitInfo.rcBranch}`, + }) + .catch(error => { + if (error?.body?.message === 'Reference already exists') { + throw new GitReleaseManagerError( + `Branch "${releaseCandidateGitInfo.rcBranch}" already exists: .../tree/${releaseCandidateGitInfo.rcBranch}`, + ); + } + throw error; + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created Release Branch', + secondaryMessage: `with ref "${createdReleaseBranch.ref}"`, + }); + + return { + ...createdReleaseBranch, + }; + }, [latestCommitRes.value, latestCommitRes.error]); + + /** + * (3) Create tag object for our soon-to-be-created annotated tag + */ + const tagObjectRes = useAsync(async () => { + abortIfError(releaseBranchRes.error); + if (!releaseBranchRes.value) return undefined; + + const { tagObject } = await pluginApiClient + .createTagObject({ + owner: project.owner, + repo: project.repo, + tag: releaseCandidateGitInfo.rcReleaseTag, + object: releaseBranchRes.value.objectSha, + taggerName: user.username, + taggerEmail: user.email, + message: TAG_OBJECT_MESSAGE, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created Tag Object', + secondaryMessage: `with sha "${tagObject.tagSha}"`, + }); + + return { + ...tagObject, + }; + }, [releaseBranchRes.value, releaseBranchRes.error]); + + /** + * (4) Create reference for tag object + */ + const createRcRes = useAsync(async () => { + abortIfError(tagObjectRes.error); + if (!tagObjectRes.value) return undefined; + + const { reference: createdRef } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + ref: `refs/tags/${releaseCandidateGitInfo.rcReleaseTag}`, + sha: tagObjectRes.value.tagSha, + }) + .catch(error => { + if (error?.body?.message === 'Reference already exists') { + throw new GitReleaseManagerError( + `Tag reference "${releaseCandidateGitInfo.rcReleaseTag}" already exists`, + ); + } + throw error; + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Cut Tag Reference', + secondaryMessage: `with ref "${createdRef.ref}"`, + }); + + return { + ...createdRef, + }; + }, [tagObjectRes.value, tagObjectRes.error]); + + /** + * (5) Compose a body for the release + */ + const getComparisonRes = useAsync(async () => { + abortIfError(createRcRes.error); + if (!createRcRes.value) return undefined; + + const previousReleaseBranch = latestRelease + ? latestRelease.targetCommitish + : defaultBranch; + const nextReleaseBranch = releaseCandidateGitInfo.rcBranch; + const { comparison } = await pluginApiClient + .getComparison({ + owner: project.owner, + repo: project.repo, + base: previousReleaseBranch, + head: nextReleaseBranch, + }) + .catch(asyncCatcher); + + const releaseBody = `**Compare** ${comparison.htmlUrl} + +**Ahead by** ${comparison.aheadBy} commits + +**Release branch** ${createRcRes.value.ref} + +--- + +`; + + addStepToResponseSteps({ + message: 'Fetched commit comparison', + secondaryMessage: `${previousReleaseBranch}...${nextReleaseBranch}`, + link: comparison.htmlUrl, + }); + + return { + ...comparison, + releaseBody, + }; + }, [createRcRes.value, createRcRes.error]); + + /** + * (6) Creates the Git Release itself + */ + const createReleaseRes = useAsync(async () => { + abortIfError(getComparisonRes.error); + if (!getComparisonRes.value) return undefined; + + const { release: createReleaseResult } = await pluginApiClient + .createRelease({ + owner: project.owner, + repo: project.repo, + tagName: releaseCandidateGitInfo.rcReleaseTag, + name: releaseCandidateGitInfo.releaseName, + targetCommitish: releaseCandidateGitInfo.rcBranch, + body: getComparisonRes.value.releaseBody, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Created Release Candidate "${createReleaseResult.name}"`, + secondaryMessage: `with tag "${releaseCandidateGitInfo.rcReleaseTag}"`, + link: createReleaseResult.htmlUrl, + }); + + return { + ...createReleaseResult, + }; + }, [getComparisonRes.value, getComparisonRes.error]); + + /** + * (7) Run onSuccess if defined + */ + useAsync(async () => { + if (onSuccess && !!createReleaseRes.value && !!getComparisonRes.value) { + abortIfError(createReleaseRes.error); + + try { + await onSuccess({ + comparisonUrl: getComparisonRes.value.htmlUrl, + createdTag: createReleaseRes.value.tagName, + gitReleaseName: createReleaseRes.value.name, + gitReleaseUrl: createReleaseRes.value.htmlUrl, + previousTag: latestRelease?.tagName, + }); + } catch (error) { + asyncCatcher(error); + } + + addStepToResponseSteps({ + message: 'Success callback successfully called 🚀', + icon: 'success', + }); + } + }, [createReleaseRes.value, createReleaseRes.error]); + + const TOTAL_STEPS = 6 + (!!onSuccess ? 1 : 0); + const [progress, setProgress] = useState(0); + useEffect(() => { + setProgress((responseSteps.length / TOTAL_STEPS) * 100); + }, [TOTAL_STEPS, responseSteps.length]); + + return { + progress, + responseSteps, + run, + runInvoked: Boolean( + latestCommitRes.loading || latestCommitRes.value || latestCommitRes.error, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/Features.test.tsx b/plugins/git-release-manager/src/features/Features.test.tsx new file mode 100644 index 0000000000..7e2428b6bc --- /dev/null +++ b/plugins/git-release-manager/src/features/Features.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, act, waitFor } from '@testing-library/react'; + +import { Features } from './Features'; +import { mockApiClient, mockCalverProject } from '../test-helpers/test-helpers'; +import { TEST_IDS } from '../test-helpers/test-ids'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); + +describe('Features', () => { + it('should omit features omitted via configuration', async () => { + const { getByTestId } = render( + , + ); + + await act(async () => { + await waitFor(() => getByTestId(TEST_IDS.info.info)); + }); + + expect(getByTestId(TEST_IDS.info.info)).toMatchInlineSnapshot(` +
+
+ Terminology +
+

+ + Git + + : The source control system where releases reside in a practical sense. Read more about + + + Git releases + + . +

+

+ + Release Candidate + + : A Git + + prerelease + + intended primarily for internal testing +

+

+ + Release Version + + : A Git release intended for end users +

+
+ `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Features.tsx b/plugins/git-release-manager/src/features/Features.tsx new file mode 100644 index 0000000000..f147a4b9f0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Features.tsx @@ -0,0 +1,146 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { ComponentProps } from 'react'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { ErrorBoundary, Progress, useApi } from '@backstage/core'; + +import { CreateReleaseCandidate } from './CreateReleaseCandidate/CreateReleaseCandidate'; +import { GitReleaseManager } from '../GitReleaseManager'; +import { gitReleaseManagerApiRef } from '../api/serviceApiRef'; +import { Info } from './Info/Info'; +import { Patch } from './Patch/Patch'; +import { PromoteRc } from './PromoteRc/PromoteRc'; +import { RefetchContext } from '../contexts/RefetchContext'; +import { useGetGitBatchInfo } from '../hooks/useGetGitBatchInfo'; +import { useProjectContext } from '../contexts/ProjectContext'; +import { useVersioningStrategyMatchesRepoTags } from '../hooks/useVersioningStrategyMatchesRepoTags'; +import { validateTagName } from '../helpers/tagParts/validateTagName'; + +export function Features({ + features, +}: { + features: ComponentProps['features']; +}) { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const { gitBatchInfo, fetchGitBatchInfo } = useGetGitBatchInfo({ + pluginApiClient, + project, + }); + + const { versioningStrategyMatches } = useVersioningStrategyMatchesRepoTags({ + latestReleaseTagName: gitBatchInfo.value?.latestRelease?.tagName, + project, + repositoryName: gitBatchInfo.value?.repository.name, + }); + + if (gitBatchInfo.error) { + return ( + + Error occured while fetching information for "{project.owner}/ + {project.repo}" ({gitBatchInfo.error.message}) + + ); + } + + if (gitBatchInfo.loading) { + return ; + } + + if (gitBatchInfo.value === undefined) { + return Failed to fetch latest Git release; + } + + if (!gitBatchInfo.value.repository.pushPermissions) { + return ( + + You lack push permissions for repository "{project.owner}/{project.repo} + " + + ); + } + + const { tagNameError } = validateTagName({ + project, + tagName: gitBatchInfo.value.latestRelease?.tagName, + }); + if (tagNameError) { + return ( + + {tagNameError.title && {tagNameError.title}} + {tagNameError.subtitle} + + ); + } + + return ( + + + {gitBatchInfo.value.latestRelease && !versioningStrategyMatches && ( + + Versioning mismatch, expected {project.versioningStrategy} version, + got "{gitBatchInfo.value.latestRelease.tagName}" + + )} + + {!gitBatchInfo.value.latestRelease && ( + + This repository doesn't have any releases yet + + )} + + {!gitBatchInfo.value.releaseBranch && ( + + This repository doesn't have any release branches + + )} + + {!features?.info?.omit && ( + + )} + + {!features?.createRc?.omit && ( + + )} + + {!features?.promoteRc?.omit && ( + + )} + + {!features?.patch?.omit && ( + + )} + + + ); +} diff --git a/plugins/git-release-manager/src/features/Info/Info.test.tsx b/plugins/git-release-manager/src/features/Info/Info.test.tsx new file mode 100644 index 0000000000..4bff92b6ab --- /dev/null +++ b/plugins/git-release-manager/src/features/Info/Info.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockCalverProject, + mockReleaseBranch, + mockReleaseCandidateCalver, +} from '../../test-helpers/test-helpers'; +import { Info } from './Info'; + +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); + +describe('Info', () => { + it('should return early if no latestRelease exists', async () => { + const { findByText } = render( + , + ); + + expect(await findByText(mockReleaseBranch.name)).toMatchInlineSnapshot(` + + rc/1.2.3 + + `); + + expect( + await findByText(`${mockCalverProject.owner}/${mockCalverProject.repo}`), + ).toMatchInlineSnapshot(` + + mock_owner/mock_repo + + `); + + expect(await findByText(mockCalverProject.versioningStrategy)) + .toMatchInlineSnapshot(` + + calver + + `); + + expect(await findByText(mockReleaseCandidateCalver.tagName)) + .toMatchInlineSnapshot(` + + rc-2020.01.01_1 + + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Info/Info.tsx b/plugins/git-release-manager/src/features/Info/Info.tsx new file mode 100644 index 0000000000..670452a032 --- /dev/null +++ b/plugins/git-release-manager/src/features/Info/Info.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { Typography, Button, Box } from '@material-ui/core'; +import BarChartIcon from '@material-ui/icons/BarChart'; + +import { + GetBranchResult, + GetLatestReleaseResult, +} from '../../api/GitReleaseClient'; +import { Differ } from '../../components/Differ'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { Stats } from '../Stats/Stats'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import flowImage from './flow.png'; +import { Link } from '@backstage/core'; + +interface InfoCardProps { + releaseBranch: GetBranchResult['branch'] | null; + latestRelease: GetLatestReleaseResult['latestRelease']; + statsEnabled: boolean; +} + +export const Info = ({ + releaseBranch, + latestRelease, + statsEnabled, +}: InfoCardProps) => { + const { project } = useProjectContext(); + const [showStats, setShowStats] = useState(false); + + return ( + + + Terminology + + + Git: The source control system where releases reside + in a practical sense. Read more about{' '} + + Git releases + + . + + + + Release Candidate: A Git prerelease intended + primarily for internal testing + + + + Release Version: A Git release intended for end users + + + + + Flow + + + + Git Release Manager is built with a specific flow in mind. For + example, it assumes your project is configured to react to tags + prefixed with rc or version. + + + + + Here's an overview of the flow: + + + flow + + + + Details + + + Repository:{' '} + + + + + Versioning strategy:{' '} + + + + + Latest release branch:{' '} + + + + + Latest release: + + + + {statsEnabled && ( + + + + {showStats && } + + )} + + ); +}; diff --git a/plugins/git-release-manager/src/features/Info/flow.png b/plugins/git-release-manager/src/features/Info/flow.png new file mode 100644 index 0000000000..e70df0d7fa Binary files /dev/null and b/plugins/git-release-manager/src/features/Info/flow.png differ diff --git a/plugins/git-release-manager/src/features/Patch/Patch.test.tsx b/plugins/git-release-manager/src/features/Patch/Patch.test.tsx new file mode 100644 index 0000000000..458861c802 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/Patch.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockReleaseBranch, + mockCalverProject, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { Patch } from './Patch'; + +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); + +describe('Patch', () => { + it('should return early if no latestRelease exists', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.components.noLatestRelease), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/Patch/Patch.tsx b/plugins/git-release-manager/src/features/Patch/Patch.tsx new file mode 100644 index 0000000000..9ccb061696 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/Patch.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Typography, Box } from '@material-ui/core'; +import { Alert, AlertTitle } from '@material-ui/lab'; + +import { + GetBranchResult, + GetLatestReleaseResult, +} from '../../api/GitReleaseClient'; +import { ComponentConfigPatch } from '../../types/types'; +import { getBumpedTag } from '../../helpers/getBumpedTag'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { NoLatestRelease } from '../../components/NoLatestRelease'; +import { PatchBody } from './PatchBody'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +interface PatchProps { + latestRelease: GetLatestReleaseResult['latestRelease']; + releaseBranch: GetBranchResult['branch'] | null; + onSuccess?: ComponentConfigPatch['onSuccess']; +} + +export const Patch = ({ + latestRelease, + releaseBranch, + onSuccess, +}: PatchProps) => { + return ( + + + + Patch Release {latestRelease?.prerelease ? 'Candidate' : 'Version'} + + + + + + ); +}; + +function BodyWrapper({ latestRelease, releaseBranch, onSuccess }: PatchProps) { + const { project } = useProjectContext(); + + if (latestRelease === null) { + return ; + } + + if (releaseBranch === null) { + return ; + } + + const bumpedTag = getBumpedTag({ + project, + tag: latestRelease.tagName, + bumpLevel: 'patch', + }); + + if (bumpedTag.error !== undefined) { + return ( + + {bumpedTag.error.title && ( + {bumpedTag.error.title} + )} + + {bumpedTag.error.subtitle} + + ); + } + + return ( + + ); +} diff --git a/plugins/git-release-manager/src/features/Patch/PatchBody.test.tsx b/plugins/git-release-manager/src/features/Patch/PatchBody.test.tsx new file mode 100644 index 0000000000..e9dc978845 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/PatchBody.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; + +import { + mockApiClient, + mockBumpedTag, + mockCalverProject, + mockReleaseBranch, + mockReleaseCandidateCalver, + mockReleaseVersionCalver, + mockTagParts, +} from '../../test-helpers/test-helpers'; +import { PatchBody } from './PatchBody'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); +jest.mock('./hooks/usePatch', () => ({ + usePatch: () => ({ + run: jest.fn(), + responseSteps: [], + progress: 0, + }), +})); + +describe('PatchBody', () => { + beforeEach(jest.clearAllMocks); + + it('should render error', async () => { + (mockApiClient.getRecentCommits as jest.Mock).mockImplementationOnce(() => { + throw new Error('banana'); + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.patch.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getByTestId(TEST_IDS.patch.error)); + + expect(getByTestId(TEST_IDS.patch.error)).toBeInTheDocument(); + }); + + it('should render not-prerelease description', async () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.patch.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getByTestId(TEST_IDS.patch.notPrerelease)); + + expect(getByTestId(TEST_IDS.patch.notPrerelease)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/Patch/PatchBody.tsx b/plugins/git-release-manager/src/features/Patch/PatchBody.tsx new file mode 100644 index 0000000000..6f0f88ae46 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/PatchBody.tsx @@ -0,0 +1,314 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { useAsync } from 'react-use'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { + Box, + Button, + Checkbox, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + Paper, + Typography, +} from '@material-ui/core'; +import FileCopyIcon from '@material-ui/icons/FileCopy'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; +import { Link, Progress, useApi } from '@backstage/core'; + +import { + GetBranchResult, + GetLatestReleaseResult, +} from '../../api/GitReleaseClient'; +import { CalverTagParts } from '../../helpers/tagParts/getCalverTagParts'; +import { ComponentConfigPatch } from '../../types/types'; +import { Differ } from '../../components/Differ'; +import { getPatchCommitSuffix } from './helpers/getPatchCommitSuffix'; +import { gitReleaseManagerApiRef } from '../../api/serviceApiRef'; +import { GitReleaseManagerError } from '../../errors/GitReleaseManagerError'; +import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog'; +import { SemverTagParts } from '../../helpers/tagParts/getSemverTagParts'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { usePatch } from './hooks/usePatch'; +import { useProjectContext } from '../../contexts/ProjectContext'; + +interface PatchBodyProps { + bumpedTag: string; + latestRelease: NonNullable; + releaseBranch: GetBranchResult['branch']; + onSuccess?: ComponentConfigPatch['onSuccess']; + tagParts: NonNullable; +} + +export const PatchBody = ({ + bumpedTag, + latestRelease, + releaseBranch, + onSuccess, + tagParts, +}: PatchBodyProps) => { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const [checkedCommitIndex, setCheckedCommitIndex] = useState(-1); + + const gitDataResponse = useAsync(async () => { + const [ + { recentCommits: recentCommitsOnDefaultBranch }, + { recentCommits: recentCommitsOnReleaseBranch }, + ] = await Promise.all([ + pluginApiClient.getRecentCommits({ + owner: project.owner, + repo: project.repo, + }), + pluginApiClient.getRecentCommits({ + owner: project.owner, + repo: project.repo, + releaseBranchName: releaseBranch.name, + }), + ]); + + return { + recentCommitsOnDefaultBranch, + recentCommitsOnReleaseBranch, + }; + }); + + const { progress, responseSteps, run, runInvoked } = usePatch({ + bumpedTag, + latestRelease, + project, + tagParts, + onSuccess, + }); + + if (responseSteps.length > 0) { + return ( + + ); + } + + if (gitDataResponse.error) { + return ( + + Unexpected error: {gitDataResponse.error.message} + + ); + } + + if (gitDataResponse.loading) { + return ( + + + + ); + } + + function Description() { + return ( + <> + {!latestRelease.prerelease && ( + + + + The current Git release is a Release Version + + It's still possible to patch it, but be extra mindful of changes + + + )} + + + + + + + + ); + } + + function CommitList() { + if (!gitDataResponse.value?.recentCommitsOnDefaultBranch) { + return null; + } + + return ( + + {gitDataResponse.value.recentCommitsOnDefaultBranch.map( + (commit, index) => { + // FIXME: Performance improvement opportunity: Convert to object lookup + const commitExistsOnReleaseBranch = !!gitDataResponse.value?.recentCommitsOnReleaseBranch.find( + releaseBranchCommit => + releaseBranchCommit.sha === commit.sha || + // The selected patch commit's sha is included in the commit message, + // which means it's part of a previous patch + releaseBranchCommit.commit.message.includes( + getPatchCommitSuffix({ commitSha: commit.sha }), + ), + ); + const hasNoParent = !commit.firstParentSha; + + return ( +
+ {commitExistsOnReleaseBranch && ( + + {' '} + Already exists on {releaseBranch?.name} + + )} + + { + if (index === checkedCommitIndex) { + setCheckedCommitIndex(-1); + } else { + setCheckedCommitIndex(index); + } + }} + > + + + + + + + {commit.sha} + {' '} + {commit.author.htmlUrl && ( + + @{commit.author.login} + + )} + + } + /> + + + { + const repoPath = pluginApiClient.getRepoPath({ + owner: project.owner, + repo: project.repo, + }); + const host = pluginApiClient.getHost(); + + const newTab = window.open( + `https://${host}/${repoPath}/compare/${releaseBranch?.name}...${commit.sha}`, + '_blank', + ); + newTab?.focus(); + }} + > + + + + +
+ ); + }, + )} +
+ ); + } + + return ( + + + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/Patch/helpers/getPatchCommitSuffix.ts b/plugins/git-release-manager/src/features/Patch/helpers/getPatchCommitSuffix.ts new file mode 100644 index 0000000000..09b867fd25 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/helpers/getPatchCommitSuffix.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const getPatchCommitSuffix = ({ commitSha }: { commitSha: string }) => { + return `[Backstage patch ${commitSha}]`; +}; diff --git a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts new file mode 100644 index 0000000000..95f3e787fd --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { + mockApiClient, + mockBumpedTag, + mockCalverProject, + mockReleaseVersionCalver, + mockSelectedPatchCommit, + mockTagParts, + mockUser, +} from '../../../test-helpers/test-helpers'; +import { usePatch } from './usePatch'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); + +describe('patch', () => { + beforeEach(jest.clearAllMocks); + + it('should return the expected responseSteps and progress', async () => { + const { result } = renderHook(() => + usePatch({ + bumpedTag: mockBumpedTag, + latestRelease: mockReleaseVersionCalver, + project: mockCalverProject, + tagParts: mockTagParts, + }), + ); + + await act(async () => { + await waitFor(() => result.current.run(mockSelectedPatchCommit)); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(9); + }); + + it('should return the expected responseSteps and progress (with onSuccess)', async () => { + const { result } = renderHook(() => + usePatch({ + bumpedTag: mockBumpedTag, + latestRelease: mockReleaseVersionCalver, + project: mockCalverProject, + tagParts: mockTagParts, + onSuccess: jest.fn(), + }), + ); + + await act(async () => { + await waitFor(() => result.current.run(mockSelectedPatchCommit)); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(10); + expect(result.current).toMatchInlineSnapshot(` + Object { + "progress": 100, + "responseSteps": Array [ + Object { + "link": "https://mock_branch_links_html", + "message": "Fetched release branch \\"rc/1.2.3\\"", + }, + Object { + "message": "Created temporary commit", + "secondaryMessage": "with message \\"mock_commit_message\\"", + }, + Object { + "message": "Forced branch \\"rc/2020.01.01_1\\" to temporary commit \\"mock_commit_sha\\"", + }, + Object { + "link": "https://mock_merge_html_url", + "message": "Merged temporary commit into \\"rc/2020.01.01_1\\"", + "secondaryMessage": "with message \\"mock_merge_commit_message\\"", + }, + Object { + "message": "Cherry-picked patch commit to \\"mock_branch_commit_sha\\"", + "secondaryMessage": "with message \\"mock_commit_message\\"", + }, + Object { + "message": "Updated reference \\"mock_update_ref_ref\\"", + }, + Object { + "message": "Created new tag object", + "secondaryMessage": "with name \\"mock_tag_object_tag\\"", + }, + Object { + "message": "Created new reference \\"mock_createRef_ref\\"", + "secondaryMessage": "for tag object \\"mock_tag_object_tag\\"", + }, + Object { + "link": "https://mock_update_release_html_url", + "message": "Updated release \\"mock_update_release_name\\"", + "secondaryMessage": "with tag mock_update_release_tag_name", + }, + Object { + "icon": "success", + "message": "Success callback successfully called 🚀", + }, + ], + "run": [Function], + "runInvoked": true, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts new file mode 100644 index 0000000000..506f218842 --- /dev/null +++ b/plugins/git-release-manager/src/features/Patch/hooks/usePatch.ts @@ -0,0 +1,374 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { useApi } from '@backstage/core'; + +import { + GetLatestReleaseResult, + GetRecentCommitsResultSingle, +} from '../../../api/GitReleaseClient'; +import { CalverTagParts } from '../../../helpers/tagParts/getCalverTagParts'; +import { ComponentConfigPatch, CardHook } from '../../../types/types'; +import { getPatchCommitSuffix } from '../helpers/getPatchCommitSuffix'; +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { Project } from '../../../contexts/ProjectContext'; +import { SemverTagParts } from '../../../helpers/tagParts/getSemverTagParts'; +import { TAG_OBJECT_MESSAGE } from '../../../constants/constants'; +import { useResponseSteps } from '../../../hooks/useResponseSteps'; +import { useUserContext } from '../../../contexts/UserContext'; + +interface Patch { + bumpedTag: string; + latestRelease: NonNullable; + project: Project; + tagParts: NonNullable; + onSuccess?: ComponentConfigPatch['onSuccess']; +} + +// Inspiration: https://stackoverflow.com/questions/53859199/how-to-cherry-pick-through-githubs-api +export function usePatch({ + bumpedTag, + latestRelease, + project, + tagParts, + onSuccess, +}: Patch): CardHook { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); + const { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + } = useResponseSteps(); + + const releaseBranchName = latestRelease.targetCommitish; + + /** + * (1) Here is the branch we want to cherry-pick to: + * > branch = GET /repos/$owner/$repo/branches/$branchName + * > branchSha = branch.commit.sha + * > branchTree = branch.commit.commit.tree.sha + */ + const [releaseBranchRes, run] = useAsyncFn( + async (selectedPatchCommit: GetRecentCommitsResultSingle) => { + const { branch: releaseBranch } = await pluginApiClient + .getBranch({ + owner: project.owner, + repo: project.repo, + branch: releaseBranchName, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Fetched release branch "${releaseBranch.name}"`, + link: releaseBranch.links.html, + }); + + return { + releaseBranch, + selectedPatchCommit, + }; + }, + ); + + /** + * (2) Create a temporary commit on the branch, which extends as a sibling of + * the commit we want but contains the current tree of the target branch: + * > parentSha = commit.parents.head // first parent -- there should only be one + * > tempCommit = POST /repos/$owner/$repo/git/commits { "message": "temp", "tree": branchTree, "parents": [parentSha] } + */ + const tempCommitRes = useAsync(async () => { + abortIfError(releaseBranchRes.error); + if (!releaseBranchRes.value) return undefined; + + const { commit: tempCommit } = await pluginApiClient + .createCommit({ + owner: project.owner, + repo: project.repo, + message: `Temporary commit for patch ${tagParts.patch}`, + parents: [ + releaseBranchRes.value.selectedPatchCommit.firstParentSha ?? '', + ], + tree: releaseBranchRes.value.releaseBranch.commit.commit.tree.sha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created temporary commit', + secondaryMessage: `with message "${tempCommit.message}"`, + }); + + return { + ...tempCommit, + }; + }, [releaseBranchRes.value, releaseBranchRes.error]); + + /** + * (3) Now temporarily force the branch over to that commit: + * > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = tempCommit.sha, force = true } + */ + const forceBranchRes = useAsync(async () => { + abortIfError(tempCommitRes.error); + if (!tempCommitRes.value) return undefined; + + await pluginApiClient + .updateRef({ + owner: project.owner, + repo: project.repo, + sha: tempCommitRes.value.sha, + ref: `heads/${releaseBranchName}`, + force: true, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Forced branch "${releaseBranchName}" to temporary commit "${tempCommitRes.value.sha}"`, + }); + + return { + trigger: 'next step 🚀 ', + }; + }, [tempCommitRes.value, tempCommitRes.error]); + + /** + * (4) Merge the commit we want into this mess: + * > merge = POST /repos/$owner/$repo/merges { "base": branchName, "head": commit.sha } + */ + const mergeRes = useAsync(async () => { + abortIfError(forceBranchRes.error); + if (!forceBranchRes.value || !releaseBranchRes.value) return undefined; + + const { merge } = await pluginApiClient + .merge({ + owner: project.owner, + repo: project.repo, + base: releaseBranchName, + head: releaseBranchRes.value.selectedPatchCommit.sha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Merged temporary commit into "${releaseBranchName}"`, + secondaryMessage: `with message "${merge.commit.message}"`, + link: merge.htmlUrl, + }); + + return { + ...merge, + }; + }, [forceBranchRes.value, forceBranchRes.error]); + + /** + * (5) Now that we know what the tree should be, create the cherry-pick commit. + * Note that branchSha is the original from up at the top. + * > cherry = POST /repos/$owner/$repo/git/commits { "message": "looks good!", "tree": mergeTree, "parents": [branchSha] } + */ + const cherryPickRes = useAsync(async () => { + abortIfError(mergeRes.error); + if (!mergeRes.value || !releaseBranchRes.value) return undefined; + + const releaseBranchSha = releaseBranchRes.value.releaseBranch.commit.sha; + const selectedPatchCommit = releaseBranchRes.value.selectedPatchCommit; + + const { commit: cherryPickCommit } = await pluginApiClient.createCommit({ + owner: project.owner, + repo: project.repo, + message: `[patch ${bumpedTag}] ${selectedPatchCommit.commit.message} + + ${getPatchCommitSuffix({ + commitSha: selectedPatchCommit.sha, + })}`, + parents: [releaseBranchSha], + tree: mergeRes.value.commit.tree.sha, + }); + + addStepToResponseSteps({ + message: `Cherry-picked patch commit to "${releaseBranchSha}"`, + secondaryMessage: `with message "${cherryPickCommit.message}"`, + }); + + return { + ...cherryPickCommit, + }; + }, [mergeRes.value, mergeRes.error]); + + /** + * (6) Replace the temp commit with the real commit: + * > PATCH /repos/$owner/$repo/git/refs/heads/$refName { sha = cherry.sha, force = true } + */ + const updatedRefRes = useAsync(async () => { + abortIfError(cherryPickRes.error); + if (!cherryPickRes.value) return undefined; + + const { reference: updatedReference } = await pluginApiClient + .updateRef({ + owner: project.owner, + repo: project.repo, + ref: `heads/${releaseBranchName}`, + sha: cherryPickRes.value.sha, + force: true, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Updated reference "${updatedReference.ref}"`, + }); + + return { + ...updatedReference, + }; + }, [cherryPickRes.value, cherryPickRes.error]); + + /** + * (7) Create tag object: https://developer.github.com/v3/git/tags/#create-a-tag-object + * > POST /repos/:owner/:repo/git/tags + */ + const createdTagObjRes = useAsync(async () => { + abortIfError(updatedRefRes.error); + if (!updatedRefRes.value) return undefined; + + const { tagObject } = await pluginApiClient + .createTagObject({ + owner: project.owner, + repo: project.repo, + tag: bumpedTag, + object: updatedRefRes.value.object.sha, + message: TAG_OBJECT_MESSAGE, + taggerName: user.username, + taggerEmail: user.email, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created new tag object', + secondaryMessage: `with name "${tagObject.tagName}"`, + }); + + return { + ...tagObject, + }; + }, [updatedRefRes.value, updatedRefRes.error]); + + /** + * (8) Create a reference: https://developer.github.com/v3/git/refs/#create-a-reference + * > POST /repos/:owner/:repo/git/refs + */ + const createdReferenceRes = useAsync(async () => { + abortIfError(createdTagObjRes.error); + if (!createdTagObjRes.value) return undefined; + + const { reference } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + ref: `refs/tags/${bumpedTag}`, + sha: createdTagObjRes.value.tagSha, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Created new reference "${reference.ref}"`, + secondaryMessage: `for tag object "${createdTagObjRes.value.tagName}"`, + }); + + return { + ...reference, + }; + }, [createdTagObjRes.value, createdTagObjRes.error]); + + /** + * (9) Update release + */ + const updatedReleaseRes = useAsync(async () => { + abortIfError(createdReferenceRes.error); + if (!createdReferenceRes.value || !releaseBranchRes.value) return undefined; + + const selectedPatchCommit = releaseBranchRes.value.selectedPatchCommit; + + const { release } = await pluginApiClient + .updateRelease({ + owner: project.owner, + repo: project.repo, + releaseId: latestRelease.id, + tagName: bumpedTag, + body: `${latestRelease.body} + +#### [Patch ${tagParts.patch}](${selectedPatchCommit.htmlUrl}) + +${selectedPatchCommit.commit.message}`, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Updated release "${release.name}"`, + secondaryMessage: `with tag ${release.tagName}`, + link: release.htmlUrl, + }); + + return { + ...release, + }; + }, [createdReferenceRes.value, createdReferenceRes.error]); + + /** + * (10) Run onSuccess if defined + */ + useAsync(async () => { + if (!onSuccess) return; + abortIfError(updatedReleaseRes.error); + + if (!updatedReleaseRes.value || !releaseBranchRes.value) return; + + try { + await onSuccess?.({ + updatedReleaseUrl: updatedReleaseRes.value.htmlUrl, + updatedReleaseName: updatedReleaseRes.value.name, + previousTag: latestRelease.tagName, + patchedTag: updatedReleaseRes.value.tagName, + patchCommitUrl: releaseBranchRes.value.selectedPatchCommit.htmlUrl, + patchCommitMessage: + releaseBranchRes.value.selectedPatchCommit.commit.message, + }); + } catch (error) { + asyncCatcher(error); + } + + addStepToResponseSteps({ + message: 'Success callback successfully called 🚀', + icon: 'success', + }); + }, [updatedReleaseRes.value, updatedReleaseRes.error]); + + const TOTAL_STEPS = 9 + (!!onSuccess ? 1 : 0); + const [progress, setProgress] = useState(0); + useEffect(() => { + setProgress((responseSteps.length / TOTAL_STEPS) * 100); + }, [TOTAL_STEPS, responseSteps.length]); + + return { + progress, + responseSteps, + run, + runInvoked: Boolean( + releaseBranchRes.loading || + releaseBranchRes.value || + releaseBranchRes.error, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.test.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.test.tsx new file mode 100644 index 0000000000..089b626da3 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockReleaseCandidateCalver, + mockReleaseVersionCalver, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { PromoteRc } from './PromoteRc'; + +jest.mock('./PromoteRcBody', () => ({ + PromoteRcBody: () => ( +
Hello
+ ), +})); + +describe('PromoteRc', () => { + it('return early if no latest release present', () => { + const { getByTestId } = render(); + + expect( + getByTestId(TEST_IDS.components.noLatestRelease), + ).toBeInTheDocument(); + }); + + it('should display not-rc warning', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.promoteRc.notRcWarning)).toBeInTheDocument(); + }); + + it('should display PromoteRcBody', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(TEST_IDS.promoteRc.mockedPromoteRcBody), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.tsx new file mode 100644 index 0000000000..ecb584a6f4 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRc.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import { Box, Typography } from '@material-ui/core'; + +import { ComponentConfigPromoteRc } from '../../types/types'; +import { GetLatestReleaseResult } from '../../api/GitReleaseClient'; +import { InfoCardPlus } from '../../components/InfoCardPlus'; +import { NoLatestRelease } from '../../components/NoLatestRelease'; +import { PromoteRcBody } from './PromoteRcBody'; +import { TEST_IDS } from '../../test-helpers/test-ids'; + +interface PromoteRcProps { + latestRelease: GetLatestReleaseResult['latestRelease']; + onSuccess?: ComponentConfigPromoteRc['onSuccess']; +} + +export const PromoteRc = ({ latestRelease, onSuccess }: PromoteRcProps) => { + function Body() { + if (latestRelease === null) { + return ; + } + + if (!latestRelease.prerelease) { + return ( + + + + Latest Git release is not a Release Candidate + + One can only promote Release Candidates to Release Versions + + + ); + } + + return ; + } + + return ( + + + Promote Release Candidate + + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.test.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.test.tsx new file mode 100644 index 0000000000..14b294e581 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { mockReleaseCandidateCalver } from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { PromoteRcBody } from './PromoteRcBody'; + +jest.mock('./hooks/usePromoteRc', () => ({ + usePromoteRc: () => ({ + run: jest.fn(), + responseSteps: [], + progress: 0, + }), +})); + +describe('PromoteRcBody', () => { + it('should display CTA', () => { + const { getByTestId } = render( + , + ); + + expect(getByTestId(TEST_IDS.promoteRc.cta)).toBeInTheDocument(); + }); +}); diff --git a/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.tsx b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.tsx new file mode 100644 index 0000000000..29e1de52cd --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/PromoteRcBody.tsx @@ -0,0 +1,80 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Button, Typography, Box } from '@material-ui/core'; + +import { ComponentConfigPromoteRc } from '../../types/types'; +import { Differ } from '../../components/Differ'; +import { GetLatestReleaseResult } from '../../api/GitReleaseClient'; +import { ResponseStepDialog } from '../../components/ResponseStepDialog/ResponseStepDialog'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { usePromoteRc } from './hooks/usePromoteRc'; + +interface PromoteRcBodyProps { + rcRelease: NonNullable; + onSuccess?: ComponentConfigPromoteRc['onSuccess']; +} + +export const PromoteRcBody = ({ rcRelease, onSuccess }: PromoteRcBodyProps) => { + const releaseVersion = rcRelease.tagName.replace('rc-', 'version-'); + + const { progress, responseSteps, run, runInvoked } = usePromoteRc({ + rcRelease, + releaseVersion, + onSuccess, + }); + + if (responseSteps.length > 0) { + return ( + + ); + } + + return ( + <> + + + Promotes the current Release Candidate to a Release Version. + + + + + + + + + + + + ); +}; diff --git a/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.test.ts b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.test.ts new file mode 100644 index 0000000000..1c4e794227 --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockReleaseCandidateCalver, + mockUser, +} from '../../../test-helpers/test-helpers'; +import { usePromoteRc } from './usePromoteRc'; + +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockCalverProject, + }), +})); +jest.mock('../../../contexts/UserContext', () => ({ + useUserContext: () => ({ user: mockUser }), +})); + +describe('usePromoteRc', () => { + beforeEach(jest.clearAllMocks); + + it('should return the expected responseSteps and progress', async () => { + const { result } = renderHook(() => + usePromoteRc({ + rcRelease: mockReleaseCandidateCalver, + releaseVersion: 'version-1.2.3', + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.error).toEqual(undefined); + expect(result.current.responseSteps).toHaveLength(4); + }); + + it('should return the expected responseSteps and progress (with onSuccess)', async () => { + const { result } = renderHook(() => + usePromoteRc({ + rcRelease: mockReleaseCandidateCalver, + releaseVersion: 'version-1.2.3', + onSuccess: jest.fn(), + }), + ); + + await act(async () => { + await waitFor(() => result.current.run()); + }); + + expect(result.current.responseSteps).toHaveLength(5); + expect(result.current).toMatchInlineSnapshot(` + Object { + "progress": 100, + "responseSteps": Array [ + Object { + "message": "Fetched most recent commit from release branch", + "secondaryMessage": "with sha \\"latestCommit.sha\\"", + }, + Object { + "message": "Created Tag Object", + "secondaryMessage": "with sha \\"mock_tag_object_sha\\"", + }, + Object { + "message": "Create Tag Reference", + "secondaryMessage": "with ref \\"mock_createRef_ref\\"", + }, + Object { + "link": "https://mock_update_release_html_url", + "message": "Promoted \\"mock_update_release_name\\"", + "secondaryMessage": "from \\"rc-2020.01.01_1\\" to \\"mock_update_release_tag_name\\"", + }, + Object { + "icon": "success", + "message": "Success callback successfully called 🚀", + }, + ], + "run": [Function], + "runInvoked": true, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.ts b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.ts new file mode 100644 index 0000000000..8b1ffa089b --- /dev/null +++ b/plugins/git-release-manager/src/features/PromoteRc/hooks/usePromoteRc.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, useEffect } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { useApi } from '@backstage/core'; + +import { CardHook, ComponentConfigPromoteRc } from '../../../types/types'; +import { GetLatestReleaseResult } from '../../../api/GitReleaseClient'; +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; +import { TAG_OBJECT_MESSAGE } from '../../../constants/constants'; +import { useProjectContext } from '../../../contexts/ProjectContext'; +import { useResponseSteps } from '../../../hooks/useResponseSteps'; +import { useUserContext } from '../../../contexts/UserContext'; + +interface PromoteRc { + rcRelease: NonNullable; + releaseVersion: string; + onSuccess?: ComponentConfigPromoteRc['onSuccess']; +} + +export function usePromoteRc({ + rcRelease, + releaseVersion, + onSuccess, +}: PromoteRc): CardHook { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { user } = useUserContext(); + const { project } = useProjectContext(); + const { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + } = useResponseSteps(); + + /** + * (1) Fetch most recent release branch commit + */ + const [latestReleaseBranchCommitSha, run] = useAsyncFn(async () => { + const { commit: latestCommit } = await pluginApiClient + .getCommit({ + owner: project.owner, + repo: project.repo, + ref: rcRelease.targetCommitish, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Fetched most recent commit from release branch', + secondaryMessage: `with sha "${latestCommit.sha}"`, + }); + + return { + ...latestCommit, + }; + }); + + /** + * (2) Create tag object for our soon-to-be-created annotated tag + */ + const tagObjectRes = useAsync(async () => { + abortIfError(latestReleaseBranchCommitSha.error); + if (!latestReleaseBranchCommitSha.value) return undefined; + + const { tagObject } = await pluginApiClient + .createTagObject({ + owner: project.owner, + repo: project.repo, + tag: releaseVersion, + object: latestReleaseBranchCommitSha.value.sha, + taggerName: user.username, + taggerEmail: user.email, + message: TAG_OBJECT_MESSAGE, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Created Tag Object', + secondaryMessage: `with sha "${tagObject.tagSha}"`, + }); + + return { + ...tagObject, + }; + }, [latestReleaseBranchCommitSha.value, latestReleaseBranchCommitSha.error]); + + /** + * (3) Create reference for tag object + */ + const createRcRes = useAsync(async () => { + abortIfError(tagObjectRes.error); + if (!tagObjectRes.value) return undefined; + + const { reference: createdRef } = await pluginApiClient + .createRef({ + owner: project.owner, + repo: project.repo, + ref: `refs/tags/${releaseVersion}`, + sha: tagObjectRes.value.tagSha, + }) + .catch(error => { + if (error?.body?.message === 'Reference already exists') { + throw new GitReleaseManagerError( + `Tag reference "${releaseVersion}" already exists`, + ); + } + throw error; + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: 'Create Tag Reference', + secondaryMessage: `with ref "${createdRef.ref}"`, + }); + + return { + ...createdRef, + }; + }, [tagObjectRes.value, tagObjectRes.error]); + + /** + * (4) Promote Release Candidate to Release Version + */ + const promotedReleaseRes = useAsync(async () => { + abortIfError(createRcRes.error); + if (!createRcRes.value) return undefined; + + const { release } = await pluginApiClient + .updateRelease({ + owner: project.owner, + repo: project.repo, + releaseId: rcRelease.id, + tagName: releaseVersion, + prerelease: false, + }) + .catch(asyncCatcher); + + addStepToResponseSteps({ + message: `Promoted "${release.name}"`, + secondaryMessage: `from "${rcRelease.tagName}" to "${release.tagName}"`, + link: release.htmlUrl, + }); + + return { + ...release, + }; + }, [createRcRes.value, createRcRes.error]); + + /** + * (5) Run onSuccess if defined + */ + useAsync(async () => { + if (onSuccess && !!promotedReleaseRes.value) { + abortIfError(promotedReleaseRes.error); + + try { + await onSuccess?.({ + gitReleaseUrl: promotedReleaseRes.value.htmlUrl, + gitReleaseName: promotedReleaseRes.value.name, + previousTagUrl: rcRelease.htmlUrl, + previousTag: rcRelease.tagName, + updatedTagUrl: promotedReleaseRes.value.htmlUrl, + updatedTag: promotedReleaseRes.value.tagName, + }); + } catch (error) { + asyncCatcher(error); + } + + addStepToResponseSteps({ + message: 'Success callback successfully called 🚀', + icon: 'success', + }); + } + }, [promotedReleaseRes.value, promotedReleaseRes.error]); + + const TOTAL_STEPS = 4 + (!!onSuccess ? 1 : 0); + const [progress, setProgress] = useState(0); + useEffect(() => { + setProgress((responseSteps.length / TOTAL_STEPS) * 100); + }, [TOTAL_STEPS, responseSteps.length]); + + return { + progress, + responseSteps, + run, + runInvoked: Boolean( + promotedReleaseRes.loading || + promotedReleaseRes.value || + promotedReleaseRes.error, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.test.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.test.tsx new file mode 100644 index 0000000000..bbe2e23e06 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockSearchCalver, + mockUser, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { Owner } from './Owner'; + +jest.mock('react-router', () => ({ + useNavigate: jest.fn(), + useLocation: jest.fn(() => ({ + search: mockSearchCalver, + })), +})); +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: jest.fn(() => ({ + project: mockCalverProject, + })), +})); +jest.mock('../../contexts/UserContext', () => ({ + useUserContext: jest.fn(() => ({ + user: mockUser, + })), +})); + +describe('Owner', () => { + beforeEach(jest.clearAllMocks); + + it('should render select', async () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getByTestId(TEST_IDS.form.owner.select)); + expect(getByTestId(TEST_IDS.form.owner.select)).toBeInTheDocument(); + }); + + it('should render select for empty owners', async () => { + (useProjectContext as jest.Mock).mockReturnValue({ + project: { ...mockCalverProject, owner: '' }, + }); + + const { getAllByTestId, getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getAllByTestId(TEST_IDS.form.owner.empty)); + expect(getAllByTestId(TEST_IDS.form.owner.empty)).toMatchInlineSnapshot(` + Array [ +

+ Select an owner (org or user) +

, +

+ Custom queries can be made via the query param + + + owner + +

, + ] + `); + }); + + it('should handle errors', async () => { + (mockApiClient.getOwners as jest.Mock).mockImplementationOnce(async () => { + throw new Error('Kaboom'); + }); + + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.owner.loading)).toBeInTheDocument(); + await waitFor(() => screen.getByTestId(TEST_IDS.form.owner.error)); + expect(getByTestId(TEST_IDS.form.owner.error)).toMatchInlineSnapshot(` +

+ Encountered an error ( + Kaboom + ) +

+ `); + }); +}); diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.tsx new file mode 100644 index 0000000000..5d6ea4f75d --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Owner.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useNavigate } from 'react-router'; +import { useAsync } from 'react-use'; +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + Box, +} from '@material-ui/core'; +import { Progress, useApi } from '@backstage/core'; + +import { gitReleaseManagerApiRef } from '../../api/serviceApiRef'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useFormClasses } from './styles'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useQueryHandler } from '../../hooks/useQueryHandler'; +import { useUserContext } from '../../contexts/UserContext'; + +export function Owner() { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const { user } = useUserContext(); + const formClasses = useFormClasses(); + const navigate = useNavigate(); + const { getQueryParamsWithUpdates } = useQueryHandler(); + + const { loading, error, value } = useAsync(() => pluginApiClient.getOwners()); + const owners = value?.owners ?? []; + const customOwnerFromUrl = !owners + .concat(['', user.username]) + .includes(project.owner); + + return ( + + {loading ? ( + + + + ) : ( + <> + Owners + + + {error && ( + + Encountered an error ({error.message}) + + )} + + {!error && project.owner.length === 0 && ( + <> + + Select an owner (org or user) + + + Custom queries can be made via the query param{' '} + owner + + + )} + + )} + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.test.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.test.tsx new file mode 100644 index 0000000000..fe942d6c54 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; + +import { + mockApiClient, + mockCalverProject, + mockSearchCalver, +} from '../../test-helpers/test-helpers'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { Repo } from './Repo'; + +jest.mock('react-router', () => ({ + useNavigate: jest.fn(), + useLocation: jest.fn(() => ({ + search: mockSearchCalver, + })), +})); +jest.mock('@backstage/core', () => ({ + ...jest.requireActual('@backstage/core'), + useApi: () => mockApiClient, +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: jest.fn(() => ({ + project: mockCalverProject, + })), +})); + +describe('Repo', () => { + beforeEach(jest.clearAllMocks); + + it('should render select', async () => { + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getByTestId(TEST_IDS.form.repo.select)); + expect(getByTestId(TEST_IDS.form.repo.select)).toBeInTheDocument(); + }); + + it('should render select for empty repo', async () => { + (useProjectContext as jest.Mock).mockReturnValue({ + project: { ...mockCalverProject, repo: '' }, + }); + + const { getAllByTestId, getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument(); + + await waitFor(() => screen.getAllByTestId(TEST_IDS.form.repo.empty)); + expect(getAllByTestId(TEST_IDS.form.repo.empty)).toMatchInlineSnapshot(` + Array [ +

+ Select a repository +

, +

+ Custom queries can be made via the query param + + + repo + +

, + ] + `); + }); + + it('should handle errors', async () => { + (mockApiClient.getRepositories as jest.Mock).mockImplementationOnce( + async () => { + throw new Error('Kaboom'); + }, + ); + + const { getByTestId } = render(); + + expect(getByTestId(TEST_IDS.form.repo.loading)).toBeInTheDocument(); + await waitFor(() => screen.getByTestId(TEST_IDS.form.repo.error)); + expect(getByTestId(TEST_IDS.form.repo.error)).toMatchInlineSnapshot(` +

+ Encountered an error ( + Kaboom + ") +

+ `); + }); +}); diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.tsx new file mode 100644 index 0000000000..bc713709b1 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/Repo.tsx @@ -0,0 +1,122 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useAsync } from 'react-use'; +import { useNavigate } from 'react-router'; +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + Box, +} from '@material-ui/core'; +import { Progress, useApi } from '@backstage/core'; + +import { gitReleaseManagerApiRef } from '../../api/serviceApiRef'; +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useFormClasses } from './styles'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useQueryHandler } from '../../hooks/useQueryHandler'; + +export function Repo() { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const navigate = useNavigate(); + const formClasses = useFormClasses(); + const { getQueryParamsWithUpdates } = useQueryHandler(); + + const { loading, error, value } = useAsync( + async () => pluginApiClient.getRepositories({ owner: project.owner }), + [project.owner], + ); + + if (project.owner.length === 0) { + return null; + } + + const repositories = value?.repositories ?? []; + const customRepoFromUrl = !repositories.concat(['']).includes(project.repo); + + return ( + + {loading ? ( + + + + ) : ( + <> + Repositories + + + {error && ( + + Encountered an error ({error.message}") + + )} + + {!error && project.repo.length === 0 && ( + <> + + Select a repository + + + Custom queries can be made via the query param{' '} + repo + + + )} + + )} + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/RepoDetailsForm.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/RepoDetailsForm.tsx new file mode 100644 index 0000000000..e77f73e0f6 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/RepoDetailsForm.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Owner } from './Owner'; +import { Repo } from './Repo'; +import { VersioningStrategy } from './VersioningStrategy'; + +export function RepoDetailsForm() { + return ( + <> + + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.test.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.test.tsx new file mode 100644 index 0000000000..42a17f9b43 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { + mockSemverProject, + mockSearchCalver, +} from '../../test-helpers/test-helpers'; +import { VersioningStrategy } from './VersioningStrategy'; + +const mockNavigate = jest.fn(); + +jest.mock('react-router', () => ({ + useNavigate: () => mockNavigate, + useLocation: jest.fn(() => ({ + search: mockSearchCalver, + })), +})); +jest.mock('../../contexts/ProjectContext', () => ({ + useProjectContext: () => ({ + project: mockSemverProject, + }), +})); + +describe('Repo', () => { + beforeEach(jest.clearAllMocks); + + it('should render radio group with default values and handle changes', async () => { + const { getByLabelText } = render(); + + const radio1 = getByLabelText('Semantic versioning'); + const radio2 = getByLabelText('Calendar versioning'); + + expect(radio1).toBeChecked(); + expect(radio2).not.toBeChecked(); + + fireEvent.click(radio2); + expect(mockNavigate.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "?versioningStrategy=calver&owner=mock_owner&repo=mock_repo", + Object { + "replace": true, + }, + ], + ] + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.tsx b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.tsx new file mode 100644 index 0000000000..67ca47f566 --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/VersioningStrategy.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { + FormControl, + FormControlLabel, + FormLabel, + Radio, + RadioGroup, +} from '@material-ui/core'; + +import { TEST_IDS } from '../../test-helpers/test-ids'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useQueryHandler } from '../../hooks/useQueryHandler'; +import { VERSIONING_STRATEGIES } from '../../constants/constants'; + +export function VersioningStrategy() { + const navigate = useNavigate(); + const { project } = useProjectContext(); + const { getParsedQuery, getQueryParamsWithUpdates } = useQueryHandler(); + + useEffect(() => { + const { parsedQuery } = getParsedQuery(); + + if (!parsedQuery.versioningStrategy && !project.isProvidedViaProps) { + const { queryParams } = getQueryParamsWithUpdates({ + updates: [ + { key: 'versioningStrategy', value: project.versioningStrategy }, + ], + }); + + navigate(`?${queryParams}`, { replace: true }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + Versioning strategy + + { + const { queryParams } = getQueryParamsWithUpdates({ + updates: [{ key: 'versioningStrategy', value: event.target.value }], + }); + + navigate(`?${queryParams}`, { replace: true }); + }} + > + } + label="Semantic versioning" + /> + + } + label="Calendar versioning" + /> + + + ); +} diff --git a/plugins/git-release-manager/src/features/RepoDetailsForm/styles.ts b/plugins/git-release-manager/src/features/RepoDetailsForm/styles.ts new file mode 100644 index 0000000000..274d0523ac --- /dev/null +++ b/plugins/git-release-manager/src/features/RepoDetailsForm/styles.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createStyles, makeStyles, Theme } from '@material-ui/core'; + +export const useFormClasses = makeStyles((theme: Theme) => + createStyles({ + formControl: { + margin: theme.spacing(1), + minWidth: 120, + }, + selectEmpty: { + marginTop: theme.spacing(2), + }, + }), +); diff --git a/plugins/git-release-manager/src/features/Stats/DialogBody.tsx b/plugins/git-release-manager/src/features/Stats/DialogBody.tsx new file mode 100644 index 0000000000..219bc2a146 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/DialogBody.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert } from '@material-ui/lab'; +import { + makeStyles, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@material-ui/core'; +import { Progress } from '@backstage/core'; + +import { getMappedReleases } from './helpers/getMappedReleases'; +import { getReleaseStats } from './helpers/getReleaseStats'; +import { Info } from './Info/Info'; +import { ReleaseStatsContext } from './contexts/ReleaseStatsContext'; +import { Row } from './Row/Row'; +import { useGetStats } from './hooks/useGetStats'; +import { useProjectContext } from '../../contexts/ProjectContext'; +import { Warn } from './Warn'; + +const useStyles = makeStyles({ + table: { + minWidth: 650, + }, +}); + +export function DialogBody() { + const classes = useStyles(); + const { stats } = useGetStats(); + const { project } = useProjectContext(); + + if (stats.error) { + return ( + Unexpected error: {stats.error.message} + ); + } + + if (stats.loading) { + return ; + } + + if (!stats.value) { + return Couldn't find any stats :(; + } + + const { allReleases, allTags } = stats.value; + const { mappedReleases } = getMappedReleases({ allReleases, project }); + const { releaseStats } = getReleaseStats({ + mappedReleases, + allTags, + project, + }); + + return ( + + + + + + + + + Release + Created at + # candidate patches + # release patches + + + + + {Object.entries(releaseStats.releases).map( + ([baseVersion, releaseStat], index) => { + return ( + + ); + }, + )} + +
+ + {(releaseStats.unmappableTags.length > 0 || + releaseStats.unmatchedTags.length > 0 || + releaseStats.unmatchedReleases.length > 0) && } +
+
+ ); +} diff --git a/plugins/git-release-manager/src/features/Stats/DialogTitle.tsx b/plugins/git-release-manager/src/features/Stats/DialogTitle.tsx new file mode 100644 index 0000000000..92e8d0d588 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/DialogTitle.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + createStyles, + IconButton, + Theme, + Typography, + withStyles, + WithStyles, +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import MuiDialogTitle from '@material-ui/core/DialogTitle'; + +import { Stats } from './Stats'; + +interface DialogTitleProps extends WithStyles { + children: React.ReactNode; + setShowStats: React.ComponentProps['setShowStats']; +} + +const styles = (theme: Theme) => + createStyles({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, + }); + +export const DialogTitle = withStyles(styles)((props: DialogTitleProps) => { + const { children, classes, setShowStats, ...other } = props; + + return ( + + {children} + setShowStats(false)} + > + + + + ); +}); diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx new file mode 100644 index 0000000000..659956ddb1 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/AverageReleaseTime.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; + +export function AverageReleaseTime({ + averageReleaseTime, +}: { + averageReleaseTime: ReturnType< + typeof useGetReleaseTimes + >['averageReleaseTime']; +}) { + if (averageReleaseTime.length === 0) { + return <>-; + } + + const average = averageReleaseTime.reduce( + (acc, { daysWithHours }) => { + acc.daysWithHours += daysWithHours / averageReleaseTime.length; + return acc; + }, + { daysWithHours: 0 }, + ); + + const days = Math.floor(average.daysWithHours); + const hours = getDecimalNumber((average.daysWithHours - days) * 24, 1); + + return ( + <> + {days} days {hours} hours + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx new file mode 100644 index 0000000000..9fbd2bc925 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/InDepth.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Box, + Button, + Tooltip as MaterialTooltip, + Typography, +} from '@material-ui/core'; +import { BarChart, Bar, XAxis, YAxis, Legend, Tooltip } from 'recharts'; + +import { AverageReleaseTime } from './AverageReleaseTime'; +import { LinearProgressWithLabel } from '../../../../components/ResponseStepDialog/LinearProgressWithLabel'; +import { LongestReleaseTime } from './LongestReleaseTime'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; +import { useReleaseStatsContext } from '../../contexts/ReleaseStatsContext'; + +export function InDepth() { + const { releaseStats } = useReleaseStatsContext(); + const { + averageReleaseTime, + progress, + releaseCommitPairs, + run, + } = useGetReleaseTimes(); + + const skipped = + Object.keys(releaseStats.releases).length - releaseCommitPairs.length; + + return ( + + + In-depth + + + + + + Release time + + + Release time is derived by comparing{' '} + createdAt of the commits belonging to the first and last + tag of each release. Releases without patches will have tags + pointing towards the same commit and will thus be omitted. This + project will omit {skipped} out of the total{' '} + {Object.keys(releaseStats.releases).length} releases. + + + + + + + In numbers + + + Average release time:{' '} + + + + + Longest release:{' '} + + + + + + {progress === 0 && ( + + + + )} + + + + + + 0 + ? averageReleaseTime + : [{ version: 'x.y.z', days: 0 }] + } + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} + layout="vertical" + > + + + + + + + + {progress > 0 && progress < 100 && ( + + + + )} + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx b/plugins/git-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx new file mode 100644 index 0000000000..7fd5dbd572 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/InDepth/LongestReleaseTime.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { useGetReleaseTimes } from '../hooks/useGetReleaseTimes'; + +export function LongestReleaseTime({ + averageReleaseTime, +}: { + averageReleaseTime: ReturnType< + typeof useGetReleaseTimes + >['averageReleaseTime']; +}) { + if (averageReleaseTime.length === 0) { + return <>-; + } + + const longestRelease = [...averageReleaseTime].sort( + (a, b) => b.daysWithHours - a.daysWithHours, + )[0]; + + return ( + <> + {longestRelease.version} ({longestRelease.days} days{' '} + {getDecimalNumber(longestRelease.hours, 1)} hours ) + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/Info.tsx b/plugins/git-release-manager/src/features/Stats/Info/Info.tsx new file mode 100644 index 0000000000..1c21f66e2c --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/Info.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Paper } from '@material-ui/core'; + +import { InDepth } from './InDepth/InDepth'; +import { Summary } from './Summary'; + +export function Info() { + return ( + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/Summary.tsx b/plugins/git-release-manager/src/features/Stats/Info/Summary.tsx new file mode 100644 index 0000000000..ff872dfffc --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/Summary.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Box, + makeStyles, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@material-ui/core'; + +import { getDecimalNumber } from '../helpers/getDecimalNumber'; +import { getSummary } from '../helpers/getSummary'; +import { useReleaseStatsContext } from '../contexts/ReleaseStatsContext'; + +const useStyles = makeStyles({ + table: { + minWidth: 650, + }, +}); + +export function Summary() { + const { releaseStats } = useReleaseStatsContext(); + const { summary } = getSummary({ releaseStats }); + const classes = useStyles(); + + return ( + + Summary + + + Total releases: {summary.totalReleases} + + + + + + + + Patches + Patches per release + + + + + + + Release Candidate + + {summary.totalCandidatePatches} + + {getDecimalNumber( + summary.totalCandidatePatches / summary.totalReleases, + )} + + + + + + Release Version + + {summary.totalVersionPatches} + + {getDecimalNumber( + summary.totalVersionPatches / summary.totalReleases, + )} + + + + + + Total + + + {summary.totalCandidatePatches + summary.totalVersionPatches} + + + {getDecimalNumber( + (summary.totalCandidatePatches + + summary.totalVersionPatches) / + summary.totalReleases, + )} + + + +
+
+
+ ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx new file mode 100644 index 0000000000..2e7f24c194 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.test.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getReleaseCommitPairs } from './getReleaseCommitPairs'; + +describe('getReleaseCommitPairs', () => { + it('should work', () => { + const nonPublishedRelease = { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.0.0', + tagSha: 'sha-1.0.0', + tagType: 'tag' as const, + }, + { + tagName: 'rc-1.0.1', + tagSha: 'sha-1.0.1', + tagType: 'tag' as const, + }, + ], + versions: [], + }; + + const releaseWithoutPatches = { + baseVersion: '2.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-2.0.0', + tagSha: 'sha-2.0.0', + tagType: 'tag' as const, + }, + ], + versions: [ + { + tagName: 'version-2.0.0', + tagSha: 'sha-2.0.0', + tagType: 'tag' as const, + }, + ], + }; + + const releaseWithPatches = { + baseVersion: '3.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-3.0.1', + tagSha: 'sha-3.0.1', + tagType: 'tag' as const, + }, + { + tagName: 'rc-3.0.0', + tagSha: 'sha-3.0.0', + tagType: 'tag' as const, + }, + ], + versions: [ + { + tagName: 'version-3.0.1', + tagSha: 'sha-3.0.1', + tagType: 'tag' as const, + }, + ], + }; + + const result = getReleaseCommitPairs({ + releaseStats: { + releases: { + nonPublishedRelease, // Should be omitted + releaseWithoutPatches, // Should be omitted + releaseWithPatches, + }, + unmatchedReleases: [], + unmappableTags: [], + unmatchedTags: [], + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "releaseCommitPairs": Array [ + Object { + "baseVersion": "2.0", + "endCommit": Object { + "tagName": "version-2.0.0", + "tagSha": "sha-2.0.0", + "tagType": "tag", + }, + "startCommit": Object { + "tagName": "rc-2.0.0", + "tagSha": "sha-2.0.0", + "tagType": "tag", + }, + }, + Object { + "baseVersion": "3.0", + "endCommit": Object { + "tagName": "version-3.0.1", + "tagSha": "sha-3.0.1", + "tagType": "tag", + }, + "startCommit": Object { + "tagName": "rc-3.0.0", + "tagSha": "sha-3.0.0", + "tagType": "tag", + }, + }, + ], + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx new file mode 100644 index 0000000000..ae54a678d3 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/helpers/getReleaseCommitPairs.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReleaseCommitPairs } from '../hooks/useGetReleaseTimes'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; + +export function getReleaseCommitPairs({ + releaseStats, +}: { + releaseStats: ReleaseStats; +}) { + const releaseCommitPairs = Object.values(releaseStats.releases).reduce( + (acc: ReleaseCommitPairs, release) => { + const startTag = [...release.candidates].reverse()[0]; + const endTag = release.versions[0]; + + // Missing Release Candidate for unknown reason + if (!startTag) { + return acc; + } + + // Missing Release Version (likely prerelease) + if (!endTag) { + return acc; + } + + return acc.concat({ + baseVersion: release.baseVersion, + startCommit: { ...startTag }, + endCommit: { ...endTag }, + }); + }, + [], + ); + + return { + releaseCommitPairs, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx b/plugins/git-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx new file mode 100644 index 0000000000..3d1d5c5bb4 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Info/hooks/useGetReleaseTimes.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; +import { useAsync, useAsyncFn } from 'react-use'; +import { DateTime } from 'luxon'; +import { useApi } from '@backstage/core'; + +import { getReleaseCommitPairs } from '../helpers/getReleaseCommitPairs'; +import { gitReleaseManagerApiRef } from '../../../../api/serviceApiRef'; +import { useProjectContext } from '../../../../contexts/ProjectContext'; +import { useReleaseStatsContext } from '../../contexts/ReleaseStatsContext'; +import { getTagDates } from '../../helpers/getTagDates'; + +export type ReleaseCommitPairs = Array<{ + baseVersion: string; + startCommit: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }; + endCommit: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }; +}>; + +type ReleaseTime = { + version: string; + daysWithHours: number; + days: number; + hours: number; + startCommitCreatedAt?: string; + endCommitCreatedAt?: string; +}; + +export function useGetReleaseTimes() { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + const { releaseStats } = useReleaseStatsContext(); + const [averageReleaseTime, setAverageReleaseTime] = useState( + [], + ); + const [progress, setProgress] = useState(0); + const { releaseCommitPairs } = getReleaseCommitPairs({ releaseStats }); + + const [releaseTimeResult, run] = useAsyncFn(() => { + setProgress(0); + return getAndSetReleaseTime({ pairIndex: 0 }); + }); + + useAsync(async () => { + if (averageReleaseTime.length === 0) return; + if (releaseCommitPairs.length === averageReleaseTime.length) return; + + await getAndSetReleaseTime({ pairIndex: averageReleaseTime.length }); + }, [releaseTimeResult.value, averageReleaseTime]); + + useEffect(() => { + const unboundedProgress = Math.round( + (averageReleaseTime.length / releaseCommitPairs.length) * 100, + ); + const boundedProgress = unboundedProgress > 100 ? 100 : unboundedProgress; + + setProgress(boundedProgress); + }, [averageReleaseTime.length, releaseCommitPairs.length]); + + async function getAndSetReleaseTime({ pairIndex }: { pairIndex: number }) { + const { baseVersion, startCommit, endCommit } = releaseCommitPairs[ + pairIndex + ]; + + const { + startDate: startCommitCreatedAt, + endDate: endCommitCreatedAt, + } = await getTagDates({ + pluginApiClient, + project, + startTag: startCommit, + endTag: endCommit, + }); + + const releaseTime: ReleaseTime = { + version: baseVersion, + daysWithHours: 0, + days: 0, + hours: 0, + startCommitCreatedAt, + endCommitCreatedAt, + }; + + if (startCommitCreatedAt && endCommitCreatedAt) { + const { days: luxDays = 0, hours: luxHours = 0 } = DateTime.fromISO( + endCommitCreatedAt, + ) + .diff(DateTime.fromISO(startCommitCreatedAt), ['days', 'hours']) + .toObject(); + + releaseTime.daysWithHours = luxDays + luxHours / 24; + releaseTime.days = luxDays; + releaseTime.hours = luxHours; + } + + setAverageReleaseTime([...averageReleaseTime, releaseTime]); + } + + return { + releaseCommitPairs, + averageReleaseTime, + progress, + run, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/Row.tsx b/plugins/git-release-manager/src/features/Stats/Row/Row.tsx new file mode 100644 index 0000000000..1d6e298df0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/Row.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { DateTime } from 'luxon'; +import { + Collapse, + IconButton, + makeStyles, + TableCell, + TableRow, +} from '@material-ui/core'; +import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { Link } from '@backstage/core'; + +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; +import { RowCollapsed } from './RowCollapsed/RowCollapsed'; + +const useRowStyles = makeStyles({ + root: { + '& > *': { + borderBottom: 'unset', + }, + }, +}); + +interface RowProps { + baseVersion: string; + releaseStat: ReleaseStats['releases']['0']; +} + +export function Row({ baseVersion, releaseStat }: RowProps) { + const [open, setOpen] = useState(false); + const classes = useRowStyles(); + + return ( + <> + + + setOpen(!open)} + > + {open ? : } + + + + + + {baseVersion} + {releaseStat.versions.length === 0 ? ' (prerelease)' : ''} + + + + + {releaseStat.createdAt + ? DateTime.fromISO(releaseStat.createdAt).toFormat('yyyy-MM-dd') + : '-'} + + + {releaseStat.candidates.length} + + {Math.max(0, releaseStat.versions.length - 1)} + + + + + + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx new file mode 100644 index 0000000000..e07b74a171 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTagList.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Box, Typography } from '@material-ui/core'; + +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; + +export function ReleaseTagList({ + releaseStat, +}: { + releaseStat: ReleaseStats['releases']['0']; +}) { + return ( + + {releaseStat.versions.length > 0 && ( + + {releaseStat.versions.map(version => ( + + {version.tagName} + + ))} + + )} + + {releaseStat.versions.length > 0 && ( + + {' 🚀 '} + + )} + + + {releaseStat.candidates.map(candidate => ( + + {candidate.tagName} + + ))} + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx new file mode 100644 index 0000000000..0a499f5cf6 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/ReleaseTime.tsx @@ -0,0 +1,141 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { useAsync } from 'react-use'; +import { DateTime } from 'luxon'; +import { Box, Typography } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import { Progress, useApi } from '@backstage/core'; + +import { getDecimalNumber } from '../../helpers/getDecimalNumber'; +import { getTagDates } from '../../helpers/getTagDates'; +import { gitReleaseManagerApiRef } from '../../../../api/serviceApiRef'; +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; +import { useProjectContext } from '../../../../contexts/ProjectContext'; + +interface ReleaseTimeProps { + releaseStat: ReleaseStats['releases']['0']; +} + +export function ReleaseTime({ releaseStat }: ReleaseTimeProps) { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + + const releaseTimes = useAsync(() => + getTagDates({ + pluginApiClient, + project, + startTag: [...releaseStat.candidates].reverse()[0], + endTag: releaseStat.versions[0], + }), + ); + + if (releaseTimes.loading || releaseTimes.loading) { + return ( + + + + ); + } + + if (releaseTimes.error) { + return ( + + Failed to fetch the first Release Candidate commit ( + {releaseTimes.error.message}) + + ); + } + + const { days = 0, hours = 0 } = + releaseTimes.value?.startDate && releaseTimes.value?.endDate + ? DateTime.fromISO(releaseTimes.value.endDate) + .diff(DateTime.fromISO(releaseTimes.value.startDate), [ + 'days', + 'hours', + ]) + .toObject() + : { days: -1 }; + + return ( + + + + {releaseStat.versions.length === 0 ? '-' : 'Release completed '} + {releaseTimes.value?.endDate && + DateTime.fromISO(releaseTimes.value.endDate).toFormat('yyyy-MM-dd')} + + + + + + {days === -1 ? ( + <>Ongoing + ) : ( + <> + Completed in: {days} days {getDecimalNumber(hours, 1)} hours + + )} + + + + + + Release Candidate created{' '} + {releaseTimes.value?.startDate && + DateTime.fromISO(releaseTimes.value.startDate).toFormat( + 'yyyy-MM-dd', + )} + + + + ); +} + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx new file mode 100644 index 0000000000..101954e49d --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Row/RowCollapsed/RowCollapsed.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Box } from '@material-ui/core'; + +import { ReleaseStats } from '../../contexts/ReleaseStatsContext'; +import { ReleaseTagList } from './ReleaseTagList'; +import { ReleaseTime } from './ReleaseTime'; + +interface RowCollapsedProps { + releaseStat: ReleaseStats['releases']['0']; +} + +export function RowCollapsed({ releaseStat }: RowCollapsedProps) { + return ( + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Stats.tsx b/plugins/git-release-manager/src/features/Stats/Stats.tsx new file mode 100644 index 0000000000..e590c0a320 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Stats.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Button, Dialog, Theme, withStyles } from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import MuiDialogActions from '@material-ui/core/DialogActions'; +import MuiDialogContent from '@material-ui/core/DialogContent'; + +import { DialogBody } from './DialogBody'; +import { DialogTitle } from './DialogTitle'; +import { Transition } from '../../components/Transition'; + +const DialogContent = withStyles((theme: Theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent); + +const DialogActions = withStyles((theme: Theme) => ({ + root: { + margin: 0, + padding: theme.spacing(1), + }, +}))(MuiDialogActions); + +interface StatsProps { + setShowStats: React.Dispatch>; +} + +export function Stats({ setShowStats }: StatsProps) { + return ( + + Stats + + + + + + + + + + ); +} diff --git a/plugins/git-release-manager/src/features/Stats/Warn.tsx b/plugins/git-release-manager/src/features/Stats/Warn.tsx new file mode 100644 index 0000000000..24997cc9b3 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/Warn.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Alert } from '@material-ui/lab'; +import { Box, Button } from '@material-ui/core'; + +import { useProjectContext } from '../../contexts/ProjectContext'; +import { useReleaseStatsContext } from './contexts/ReleaseStatsContext'; + +export const Warn = () => { + const { releaseStats } = useReleaseStatsContext(); + const { project } = useProjectContext(); + + return ( + + + {releaseStats.unmappableTags.length > 0 && ( +
+ Failed to map {releaseStats.unmappableTags.length}{' '} + tags to releases +
+ )} + + {releaseStats.unmatchedTags.length > 0 && ( +
+ Failed to match {releaseStats.unmatchedTags.length}{' '} + tags to {project.versioningStrategy} +
+ )} + + {releaseStats.unmatchedReleases.length > 0 && ( +
+ Failed to match{' '} + {releaseStats.unmatchedReleases.length} releases to{' '} + {project.versioningStrategy} +
+ )} + + + + +
+
+ ); +}; diff --git a/plugins/git-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx b/plugins/git-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx new file mode 100644 index 0000000000..85f526b72e --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/contexts/ReleaseStatsContext.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createContext, useContext } from 'react'; + +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; + +export interface ReleaseStats { + unmappableTags: string[]; + unmatchedTags: string[]; + unmatchedReleases: string[]; + releases: { + [baseVersion: string]: { + baseVersion: string; + createdAt: string | null; + htmlUrl: string; + candidates: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }[]; + versions: { + tagName: string; + tagSha: string; + tagType: 'tag' | 'commit'; + }[]; + }; + }; +} + +export const ReleaseStatsContext = createContext< + { releaseStats: ReleaseStats } | undefined +>(undefined); + +export const useReleaseStatsContext = () => { + const { releaseStats } = useContext(ReleaseStatsContext) ?? {}; + + if (!releaseStats) { + throw new GitReleaseManagerError('releaseStats not found'); + } + + return { + releaseStats, + }; +}; diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx new file mode 100644 index 0000000000..b4b1a1df4c --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getDecimalNumber } from './getDecimalNumber'; + +describe('getDecimalNumber', () => { + it('should handle NaN', () => { + const result = getDecimalNumber(NaN); + + expect(result).toEqual(0); + }); + + it('should only handle decimals', () => { + const result = getDecimalNumber(1); + + expect(result).toEqual(1); + }); + + it('should get decimal number with default decimals = 2', () => { + const result = getDecimalNumber(1 / 3); + + expect(result).toMatchInlineSnapshot(`0.33`); + }); + + it('should get decimal number for decimals = 1', () => { + const result = getDecimalNumber(1 / 3, 1); + + expect(result).toMatchInlineSnapshot(`0.3`); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx new file mode 100644 index 0000000000..ab4a83fa79 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getDecimalNumber.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function getDecimalNumber(n: number, decimals = 2) { + if (isNaN(n)) { + return 0; + } + + if (n.toString().includes('.')) { + return parseFloat(n.toFixed(decimals)); + } + + return n; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx new file mode 100644 index 0000000000..fd5b23beaf --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getMappedReleases } from './getMappedReleases'; +import { mockSemverProject } from '../../../test-helpers/test-helpers'; + +describe('getMappedReleases', () => { + it('should get mapped releases', () => { + const createRelease = (tagName: string) => ({ + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + id: 1, + name: 'name', + tagName, + }); + + const result = getMappedReleases({ + project: mockSemverProject, + allReleases: [createRelease('rc-1.0.0'), createRelease('rc-1.1.0')], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "mappedReleases": Object { + "releases": Object { + "1.0": Object { + "baseVersion": "1.0", + "candidates": Array [], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + "1.1": Object { + "baseVersion": "1.1", + "candidates": Array [], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + }, + "unmappableTags": Array [], + "unmatchedReleases": Array [], + "unmatchedTags": Array [], + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.tsx new file mode 100644 index 0000000000..0ea94d29ac --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getMappedReleases.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts'; +import { GetAllReleasesResult } from '../../../api/GitReleaseClient'; +import { Project } from '../../../contexts/ProjectContext'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; +import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts'; + +export function getMappedReleases({ + allReleases, + project, +}: { + allReleases: GetAllReleasesResult['releases']; + project: Project; +}) { + return { + mappedReleases: allReleases.reduce( + (acc: ReleaseStats, release) => { + const match = + project.versioningStrategy === 'semver' + ? release.tagName.match(semverRegexp) + : release.tagName.match(calverRegexp); + + if (!match) { + acc.unmatchedReleases.push(release.tagName); + return acc; + } + + const baseVersion = + project.versioningStrategy === 'semver' + ? `${match[2]}.${match[3]}` + : match[2]; + + if (!acc.releases[baseVersion]) { + acc.releases[baseVersion] = { + baseVersion, + createdAt: release.createdAt, + htmlUrl: release.htmlUrl, + candidates: [], + versions: [], + }; + + return acc; + } + + return acc; + }, + { + releases: {}, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], + }, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx new file mode 100644 index 0000000000..77e4fe8dc0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getReleaseStats } from './getReleaseStats'; +import { mockSemverProject } from '../../../test-helpers/test-helpers'; + +describe('getReleaseStats', () => { + it('should get releases with tags', () => { + const result = getReleaseStats({ + project: mockSemverProject, + mappedReleases: { + releases: { + '1.0': { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [], + versions: [], + }, + '1.1': { + baseVersion: '1.1', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [], + + versions: [], + }, + }, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], + }, + allTags: [ + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.0' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.1' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.0.2' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'version-1.0.2' }, + { tagType: 'tag' as const, tagSha: 'sha', tagName: 'rc-1.1.1' }, + + { tagType: 'tag' as const, tagSha: 'unmatchable', tagName: 'rc-1/2/3' }, + { + tagType: 'tag' as const, + tagSha: 'unmappable', + tagName: 'rc-123.123.123', + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "releaseStats": Object { + "releases": Object { + "1.0": Object { + "baseVersion": "1.0", + "candidates": Array [ + Object { + "tagName": "rc-1.0.0", + "tagSha": "sha", + "tagType": "tag", + }, + Object { + "tagName": "rc-1.0.1", + "tagSha": "sha", + "tagType": "tag", + }, + Object { + "tagName": "rc-1.0.2", + "tagSha": "sha", + "tagType": "tag", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [ + Object { + "tagName": "version-1.0.2", + "tagSha": "sha", + "tagType": "tag", + }, + ], + }, + "1.1": Object { + "baseVersion": "1.1", + "candidates": Array [ + Object { + "tagName": "rc-1.1.1", + "tagSha": "sha", + "tagType": "tag", + }, + ], + "createdAt": "2021-01-01T10:11:12Z", + "htmlUrl": "html_url", + "versions": Array [], + }, + }, + "unmappableTags": Array [ + "rc-123.123.123", + ], + "unmatchedReleases": Array [], + "unmatchedTags": Array [ + "rc-1/2/3", + ], + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.tsx new file mode 100644 index 0000000000..e4e57c1114 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getReleaseStats.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { calverRegexp } from '../../../helpers/tagParts/getCalverTagParts'; +import { GetAllTagsResult } from '../../../api/GitReleaseClient'; +import { Project } from '../../../contexts/ProjectContext'; +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; +import { semverRegexp } from '../../../helpers/tagParts/getSemverTagParts'; + +export function getReleaseStats({ + allTags, + project, + mappedReleases, +}: { + allTags: GetAllTagsResult['tags']; + project: Project; + mappedReleases: ReleaseStats; +}) { + const releaseStats = allTags.reduce( + (acc: ReleaseStats, tag) => { + const match = + project.versioningStrategy === 'semver' + ? tag.tagName.match(semverRegexp) + : tag.tagName.match(calverRegexp); + + if (!match) { + acc.unmatchedTags.push(tag.tagName); + return acc; + } + + const prefix = match[1] as 'rc' | 'version'; + const baseVersion = + project.versioningStrategy === 'semver' + ? `${match[2]}.${match[3]}` // major.minor + : match[2]; // yyyy.MM.dd + + const release = acc.releases[baseVersion]; + + if (!release) { + acc.unmappableTags.push(tag.tagName); + return acc; + } + + const dest = release[prefix === 'rc' ? 'candidates' : 'versions']; + dest.push(tag); + + return acc; + }, + { + ...mappedReleases, + }, + ); + + return { + releaseStats, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getSummary.test.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.test.tsx new file mode 100644 index 0000000000..be6052c2a0 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getSummary } from './getSummary'; +import { mockReleaseStats } from '../../../test-helpers/stats'; + +describe('getSummary', () => { + it('should get summary', () => { + const result = getSummary({ releaseStats: mockReleaseStats }); + + expect(result).toMatchInlineSnapshot(` + Object { + "summary": Object { + "totalCandidatePatches": 3, + "totalReleases": 2, + "totalVersionPatches": 1, + }, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getSummary.tsx b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.tsx new file mode 100644 index 0000000000..e42c5c7379 --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getSummary.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReleaseStats } from '../contexts/ReleaseStatsContext'; + +export function getSummary({ releaseStats }: { releaseStats: ReleaseStats }) { + return { + summary: Object.entries(releaseStats.releases).reduce( + ( + acc: { + totalReleases: number; + totalCandidatePatches: number; + totalVersionPatches: number; + }, + [_baseVersion, mappedRelease], + ) => { + const candidatePatches = + Object.keys(mappedRelease.candidates).length - 1; + const versionPatches = Object.keys(mappedRelease.versions).length - 1; + + acc.totalReleases += 1; + acc.totalCandidatePatches += + candidatePatches >= 0 ? candidatePatches : 0; + acc.totalVersionPatches += versionPatches >= 0 ? versionPatches : 0; + + return acc; + }, + { + totalReleases: 0, + totalCandidatePatches: 0, + totalVersionPatches: 0, + }, + ), + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.test.ts b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.test.ts new file mode 100644 index 0000000000..3274ecc31f --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.test.ts @@ -0,0 +1,212 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createMockCommit, + createMockTag, + mockApiClient, + mockSemverProject, +} from '../../../test-helpers/test-helpers'; +import { getTagDates } from './getTagDates'; + +describe('getTagDates', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + it('should get tag dates for startTag when is tag and endTag is undefined', async () => { + (mockApiClient.getTag as jest.Mock).mockResolvedValueOnce( + createMockTag({ date: 'TAG-START' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'tag', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": undefined, + "startDate": "TAG-START", + } + `); + }); + + it('should get tag dates for startTag when startTag is commit and endTag is undefined', async () => { + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_START' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'commit', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": undefined, + "startDate": "COMMIT_START", + } + `); + }); + + it('should get tag dates when startTag & endTag both are of type tag', async () => { + (mockApiClient.getTag as jest.Mock) + .mockResolvedValueOnce(createMockTag({ date: 'TAG-START' })) + .mockResolvedValueOnce(createMockTag({ date: 'TAG-END' })); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'tag', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'tag', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "TAG-END", + "startDate": "TAG-START", + } + `); + }); + + it('should get commit createdAt when startTag & endTag both are of type commit', async () => { + (mockApiClient.getCommit as jest.Mock) + .mockResolvedValueOnce(createMockCommit({ createdAt: 'COMMIT_START' })) + .mockResolvedValueOnce(createMockCommit({ createdAt: 'COMMIT_END' })); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'commit', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'commit', + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "COMMIT_END", + "startDate": "COMMIT_START", + } + `); + }); + + it('should get commit createdAt when startTag is of type tag but endTag is of type commit', async () => { + (mockApiClient.getTag as jest.Mock).mockResolvedValueOnce( + createMockTag({ objectSha: 'OBJECT_SHA_START' }), + ); + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_START' }), + ); + + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_END' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'tag', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'commit', + }, + }); + + const { owner, repo } = mockSemverProject; + expect(mockApiClient.getTag).toHaveBeenCalledWith({ + owner, + repo, + tagSha: 'sha-start', + }); + expect((mockApiClient.getCommit as jest.Mock).mock.calls).toEqual([ + [{ owner, ref: 'sha-end', repo }], + [{ owner, ref: 'OBJECT_SHA_START', repo }], + ]); + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "COMMIT_START", + "startDate": "COMMIT_END", + } + `); + }); + + it('should get commit createdAt when endTag is of type tag but startTag is of type commit', async () => { + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_START' }), + ); + + (mockApiClient.getTag as jest.Mock).mockResolvedValueOnce( + createMockTag({ objectSha: 'OBJECT_SHA_END' }), + ); + (mockApiClient.getCommit as jest.Mock).mockResolvedValueOnce( + createMockCommit({ createdAt: 'COMMIT_END' }), + ); + + const result = await getTagDates({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + startTag: { + tagSha: 'sha-start', + tagType: 'commit', + }, + endTag: { + tagSha: 'sha-end', + tagType: 'tag', + }, + }); + + const { owner, repo } = mockSemverProject; + expect(mockApiClient.getTag).toHaveBeenCalledWith({ + owner, + repo, + tagSha: 'sha-end', + }); + expect((mockApiClient.getCommit as jest.Mock).mock.calls).toEqual([ + [{ owner, ref: 'sha-start', repo }], + [{ owner, ref: 'OBJECT_SHA_END', repo }], + ]); + expect(result).toMatchInlineSnapshot(` + Object { + "endDate": "COMMIT_END", + "startDate": "COMMIT_START", + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.ts b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.ts new file mode 100644 index 0000000000..d79d7489fd --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/helpers/getTagDates.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitReleaseApi } from '../../../api/GitReleaseClient'; +import { GitReleaseManagerError } from '../../../errors/GitReleaseManagerError'; +import { Project } from '../../../contexts/ProjectContext'; + +interface GetTagDates { + pluginApiClient: GitReleaseApi; + project: Project; + startTag: { + tagSha: string; + tagType: 'tag' | 'commit'; + }; + endTag?: { + tagSha: string; + tagType: 'tag' | 'commit'; + }; +} + +export const getTagDates = async ({ + pluginApiClient, + project, + startTag, + endTag, +}: GetTagDates) => { + if (!endTag) { + if (startTag.tagType === 'tag') { + const { tag: startTagResponse } = await pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: startTag.tagSha, + }); + + return { + startDate: startTagResponse.date, + endDate: undefined, + }; + } + + // If tagType is not a 'tag', it has to be a commit + const { commit: startCommit } = await pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: startTag.tagSha, + }); + + return { + startDate: startCommit.createdAt, + endDate: undefined, + }; + } + + if (startTag.tagType === 'tag' && endTag.tagType === 'tag') { + const [ + { tag: startTagResponse }, + { tag: endTagResponse }, + ] = await Promise.all([ + pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: startTag.tagSha, + }), + pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: endTag.tagSha, + }), + ]); + + return { + startDate: startTagResponse.date, + endDate: endTagResponse.date, + }; + } + + if (startTag.tagType === 'commit' && endTag.tagType === 'commit') { + const [{ commit: startCommit }, { commit: endCommit }] = await Promise.all([ + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: startTag.tagSha, + }), + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: endTag.tagSha, + }), + ]); + + return { + startDate: startCommit.createdAt, + endDate: endCommit.createdAt, + }; + } + + if (startTag.tagType === 'tag' && endTag.tagType === 'commit') { + const [{ date: startDate }, { commit: endCommit }] = await Promise.all([ + getCommitFromTag({ pluginApiClient, project, tag: startTag }), + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: endTag.tagSha, + }), + ]); + + return { + startDate, + endDate: endCommit.createdAt, + }; + } + + if (startTag.tagType === 'commit' && endTag.tagType === 'tag') { + const [{ commit: startCommit }, { date: endDate }] = await Promise.all([ + pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: startTag.tagSha, + }), + getCommitFromTag({ pluginApiClient, project, tag: endTag }), + ]); + + return { + startDate: startCommit.createdAt, + endDate, + }; + } + + throw new GitReleaseManagerError( + `Failed to get tag dates for tags with type "${startTag.tagType}" and "${endTag.tagType}"`, + ); +}; + +async function getCommitFromTag({ + pluginApiClient, + project, + tag, +}: { + pluginApiClient: GetTagDates['pluginApiClient']; + project: GetTagDates['project']; + tag: GetTagDates['startTag'] | NonNullable; +}) { + const { tag: tagResponse } = await pluginApiClient.getTag({ + owner: project.owner, + repo: project.repo, + tagSha: tag.tagSha, + }); + const { commit: startCommit } = await pluginApiClient.getCommit({ + owner: project.owner, + repo: project.repo, + ref: tagResponse.objectSha, + }); + + return { + date: startCommit.createdAt, + }; +} diff --git a/plugins/git-release-manager/src/features/Stats/hooks/useGetStats.ts b/plugins/git-release-manager/src/features/Stats/hooks/useGetStats.ts new file mode 100644 index 0000000000..e87d29c29a --- /dev/null +++ b/plugins/git-release-manager/src/features/Stats/hooks/useGetStats.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core'; +import { useAsync } from 'react-use'; + +import { gitReleaseManagerApiRef } from '../../../api/serviceApiRef'; +import { useProjectContext } from '../../../contexts/ProjectContext'; + +export const useGetStats = () => { + const pluginApiClient = useApi(gitReleaseManagerApiRef); + const { project } = useProjectContext(); + + const stats = useAsync(async () => { + const [{ releases: allReleases }, { tags: allTags }] = await Promise.all([ + pluginApiClient.getAllReleases({ + owner: project.owner, + repo: project.repo, + }), + pluginApiClient.getAllTags({ + owner: project.owner, + repo: project.repo, + }), + ]); + + return { + allReleases, + allTags, + }; + }, [project]); + + return { + stats, + }; +}; diff --git a/plugins/git-release-manager/src/helpers/createResponseStepError.test.ts b/plugins/git-release-manager/src/helpers/createResponseStepError.test.ts new file mode 100644 index 0000000000..b6069f2fd6 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/createResponseStepError.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createResponseStepError } from './createResponseStepError'; + +describe('createResponseStepError', () => { + it('should work', () => { + const result = createResponseStepError(new Error('banana')); + + expect(result).toMatchInlineSnapshot(` + Object { + "icon": "failure", + "message": "Something went wrong ❌", + "secondaryMessage": "Error message: banana", + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/createResponseStepError.ts b/plugins/git-release-manager/src/helpers/createResponseStepError.ts new file mode 100644 index 0000000000..7de042a7e7 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/createResponseStepError.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResponseStep } from '../types/types'; + +export function createResponseStepError(error: Error): ResponseStep { + return { + message: 'Something went wrong ❌', + secondaryMessage: `Error message: ${error.message}`, + icon: 'failure', + }; +} diff --git a/plugins/git-release-manager/src/helpers/getBumpedTag.test.ts b/plugins/git-release-manager/src/helpers/getBumpedTag.test.ts new file mode 100644 index 0000000000..27023f653f --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getBumpedTag.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockCalverProject, + mockSemverProject, +} from '../test-helpers/test-helpers'; +import { getBumpedTag } from './getBumpedTag'; + +describe('getBumpedTag', () => { + describe('calver', () => { + it('should increment patch by 1', () => { + const result = getBumpedTag({ + project: mockCalverProject, + tag: 'rc-2020.01.01_1', + bumpLevel: 'patch', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-2020.01.01_2", + "error": undefined, + "tagParts": Object { + "calver": "2020.01.01", + "patch": 2, + "prefix": "rc", + }, + } + `); + }); + + it('should increment patch by 1 regardless of semver-specific arg "bumpLevel"', () => { + const result = getBumpedTag({ + project: mockCalverProject, + tag: 'rc-2020.01.01_1', + bumpLevel: 'major', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-2020.01.01_2", + "error": undefined, + "tagParts": Object { + "calver": "2020.01.01", + "patch": 2, + "prefix": "rc", + }, + } + `); + }); + }); + + describe('semver', () => { + it('should increment patch by 1', () => { + const result = getBumpedTag({ + project: mockSemverProject, + tag: 'rc-1.2.3', + bumpLevel: 'patch', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-1.2.4", + "error": undefined, + "tagParts": Object { + "major": 1, + "minor": 2, + "patch": 4, + "prefix": "rc", + }, + } + `); + }); + + it('should increment minor by 1', () => { + const result = getBumpedTag({ + project: mockSemverProject, + tag: 'rc-1.2.3', + bumpLevel: 'minor', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-1.3.0", + "error": undefined, + "tagParts": Object { + "major": 1, + "minor": 3, + "patch": 0, + "prefix": "rc", + }, + } + `); + }); + + it('should increment major by 1', () => { + const result = getBumpedTag({ + project: mockSemverProject, + tag: 'rc-1.2.3', + bumpLevel: 'major', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "bumpedTag": "rc-2.0.0", + "error": undefined, + "tagParts": Object { + "major": 2, + "minor": 0, + "patch": 0, + "prefix": "rc", + }, + } + `); + }); + }); + + describe('errors', () => { + it('should propagate errors for invalid tags', () => { + const result = getBumpedTag({ + project: mockCalverProject, + tag: '😬', + bumpLevel: 'patch', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"😬\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/getBumpedTag.ts b/plugins/git-release-manager/src/helpers/getBumpedTag.ts new file mode 100644 index 0000000000..f8b976b01f --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getBumpedTag.ts @@ -0,0 +1,103 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CalverTagParts } from './tagParts/getCalverTagParts'; +import { getTagParts } from './tagParts/getTagParts'; +import { isCalverTagParts } from './isCalverTagParts'; +import { Project } from '../contexts/ProjectContext'; +import { SEMVER_PARTS } from '../constants/constants'; +import { SemverTagParts } from './tagParts/getSemverTagParts'; + +export function getBumpedTag({ + project, + tag, + bumpLevel, +}: { + project: Project; + tag: string; + bumpLevel: keyof typeof SEMVER_PARTS; +}) { + const tagParts = getTagParts({ project, tag }); + + if (tagParts.error !== undefined) { + return { + error: tagParts.error, + }; + } + + if (isCalverTagParts(project, tagParts.tagParts)) { + return getPatchedCalverTag(tagParts.tagParts); + } + + return getBumpedSemverTag(tagParts.tagParts, bumpLevel); +} + +function getPatchedCalverTag(tagParts: CalverTagParts) { + const bumpedTagParts: CalverTagParts = { + ...tagParts, + patch: tagParts.patch + 1, + }; + const bumpedTag = `${bumpedTagParts.prefix}-${bumpedTagParts.calver}_${bumpedTagParts.patch}`; + + return { + bumpedTag, + tagParts: bumpedTagParts, + error: undefined, + }; +} + +function getBumpedSemverTag( + tagParts: SemverTagParts, + semverBumpLevel: keyof typeof SEMVER_PARTS, +) { + const { bumpedTagParts } = getBumpedSemverTagParts(tagParts, semverBumpLevel); + + const bumpedTag = `${bumpedTagParts.prefix}-${bumpedTagParts.major}.${bumpedTagParts.minor}.${bumpedTagParts.patch}`; + + return { + bumpedTag, + tagParts: bumpedTagParts, + error: undefined, + }; +} + +export function getBumpedSemverTagParts( + tagParts: SemverTagParts, + semverBumpLevel: keyof typeof SEMVER_PARTS, +) { + const bumpedTagParts = { + ...tagParts, + }; + + if (semverBumpLevel === 'major') { + bumpedTagParts.major = bumpedTagParts.major + 1; + bumpedTagParts.minor = 0; + bumpedTagParts.patch = 0; + } + + if (semverBumpLevel === 'minor') { + bumpedTagParts.minor = bumpedTagParts.minor + 1; + bumpedTagParts.patch = 0; + } + + if (semverBumpLevel === 'patch') { + bumpedTagParts.patch = bumpedTagParts.patch + 1; + } + + return { + bumpedTagParts, + }; +} diff --git a/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.test.ts b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.test.ts new file mode 100644 index 0000000000..c27e37ce86 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime } from 'luxon'; + +import { + mockCalverProject, + mockReleaseVersionCalver, + mockReleaseVersionSemver, + mockSemverProject, +} from '../test-helpers/test-helpers'; +import { getReleaseCandidateGitInfo } from './getReleaseCandidateGitInfo'; + +describe('getReleaseCandidateGitInfo', () => { + describe('DateTime', () => { + it('should format dates as expected', () => { + const formattedDate = DateTime.now().toFormat('yyyy.MM.dd'); + + expect(formattedDate).toMatch(/^\d{4}.\d{2}.\d{2}$/); + }); + }); + + describe('calver', () => { + it('should return correct Git info', () => { + expect( + getReleaseCandidateGitInfo({ + project: mockCalverProject, + latestRelease: mockReleaseVersionCalver, + semverBumpLevel: 'minor', + injectedDate: '2021.01.28', + }), + ).toMatchInlineSnapshot(` + Object { + "rcBranch": "rc/2021.01.28", + "rcReleaseTag": "rc-2021.01.28_0", + "releaseName": "Version 2021.01.28", + } + `); + }); + }); + + describe('semver', () => { + it("should return correct Git info when there's previous releases", () => { + expect( + getReleaseCandidateGitInfo({ + project: mockSemverProject, + latestRelease: mockReleaseVersionSemver, + semverBumpLevel: 'minor', + }), + ).toMatchInlineSnapshot(` + Object { + "rcBranch": "rc/1.3.0", + "rcReleaseTag": "rc-1.3.0", + "releaseName": "Version 1.3.0", + } + `); + }); + + it("should return correct Git info when there's no previous release", () => { + expect( + getReleaseCandidateGitInfo({ + project: mockSemverProject, + latestRelease: null, + semverBumpLevel: 'minor', + }), + ).toMatchInlineSnapshot(` + Object { + "rcBranch": "rc/0.0.1", + "rcReleaseTag": "rc-0.0.1", + "releaseName": "Version 0.0.1", + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.ts b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.ts new file mode 100644 index 0000000000..729439cea6 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getReleaseCandidateGitInfo.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime } from 'luxon'; + +import { getBumpedSemverTagParts } from './getBumpedTag'; +import { GetLatestReleaseResult } from '../api/GitReleaseClient'; +import { getSemverTagParts } from './tagParts/getSemverTagParts'; +import { Project } from '../contexts/ProjectContext'; +import { SEMVER_PARTS } from '../constants/constants'; + +interface GetReleaseCandidateGitInfo { + project: Project; + latestRelease: GetLatestReleaseResult['latestRelease']; + semverBumpLevel: keyof typeof SEMVER_PARTS; + injectedDate?: string; +} + +export const getReleaseCandidateGitInfo = ({ + project, + latestRelease, + semverBumpLevel, + injectedDate = DateTime.now().toFormat('yyyy.MM.dd'), +}: GetReleaseCandidateGitInfo) => { + if (project.versioningStrategy === 'calver') { + return { + rcBranch: `rc/${injectedDate}`, + rcReleaseTag: `rc-${injectedDate}_0`, + releaseName: `Version ${injectedDate}`, + }; + } + + if (!latestRelease) { + return { + rcBranch: 'rc/0.0.1', + rcReleaseTag: 'rc-0.0.1', + releaseName: 'Version 0.0.1', + }; + } + + const semverTagParts = getSemverTagParts(latestRelease.tagName); + if (semverTagParts.error !== undefined) { + return { + error: semverTagParts.error, + }; + } + + const { bumpedTagParts } = getBumpedSemverTagParts( + semverTagParts.tagParts, + semverBumpLevel, + ); + + const bumpedTag = `${bumpedTagParts.major}.${bumpedTagParts.minor}.${bumpedTagParts.patch}`; + + return { + rcBranch: `rc/${bumpedTag}`, + rcReleaseTag: `rc-${bumpedTag}`, + releaseName: `Version ${bumpedTag}`, + }; +}; diff --git a/plugins/git-release-manager/src/helpers/getShortCommitHash.test.ts b/plugins/git-release-manager/src/helpers/getShortCommitHash.test.ts new file mode 100644 index 0000000000..82cfd031cf --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getShortCommitHash.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getShortCommitHash } from './getShortCommitHash'; + +describe('getShortCommitHash', () => { + it('should get the short version of the commit hash', () => { + const result = getShortCommitHash( + 'bd3fc6f6351018a748bb7f94c0ecf6c1577d8e06', + ); + + expect(result).toEqual('bd3fc6f'); + expect(result.length).toEqual(7); + }); + + it('should throw for invalid commit hashes (too short)', () => { + expect(() => getShortCommitHash('bd3')).toThrowErrorMatchingInlineSnapshot( + `"Invalid shortCommitHash: less than 7 characters"`, + ); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/getShortCommitHash.ts b/plugins/git-release-manager/src/helpers/getShortCommitHash.ts new file mode 100644 index 0000000000..55241b390a --- /dev/null +++ b/plugins/git-release-manager/src/helpers/getShortCommitHash.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitReleaseManagerError } from '../errors/GitReleaseManagerError'; + +export function getShortCommitHash(hash: string) { + const shortCommitHash = hash.substr(0, 7); + + if (shortCommitHash.length < 7) { + throw new GitReleaseManagerError( + 'Invalid shortCommitHash: less than 7 characters', + ); + } + + return shortCommitHash; +} diff --git a/plugins/git-release-manager/src/helpers/isCalverTagParts.test.ts b/plugins/git-release-manager/src/helpers/isCalverTagParts.test.ts new file mode 100644 index 0000000000..1f74f2fe40 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isCalverTagParts.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockCalverProject, + mockSemverProject, +} from '../test-helpers/test-helpers'; +import { isCalverTagParts } from './isCalverTagParts'; + +describe('isCalverTagParts', () => { + describe('calver', () => { + it('should return true', () => + expect(isCalverTagParts(mockCalverProject, {})).toEqual(true)); + }); + + describe('semver', () => { + it('should return false', () => + expect(isCalverTagParts(mockSemverProject, {})).toEqual(false)); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/isCalverTagParts.ts b/plugins/git-release-manager/src/helpers/isCalverTagParts.ts new file mode 100644 index 0000000000..5e6d8cfa9c --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isCalverTagParts.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Project } from '../contexts/ProjectContext'; +import { CalverTagParts } from './tagParts/getCalverTagParts'; + +export function isCalverTagParts( + project: Project, + _tagParts: unknown, +): _tagParts is CalverTagParts { + return project.versioningStrategy === 'calver'; +} diff --git a/plugins/git-release-manager/src/helpers/isProjectValid.test.ts b/plugins/git-release-manager/src/helpers/isProjectValid.test.ts new file mode 100644 index 0000000000..44cec0e571 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isProjectValid.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockSemverProject } from '../test-helpers/test-helpers'; +import { isProjectValid } from './isProjectValid'; + +describe('isProjectValid', () => { + it('should return true for valid project', () => { + const result = isProjectValid(mockSemverProject); + + expect(result).toEqual(true); + }); + + it('should return false for invalid project (undefined argument)', () => { + const result = isProjectValid(undefined); + + expect(result).toEqual(false); + }); + + it('should return false for invalid project (empty object argument)', () => { + const result = isProjectValid({}); + + expect(result).toEqual(false); + }); + + it('should return false for invalid project (invalid versioningStrategy argument)', () => { + const result = isProjectValid({ + ...mockSemverProject, + versioningStrategy: 'banana', + }); + + expect(result).toEqual(false); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/isProjectValid.ts b/plugins/git-release-manager/src/helpers/isProjectValid.ts new file mode 100644 index 0000000000..8a48ac5bbd --- /dev/null +++ b/plugins/git-release-manager/src/helpers/isProjectValid.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Project } from '../contexts/ProjectContext'; + +export function isProjectValid(project: any): project is Project { + return ( + project?.owner?.length > 0 && + project?.repo?.length > 0 && + (['semver', 'calver'] as Project['versioningStrategy'][]).includes( + project?.versioningStrategy, + ) + ); +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.test.ts new file mode 100644 index 0000000000..c74438b63a --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockReleaseVersionCalver, + mockReleaseCandidateCalver, +} from '../../test-helpers/test-helpers'; +import { getCalverTagParts } from './getCalverTagParts'; + +describe('getCalverTagParts', () => { + describe('happy path', () => { + it('should return tagParts for RC tag', () => { + const result = getCalverTagParts(mockReleaseCandidateCalver.tagName); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "calver": "2020.01.01", + "patch": 1, + "prefix": "rc", + }, + } + `); + }); + + it('should return tagParts for Version tag', () => { + const result = getCalverTagParts(mockReleaseVersionCalver.tagName); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "calver": "2020.01.01", + "patch": 1, + "prefix": "version", + }, + } + `); + }); + }); + + describe('invalid calver tags', () => { + it('should return error for invalid prefix', () => { + const result = getCalverTagParts('invalid-2020.01.01_1'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"invalid-2020.01.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid calver (missing padded zero)', () => { + const result = getCalverTagParts('rc-2020.1.01_1'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-2020.1.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid calver (missing day)', () => { + const result = getCalverTagParts('rc-2020.01_1'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-2020.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid patch (letter instead of number)', () => { + const result = getCalverTagParts('rc-2020.01.01_a'); + + expect(result).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-2020.01.01_a\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.ts b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.ts new file mode 100644 index 0000000000..09f3b3e3d5 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getCalverTagParts.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AlertError } from '../../types/types'; + +export type CalverTagParts = { + prefix: string; + calver: string; + patch: number; +}; + +export const calverRegexp = /(rc|version)-([0-9]{4}\.[0-9]{2}\.[0-9]{2})_([0-9]+)/; + +export function getCalverTagParts(tag: string) { + const match = tag.match(calverRegexp); + + if (match === null || match.length < 4) { + const error: AlertError = { + title: 'Invalid tag', + subtitle: `Expected calver matching "${calverRegexp}", found "${tag}"`, + }; + + return { + error, + }; + } + + const tagParts: CalverTagParts = { + prefix: match[1], + calver: match[2], + patch: parseInt(match[3], 10), + }; + + return { + tagParts, + }; +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.test.ts new file mode 100644 index 0000000000..4a5e218eaf --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockReleaseCandidateSemver, + mockReleaseVersionSemver, +} from '../../test-helpers/test-helpers'; +import { getSemverTagParts } from './getSemverTagParts'; + +describe('getSemverTagParts', () => { + describe('happy path', () => { + it('should return tagParts for RC tag', () => { + const semverTagParts = getSemverTagParts( + mockReleaseCandidateSemver.tagName, + ); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "major": 1, + "minor": 2, + "patch": 3, + "prefix": "rc", + }, + } + `); + }); + + it('should return tagParts for Version tag', () => { + const semverTagParts = getSemverTagParts( + mockReleaseVersionSemver.tagName, + ); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "tagParts": Object { + "major": 1, + "minor": 2, + "patch": 3, + "prefix": "version", + }, + } + `); + }); + }); + + describe('invalid semver tags', () => { + it('should return error for invalid prefix', () => { + const semverTagParts = getSemverTagParts('invalid-1.2.3'); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found \\"invalid-1.2.3\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid semver (missing patch)', () => { + const semverTagParts = getSemverTagParts('rc-1.2'); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found \\"rc-1.2\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for invalid semver (founds calver)', () => { + const semverTagParts = getSemverTagParts('rc-1337.01.01_1'); + + expect(semverTagParts).toMatchInlineSnapshot(` + Object { + "error": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found calver \\"rc-1337.01.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.ts b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.ts new file mode 100644 index 0000000000..8f5d53f488 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getSemverTagParts.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AlertError } from '../../types/types'; +import { calverRegexp } from './getCalverTagParts'; + +export type SemverTagParts = { + prefix: string; + major: number; + minor: number; + patch: number; +}; + +export const semverRegexp = /(rc|version)-([0-9]+)\.([0-9]+)\.([0-9]+)/; + +export function getSemverTagParts(tag: string) { + const match = tag.match(semverRegexp); + + if (match === null || match.length < 4) { + const error: AlertError = { + title: 'Invalid tag', + subtitle: `Expected semver matching "${semverRegexp}", found "${tag}"`, + }; + + return { + error, + }; + } + + if (tag.match(calverRegexp)) { + const error: AlertError = { + title: 'Invalid tag', + subtitle: `Expected semver matching "${semverRegexp}", found calver "${tag}"`, + }; + + return { + error, + }; + } + + const tagParts: SemverTagParts = { + prefix: match[1], + major: parseInt(match[2], 10), + minor: parseInt(match[3], 10), + patch: parseInt(match[4], 10), + }; + + return { + tagParts, + }; +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/getTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.test.ts new file mode 100644 index 0000000000..825d23c001 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockCalverProject, + mockSemverProject, +} from '../../test-helpers/test-helpers'; +import { getCalverTagParts } from './getCalverTagParts'; +import { getSemverTagParts } from './getSemverTagParts'; +import { getTagParts } from './getTagParts'; + +jest.mock('./getCalverTagParts', () => ({ + getCalverTagParts: jest.fn(), +})); +jest.mock('./getSemverTagParts', () => ({ + getSemverTagParts: jest.fn(), +})); + +describe('getTagParts', () => { + beforeEach(jest.resetAllMocks); + + describe('calver', () => { + it('should call getCalverTagParts for calver projects', () => { + getTagParts({ project: mockCalverProject, tag: 'banana' }); + + expect(getCalverTagParts).toHaveBeenCalledTimes(1); + }); + }); + + describe('semver', () => { + it('should call getSemverTagParts for calver projects', () => { + getTagParts({ project: mockSemverProject, tag: 'banana' }); + + expect(getSemverTagParts).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts new file mode 100644 index 0000000000..a3f8665554 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/getTagParts.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getCalverTagParts } from './getCalverTagParts'; +import { getSemverTagParts } from './getSemverTagParts'; +import { Project } from '../../contexts/ProjectContext'; + +export function getTagParts({ + project, + tag, +}: { + project: Project; + tag: string; +}) { + if (project.versioningStrategy === 'calver') { + return getCalverTagParts(tag); + } + + return getSemverTagParts(tag); +} diff --git a/plugins/git-release-manager/src/helpers/tagParts/validateTagName.ts b/plugins/git-release-manager/src/helpers/tagParts/validateTagName.ts new file mode 100644 index 0000000000..e7d6124c81 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/validateTagName.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getCalverTagParts } from './getCalverTagParts'; +import { getSemverTagParts } from './getSemverTagParts'; +import { Project } from '../../contexts/ProjectContext'; + +export const validateTagName = ({ + project, + tagName, +}: { + project: Project; + tagName?: string; +}) => { + if (!tagName) { + return { + tagNameError: null, + }; + } + + if (project.versioningStrategy === 'calver') { + const { error } = getCalverTagParts(tagName); + + return { + tagNameError: error, + }; + } + + const { error } = getSemverTagParts(tagName); + + return { + tagNameError: error, + }; +}; diff --git a/plugins/git-release-manager/src/helpers/tagParts/validateTagParts.test.ts b/plugins/git-release-manager/src/helpers/tagParts/validateTagParts.test.ts new file mode 100644 index 0000000000..9a32b1a806 --- /dev/null +++ b/plugins/git-release-manager/src/helpers/tagParts/validateTagParts.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockCalverProject, + mockReleaseCandidateCalver, + mockReleaseCandidateSemver, + mockSemverProject, +} from '../../test-helpers/test-helpers'; +import { validateTagName } from './validateTagName'; + +describe('validateTagName', () => { + describe('valid tags', () => { + it('should not return any error for valid semver project', () => { + const result = validateTagName({ + project: mockSemverProject, + tagName: mockReleaseCandidateSemver.tagName, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": undefined, + } + `); + }); + + it('should not return any error for semver project without any releases (i.e. no tagName)', () => { + const result = validateTagName({ + project: mockSemverProject, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": null, + } + `); + }); + }); + + describe('mismatching tags', () => { + it('should return error for semver project and calver tag', () => { + const result = validateTagName({ + project: mockSemverProject, + tagName: mockReleaseCandidateCalver.tagName, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found calver \\"rc-2020.01.01_1\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for calver project and semver tag', () => { + const result = validateTagName({ + project: mockCalverProject, + tagName: mockReleaseCandidateSemver.tagName, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"rc-1.2.3\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); + + describe('invalid tags', () => { + it('should return error for semver project and totally invalid tag', () => { + const result = validateTagName({ + project: mockSemverProject, + tagName: 'this-is-so-invalid', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected semver matching \\"/(rc|version)-([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)/\\", found \\"this-is-so-invalid\\"", + "title": "Invalid tag", + }, + } + `); + }); + + it('should return error for calver project and totally invalid tag', () => { + const result = validateTagName({ + project: mockCalverProject, + tagName: 'this-is-so-invalid', + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "tagNameError": Object { + "subtitle": "Expected calver matching \\"/(rc|version)-([0-9]{4}\\\\.[0-9]{2}\\\\.[0-9]{2})_([0-9]+)/\\", found \\"this-is-so-invalid\\"", + "title": "Invalid tag", + }, + } + `); + }); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.test.ts b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.test.ts new file mode 100644 index 0000000000..3eae247cb9 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/react'; + +import { mockApiClient, mockSemverProject } from '../test-helpers/test-helpers'; +import { useGetGitBatchInfo } from './useGetGitBatchInfo'; + +describe('useGetHubBatchInfo', () => { + it('should handle repositories with releases', async () => { + const { result } = renderHook(() => + useGetGitBatchInfo({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + }), + ); + + await act(async () => { + await waitFor(() => result.current.gitBatchInfo !== undefined); + }); + + expect(result.current.gitBatchInfo).toMatchInlineSnapshot(` + Object { + "loading": false, + "value": Object { + "latestRelease": Object { + "htmlUrl": "https://mock_release_html_url", + "id": 1, + "prerelease": false, + "tagName": "rc-2020.01.01_1", + "targetCommitish": "rc/2020.01.01_1", + }, + "releaseBranch": Object { + "commit": Object { + "commit": Object { + "tree": Object { + "sha": "mock_branch_commit_commit_tree_sha", + }, + }, + "sha": "mock_branch_commit_sha", + }, + "links": Object { + "html": "https://mock_branch_links_html", + }, + "name": "rc/1.2.3", + }, + "repository": Object { + "defaultBranch": "mock_defaultBranch", + "name": "mock_repo", + "pushPermissions": true, + }, + }, + } + `); + }); + + it('should handle repositories without any releases', async () => { + (mockApiClient.getLatestRelease as jest.Mock).mockResolvedValueOnce(null); + + const { result } = renderHook(() => + useGetGitBatchInfo({ + pluginApiClient: mockApiClient, + project: mockSemverProject, + }), + ); + + await act(async () => { + await waitFor(() => result.current.gitBatchInfo !== undefined); + }); + + expect(result.current.gitBatchInfo).toMatchInlineSnapshot(` + Object { + "error": [TypeError: Cannot read property 'latestRelease' of null], + "loading": false, + } + `); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.ts b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.ts new file mode 100644 index 0000000000..39935b7a6a --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useGetGitBatchInfo.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect } from 'react'; +import { useAsyncFn } from 'react-use'; + +import { GitReleaseApi } from '../api/GitReleaseClient'; +import { Project } from '../contexts/ProjectContext'; + +interface GetGitBatchInfo { + project: Project; + pluginApiClient: GitReleaseApi; +} + +export const useGetGitBatchInfo = ({ + project, + pluginApiClient, +}: GetGitBatchInfo) => { + const [gitBatchInfo, fetchGitBatchInfo] = useAsyncFn(async () => { + const [{ repository }, { latestRelease }] = await Promise.all([ + pluginApiClient.getRepository({ + owner: project.owner, + repo: project.repo, + }), + pluginApiClient.getLatestRelease({ + owner: project.owner, + repo: project.repo, + }), + ]); + + if (latestRelease === null) { + return { + latestRelease, + releaseBranch: null, + repository, + }; + } + + const { branch: releaseBranch } = await pluginApiClient.getBranch({ + owner: project.owner, + repo: project.repo, + branch: latestRelease.targetCommitish, + }); + + return { + latestRelease, + releaseBranch, + repository, + }; + }); + + useEffect(() => { + fetchGitBatchInfo(); + }, [fetchGitBatchInfo, project]); + + return { + gitBatchInfo, + fetchGitBatchInfo, + }; +}; diff --git a/plugins/git-release-manager/src/hooks/useQueryHandler.test.tsx b/plugins/git-release-manager/src/hooks/useQueryHandler.test.tsx new file mode 100644 index 0000000000..4024e37054 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useQueryHandler.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { mockSearchSemver } from '../test-helpers/test-helpers'; +import { useQueryHandler } from './useQueryHandler'; + +jest.mock('react-router', () => ({ + useLocation: jest.fn(() => ({ + search: mockSearchSemver, + })), +})); + +const TEST_ID = 'grm--use-query-handler'; + +const MockComponent = () => { + const { getParsedQuery, getQueryParamsWithUpdates } = useQueryHandler(); + + const { parsedQuery } = getParsedQuery(); + const { queryParams } = getQueryParamsWithUpdates({ + updates: [{ key: 'repo', value: 'updated_mock_repo' }], + }); + + return ( +
+ {JSON.stringify({ parsedQuery, queryParams }, null, 2)} +
+ ); +}; + +describe('useQueryHandler', () => { + it('should get parsedQuery and queryParams', () => { + const { getByTestId } = render(); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toMatchInlineSnapshot(` + "{ + \\"parsedQuery\\": { + \\"versioningStrategy\\": \\"semver\\", + \\"owner\\": \\"mock_owner\\", + \\"repo\\": \\"mock_repo\\" + }, + \\"queryParams\\": \\"versioningStrategy=semver&owner=mock_owner&repo=updated_mock_repo\\" + }" + `); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useQueryHandler.ts b/plugins/git-release-manager/src/hooks/useQueryHandler.ts new file mode 100644 index 0000000000..c2b6e87a0f --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useQueryHandler.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useLocation } from 'react-router'; +import qs from 'qs'; + +import { Project } from '../contexts/ProjectContext'; + +export function useQueryHandler() { + const location = useLocation(); + + function getParsedQuery() { + const { decodedSearch } = getDecodedSearch(location); + const parsedQuery: Partial = qs.parse(decodedSearch); + + return { + parsedQuery, + }; + } + + function getQueryParamsWithUpdates({ + updates, + }: { + updates: { + key: keyof Project; + value: string; + }[]; + }) { + const { decodedSearch } = getDecodedSearch(location); + const queryParams = qs.parse(decodedSearch); + + for (const { key, value } of updates) { + queryParams[key] = value; + } + + return { + queryParams: qs.stringify(queryParams), + }; + } + + return { + getParsedQuery, + getQueryParamsWithUpdates, + }; +} + +function getDecodedSearch(location: ReturnType) { + return { + decodedSearch: new URLSearchParams(location.search).toString(), + }; +} + +export const testables = { + getDecodedSearch, +}; diff --git a/plugins/git-release-manager/src/hooks/useResponseSteps.test.ts b/plugins/git-release-manager/src/hooks/useResponseSteps.test.ts new file mode 100644 index 0000000000..4460487f06 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useResponseSteps.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useResponseSteps } from './useResponseSteps'; + +describe('useResponseSteps', () => { + it('should export expected variables', () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current).toMatchInlineSnapshot(` + Object { + "abortIfError": [Function], + "addStepToResponseSteps": [Function], + "asyncCatcher": [Function], + "responseSteps": Array [], + } + `); + }); + + describe('addStepToResponseSteps', () => { + it('should add responseSteps to state', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + act(() => { + result.current.addStepToResponseSteps({ + message: 'totally added a messaage ✌🏼', + }); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "message": "totally added a messaage ✌🏼", + }, + ] + `); + }); + }); + + describe('asyncCatcher', () => { + it('should catch Errors and add as failure step, then throw', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + await act(async () => { + await new Promise((_, reject) => reject(new Error(':('))) + .catch(result.current.asyncCatcher) + .catch( + () => void 0, // swallow + ); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "icon": "failure", + "message": "Something went wrong 🔥", + "secondaryMessage": "Error message: :(", + }, + ] + `); + }); + + it('should catch unknown Errors and add as failure step, then throw', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + await act(async () => { + await new Promise((_, reject) => reject()) + .catch(result.current.asyncCatcher) + .catch( + () => void 0, // swallow + ); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "icon": "failure", + "message": "Something went wrong 🔥", + "secondaryMessage": "Error message: unknown", + }, + ] + `); + }); + }); + + describe('abortIfError', () => { + it('should throw if Error and add a failure step', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + act(() => { + try { + result.current.abortIfError(new Error('Das kaboom')); + } catch (error) { + // + } + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(` + Array [ + Object { + "icon": "failure", + "message": "Skipped due to error in previous step", + }, + ] + `); + }); + + it('should do nothing if not Error', async () => { + const { result } = renderHook(() => useResponseSteps()); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + + act(() => { + result.current.abortIfError(undefined); + }); + + expect(result.current.responseSteps).toMatchInlineSnapshot(`Array []`); + }); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useResponseSteps.ts b/plugins/git-release-manager/src/hooks/useResponseSteps.ts new file mode 100644 index 0000000000..e20587d078 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useResponseSteps.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState } from 'react'; + +import { ResponseStep } from '../types/types'; + +const RESPONSE_STEP_FAILURE_ABORT: ResponseStep = { + message: 'Skipped due to error in previous step', + icon: 'failure', +}; + +export function useResponseSteps() { + const [responseSteps, setResponseSteps] = useState([]); + + const addStepToResponseSteps = (responseStep: ResponseStep) => { + setResponseSteps([...responseSteps, responseStep]); + }; + + const asyncCatcher = (error: Error): never => { + const responseStepError: ResponseStep = { + message: 'Something went wrong 🔥', + secondaryMessage: `Error message: ${ + error?.message ? error.message : 'unknown' + }`, + icon: 'failure', + }; + + addStepToResponseSteps(responseStepError); + throw error; + }; + + const abortIfError = (error?: Error) => { + if (error) { + addStepToResponseSteps(RESPONSE_STEP_FAILURE_ABORT); + throw error; + } + }; + + return { + responseSteps, + addStepToResponseSteps, + asyncCatcher, + abortIfError, + }; +} diff --git a/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.test.tsx b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.test.tsx new file mode 100644 index 0000000000..0a7ba54794 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { + mockReleaseVersionCalver, + mockReleaseVersionSemver, + mockSemverProject, +} from '../test-helpers/test-helpers'; +import { Project } from '../contexts/ProjectContext'; +import { useVersioningStrategyMatchesRepoTags } from './useVersioningStrategyMatchesRepoTags'; + +const TEST_ID = 'grm--use-versioning-strategy-matches-repo-tags'; +const MATCH = 'match ✅'; +const NO_MATCH = 'NO match ❌'; + +const MockComponent = ({ + project, + latestReleaseTagName, + repositoryName, +}: { + project: Project; + latestReleaseTagName?: string; + repositoryName?: string; +}) => { + const { versioningStrategyMatches } = useVersioningStrategyMatchesRepoTags({ + project, + latestReleaseTagName, + repositoryName, + }); + + return ( +
+ {versioningStrategyMatches ? MATCH : NO_MATCH} +
+ ); +}; + +describe('useVersioningStrategyMatchesRepoTags', () => { + it('should NOT match for missing latestReleaseTagName & repositoryName', () => { + const { getByTestId } = render( + , + ); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toEqual(NO_MATCH); + }); + + it('should NOT match for mismatching versioning strategies', () => { + const { getByTestId } = render( + , + ); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toEqual(NO_MATCH); + }); + + it('should match for matching repositories with same versioning strategy', () => { + const { getByTestId } = render( + , + ); + + const result = getByTestId(TEST_ID).innerHTML; + + expect(result).toEqual(MATCH); + }); +}); diff --git a/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.ts b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.ts new file mode 100644 index 0000000000..032a5280a1 --- /dev/null +++ b/plugins/git-release-manager/src/hooks/useVersioningStrategyMatchesRepoTags.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useState } from 'react'; + +import { Project } from '../contexts/ProjectContext'; +import { getTagParts } from '../helpers/tagParts/getTagParts'; + +export const useVersioningStrategyMatchesRepoTags = ({ + project, + latestReleaseTagName, + repositoryName, +}: { + project: Project; + latestReleaseTagName?: string; + repositoryName?: string; +}) => { + const [versioningStrategyMatches, setVersioningStrategyMatches] = useState( + false, + ); + useEffect(() => { + setVersioningStrategyMatches(false); + + if (latestReleaseTagName) { + if (project.repo === repositoryName) { + const { error } = getTagParts({ project, tag: latestReleaseTagName }); + setVersioningStrategyMatches(error === undefined); + } + } + }, [latestReleaseTagName, project, repositoryName]); + + return { + versioningStrategyMatches, + }; +}; diff --git a/plugins/git-release-manager/src/index.ts b/plugins/git-release-manager/src/index.ts new file mode 100644 index 0000000000..38b6a4ebb3 --- /dev/null +++ b/plugins/git-release-manager/src/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + gitReleaseManagerPlugin, + GitReleaseManagerPage, + gitReleaseManagerApiRef, +} from './plugin'; diff --git a/plugins/git-release-manager/src/plugin.test.ts b/plugins/git-release-manager/src/plugin.test.ts new file mode 100644 index 0000000000..a4882ceb9b --- /dev/null +++ b/plugins/git-release-manager/src/plugin.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as plugin from './plugin'; + +describe('git-release-manager', () => { + it('should export plugin & friends', () => { + expect(Object.keys(plugin)).toMatchInlineSnapshot(` + Array [ + "gitReleaseManagerApiRef", + "gitReleaseManagerPlugin", + "GitReleaseManagerPage", + ] + `); + }); +}); diff --git a/plugins/git-release-manager/src/plugin.ts b/plugins/git-release-manager/src/plugin.ts new file mode 100644 index 0000000000..6b15b79a9f --- /dev/null +++ b/plugins/git-release-manager/src/plugin.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + configApiRef, + createPlugin, + createApiFactory, + githubAuthApiRef, + createRoutableExtension, +} from '@backstage/core'; + +import { gitReleaseManagerApiRef } from './api/serviceApiRef'; +import { GitReleaseClient } from './api/GitReleaseClient'; +import { rootRouteRef } from './routes'; + +export { gitReleaseManagerApiRef }; + +export const gitReleaseManagerPlugin = createPlugin({ + id: 'git-release-manager', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: gitReleaseManagerApiRef, + deps: { + configApi: configApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ configApi, githubAuthApi }) => { + return new GitReleaseClient({ + configApi, + githubAuthApi, + }); + }, + }), + ], +}); + +export const GitReleaseManagerPage = gitReleaseManagerPlugin.provide( + createRoutableExtension({ + component: () => + import('./GitReleaseManager').then(m => m.GitReleaseManager), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/git-release-manager/src/routes.ts b/plugins/git-release-manager/src/routes.ts new file mode 100644 index 0000000000..3b3ea80cc2 --- /dev/null +++ b/plugins/git-release-manager/src/routes.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + title: 'git-release-manager', +}); diff --git a/plugins/git-release-manager/src/setupTests.ts b/plugins/git-release-manager/src/setupTests.ts new file mode 100644 index 0000000000..3ffe1424cc --- /dev/null +++ b/plugins/git-release-manager/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/git-release-manager/src/test-helpers/stats.ts b/plugins/git-release-manager/src/test-helpers/stats.ts new file mode 100644 index 0000000000..bd1a33f95a --- /dev/null +++ b/plugins/git-release-manager/src/test-helpers/stats.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReleaseStats } from '../features/Stats/contexts/ReleaseStatsContext'; + +export const mockReleaseStats: ReleaseStats = { + releases: { + '1.0': { + baseVersion: '1.0', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.0.1', + tagSha: 'sha-1.0.1', + tagType: 'tag', + }, + { + tagName: 'rc-1.0.0', + tagSha: 'sha-1.0.0', + tagType: 'tag', + }, + ], + versions: [], + }, + '1.1': { + baseVersion: '1.1', + createdAt: '2021-01-01T10:11:12Z', + htmlUrl: 'html_url', + candidates: [ + { + tagName: 'rc-1.1.2', + tagSha: 'sha-1.1.2', + tagType: 'tag', + }, + { + tagName: 'rc-1.1.1', + tagSha: 'sha-1.1.1', + tagType: 'tag', + }, + { + tagName: 'rc-1.1.0', + tagSha: 'sha-1.1.0', + tagType: 'tag', + }, + ], + versions: [ + { + tagName: 'version-1.1.3', + tagSha: 'sha-1.1.3', + tagType: 'tag', + }, + { + tagName: 'version-1.1.2', + tagSha: 'sha-1.1.2', + tagType: 'tag', + }, + ], + }, + }, + unmappableTags: [], + unmatchedReleases: [], + unmatchedTags: [], +}; diff --git a/plugins/git-release-manager/src/test-helpers/test-helpers.ts b/plugins/git-release-manager/src/test-helpers/test-helpers.ts new file mode 100644 index 0000000000..09c90d3f76 --- /dev/null +++ b/plugins/git-release-manager/src/test-helpers/test-helpers.ts @@ -0,0 +1,359 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + GetBranchResult, + GetLatestReleaseResult, + GetRecentCommitsResultSingle, + GetTagResult, + GitReleaseApi, + GetCommitResult, +} from '../api/GitReleaseClient'; +import { CalverTagParts } from '../helpers/tagParts/getCalverTagParts'; +import { Project } from '../contexts/ProjectContext'; +import { getReleaseCandidateGitInfo } from '../helpers/getReleaseCandidateGitInfo'; + +const mockUsername = 'mock_username'; +const mockEmail = 'mock_email'; +const mockOwner = 'mock_owner'; +const mockRepo = 'mock_repo'; + +const A_CALVER_VERSION = '2020.01.01_1'; +const MOCK_RELEASE_NAME_CALVER = `Version ${A_CALVER_VERSION}`; +const MOCK_RELEASE_BRANCH_NAME_CALVER = `rc/${A_CALVER_VERSION}`; +const MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER = `rc-${A_CALVER_VERSION}`; +const MOCK_RELEASE_VERSION_TAG_NAME_CALVER = `version-${A_CALVER_VERSION}`; + +const A_SEMVER_VERSION = '1.2.3'; +const MOCK_RELEASE_NAME_SEMVER = `Version ${A_SEMVER_VERSION}`; +const MOCK_RELEASE_BRANCH_NAME_SEMVER = `rc/${A_SEMVER_VERSION}`; +const MOCK_RELEASE_CANDIDATE_TAG_NAME_SEMVER = `rc-${A_SEMVER_VERSION}`; +const MOCK_RELEASE_VERSION_TAG_NAME_SEMVER = `version-${A_SEMVER_VERSION}`; + +export const createMockTag = ( + overrides: Partial, +): GetTagResult => ({ + tag: { + date: '2000-01-01T10:00:00.000Z', + objectSha: 'mock_tag_object_sha', + userEmail: mockEmail, + username: mockUsername, + ...overrides, + }, +}); + +export const createMockCommit = ( + overrides: Partial, +): GetCommitResult => ({ + commit: { + commit: { + message: 'mock_commit_commit_message', + }, + htmlUrl: 'https://mock_commit_html_url', + sha: 'mock_commit_sha', + createdAt: '2000-01-01T10:00:00.000Z', + ...overrides, + }, +}); + +export const mockUser = { + username: mockUsername, + email: mockEmail, +}; + +export const mockSemverProject: Project = { + owner: mockOwner, + repo: mockRepo, + versioningStrategy: 'semver', + isProvidedViaProps: false, +}; + +export const mockCalverProject: Project = { + owner: mockOwner, + repo: mockRepo, + versioningStrategy: 'calver', + isProvidedViaProps: false, +}; + +export const mockSearchCalver = `?versioningStrategy=${mockCalverProject.versioningStrategy}&owner=${mockCalverProject.owner}&repo=${mockCalverProject.repo}`; + +export const mockSearchSemver = `?versioningStrategy=${mockSemverProject.versioningStrategy}&owner=${mockSemverProject.owner}&repo=${mockSemverProject.repo}`; + +export const mockDefaultBranch = 'mock_defaultBranch'; + +export const mockNextGitInfoSemver: ReturnType< + typeof getReleaseCandidateGitInfo +> = { + rcBranch: MOCK_RELEASE_BRANCH_NAME_SEMVER, + rcReleaseTag: MOCK_RELEASE_CANDIDATE_TAG_NAME_SEMVER, + releaseName: MOCK_RELEASE_NAME_SEMVER, +}; + +export const mockNextGitInfoCalver: ReturnType< + typeof getReleaseCandidateGitInfo +> = { + rcBranch: MOCK_RELEASE_BRANCH_NAME_CALVER, + rcReleaseTag: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + releaseName: MOCK_RELEASE_NAME_CALVER, +}; + +export const mockTagParts = { + prefix: 'rc', + calver: '2020.01.01', + patch: 1, +} as CalverTagParts; + +export const mockBumpedTag = 'rc-2020.01.01_1337'; + +/** + * MOCK RELEASE + */ +const createMockRelease = ({ + id = 1, + prerelease = false, + ...rest +}: Partial< + NonNullable +> = {}): NonNullable => ({ + id, + htmlUrl: 'https://mock_release_html_url', + prerelease, + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_CALVER, + ...rest, +}); + +export const mockReleaseCandidateCalver = createMockRelease({ + prerelease: true, + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_CALVER, +}); + +export const mockReleaseVersionCalver = createMockRelease({ + prerelease: false, + tagName: MOCK_RELEASE_VERSION_TAG_NAME_CALVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_CALVER, +}); + +export const mockReleaseCandidateSemver = createMockRelease({ + prerelease: true, + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_SEMVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_SEMVER, +}); +export const mockReleaseVersionSemver = createMockRelease({ + prerelease: false, + tagName: MOCK_RELEASE_VERSION_TAG_NAME_SEMVER, + targetCommitish: MOCK_RELEASE_BRANCH_NAME_SEMVER, +}); + +/** + * MOCK BRANCH + */ +const createMockBranch = ({ + ...rest +}: Partial = {}): GetBranchResult['branch'] => ({ + name: MOCK_RELEASE_BRANCH_NAME_SEMVER, + commit: { + sha: 'mock_branch_commit_sha', + commit: { + tree: { + sha: 'mock_branch_commit_commit_tree_sha', + }, + }, + }, + links: { + html: 'https://mock_branch_links_html', + }, + ...rest, +}); +export const mockReleaseBranch = createMockBranch(); + +/** + * MOCK COMMIT + */ +const createMockRecentCommit = ({ + ...rest +}: Partial): GetRecentCommitsResultSingle => ({ + author: { + htmlUrl: 'https://author_html_url', + login: 'author_login', + }, + commit: { + message: 'commit_message', + }, + sha: 'mock_sha', + firstParentSha: 'mock_first_parent_sha', + htmlUrl: 'https://mock_htmlUrl', + ...rest, +}); + +export const mockSelectedPatchCommit = createMockRecentCommit({ + sha: 'mock_sha_selected_patch_commit', +}); + +/** + * MOCK API CLIENT + */ +export const mockApiClient: GitReleaseApi = { + getHost: jest.fn(() => 'github.com'), + + getRepoPath: jest.fn(() => `${mockOwner}/${mockRepo}`), + + getOwners: jest.fn(async () => ({ + owners: [mockOwner, `${mockOwner}2`], + })), + + getRepositories: jest.fn(async () => ({ + repositories: [mockRepo, `${mockRepo}2`], + })), + + getUser: jest.fn(async () => ({ + user: { + username: mockOwner, + email: mockEmail, + }, + })), + + getRecentCommits: jest.fn(async () => ({ + recentCommits: [ + createMockRecentCommit({ sha: 'mock_sha_recent_commits_1' }), + createMockRecentCommit({ sha: 'mock_sha_recent_commits_2' }), + ], + })), + + getLatestRelease: jest.fn(async () => ({ + latestRelease: createMockRelease(), + })), + + getRepository: jest.fn(async () => ({ + repository: { + pushPermissions: true, + defaultBranch: mockDefaultBranch, + name: mockRepo, + }, + })), + + getCommit: jest.fn(async () => ({ + commit: { + sha: 'latestCommit.sha', + htmlUrl: 'https://latestCommit.html_url', + commit: { + message: 'latestCommit.commit.message', + }, + createdAt: '2021-01-01T10:11:12Z', + }, + })), + + getBranch: jest.fn(async () => ({ + branch: createMockBranch(), + })), + + createRef: jest.fn(async () => ({ + reference: { + ref: 'mock_createRef_ref', + objectSha: 'mock_createRef_objectSha', + }, + })), + + createRelease: jest.fn(async () => ({ + release: { + name: 'mock_createRelease_name', + htmlUrl: 'https://mock_createRelease_html_url', + tagName: 'mock_createRelease_tag_name', + }, + })), + + getComparison: jest.fn(async () => ({ + comparison: { + htmlUrl: 'https://mock_compareCommits_html_url', + aheadBy: 1, + }, + })), + + createTagObject: jest.fn(async () => ({ + tagObject: { + tagName: 'mock_tag_object_tag', + tagSha: 'mock_tag_object_sha', + }, + })), + + createCommit: jest.fn(async () => ({ + commit: { + message: 'mock_commit_message', + sha: 'mock_commit_sha', + }, + })), + + updateRef: jest.fn(async () => ({ + reference: { + ref: 'mock_update_ref_ref', + object: { + sha: 'mock_update_ref_object_sha', + }, + }, + })), + + merge: jest.fn(async () => ({ + merge: { + htmlUrl: 'https://mock_merge_html_url', + commit: { + message: 'mock_merge_commit_message', + tree: { + sha: 'mock_merge_commit_tree_sha', + }, + }, + }, + })), + + updateRelease: jest.fn(async () => ({ + release: { + name: 'mock_update_release_name', + tagName: 'mock_update_release_tag_name', + htmlUrl: 'https://mock_update_release_html_url', + }, + })), + + getAllTags: jest.fn(async () => ({ + tags: [ + { + tagName: MOCK_RELEASE_CANDIDATE_TAG_NAME_CALVER, + tagSha: 'mock_sha', + tagType: 'tag' as const, + }, + ], + })), + + getAllReleases: jest.fn(async () => ({ + releases: [ + { + id: 1, + name: 'mock_release_name', + tagName: 'mock_release_tag_name', + createdAt: 'mock_release_published_at', + htmlUrl: 'https://mock_release_html_url', + }, + ], + })), + + getTag: jest.fn(async () => ({ + tag: { + date: '2021-04-29T12:48:30.120Z', + username: 'mock_user_single_tag_name', + userEmail: 'mock_user_single_tag_email', + objectSha: 'mock_single_tag_object_sha', + }, + })), +}; diff --git a/plugins/git-release-manager/src/test-helpers/test-ids.ts b/plugins/git-release-manager/src/test-helpers/test-ids.ts new file mode 100644 index 0000000000..5e4175ba4d --- /dev/null +++ b/plugins/git-release-manager/src/test-helpers/test-ids.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const TEST_IDS = { + info: { + info: 'grm--info', + infoFeaturePlus: 'grm--info-feature-plus', + }, + createRc: { + cta: 'grm--create-rc--cta', + semverSelect: 'grm--create-rc--semver-select', + }, + promoteRc: { + mockedPromoteRcBody: 'grm-mocked-promote-rc-body', + notRcWarning: 'grm--promote-rc--not-rc-warning', + promoteRc: 'grm--promote-rc', + cta: 'grm--promote-rc-body--cta', + }, + patch: { + error: 'grm--patch-body--error', + loading: 'grm--patch-body--loading', + notPrerelease: 'grm--patch-body--not-prerelease--info', + body: 'grm--patch-body', + }, + form: { + owner: { + loading: 'grm--form--owner--loading', + select: 'grm--form--owner--select', + error: 'grm--form--owner--error', + empty: 'grm--form--owner--empty', + }, + repo: { + loading: 'grm--form--repo--loading', + select: 'grm--form--repo--select', + error: 'grm--form--repo--error', + empty: 'grm--form--repo--empty', + }, + versioningStrategy: { + radioGroup: 'grm--form--versioning-strategy--radio-group', + }, + }, + components: { + divider: 'grm--divider', + noLatestRelease: 'grm--no-latest-release', + circularProgress: 'grm--circular-progress', + responseStepListDialogContent: 'grm--response-step-list--dialog-content', + responseStepListItem: 'grm--response-step-list-item', + responseStepListItemIconSuccess: + 'grm--response-step-list-item--item-icon--success', + responseStepListItemIconFailure: + 'grm--response-step-list-item--item-icon--failure', + responseStepListItemIconLink: + 'grm--response-step-list-item--item-icon--link', + responseStepListItemIconDefault: + 'grm--response-step-list-item--item-icon--default', + differ: { + current: 'grm--differ-current', + next: 'grm--differ-next', + icons: { + tag: 'grm--differ--icons--tag', + branch: 'grm--differ--icons--branch', + github: 'grm--differ--icons--git', + slack: 'grm--differ--icons--slack', + versioning: 'grm--differ--icons--versioning', + }, + }, + linearProgressWithLabel: 'grm--linear-progress-with-label', + }, +}; diff --git a/plugins/git-release-manager/src/types/helpers.ts b/plugins/git-release-manager/src/types/helpers.ts new file mode 100644 index 0000000000..55bc6fdcac --- /dev/null +++ b/plugins/git-release-manager/src/types/helpers.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type UnboxPromise> = T extends Promise + ? U + : never; + +export type UnboxReturnedPromise< + T extends (...args: any) => Promise +> = UnboxPromise>; + +export type UnboxArray = T extends (infer U)[] ? U : T; diff --git a/plugins/git-release-manager/src/types/types.ts b/plugins/git-release-manager/src/types/types.ts new file mode 100644 index 0000000000..5feac149ca --- /dev/null +++ b/plugins/git-release-manager/src/types/types.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type ComponentConfig = { + omit?: boolean; + onSuccess?: (args: Args) => Promise | void; +}; + +interface CreateRcOnSuccessArgs { + gitReleaseUrl: string; + gitReleaseName: string | null; + comparisonUrl: string; + previousTag?: string; + createdTag: string; +} +export type ComponentConfigCreateRc = ComponentConfig; + +interface PromoteRcOnSuccessArgs { + gitReleaseUrl: string; + gitReleaseName: string | null; + previousTagUrl: string; + previousTag: string; + updatedTagUrl: string; + updatedTag: string; +} +export type ComponentConfigPromoteRc = ComponentConfig; + +interface PatchOnSuccessArgs { + updatedReleaseUrl: string; + updatedReleaseName: string | null; + previousTag: string; + patchedTag: string; + patchCommitUrl: string; + patchCommitMessage: string; +} +export type ComponentConfigPatch = ComponentConfig; + +export interface ResponseStep { + message: string | React.ReactNode; + secondaryMessage?: string | React.ReactNode; + link?: string; + icon?: 'success' | 'failure'; +} + +export interface CardHook { + progress: number; + responseSteps: ResponseStep[]; + run: (args: RunArgs) => Promise; + runInvoked: boolean; +} + +export interface AlertError { + title?: string; + subtitle: string; +} diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index aa2fec5512..f435ff31bc 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-github-actions +## 0.4.6 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.4.5 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 95608c3c49..841bb3ebc5 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-actions", - "version": "0.4.5", + "version": "0.4.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.5", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/core": "^0.7.9", "@backstage/integration": "^0.5.2", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -50,12 +50,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index 23c8943349..3212382684 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-github-deployments +## 0.1.5 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- f5a916c49: Support for GitHub Enterprise with GithubDeploymentsPlugin +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.1.4 ### Patch Changes diff --git a/plugins/github-deployments/README.md b/plugins/github-deployments/README.md index 8284ceba56..3c5435b4a6 100644 --- a/plugins/github-deployments/README.md +++ b/plugins/github-deployments/README.md @@ -52,3 +52,19 @@ spec: owner: CNCF lifecycle: experimental ``` + +### Self-hosted / Enterprise GitHub + +The plugin will try to use `backstage.io/source-location` or `backstage.io/managed-by-location` +annotations to figure out the location of the source code. + +1. Add the `host` and `apiBaseUrl` to your `app-config.yaml` + +```yaml +# app-config.yaml + +integrations: + github: + - host: 'your-github-host.com' + apiBaseUrl: 'https://api.your-github-host.com' +``` diff --git a/plugins/github-deployments/package.json b/plugins/github-deployments/package.json index 84a0fe1660..1058a30fed 100644 --- a/plugins/github-deployments/package.json +++ b/plugins/github-deployments/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-github-deployments", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.3", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/errors": "^0.1.1", + "@backstage/integration": "^0.5.1", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -34,12 +37,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/github-deployments/src/api/index.ts b/plugins/github-deployments/src/api/index.ts index 7496370bc9..23a381f771 100644 --- a/plugins/github-deployments/src/api/index.ts +++ b/plugins/github-deployments/src/api/index.ts @@ -13,9 +13,42 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { parseLocationReference } from '@backstage/catalog-model'; import { createApiRef, OAuthApi } from '@backstage/core'; +import { InputError } from '@backstage/errors'; +import { ScmIntegrationRegistry } from '@backstage/integration'; import { graphql } from '@octokit/graphql'; +const getBaseUrl = ( + scmIntegrationsApi: ScmIntegrationRegistry, + host: string | undefined, +): string => { + if (host === undefined) { + return 'https://api.github.com'; + } + + const location = parseLocationReference(host); + if (location.type !== 'github') { + return 'https://api.github.com'; + } + + const config = scmIntegrationsApi.github.byHost(location.target); + + if (!config) { + throw new InputError( + `No matching GitHub integration configuration for host ${host}, please check your integrations config`, + ); + } + + if (!config.config.apiBaseUrl) { + throw new InputError( + `No apiBaseUrl available for host ${host}, please check your integrations config`, + ); + } + + return config?.config.apiBaseUrl; +}; + export type GithubDeployment = { environment: string; state: string; @@ -30,12 +63,15 @@ export type GithubDeployment = { payload: string; }; +type QueryParams = { + host: string | undefined; + owner: string; + repo: string; + last: number; +}; + export interface GithubDeploymentsApi { - listDeployments(options: { - owner: string; - repo: string; - last: number; - }): Promise; + listDeployments(params: QueryParams): Promise; } export const githubDeploymentsApiRef = createApiRef({ @@ -45,6 +81,7 @@ export const githubDeploymentsApiRef = createApiRef({ export type Options = { githubAuthApi: OAuthApi; + scmIntegrationsApi: ScmIntegrationRegistry; }; const deploymentsQuery = ` @@ -79,19 +116,19 @@ export type QueryResponse = { export class GithubDeploymentsApiClient implements GithubDeploymentsApi { private readonly githubAuthApi: OAuthApi; + private readonly scmIntegrationsApi: ScmIntegrationRegistry; constructor(options: Options) { this.githubAuthApi = options.githubAuthApi; + this.scmIntegrationsApi = options.scmIntegrationsApi; } - async listDeployments(options: { - owner: string; - repo: string; - last: number; - }): Promise { + async listDeployments(params: QueryParams): Promise { + const baseUrl = getBaseUrl(this.scmIntegrationsApi, params.host); const token = await this.githubAuthApi.getAccessToken(['repo']); const graphQLWithAuth = graphql.defaults({ + baseUrl, headers: { authorization: `token ${token}`, }, @@ -99,7 +136,7 @@ export class GithubDeploymentsApiClient implements GithubDeploymentsApi { const response: QueryResponse = await graphQLWithAuth( deploymentsQuery, - options, + params, ); return response.repository?.deployments?.nodes?.reverse() || []; } diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx index f128e6274b..bec60c47bc 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.test.tsx @@ -43,18 +43,48 @@ import { import { setupServer } from 'msw/node'; import { graphql } from 'msw'; +import { ScmIntegrations } from '@backstage/integration'; +import { Entity } from '@backstage/catalog-model'; import { GithubDeploymentsTable } from './GithubDeploymentsTable'; import { Box } from '@material-ui/core'; +let entity: { entity: Entity }; + jest.mock('@backstage/plugin-catalog-react', () => ({ useEntity: () => { - return entityStub; + return entity; }, })); const errorApiMock = { post: jest.fn(), error$: jest.fn() }; -const configApi: ConfigApi = new ConfigReader({}); +const configApi: ConfigApi = new ConfigReader({ + integrations: { + github: [ + { + host: 'my-github-1.com', + apiBaseUrl: 'https://api.my-github-1.com', + }, + { + host: 'my-github-2.com', + apiBaseUrl: 'https://api.my-github-2.com', + }, + { + host: 'my-github-3.com', + }, + ], + }, +}); + +const GRAPHQL_GITHUB_API = graphql.link('https://api.github.com/graphql'); +const GRAPHQL_CUSTOM_API_1 = graphql.link( + 'https://api.my-github-1.com/graphql', +); +const GRAPHQL_CUSTOM_API_2 = graphql.link( + 'https://api.my-github-2.com/graphql', +); + +const scmIntegrationsApi = ScmIntegrations.fromConfig(configApi); const githubAuthApi: OAuthApi = { getAccessToken: async _ => 'access_token', }; @@ -62,14 +92,42 @@ const githubAuthApi: OAuthApi = { const apis = ApiRegistry.from([ [configApiRef, configApi], [errorApiRef, errorApiMock], - [githubDeploymentsApiRef, new GithubDeploymentsApiClient({ githubAuthApi })], + [ + githubDeploymentsApiRef, + new GithubDeploymentsApiClient({ scmIntegrationsApi, githubAuthApi }), + ], ]); +const assertFetchedData = async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect(await rendered.findByText('GitHub Deployments')).toBeInTheDocument(); + + expect(await rendered.findByText('active')).toBeInTheDocument(); + expect(await rendered.findByText('prd')).toBeInTheDocument(); + expect(await rendered.findByText('12345')).toHaveAttribute( + 'href', + 'https://exampleapi.com/123456789', + ); + + expect(await rendered.findByText('pending')).toBeInTheDocument(); + expect(await rendered.findByText('lab')).toBeInTheDocument(); + expect(await rendered.findByText('54321')).toHaveAttribute( + 'href', + 'https://exampleapi.com/543212345', + ); +}; + describe('github-deployments', () => { const worker = setupServer(); msw.setupDefaultHandlers(worker); beforeEach(() => { + worker.resetHandlers(); jest.resetAllMocks(); }); @@ -80,40 +138,27 @@ describe('github-deployments', () => { }); describe('GithubDeploymentsCard', () => { - it('should display fetched data', async () => { + beforeEach(() => { + entity = entityStub; + entity.entity.metadata.annotations = { + 'github.com/project-slug': 'org/repo', + }; + }); + + it('displays fetched data using default github api', async () => { worker.use( - graphql.query('deployments', (_, res, ctx) => + GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) => res(ctx.data(responseStub)), ), ); - const rendered = await renderInTestApp( - - - , - ); - - expect( - await rendered.findByText('GitHub Deployments'), - ).toBeInTheDocument(); - expect(await rendered.findByText('active')).toBeInTheDocument(); - expect(await rendered.findByText('prd')).toBeInTheDocument(); - expect(await rendered.findByText('12345')).toHaveAttribute( - 'href', - 'https://exampleapi.com/123456789', - ); - - expect(await rendered.findByText('pending')).toBeInTheDocument(); - expect(await rendered.findByText('lab')).toBeInTheDocument(); - expect(await rendered.findByText('54321')).toHaveAttribute( - 'href', - 'https://exampleapi.com/543212345', - ); + await assertFetchedData(); + expect.assertions(7); }); it('should display empty state when no data', async () => { worker.use( - graphql.query('deployments', (_, res, ctx) => + GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) => res(ctx.data(noDataResponseStub)), ), ); @@ -132,9 +177,9 @@ describe('github-deployments', () => { ).toBeInTheDocument(); }); - it('should shows new data on reload', async () => { + it('should show new data on reload', async () => { worker.use( - graphql.query('deployments', (_, res, ctx) => + GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) => res(ctx.data(responseStub)), ), ); @@ -152,7 +197,7 @@ describe('github-deployments', () => { worker.resetHandlers(); worker.use( - graphql.query('deployments', (_, res, ctx) => + GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) => res(ctx.data(refreshedResponseStub)), ), ); @@ -199,5 +244,144 @@ describe('github-deployments', () => { expect(await rendered.findByText('moon')).toBeInTheDocument(); expect(await rendered.findByText('sun')).toBeInTheDocument(); }); + + describe('entity with source location', () => { + beforeEach(() => { + entity = entityStub; + entity.entity.metadata.annotations = { + 'github.com/project-slug': 'org/repo', + 'backstage.io/source-location': 'github:my-github-1.com', + }; + }); + + it('should fetch data using custom api', async () => { + worker.use( + GRAPHQL_CUSTOM_API_1.query('deployments', (_, res, ctx) => + res(ctx.data(responseStub)), + ), + ); + + await assertFetchedData(); + expect.assertions(7); + }); + }); + + describe('entity with managed by location', () => { + beforeEach(() => { + entity = entityStub; + entity.entity.metadata.annotations = { + 'github.com/project-slug': 'org/repo', + 'backstage.io/managed-by-location': 'github:my-github-2.com', + }; + }); + + it('should fetch data using custom api', async () => { + worker.use( + GRAPHQL_CUSTOM_API_2.query('deployments', (_, res, ctx) => + res(ctx.data(responseStub)), + ), + ); + + await assertFetchedData(); + expect.assertions(7); + }); + }); + + describe('entity with location without baseApiURL', () => { + beforeEach(() => { + entity = entityStub; + entity.entity.metadata.annotations = { + 'github.com/project-slug': 'org/repo', + 'backstage.io/managed-by-location': 'github:my-github-3.com', + }; + }); + + it('shows no apiBaseUrl error', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect( + await rendered.findByText( + 'Warning: No apiBaseUrl available for host github:my-github-3.com, please check your integrations config', + ), + ).toBeInTheDocument(); + }); + }); + + describe('entity with location without matching host', () => { + beforeEach(() => { + entity = entityStub; + entity.entity.metadata.annotations = { + 'github.com/project-slug': 'org/repo', + 'backstage.io/managed-by-location': + 'github:my-github-unknown.com/org/repo', + }; + }); + + it('shows no matching host error', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect( + await rendered.findByText( + 'Warning: No matching GitHub integration configuration for host github:my-github-unknown.com/org/repo, please check your integrations config', + ), + ).toBeInTheDocument(); + }); + }); + + describe('entity with url location type', () => { + beforeEach(() => { + entity = entityStub; + entity.entity.metadata.annotations = { + 'github.com/project-slug': 'org/repo', + 'backstage.io/source-location': + 'url:my-favourite-url-location/org/repo', + 'backstage.io/managed-by-location': + 'url:my-favourite-url-location/org/repo', + }; + }); + + it('displays fetched data using default github api', async () => { + worker.use( + GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) => + res(ctx.data(responseStub)), + ), + ); + + await assertFetchedData(); + expect.assertions(7); + }); + }); + + describe('entity with other location types', () => { + beforeEach(() => { + entity = entityStub; + entity.entity.metadata.annotations = { + 'github.com/project-slug': 'org/repo', + 'backstage.io/source-location': + 'some-other-managed-location:my-favourite-location/org/repo', + 'backstage.io/managed-by-location': + 'file:my-favourite-file/org/repo.yaml', + }; + }); + + it('displays fetched data using default github api', async () => { + worker.use( + GRAPHQL_GITHUB_API.query('deployments', (_, res, ctx) => + res(ctx.data(responseStub)), + ), + ); + + await assertFetchedData(); + expect.assertions(7); + }); + }); }); }); diff --git a/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx index 073f78af2e..a13c205084 100644 --- a/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx +++ b/plugins/github-deployments/src/components/GithubDeploymentsCard.tsx @@ -28,21 +28,27 @@ import { isGithubDeploymentsAvailable, } from '../Router'; import { GithubDeploymentsTable } from './GithubDeploymentsTable/GithubDeploymentsTable'; +import { + LOCATION_ANNOTATION, + SOURCE_LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; const GithubDeploymentsComponent = ({ projectSlug, last, columns, + host, }: { projectSlug: string; last: number; columns: TableColumn[]; + host: string | undefined; }) => { const api = useApi(githubDeploymentsApiRef); const [owner, repo] = projectSlug.split('/'); const { loading, value, error, retry: reload } = useAsyncRetry( - async () => await api.listDeployments({ owner, repo, last }), + async () => await api.listDeployments({ host, owner, repo, last }), ); if (error) { @@ -67,6 +73,10 @@ export const GithubDeploymentsCard = ({ columns?: TableColumn[]; }) => { const { entity } = useEntity(); + const [host] = [ + entity?.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION], + entity?.metadata.annotations?.[LOCATION_ANNOTATION], + ].filter(Boolean); return !isGithubDeploymentsAvailable(entity) ? ( @@ -76,6 +86,7 @@ export const GithubDeploymentsCard = ({ entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION] || '' } last={last || 10} + host={host} columns={columns || GithubDeploymentsTable.defaultDeploymentColumns} /> ); diff --git a/plugins/github-deployments/src/mocks/mocks.ts b/plugins/github-deployments/src/mocks/mocks.ts index c0afb325db..b2a75ac7b0 100644 --- a/plugins/github-deployments/src/mocks/mocks.ts +++ b/plugins/github-deployments/src/mocks/mocks.ts @@ -13,15 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { QueryResponse } from '../api'; -export const entityStub = { +export const entityStub: { entity: Entity } = { entity: { metadata: { namespace: 'default', - annotations: { - 'github.com/project-slug': 'org/repo', - }, + annotations: {}, name: 'sample-service', description: 'Sample service', uid: 'g0h33dd9-56h7-835b-b63v-7x5da3j64851', diff --git a/plugins/github-deployments/src/plugin.ts b/plugins/github-deployments/src/plugin.ts index 3707b1cf68..943d5e0bc6 100644 --- a/plugins/github-deployments/src/plugin.ts +++ b/plugins/github-deployments/src/plugin.ts @@ -19,6 +19,7 @@ import { createPlugin, githubAuthApiRef, } from '@backstage/core'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { githubDeploymentsApiRef, GithubDeploymentsApiClient } from './api'; export const githubDeploymentsPlugin = createPlugin({ @@ -26,9 +27,12 @@ export const githubDeploymentsPlugin = createPlugin({ apis: [ createApiFactory({ api: githubDeploymentsApiRef, - deps: { githubAuthApi: githubAuthApiRef }, - factory: ({ githubAuthApi }) => - new GithubDeploymentsApiClient({ githubAuthApi }), + deps: { + scmIntegrationsApi: scmIntegrationsApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ scmIntegrationsApi, githubAuthApi }) => + new GithubDeploymentsApiClient({ scmIntegrationsApi, githubAuthApi }), }), ], }); diff --git a/plugins/gitops-profiles/CHANGELOG.md b/plugins/gitops-profiles/CHANGELOG.md index b72e3c23c5..ae6672581f 100644 --- a/plugins/gitops-profiles/CHANGELOG.md +++ b/plugins/gitops-profiles/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-gitops-profiles +## 0.2.7 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.2.6 ### Patch Changes diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index c00dbde482..dbb84e4994 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-gitops-profiles", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,12 +42,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index 681005b13f..e0086af107 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-graphiql +## 0.2.11 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.2.10 ### Patch Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index ae3c0dfe8f..66c8790b8f 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-graphiql", "description": "Backstage plugin for browsing GraphQL APIs", - "version": "0.2.10", + "version": "0.2.11", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,12 +43,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/codemirror": "^0.0.108", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", diff --git a/plugins/graphql/CHANGELOG.md b/plugins/graphql/CHANGELOG.md index fc31d831dc..2604a15230 100644 --- a/plugins/graphql/CHANGELOG.md +++ b/plugins/graphql/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-graphql-backend +## 0.1.8 + +### Patch Changes + +- Updated dependencies [3be844496] +- Updated dependencies [22fd8ce2a] +- Updated dependencies [f9fb4a205] + - @backstage/plugin-catalog-graphql@0.2.9 + - @backstage/backend-common@0.8.0 + ## 0.1.7 ### Patch Changes diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index 0df78298d7..619df1d33b 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.7", + "version": "0.1.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/config": "^0.1.5", - "@backstage/plugin-catalog-graphql": "^0.2.8", + "@backstage/plugin-catalog-graphql": "^0.2.9", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index ac734e9a95..79e204b350 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-jenkins +## 0.4.3 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.4.2 ### Patch Changes diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index c4ba600d7e..d14a18ffe7 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.4.2", + "version": "0.4.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.3", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,12 +47,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/testing-library__jest-dom": "^5.9.1", diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index ef006a360f..b71ac92e5a 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-kafka-backend +## 0.2.5 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.2.4 ### Patch Changes diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 3e3f22654a..14bbfef486 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka-backend", - "version": "0.2.4", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", - "@backstage/catalog-model": "^0.7.8", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md index f1c4c215ed..67c70c321d 100644 --- a/plugins/kafka/CHANGELOG.md +++ b/plugins/kafka/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-kafka +## 0.2.7 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.6 ### Patch Changes diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index e405460262..b6c600a90c 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,9 +20,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,13 +33,13 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index a9893925e2..265b7eb414 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-kubernetes-backend +## 0.3.7 + +### Patch Changes + +- f9f9d633d: Add possibility to configure TLS verification for `gke` type clusters +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.3.6 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index f73166d5fc..9e438ce788 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.3.6", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", - "@backstage/catalog-model": "^0.7.8", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/plugin-kubernetes-common": "^0.1.0", "@google-cloud/container": "^2.2.0", @@ -53,7 +53,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/aws4": "^1.5.1", "supertest": "^6.1.3" }, diff --git a/plugins/kubernetes-backend/schema.d.ts b/plugins/kubernetes-backend/schema.d.ts index fa5bc52abf..71dbace6c7 100644 --- a/plugins/kubernetes-backend/schema.d.ts +++ b/plugins/kubernetes-backend/schema.d.ts @@ -14,14 +14,43 @@ * limitations under the License. */ -import { ClusterLocatorMethod, CustomResource } from './src/types'; - export interface Config { kubernetes?: { serviceLocatorMethod: { type: 'multiTenant'; }; - clusterLocatorMethods: ClusterLocatorMethod[]; - customResources?: CustomResource[]; + clusterLocatorMethods: Array< + | { + /** @visibility frontend */ + type: 'gke'; + /** @visibility frontend */ + projectId: string; + /** @visibility frontend */ + region?: string; + /** @visibility frontend */ + skipTLSVerify?: boolean; + } + | { + /** @visibility frontend */ + type: 'config'; + clusters: Array<{ + /** @visibility frontend */ + url: string; + /** @visibility frontend */ + name: string; + /** @visibility secret */ + serviceAccountToken?: string; + /** @visibility frontend */ + authProvider: 'aws' | 'google' | 'serviceAccount'; + /** @visibility frontend */ + skipTLSVerify?: boolean; + }>; + } + >; + customResources?: Array<{ + group: string; + apiVersion: string; + plural: string; + }>; }; } diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts index cf555bd8c9..f45368479c 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.test.ts @@ -106,6 +106,7 @@ describe('GkeClusterLocator', () => { authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + skipTLSVerify: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -141,6 +142,7 @@ describe('GkeClusterLocator', () => { authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + skipTLSVerify: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); @@ -181,11 +183,13 @@ describe('GkeClusterLocator', () => { authProvider: 'google', name: 'some-cluster', url: 'https://1.2.3.4', + skipTLSVerify: false, }, { authProvider: 'google', name: 'some-other-cluster', url: 'https://6.7.8.9', + skipTLSVerify: false, }, ]); expect(mockedListClusters).toBeCalledTimes(1); diff --git a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts index 7bfd62ef8d..b2bdc3bca8 100644 --- a/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts +++ b/plugins/kubernetes-backend/src/cluster-locator/GkeClusterLocator.ts @@ -18,28 +18,28 @@ import { Config } from '@backstage/config'; import * as container from '@google-cloud/container'; import { ClusterDetails, KubernetesClustersSupplier } from '../types/types'; -export class GkeClusterLocator implements KubernetesClustersSupplier { - private readonly projectId: string; - private readonly region: string | undefined; - private readonly client: container.v1.ClusterManagerClient; +type GkeClusterLocatorOptions = { + projectId: string; + region?: string; + skipTLSVerify?: boolean; +}; +export class GkeClusterLocator implements KubernetesClustersSupplier { constructor( - projectId: string, - client: container.v1.ClusterManagerClient, - region?: string, - ) { - this.projectId = projectId; - this.region = region; - this.client = client; - } + private readonly options: GkeClusterLocatorOptions, + private readonly client: container.v1.ClusterManagerClient, + ) {} static fromConfigWithClient( config: Config, client: container.v1.ClusterManagerClient, ): GkeClusterLocator { - const projectId = config.getString('projectId'); - const region = config.getOptionalString('region'); - return new GkeClusterLocator(projectId, client, region); + const options = { + projectId: config.getString('projectId'), + region: config.getOptionalString('region') ?? '-', + skipTLSVerify: config.getOptionalBoolean('skipTLSVerify') ?? false, + }; + return new GkeClusterLocator(options, client); } static fromConfig(config: Config): GkeClusterLocator { @@ -50,9 +50,9 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { } async getClusters(): Promise { - const region = this.region ?? '-'; + const { projectId, region, skipTLSVerify } = this.options; const request = { - parent: `projects/${this.projectId}/locations/${region}`, + parent: `projects/${projectId}/locations/${region}`, }; try { @@ -62,10 +62,11 @@ export class GkeClusterLocator implements KubernetesClustersSupplier { name: r.name ?? 'unknown', url: `https://${r.endpoint ?? ''}`, authProvider: 'google', + skipTLSVerify, })); } catch (e) { throw new Error( - `There was an error retrieving clusters from GKE for projectId=${this.projectId} region=${region} : [${e.message}]`, + `There was an error retrieving clusters from GKE for projectId=${projectId} region=${region} : [${e.message}]`, ); } } diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 0be0b447af..ae254dfdae 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -19,50 +19,6 @@ import type { KubernetesFetchError, } from '@backstage/plugin-kubernetes-common'; -export type ClusterLocatorMethod = - | ConfigClusterLocatorMethod - | GKEClusterLocatorMethod; - -export interface ConfigClusterLocatorMethod { - /** - * @visibility frontend - */ - type: 'config'; - clusters: { - /** - * @visibility frontend - */ - url: string; - /** - * @visibility frontend - */ - name: string; - /** - * @visibility secret - */ - serviceAccountToken: string | undefined; - /** - * @visibility frontend - */ - authProvider: 'aws' | 'google' | 'serviceAccount'; - }[]; -} - -export interface GKEClusterLocatorMethod { - /** - * @visibility frontend - */ - type: 'gke'; - /** - * @visibility frontend - */ - projectId: string; - /** - * @visibility frontend - */ - region?: string; -} - export interface CustomResource { group: string; apiVersion: string; diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index d78842f351..cca63f6389 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-kubernetes +## 0.4.4 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- ea21d46f0: Export types +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.4.3 ### Patch Changes diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 19865a6fcc..5036e08852 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.4.3", + "version": "0.4.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.5", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/plugin-kubernetes-common": "^0.1.0", "@backstage/theme": "^0.2.7", "@kubernetes/client-node": "^0.14.0", @@ -49,13 +49,13 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/kubernetes/src/index.ts b/plugins/kubernetes/src/index.ts index eca6b068f1..aabf36f1dc 100644 --- a/plugins/kubernetes/src/index.ts +++ b/plugins/kubernetes/src/index.ts @@ -19,3 +19,4 @@ export { EntityKubernetesContent, } from './plugin'; export { Router } from './Router'; +export * from './kubernetes-auth-provider'; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/index.ts b/plugins/kubernetes/src/kubernetes-auth-provider/index.ts new file mode 100644 index 0000000000..8f54913903 --- /dev/null +++ b/plugins/kubernetes/src/kubernetes-auth-provider/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 { kubernetesAuthProvidersApiRef } from './types'; +export { KubernetesAuthProviders } from './KubernetesAuthProviders'; diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index cb2cb5b9d1..d9b9b541d0 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-lighthouse +## 0.2.16 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.15 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index f70e29f69b..68f4722157 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.15", + "version": "0.2.16", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,10 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,12 +46,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/react": "^16.9", diff --git a/plugins/newrelic/CHANGELOG.md b/plugins/newrelic/CHANGELOG.md index f2745796d8..2091ef6a1d 100644 --- a/plugins/newrelic/CHANGELOG.md +++ b/plugins/newrelic/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-newrelic +## 0.2.7 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.2.6 ### Patch Changes diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 20ee5521f4..7938f19100 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-newrelic", - "version": "0.2.6", + "version": "0.2.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,12 +41,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index c62c618ff4..33ef38a7c2 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-org +## 0.3.13 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- f59a945b7: Paginate group members to only display 50 members maximum. +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/core-api@0.2.18 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.3.12 ### Patch Changes diff --git a/plugins/org/package.json b/plugins/org/package.json index 012514f1c4..4f8e37fa99 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-org", - "version": "0.3.12", + "version": "0.3.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.8", - "@backstage/core-api": "^0.2.16", - "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/core-api": "^0.2.18", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -35,12 +35,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index d68328ef1a..58dcefaaa4 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -40,6 +40,7 @@ import { Theme, Typography, } from '@material-ui/core'; +import Pagination from '@material-ui/lab/Pagination'; import React from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; import { useAsync } from 'react-use'; @@ -120,6 +121,12 @@ export const MembersListCard = (_props: { const groupNamespace = grpNamespace || ENTITY_DEFAULT_NAMESPACE; + const [page, setPage] = React.useState(1); + const pageChange = (_: React.ChangeEvent, pageIndex: number) => { + setPage(pageIndex); + }; + const pageSize = 50; + const { loading, error, value: members } = useAsync(async () => { const membersList = await catalogApi.getEntities({ filter: { kind: 'User' }, @@ -144,17 +151,33 @@ export const MembersListCard = (_props: { return ; } + const nbPages = Math.ceil((members?.length || 0) / pageSize); + const paginationLabel = nbPages < 2 ? '' : `, page ${page} of ${nbPages}`; + + const pagination = ( + + ); + return ( {members && members.length > 0 ? ( - members.map(member => ( - - )) + members + .slice(pageSize * (page - 1), pageSize * page) + .map(member => ( + + )) ) : ( This group has no members. diff --git a/plugins/pagerduty/CHANGELOG.md b/plugins/pagerduty/CHANGELOG.md index b7fc7fec40..21e79445b0 100644 --- a/plugins/pagerduty/CHANGELOG.md +++ b/plugins/pagerduty/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-pagerduty +## 0.3.4 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.3.3 ### Patch Changes diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 53f0ddbb23..b3fbc37569 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-pagerduty", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,12 +46,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/proxy-backend/CHANGELOG.md b/plugins/proxy-backend/CHANGELOG.md index 06a96576e6..2890039b96 100644 --- a/plugins/proxy-backend/CHANGELOG.md +++ b/plugins/proxy-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-proxy-backend +## 0.2.8 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [f9fb4a205] + - @backstage/backend-common@0.8.0 + ## 0.2.7 ### Patch Changes diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 2c6fc9e08d..d2b0d64678 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-proxy-backend", - "version": "0.2.7", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,7 +28,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -42,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/register-component/CHANGELOG.md b/plugins/register-component/CHANGELOG.md index 3634972e9a..ae0ae6562b 100644 --- a/plugins/register-component/CHANGELOG.md +++ b/plugins/register-component/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-register-component +## 0.2.15 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.14 ### Patch Changes diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 3efb61b982..d10bdcfc38 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-register-component", - "version": "0.2.14", + "version": "0.2.15", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,12 +45,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md index 9aa0d77e69..0318d3a111 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-rollbar-backend +## 0.1.11 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [f9fb4a205] + - @backstage/backend-common@0.8.0 + ## 0.1.10 ### Patch Changes diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index c7eb819dc1..03be93dbfa 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar-backend", - "version": "0.1.10", + "version": "0.1.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/rollbar/CHANGELOG.md b/plugins/rollbar/CHANGELOG.md index 1d8c2c399e..26b9913df2 100644 --- a/plugins/rollbar/CHANGELOG.md +++ b/plugins/rollbar/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-rollbar +## 0.3.5 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.3.4 ### Patch Changes diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 8b559e0bf1..37ada55f5f 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-rollbar", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,13 +47,13 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/react": "^16.9", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index b4aa856606..cdb8fd734b 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-scaffolder-backend +## 0.11.1 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 82ca1ac22: The apiBaseUrl setting for Bitbucket Server integrations will now be used when it is set. Otherwise, it will default back to the host setting. +- fd39d4662: Move `jest-when` to the dev dependencies +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.11.0 ### Minor Changes @@ -16,7 +30,6 @@ + SingleHostDiscovery, + } from '@backstage/backend-common'; - export default async function createPlugin({ logger, config, diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 9cd951f7d8..2565a9d05a 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.11.0", + "version": "0.11.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.2", @@ -53,7 +53,6 @@ "handlebars": "^4.7.6", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", - "jest-when": "^3.1.0", "jsonschema": "^1.2.6", "knex": "^0.95.1", "lodash": "^4.17.21", @@ -65,11 +64,12 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/test-utils": "^0.1.11", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", + "jest-when": "^3.1.0", "mock-fs": "^4.13.0", "msw": "^0.21.2", "supertest": "^6.1.3", diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index 33fa51af1b..25ba11d7fb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -170,4 +170,59 @@ describe('Bitbucket Publisher', () => { }); }); }); + + it('should use apiBaseUrl to create the repository if it is set', async () => { + server.use( + rest.post( + 'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0/projects/project/repos', + (_, res, ctx) => + res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({ + links: { + self: [ + { + href: + 'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo', + }, + ], + clone: [ + { + name: 'http', + href: + 'https://bitbucket.mycompany.com/bitbucket/scm/project/repo', + }, + ], + }, + }), + ), + ), + ); + + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.mycompany.com', + username: 'foo', + token: 'fake-token', + apiBaseUrl: 'https://bitbucket.mycompany.com/bitbucket/rest/api/1.0', + }, + { repoVisibility: 'private' }, + ); + + const result = await publisher.publish({ + values: { + storePath: 'https://bitbucket.mycompany.com/project/repo', + owner: 'bob', + }, + workspacePath, + logger: logger, + }); + + expect(result).toEqual({ + remoteUrl: 'https://bitbucket.mycompany.com/bitbucket/scm/project/repo', + catalogInfoUrl: + 'https://bitbucket.mycompany.com/bitbucket/projects/project/repos/repo/catalog-info.yaml', + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index aef0625c79..922662ebe3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -42,6 +42,7 @@ export class BitbucketPublisher implements PublisherBase { token: config.token, appPassword: config.appPassword, username: config.username, + apiBaseUrl: config.apiBaseUrl, repoVisibility, }); } @@ -52,6 +53,7 @@ export class BitbucketPublisher implements PublisherBase { token?: string; appPassword?: string; username?: string; + apiBaseUrl?: string; repoVisibility: RepoVisibilityOptions; }, ) {} @@ -181,10 +183,11 @@ export class BitbucketPublisher implements PublisherBase { }; try { - response = await fetch( - `https://${this.config.host}/rest/api/1.0/projects/${project}/repos`, - options, - ); + const baseUrl = this.config.apiBaseUrl + ? this.config.apiBaseUrl + : `https://${this.config.host}/rest/api/1.0`; + + response = await fetch(`${baseUrl}/projects/${project}/repos`, options); } catch (e) { throw new Error(`Unable to create repository, ${e}`); } diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index b67740ecc1..7e55dabc55 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/plugin-scaffolder +## 0.9.4 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 81ef1d57b: Show error on task page if task does not exist. +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.9.3 ### Patch Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 1992028157..a29251f705 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.9.3", + "version": "0.9.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,13 +31,13 @@ }, "dependencies": { "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/integration": "^0.5.2", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.5", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -60,12 +60,12 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/humanize-duration": "^3.18.1", "@testing-library/react-hooks": "^3.3.0", "@types/jest": "^26.0.7", diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json index 99077b9b62..23234aca2f 100644 --- a/plugins/search-backend-node/package.json +++ b/plugins/search-backend-node/package.json @@ -25,8 +25,8 @@ "@types/lunr": "^2.3.3" }, "devDependencies": { - "@backstage/backend-common": "^0.7.0", - "@backstage/cli": "^0.6.10" + "@backstage/backend-common": "^0.8.0", + "@backstage/cli": "^0.6.11" }, "files": [ "dist" diff --git a/plugins/search-backend/CHANGELOG.md b/plugins/search-backend/CHANGELOG.md index fd429aae62..9d34d92575 100644 --- a/plugins/search-backend/CHANGELOG.md +++ b/plugins/search-backend/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/plugin-search-backend +## 0.1.5 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [f9fb4a205] + - @backstage/backend-common@0.8.0 + ## 0.1.4 ### Patch Changes diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index 4de8ada9bc..c06f54a266 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search-backend", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,7 +19,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/search-common": "^0.1.1", "@backstage/plugin-search-backend-node": "^0.1.3", "@types/express": "^4.17.6", @@ -29,7 +29,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index b4bcd1f855..9c0ab2fa8c 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-search +## 0.3.6 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.3.5 ### Patch Changes diff --git a/plugins/search/package.json b/plugins/search/package.json index f503b9a78e..7593a2901d 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.8", - "@backstage/catalog-model": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/core": "^0.7.9", + "@backstage/catalog-model": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/search-common": "^0.1.1", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", @@ -44,12 +44,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/sentry/CHANGELOG.md b/plugins/sentry/CHANGELOG.md index 261972384a..b22b5539ef 100644 --- a/plugins/sentry/CHANGELOG.md +++ b/plugins/sentry/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-sentry +## 0.3.10 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.3.9 ### Patch Changes diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 6784debcf9..7da547dd94 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sentry", - "version": "0.3.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,12 +46,12 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "@types/react": "^16.9", diff --git a/plugins/shortcuts/.eslintrc.js b/plugins/shortcuts/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/shortcuts/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/shortcuts/CHANGELOG.md b/plugins/shortcuts/CHANGELOG.md new file mode 100644 index 0000000000..bdc4b3adba --- /dev/null +++ b/plugins/shortcuts/CHANGELOG.md @@ -0,0 +1,12 @@ +# @backstage/plugin-shortcuts + +## 0.1.2 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 diff --git a/plugins/shortcuts/README.md b/plugins/shortcuts/README.md new file mode 100644 index 0000000000..162afe009b --- /dev/null +++ b/plugins/shortcuts/README.md @@ -0,0 +1,52 @@ +# shortcuts + +The shortcuts plugin allows a user to have easy access to pages within a Backstage app by storing them as "shortcuts" in the Sidebar. + +## Usage + +Install the package: + +```bash +yarn add @backstage/plugin-shortcuts +``` + +Add it to your App's `plugins.ts` file: + +```ts +// ... +export { shortcutsPlugin } from '@backstage/plugin-shortcuts'; +``` + +Add the `` component within your ``: + +```tsx +import { Sidebar, SidebarDivider, SidebarSpace } from '@backstage/core'; +import { Shortcuts } from '@backstage/plugin-shortcuts'; + +export const SidebarComponent = () => ( + + {/* ... */} + + + + +); +``` + +The plugin exports a `shortcutApiRef` but the plugin includes a default implementation of the `ShortcutApi` that uses `localStorage` to store each user's shortcuts. + +To overwrite the default implementation add it to the App's `apis.ts`: + +```ts +import { shortcutsApiRef } from '@backstage/plugin-shortcuts'; +import { CustomShortcutsImpl } from '...'; + +export const apis = [ + // ... + createApiFactory({ + api: shortcutsApiRef, + deps: {}, + factory: () => new CustomShortcutsImpl(), + }), +]; +``` diff --git a/plugins/shortcuts/dev/index.tsx b/plugins/shortcuts/dev/index.tsx new file mode 100644 index 0000000000..0da3a90054 --- /dev/null +++ b/plugins/shortcuts/dev/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { shortcutsPlugin, Shortcuts } from '../src/plugin'; + +createDevApp() + .registerPlugin(shortcutsPlugin) + .addPage({ + element: , + title: 'Root Page', + }) + .render(); diff --git a/plugins/shortcuts/package.json b/plugins/shortcuts/package.json new file mode 100644 index 0000000000..aea2f8461e --- /dev/null +++ b/plugins/shortcuts/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-shortcuts", + "version": "0.1.2", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core": "^0.7.9", + "@backstage/theme": "^0.2.7", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@types/zen-observable": "^0.8.2", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-hook-form": "^7.1.1", + "react-router": "6.0.0-beta.0", + "react-use": "^17.2.4", + "uuid": "^8.3.2", + "zen-observable": "^0.8.15" + }, + "devDependencies": { + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.1.8", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/shortcuts/src/AddShortcut.test.tsx b/plugins/shortcuts/src/AddShortcut.test.tsx new file mode 100644 index 0000000000..8001cdc293 --- /dev/null +++ b/plugins/shortcuts/src/AddShortcut.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { AddShortcut } from './AddShortcut'; +import { LocalStoredShortcuts } from './api'; +import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { AlertDisplay } from '@backstage/core'; + +describe('AddShortcut', () => { + const api = new LocalStoredShortcuts(MockStorageApi.create()); + + const props = { + onClose: jest.fn(), + anchorEl: document.createElement('div'), + api, + }; + + beforeEach(() => { + jest.clearAllMocks(); + document.title = 'some document title'; + }); + + it('displays the title', async () => { + await renderInTestApp(); + + expect(screen.getByText('Add Shortcut')).toBeInTheDocument(); + }); + + it('closes the popup', async () => { + await renderInTestApp(); + + fireEvent.click(screen.getByText('Cancel')); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('saves the input', async () => { + const spy = jest.spyOn(api, 'add'); + + await renderInTestApp(); + + const urlInput = screen.getByPlaceholderText('Enter a URL'); + const titleInput = screen.getByPlaceholderText('Enter a display name'); + fireEvent.change(urlInput, { target: { value: '/some-url' } }); + fireEvent.change(titleInput, { target: { value: 'some title' } }); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(spy).toBeCalledWith({ + title: 'some title', + url: '/some-url', + }); + }); + }); + + it('pastes the values', async () => { + const spy = jest.spyOn(api, 'add'); + + await renderInTestApp(, { + routeEntries: ['/some-initial-url'], + }); + + fireEvent.click(screen.getByText('Use current page')); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(spy).toBeCalledWith({ + title: 'some document title', + url: '/some-initial-url', + }); + }); + }); + + it('displays errors', async () => { + jest.spyOn(api, 'add').mockRejectedValueOnce(new Error('some add error')); + + await renderInTestApp( + <> + + + , + ); + + fireEvent.click(screen.getByText('Use current page')); + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect( + screen.getByText('Could not add shortcut: some add error'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/shortcuts/src/AddShortcut.tsx b/plugins/shortcuts/src/AddShortcut.tsx new file mode 100644 index 0000000000..74a0653b4c --- /dev/null +++ b/plugins/shortcuts/src/AddShortcut.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { useLocation } from 'react-router'; +import { SubmitHandler } from 'react-hook-form'; +import { alertApiRef, useApi } from '@backstage/core'; +import { + Button, + Card, + CardHeader, + makeStyles, + Popover, +} from '@material-ui/core'; +import { ShortcutForm } from './ShortcutForm'; +import { FormValues, Shortcut } from './types'; +import { ShortcutApi } from './api'; + +const useStyles = makeStyles(theme => ({ + card: { + width: 400, + }, + header: { + marginBottom: theme.spacing(1), + }, + button: { + marginTop: theme.spacing(1), + }, +})); + +type Props = { + onClose: () => void; + anchorEl?: Element; + api: ShortcutApi; +}; + +export const AddShortcut = ({ onClose, anchorEl, api }: Props) => { + const classes = useStyles(); + const alertApi = useApi(alertApiRef); + const { pathname } = useLocation(); + const [formValues, setFormValues] = useState(); + const open = Boolean(anchorEl); + + const handleSave: SubmitHandler = async ({ url, title }) => { + const shortcut: Omit = { url, title }; + + try { + await api.add(shortcut); + alertApi.post({ + message: `Added shortcut '${title}' to your sidebar`, + severity: 'success', + }); + } catch (error) { + alertApi.post({ + message: `Could not add shortcut: ${error.message}`, + severity: 'error', + }); + } + + onClose(); + }; + + const handlePaste = () => { + setFormValues({ url: pathname, title: document.title }); + }; + + const handleClose = () => { + setFormValues(undefined); + onClose(); + }; + + return ( + + + + Use current page + + } + /> + + + + ); +}; diff --git a/plugins/shortcuts/src/EditShortcut.test.tsx b/plugins/shortcuts/src/EditShortcut.test.tsx new file mode 100644 index 0000000000..eaab6cf609 --- /dev/null +++ b/plugins/shortcuts/src/EditShortcut.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { EditShortcut } from './EditShortcut'; +import { Shortcut } from './types'; +import { LocalStoredShortcuts } from './api'; +import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { AlertDisplay } from '@backstage/core'; + +describe('EditShortcut', () => { + const shortcut: Shortcut = { + id: 'id', + url: '/some-url', + title: 'some title', + }; + const api = new LocalStoredShortcuts(MockStorageApi.create()); + + const props = { + onClose: jest.fn(), + anchorEl: document.createElement('div'), + shortcut, + api, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('displays the title', async () => { + await renderInTestApp(); + + expect(screen.getByText('Edit Shortcut')).toBeInTheDocument(); + }); + + it('closes the popup', async () => { + await renderInTestApp(); + + fireEvent.click(screen.getByText('Cancel')); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('updates the shortcut', async () => { + const spy = jest.spyOn(api, 'update'); + + await renderInTestApp(); + + const urlInput = screen.getByPlaceholderText('Enter a URL'); + const titleInput = screen.getByPlaceholderText('Enter a display name'); + fireEvent.change(urlInput, { target: { value: '/some-new-url' } }); + fireEvent.change(titleInput, { target: { value: 'some new title' } }); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(spy).toBeCalledWith({ + id: 'id', + title: 'some new title', + url: '/some-new-url', + }); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + }); + + it('removes the shortcut', async () => { + const spy = jest.spyOn(api, 'remove'); + + await renderInTestApp(); + + fireEvent.click(screen.getByText('Remove')); + expect(spy).toBeCalledWith('id'); + }); + + it('displays errors', async () => { + jest + .spyOn(api, 'update') + .mockRejectedValueOnce(new Error('some update error')); + + jest + .spyOn(api, 'remove') + .mockRejectedValueOnce(new Error('some remove error')); + + await renderInTestApp( + <> + + + , + ); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect( + screen.getByText('Could not update shortcut: some update error'), + ).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('error-button-close')); + + fireEvent.click(screen.getByText('Remove')); + await waitFor(() => { + expect( + screen.getByText('Could not delete shortcut: some remove error'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/shortcuts/src/EditShortcut.tsx b/plugins/shortcuts/src/EditShortcut.tsx new file mode 100644 index 0000000000..efe8726992 --- /dev/null +++ b/plugins/shortcuts/src/EditShortcut.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { SubmitHandler } from 'react-hook-form'; +import { alertApiRef, useApi } from '@backstage/core'; +import { + Button, + Card, + CardHeader, + makeStyles, + Popover, +} from '@material-ui/core'; +import { ShortcutForm } from './ShortcutForm'; +import { FormValues, Shortcut } from './types'; +import DeleteIcon from '@material-ui/icons/Delete'; +import { ShortcutApi } from './api'; + +const useStyles = makeStyles(theme => ({ + card: { + width: 400, + }, + header: { + marginBottom: theme.spacing(1), + }, + button: { + marginTop: theme.spacing(1), + }, +})); + +type Props = { + shortcut: Shortcut; + onClose: () => void; + anchorEl?: Element; + api: ShortcutApi; +}; + +export const EditShortcut = ({ shortcut, onClose, anchorEl, api }: Props) => { + const classes = useStyles(); + const alertApi = useApi(alertApiRef); + const open = Boolean(anchorEl); + + const handleSave: SubmitHandler = async ({ url, title }) => { + const newShortcut: Shortcut = { + ...shortcut, + url, + title, + }; + + try { + await api.update(newShortcut); + alertApi.post({ + message: `Updated shortcut '${title}'`, + severity: 'success', + }); + } catch (error) { + alertApi.post({ + message: `Could not update shortcut: ${error.message}`, + severity: 'error', + }); + } + + onClose(); + }; + + const handleRemove = async () => { + try { + await api.remove(shortcut.id); + alertApi.post({ + message: `Removed shortcut '${shortcut.title}' from your sidebar`, + severity: 'success', + }); + } catch (error) { + alertApi.post({ + message: `Could not delete shortcut: ${error.message}`, + severity: 'error', + }); + } + }; + + const handleClose = () => { + onClose(); + }; + + return ( + + + } + onClick={handleRemove} + > + Remove + + } + /> + + + + ); +}; diff --git a/plugins/shortcuts/src/ShortcutForm.test.tsx b/plugins/shortcuts/src/ShortcutForm.test.tsx new file mode 100644 index 0000000000..23a36126c6 --- /dev/null +++ b/plugins/shortcuts/src/ShortcutForm.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { ShortcutForm } from './ShortcutForm'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('ShortcutForm', () => { + const props = { + onSave: jest.fn(), + onClose: jest.fn(), + }; + + it('displays validation messages', async () => { + await renderInTestApp(); + + const urlInput = screen.getByPlaceholderText('Enter a URL'); + const titleInput = screen.getByPlaceholderText('Enter a display name'); + fireEvent.change(urlInput, { target: { value: 'url' } }); + fireEvent.change(titleInput, { target: { value: 't' } }); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect( + screen.getByText('Must be a relative URL (starts with a /)'), + ).toBeInTheDocument(); + expect( + screen.getByText('Must be at least 2 characters'), + ).toBeInTheDocument(); + }); + }); + + it('calls the save handler', async () => { + await renderInTestApp( + , + ); + + fireEvent.click(screen.getByText('Save')); + await waitFor(() => { + expect(props.onSave).toHaveBeenCalledWith( + expect.objectContaining({ title: 'some title', url: '/some-url' }), + expect.anything(), + ); + }); + }); + + it('calls the close handler', async () => { + await renderInTestApp(); + + fireEvent.click(screen.getByText('Cancel')); + await waitFor(() => { + expect(props.onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/shortcuts/src/ShortcutForm.tsx b/plugins/shortcuts/src/ShortcutForm.tsx new file mode 100644 index 0000000000..6d6e0679c3 --- /dev/null +++ b/plugins/shortcuts/src/ShortcutForm.tsx @@ -0,0 +1,140 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useEffect } from 'react'; +import { useForm, SubmitHandler, Controller } from 'react-hook-form'; +import { + Button, + CardActions, + CardContent, + makeStyles, + TextField, +} from '@material-ui/core'; +import { FormValues } from './types'; + +const useStyles = makeStyles(theme => ({ + field: { + marginBottom: theme.spacing(2), + }, + actionRoot: { + paddingLeft: theme.spacing(2), + paddingBottom: theme.spacing(3), + justifyContent: 'flex-start', + }, +})); + +type Props = { + formValues?: FormValues; + onSave: SubmitHandler; + onClose: () => void; +}; + +export const ShortcutForm = ({ formValues, onSave, onClose }: Props) => { + const classes = useStyles(); + + const { + handleSubmit, + reset, + control, + formState: { errors }, + } = useForm({ + mode: 'onChange', + defaultValues: { + url: formValues?.url ?? '', + title: formValues?.title ?? '', + }, + }); + + useEffect(() => { + reset(formValues); + }, [reset, formValues]); + + return ( + <> + + ( + + )} + /> + ( + + )} + /> + + + + + + + ); +}; diff --git a/plugins/shortcuts/src/ShortcutIcon.tsx b/plugins/shortcuts/src/ShortcutIcon.tsx new file mode 100644 index 0000000000..c737596647 --- /dev/null +++ b/plugins/shortcuts/src/ShortcutIcon.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +type Props = { + text: string; + color: string; +}; + +export const ShortcutIcon = ({ text, color }: Props) => { + const size = 28; + return ( + + + + {text} + + + ); +}; diff --git a/plugins/shortcuts/src/ShortcutItem.test.tsx b/plugins/shortcuts/src/ShortcutItem.test.tsx new file mode 100644 index 0000000000..080f847952 --- /dev/null +++ b/plugins/shortcuts/src/ShortcutItem.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { ShortcutItem } from './ShortcutItem'; +import { Shortcut } from './types'; +import { SidebarContext } from '@backstage/core'; +import { LocalStoredShortcuts } from './api'; +import { + MockStorageApi, + renderInTestApp, + wrapInTestApp, +} from '@backstage/test-utils'; +import { pageTheme } from '@backstage/theme'; + +describe('ShortcutItem', () => { + const shortcut: Shortcut = { + id: 'id', + url: '/some-url', + title: 'some title', + }; + const api = new LocalStoredShortcuts(MockStorageApi.create()); + + it('displays the shortcut', async () => { + await renderInTestApp( + + + , + ); + expect(screen.getByText('ST')).toBeInTheDocument(); + expect(screen.getByText('some title')).toBeInTheDocument(); + }); + + it('calculates the shortcut text correctly', async () => { + const shortcut1: Shortcut = { + id: 'id1', + url: '/some-url', + title: 'onetitle', + }; + const shortcut2: Shortcut = { + id: 'id2', + url: '/some-url', + title: 'two title', + }; + const shortcut3: Shortcut = { + id: 'id3', + url: '/some-url', + title: 'more | title words', + }; + + const { rerender } = await renderInTestApp( + , + ); + expect(screen.getByText('On')).toBeInTheDocument(); + + rerender(wrapInTestApp()); + await waitFor(() => { + expect(screen.getByText('TT')).toBeInTheDocument(); + }); + + rerender(wrapInTestApp()); + await waitFor(() => { + expect(screen.getByText('MT')).toBeInTheDocument(); + }); + }); + + it('gets the color based on the theme', async () => { + const { rerender } = await renderInTestApp( + , + ); + + expect(document.querySelector('circle')?.getAttribute('fill')).toEqual( + pageTheme.tool.colors[0], + ); + + const newShortcut: Shortcut = { + id: 'id', + url: '/catalog', + title: 'some title', + }; + rerender(wrapInTestApp()); + + await waitFor(() => { + expect(document.querySelector('circle')?.getAttribute('fill')).toEqual( + pageTheme.home.colors[0], + ); + }); + }); +}); diff --git a/plugins/shortcuts/src/ShortcutItem.tsx b/plugins/shortcuts/src/ShortcutItem.tsx new file mode 100644 index 0000000000..f9868182f0 --- /dev/null +++ b/plugins/shortcuts/src/ShortcutItem.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { SidebarItem } from '@backstage/core'; +import { IconButton, makeStyles } from '@material-ui/core'; +import EditIcon from '@material-ui/icons/Edit'; +import { ShortcutIcon } from './ShortcutIcon'; +import { EditShortcut } from './EditShortcut'; +import { ShortcutApi } from './api'; +import { Shortcut } from './types'; + +const useStyles = makeStyles({ + root: { + '&:hover #edit': { + visibility: 'visible', + }, + }, + button: { + visibility: 'hidden', + }, + icon: { + color: 'white', + fontSize: 16, + }, +}); + +const getIconText = (title: string) => + title.split(' ').length === 1 + ? // If there's only one word, keep the first two characters + title[0].toUpperCase() + title[1].toLowerCase() + : // If there's more than one word, take the first character of the first two words + title + .replace(/\B\W/g, '') + .split(' ') + .map(s => s[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + +type Props = { + shortcut: Shortcut; + api: ShortcutApi; +}; + +export const ShortcutItem = ({ shortcut, api }: Props) => { + const classes = useStyles(); + const [anchorEl, setAnchorEl] = React.useState(); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(undefined); + }; + + const text = getIconText(shortcut.title); + const color = api.getColor(shortcut.url); + + return ( + <> + } + > + + + + + + + ); +}; diff --git a/plugins/shortcuts/src/Shortcuts.test.tsx b/plugins/shortcuts/src/Shortcuts.test.tsx new file mode 100644 index 0000000000..b697f9ed27 --- /dev/null +++ b/plugins/shortcuts/src/Shortcuts.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { SidebarContext, ApiProvider, ApiRegistry } from '@backstage/core'; +import { MockStorageApi, renderInTestApp } from '@backstage/test-utils'; +import { screen, waitFor } from '@testing-library/react'; +import { Shortcuts } from './Shortcuts'; +import { LocalStoredShortcuts, shortcutsApiRef } from './api'; + +const apis = ApiRegistry.from([ + [shortcutsApiRef, new LocalStoredShortcuts(MockStorageApi.create())], +]); + +describe('Shortcuts', () => { + it('displays an add button', async () => { + await renderInTestApp( + + + + + , + ); + await waitFor(() => !screen.queryByTestId('progress')); + expect(screen.getByText('Add Shortcuts')).toBeInTheDocument(); + }); +}); diff --git a/plugins/shortcuts/src/Shortcuts.tsx b/plugins/shortcuts/src/Shortcuts.tsx new file mode 100644 index 0000000000..2c49245e76 --- /dev/null +++ b/plugins/shortcuts/src/Shortcuts.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useMemo } from 'react'; +import { useObservable } from 'react-use'; +import { Progress, SidebarItem, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core'; +import PlayListAddIcon from '@material-ui/icons/PlaylistAdd'; +import { ShortcutItem } from './ShortcutItem'; +import { AddShortcut } from './AddShortcut'; +import { shortcutsApiRef } from './api'; + +const useStyles = makeStyles({ + root: { + flex: '1 1 auto', + overflow: 'scroll', + }, +}); + +export const Shortcuts = () => { + const classes = useStyles(); + const shortcutApi = useApi(shortcutsApiRef); + const shortcuts = useObservable( + useMemo(() => shortcutApi.shortcut$(), [shortcutApi]), + ); + const [anchorEl, setAnchorEl] = React.useState(); + const loading = Boolean(!shortcuts); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(undefined); + }; + + return ( +
+ + + {loading ? ( + + ) : ( + shortcuts?.map(shortcut => ( + + )) + )} +
+ ); +}; diff --git a/plugins/shortcuts/src/api/LocalStoredShortcuts.test.ts b/plugins/shortcuts/src/api/LocalStoredShortcuts.test.ts new file mode 100644 index 0000000000..7ca457fd39 --- /dev/null +++ b/plugins/shortcuts/src/api/LocalStoredShortcuts.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockStorageApi } from '@backstage/test-utils'; +import { pageTheme } from '@backstage/theme'; +import { Shortcut } from '../types'; +import { LocalStoredShortcuts } from './LocalStoredShortcuts'; +import { ShortcutApi } from './ShortcutApi'; + +describe('LocalStoredShortcuts', () => { + // eslint-disable-next-line jest/no-done-callback + it('should observe shortcuts', async done => { + const shortcutApi: ShortcutApi = new LocalStoredShortcuts( + MockStorageApi.create(), + ); + const shortcut: Shortcut = { id: 'id', title: 'title', url: '/url' }; + + await shortcutApi.add(shortcut); + shortcutApi.shortcut$().subscribe(data => { + expect(data).toEqual( + expect.arrayContaining([{ ...shortcut, id: expect.anything() }]), + ); + done(); + }); + }); + + it('should add shortcuts with ids', async () => { + const storageApi = MockStorageApi.create(); + const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi); + const shortcut: Omit = { title: 'title', url: '/url' }; + const spy = jest.spyOn(storageApi, 'set'); + + await shortcutApi.add(shortcut); + expect(spy).toHaveBeenCalledWith( + 'items', + expect.objectContaining([{ ...shortcut, id: expect.anything() }]), + ); + }); + + it('should update shortcuts', async () => { + const storageApi = MockStorageApi.create(); + const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi); + const shortcut: Shortcut = { id: 'someid', title: 'title', url: '/url' }; + const spy = jest.spyOn(storageApi, 'set'); + + await shortcutApi.update(shortcut); + expect(spy).toHaveBeenCalledWith( + 'items', + expect.objectContaining([shortcut]), + ); + }); + + it('should remove shortcuts', async () => { + const storageApi = MockStorageApi.create(); + const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi); + const shortcut: Shortcut = { id: 'someid', title: 'title', url: '/url' }; + const spy = jest.spyOn(storageApi, 'set'); + + await shortcutApi.remove(shortcut.id); + expect(spy).toHaveBeenCalledWith('items', []); + }); + + it('should get a color', () => { + const storageApi = MockStorageApi.create(); + const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi); + + expect(shortcutApi.getColor('/catalog')).toEqual(pageTheme.home.colors[0]); + }); +}); diff --git a/plugins/shortcuts/src/api/LocalStoredShortcuts.ts b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts new file mode 100644 index 0000000000..ee8735edb4 --- /dev/null +++ b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StorageApi } from '@backstage/core'; +import { pageTheme } from '@backstage/theme'; +import ObservableImpl from 'zen-observable'; +import { v4 as uuid } from 'uuid'; +import { ShortcutApi } from './ShortcutApi'; +import { Shortcut } from '../types'; + +/** + * Implementation of the ShortcutApi that uses the StorageApi to store shortcuts. + */ +export class LocalStoredShortcuts implements ShortcutApi { + constructor(private readonly storageApi: StorageApi) {} + + shortcut$() { + return this.observable; + } + + async add(shortcut: Omit) { + const shortcuts = this.get(); + shortcuts.push({ ...shortcut, id: uuid() }); + + await this.storageApi.set('items', shortcuts); + this.notify(); + } + + async remove(id: string) { + const shortcuts = this.get().filter(s => s.id !== id); + + await this.storageApi.set('items', shortcuts); + this.notify(); + } + + async update(shortcut: Shortcut) { + const shortcuts = this.get().filter(s => s.id !== shortcut.id); + shortcuts.push(shortcut); + + await this.storageApi.set('items', shortcuts); + this.notify(); + } + + getColor(url: string) { + const type = url.split('/')[1]; + const theme = + this.THEME_MAP[type] ?? + (Object.keys(pageTheme).includes(type) ? type : 'tool'); + + return pageTheme[theme].colors[0]; + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl(subscriber => { + subscriber.next(this.get()); + this.subscribers.add(subscriber); + + return () => { + this.subscribers.delete(subscriber); + }; + }); + + private readonly THEME_MAP: Record = { + catalog: 'home', + docs: 'documentation', + }; + + private get() { + return ( + (this.storageApi.get('items') as Shortcut[])?.sort((a, b) => + a.title >= b.title ? 1 : -1, + ) ?? [] + ); + } + + private notify() { + for (const subscription of this.subscribers) { + subscription.next(this.get()); + } + } +} diff --git a/plugins/shortcuts/src/api/ShortcutApi.ts b/plugins/shortcuts/src/api/ShortcutApi.ts new file mode 100644 index 0000000000..74b6aaf777 --- /dev/null +++ b/plugins/shortcuts/src/api/ShortcutApi.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef, Observable } from '@backstage/core'; +import { Shortcut } from '../types'; + +export const shortcutsApiRef = createApiRef({ + id: 'plugin.shortcuts.api', + description: 'API to handle shortcuts in a Backstage Sidebar', +}); + +export interface ShortcutApi { + /** + * Returns an Observable that will subscribe to changes. + */ + shortcut$(): Observable; + + /** + * Generates a unique id for the shortcut and then saves it. + */ + add(shortcut: Omit): Promise; + + /** + * Removes the shortcut. + */ + remove(id: string): Promise; + + /** + * Finds an existing shortcut that matches the ID of the + * supplied shortcut and updates its values. + */ + update(shortcut: Shortcut): Promise; + + /** + * Each shortcut should get a color for its icon based on the url. + * + * Preferably using some abstraction between the url and the actual + * color value. + */ + getColor(url: string): string; +} diff --git a/plugins/shortcuts/src/api/index.ts b/plugins/shortcuts/src/api/index.ts new file mode 100644 index 0000000000..4705702cbe --- /dev/null +++ b/plugins/shortcuts/src/api/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { LocalStoredShortcuts } from './LocalStoredShortcuts'; +export { shortcutsApiRef } from './ShortcutApi'; +export type { ShortcutApi } from './ShortcutApi'; diff --git a/plugins/shortcuts/src/index.ts b/plugins/shortcuts/src/index.ts new file mode 100644 index 0000000000..70f874e005 --- /dev/null +++ b/plugins/shortcuts/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { shortcutsPlugin, Shortcuts } from './plugin'; +export * from './api'; +export type { Shortcut } from './types'; diff --git a/plugins/shortcuts/src/plugin.test.ts b/plugins/shortcuts/src/plugin.test.ts new file mode 100644 index 0000000000..885b4e226e --- /dev/null +++ b/plugins/shortcuts/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { shortcutsPlugin } from './plugin'; + +describe('shortcuts', () => { + it('should export plugin', () => { + expect(shortcutsPlugin).toBeDefined(); + }); +}); diff --git a/plugins/shortcuts/src/plugin.ts b/plugins/shortcuts/src/plugin.ts new file mode 100644 index 0000000000..e2cd819fc6 --- /dev/null +++ b/plugins/shortcuts/src/plugin.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createApiFactory, + createComponentExtension, + createPlugin, + storageApiRef, +} from '@backstage/core'; +import { LocalStoredShortcuts, shortcutsApiRef } from './api'; + +export const shortcutsPlugin = createPlugin({ + id: 'shortcuts', + apis: [ + createApiFactory({ + api: shortcutsApiRef, + deps: { storageApi: storageApiRef }, + factory: ({ storageApi }) => + new LocalStoredShortcuts(storageApi.forBucket('shortcuts')), + }), + ], +}); + +export const Shortcuts = shortcutsPlugin.provide( + createComponentExtension({ + component: { lazy: () => import('./Shortcuts').then(m => m.Shortcuts) }, + }), +); diff --git a/plugins/shortcuts/src/setupTests.ts b/plugins/shortcuts/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/shortcuts/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/shortcuts/src/types.ts b/plugins/shortcuts/src/types.ts new file mode 100644 index 0000000000..33c52f4766 --- /dev/null +++ b/plugins/shortcuts/src/types.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Shortcut = { + id: string; + url: string; + title: string; +}; + +export type FormValues = { + url: string; + title: string; +}; diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 48b9292791..e457d2bdf9 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-sonarqube +## 0.1.18 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.1.17 ### Patch Changes diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index df5304d746..9e3d6bf03b 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.17", + "version": "0.1.18", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,12 +47,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/splunk-on-call/CHANGELOG.md b/plugins/splunk-on-call/CHANGELOG.md index b3151c3e04..4f34ea3bcc 100644 --- a/plugins/splunk-on-call/CHANGELOG.md +++ b/plugins/splunk-on-call/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-splunk-on-call +## 0.2.1 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.2.0 ### Minor Changes diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 020fbc37cd..ebb6fdb42e 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-splunk-on-call", - "version": "0.2.0", + "version": "0.2.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.8", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,12 +45,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", "@types/node": "^14.14.32", diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 9f0a5b87c3..64f255adb1 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-tech-radar +## 0.3.11 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.3.10 ### Patch Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 5e33024f87..e65e3d27a7 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-tech-radar", - "version": "0.3.10", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,12 +43,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/color": "^3.0.1", "@types/d3-force": "^2.1.1", "@types/jest": "^26.0.7", diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 59a116f653..4b348d49a4 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-techdocs-backend +## 0.8.1 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] +- Updated dependencies [e04f1ccfb] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + - @backstage/techdocs-common@0.6.1 + ## 0.8.0 ### Minor Changes diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 1424381f30..a702f63622 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.8.0", + "version": "0.8.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,11 +30,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", - "@backstage/catalog-model": "^0.7.8", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/techdocs-common": "^0.6.0", + "@backstage/techdocs-common": "^0.6.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", @@ -45,7 +45,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/dockerode": "^3.2.1", "supertest": "^6.1.3" }, diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 69e988a433..8382ef9419 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-techdocs +## 0.9.2 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/integration-react@0.1.2 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 + ## 0.9.1 ### Patch Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 20fca364da..ce748f836d 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.9.1", + "version": "0.9.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ }, "dependencies": { "@backstage/config": "^0.1.5", - "@backstage/catalog-model": "^0.7.8", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", "@backstage/integration": "^0.5.2", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.5", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@backstage/errors": "^0.1.1", "@material-ui/core": "^4.11.0", @@ -51,12 +51,12 @@ "sanitize-html": "^2.3.2" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/react": "^16.9", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", diff --git a/plugins/todo-backend/CHANGELOG.md b/plugins/todo-backend/CHANGELOG.md index b42f631a98..ebe3c2003c 100644 --- a/plugins/todo-backend/CHANGELOG.md +++ b/plugins/todo-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-todo-backend +## 0.1.5 + +### Patch Changes + +- Updated dependencies [22fd8ce2a] +- Updated dependencies [10c008a3a] +- Updated dependencies [f9fb4a205] +- Updated dependencies [16be1d093] + - @backstage/backend-common@0.8.0 + - @backstage/catalog-model@0.7.9 + ## 0.1.4 ### Patch Changes diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json index 1af9f9b40c..5225adb648 100644 --- a/plugins/todo-backend/package.json +++ b/plugins/todo-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-todo-backend", - "version": "0.1.4", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -24,9 +24,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.7.0", + "@backstage/backend-common": "^0.8.0", "@backstage/catalog-client": "^0.3.11", - "@backstage/catalog-model": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.2", @@ -39,7 +39,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.10", + "@backstage/cli": "^0.6.11", "@types/supertest": "^2.0.8", "msw": "^0.21.2", "supertest": "^6.1.3" diff --git a/plugins/todo/CHANGELOG.md b/plugins/todo/CHANGELOG.md new file mode 100644 index 0000000000..f3732c923e --- /dev/null +++ b/plugins/todo/CHANGELOG.md @@ -0,0 +1,17 @@ +# @backstage/plugin-todo + +## 0.1.1 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [10c008a3a] +- Updated dependencies [889d89b6e] +- Updated dependencies [16be1d093] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + - @backstage/plugin-catalog-react@0.1.6 + - @backstage/catalog-model@0.7.9 diff --git a/plugins/todo/package.json b/plugins/todo/package.json index b7564f7080..c9a9f4bb51 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-todo", - "version": "0.1.0", + "version": "0.1.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -26,10 +26,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.8", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.9", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -39,12 +39,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "msw": "^0.21.2", diff --git a/plugins/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md index f6f3046bef..5b1706e0c7 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-user-settings +## 0.2.10 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.2.9 ### Patch Changes diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 32e232cad4..f47f5b50af 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-user-settings", - "version": "0.2.9", + "version": "0.2.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,12 +41,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/welcome/CHANGELOG.md b/plugins/welcome/CHANGELOG.md index cf907b5b4f..53ffd59264 100644 --- a/plugins/welcome/CHANGELOG.md +++ b/plugins/welcome/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-welcome +## 0.2.8 + +### Patch Changes + +- 062bbf90f: chore: bump `@testing-library/user-event` from 12.8.3 to 13.1.8 +- 675a569a9: chore: bump `react-use` dependency in all packages +- Updated dependencies [062bbf90f] +- Updated dependencies [889d89b6e] +- Updated dependencies [3f988cb63] +- Updated dependencies [675a569a9] + - @backstage/core@0.7.9 + ## 0.2.7 ### Patch Changes diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 72ec24bf71..df16ec7f4d 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.2.7", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.8", + "@backstage/core": "^0.7.9", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,12 +41,12 @@ "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.10", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.11", + "@backstage/dev-utils": "^0.1.14", + "@backstage/test-utils": "^0.1.11", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", - "@testing-library/user-event": "^12.0.7", + "@testing-library/user-event": "^13.1.8", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/yarn.lock b/yarn.lock index cc9e83ac7e..07a99d7df1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2219,10 +2219,10 @@ through2 "^3.0.0" xdg-basedir "^3.0.0" -"@google-cloud/storage@^5.6.0": - version "5.8.1" - resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.8.1.tgz#00e627723614bcf97e6e29f9a59ec39339171847" - integrity sha512-qP8gCJ2myyMN3JMJN12d82Oo8VBSDO8vO4/x56dtQZX9+WISqcagurntnJVyFX885tIOtS97bsyv8qR1xv6HMg== +"@google-cloud/storage@^5.6.0", "@google-cloud/storage@^5.8.0": + version "5.8.5" + resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.8.5.tgz#2cf1e2e0ef8ca552abc4450301fef3fea4900ef6" + integrity sha512-i0gB9CRwQeOBYP7xuvn14M40LhHCwMjceBjxE4CTvsqL519sVY5yVKxLiAedHWGwUZHJNRa7Q2CmNfkdRwVNPg== dependencies: "@google-cloud/common" "^3.6.0" "@google-cloud/paginator" "^3.0.0" @@ -2230,38 +2230,11 @@ arrify "^2.0.0" async-retry "^1.3.1" compressible "^2.0.12" - date-and-time "^0.14.2" + date-and-time "^1.0.0" duplexify "^4.0.0" extend "^3.0.2" gaxios "^4.0.0" - gcs-resumable-upload "^3.1.3" - get-stream "^6.0.0" - hash-stream-validation "^0.2.2" - mime "^2.2.0" - mime-types "^2.0.8" - onetime "^5.1.0" - p-limit "^3.0.1" - pumpify "^2.0.0" - snakeize "^0.1.0" - stream-events "^1.0.1" - xdg-basedir "^4.0.0" - -"@google-cloud/storage@^5.8.0": - version "5.8.0" - resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.8.0.tgz#1f580e276f1d453790b382156421d1bcc4bd3f4b" - integrity sha512-WOShvBPOfkDXUzXMO+3j8Bzus+PFI9r1Ey9dLG2Zf458/PVuFTtaRWntd9ZiDG8g90zl2LmnA1JkDCreGUKr5g== - dependencies: - "@google-cloud/common" "^3.6.0" - "@google-cloud/paginator" "^3.0.0" - "@google-cloud/promisify" "^2.0.0" - arrify "^2.0.0" - async-retry "^1.3.1" - compressible "^2.0.12" - date-and-time "^0.14.2" - duplexify "^4.0.0" - extend "^3.0.2" - gaxios "^4.0.0" - gcs-resumable-upload "^3.1.3" + gcs-resumable-upload "^3.1.4" get-stream "^6.0.0" hash-stream-validation "^0.2.2" mime "^2.2.0" @@ -5633,10 +5606,10 @@ "@babel/runtime" "^7.12.5" "@testing-library/dom" "^7.28.1" -"@testing-library/user-event@^12.0.7": - version "12.8.3" - resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz#1aa3ed4b9f79340a1e1836bc7f57c501e838704a" - integrity sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ== +"@testing-library/user-event@^13.1.8": + version "13.1.8" + resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.1.8.tgz#9cbf342b88d837ee188f9f9f4df6d1beaaf179c2" + integrity sha512-M04HgOlJvxILf5xyrkJaEQfFOtcvhy3usLldQIEg9zgFIYQofSmFGVfFlS7BWowqlBGLrItwGMlPXCoBgoHSiw== dependencies: "@babel/runtime" "^7.12.5" @@ -7056,7 +7029,7 @@ resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e" integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== -"@types/zen-observable@^0.8.0": +"@types/zen-observable@^0.8.0", "@types/zen-observable@^0.8.2": version "0.8.2" resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== @@ -10638,13 +10611,20 @@ cross-env@^7.0.0: dependencies: cross-spawn "^7.0.1" -cross-fetch@3.0.6, cross-fetch@^3.0.4, cross-fetch@^3.0.5, cross-fetch@^3.0.6: +cross-fetch@3.0.6: version "3.0.6" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== dependencies: node-fetch "2.6.1" +cross-fetch@^3.0.4, cross-fetch@^3.0.5, cross-fetch@^3.0.6: + version "3.1.4" + resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" + integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14" @@ -11262,16 +11242,16 @@ dataloader@2.0.0: resolved "https://registry.npmjs.org/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f" integrity sha512-YzhyDAwA4TaQIhM5go+vCLmU0UikghC/t9DTQYZR2M/UvZ1MdOhPezSDZcjj9uqQJOMqjLcpWtyW2iNINdlatQ== -date-and-time@^0.14.2: - version "0.14.2" - resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.2.tgz#a4266c3dead460f6c231fe9674e585908dac354e" - integrity sha512-EFTCh9zRSEpGPmJaexg7HTuzZHh6cnJj1ui7IGCFNXzd2QdpsNh05Db5TF3xzJm30YN+A8/6xHSuRcQqoc3kFA== - date-and-time@^0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-0.6.3.tgz#2daee52df67c28bd93bce862756ac86b68cf4237" integrity sha512-lcWy3AXDRJOD7MplwZMmNSRM//kZtJaLz4n6D1P5z9wEmZGBKhJRBIr1Xs9KNQJmdXPblvgffynYji4iylUTcA== +date-and-time@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-1.0.0.tgz#0062394bdf6f44e961f0db00511cb19cdf3cc0a5" + integrity sha512-477D7ypIiqlXBkxhU7YtG9wWZJEQ+RUpujt2quTfgf4+E8g5fNUkB0QIL0bVyP5/TKBg8y55Hfa1R/c4bt3dEw== + date-fns@^1.27.2: version "1.30.1" resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" @@ -11522,6 +11502,11 @@ delegates@^1.0.0: resolved "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= +denque@^1.4.1: + version "1.5.0" + resolved "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de" + integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ== + depd@^1.1.2, depd@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -13517,7 +13502,7 @@ for-own@^0.1.3: foreach@^2.0.4: version "2.0.5" - resolved "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= forever-agent@~0.6.1: @@ -13836,10 +13821,10 @@ gcs-resumable-upload@^1.0.0: pumpify "^1.5.1" stream-events "^1.0.4" -gcs-resumable-upload@^3.1.3: - version "3.1.3" - resolved "https://registry.npmjs.org/gcs-resumable-upload/-/gcs-resumable-upload-3.1.3.tgz#1e38e1339600b85812e6430a5ab455453c64cce3" - integrity sha512-LjVrv6YVH0XqBr/iBW0JgRA1ndxhK6zfEFFJR4im51QVTj/4sInOXimY2evDZuSZ75D3bHxTaQAdXRukMc1y+w== +gcs-resumable-upload@^3.1.4: + version "3.1.4" + resolved "https://registry.npmjs.org/gcs-resumable-upload/-/gcs-resumable-upload-3.1.4.tgz#2e591889efb02247af26868de300b398346b17b5" + integrity sha512-5dyDfHrrVcIskiw/cPssVD4HRiwoHjhk1Nd6h5W3pQ/qffDvhfy4oNCr1f3ZXFPwTnxkCbibsB+73oOM+NvmJQ== dependencies: abort-controller "^3.0.0" configstore "^5.0.0" @@ -13849,6 +13834,13 @@ gcs-resumable-upload@^3.1.3: pumpify "^2.0.0" stream-events "^1.0.4" +generate-function@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f" + integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ== + dependencies: + is-property "^1.0.2" + generic-names@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz#f8a378ead2ccaa7a34f0317b05554832ae41b872" @@ -14510,9 +14502,11 @@ graphql-subscriptions@^1.0.0: iterall "^1.2.1" graphql-tag@^2.11.0: - version "2.11.0" - resolved "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.11.0.tgz#1deb53a01c46a7eb401d6cb59dec86fa1cccbffd" - integrity sha512-VmsD5pJqWJnQZMUeRwrDhfgoyqcfwEkvtpANqcoUG8/tOLkwNgU9mzub/Mc78OJMhHjx7gfAMTxzdG43VGg3bA== + version "2.12.4" + resolved "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.4.tgz#d34066688a4f09e72d6f4663c74211e9b4b7c4bf" + integrity sha512-VV1U4O+9x99EkNpNmCUV5RZwq6MnK4+pGbRYWG+lA/m3uo7TSqJF81OkcOP148gFP6fzdl7JWYBrwWVTS9jXww== + dependencies: + tslib "^2.1.0" graphql-tools@5.0.0: version "5.0.0" @@ -14829,9 +14823,9 @@ hoopy@^0.1.4: integrity sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ== hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== hosted-git-info@^3.0.6: version "3.0.8" @@ -15947,6 +15941,11 @@ is-promise@^2.1.0, is-promise@^2.2.2: resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== +is-property@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" + integrity sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ= + is-reference@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" @@ -16897,7 +16896,7 @@ json-buffer@3.0.0: resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= -json-buffer@3.0.1: +json-buffer@3.0.1, json-buffer@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== @@ -16913,9 +16912,9 @@ json-parse-even-better-errors@^2.3.0: integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== json-pointer@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.0.tgz#8e500550a6aac5464a473377da57aa6cc22828d7" - integrity sha1-jlAFUKaqxUZKRzN32leqbMIoKNc= + version "0.6.1" + resolved "https://registry.yarnpkg.com/json-pointer/-/json-pointer-0.6.1.tgz#3c6caa6ac139e2599f5a1659d39852154015054d" + integrity sha512-3OvjqKdCBvH41DLpV4iSt6v2XhZXV1bPB4OROuknvUXI7ZQNofieCPkmE26stEJ9zdQuvIxDHCuYhfgxFAAs+Q== dependencies: foreach "^2.0.4" @@ -17249,6 +17248,14 @@ keytar@^5.4.0: nan "2.14.1" prebuild-install "5.3.3" +keyv-memcache@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/keyv-memcache/-/keyv-memcache-1.2.5.tgz#9097af5c617dc740e7300ebfd4a2efb8917429e3" + integrity sha512-iG+GHlhXyV83gmlCtMFIqNYVvv0i0towAy42NvziUR7D+k9jp81fAq0lu66H4gooOnW+ojkdigRvRpL40j1f7w== + dependencies: + json-buffer "^3.0.1" + memjs "^1.3.0" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -17263,6 +17270,13 @@ keyv@^4.0.0: dependencies: json-buffer "3.0.1" +keyv@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.3.tgz#4f3aa98de254803cafcd2896734108daa35e4254" + integrity sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA== + dependencies: + json-buffer "3.0.1" + killable@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" @@ -18023,7 +18037,7 @@ lru-cache@2: resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" integrity sha1-bUUk6LlV+V1PW1iFHOId1y+06VI= -lru-cache@^4.0.1: +lru-cache@^4.0.1, lru-cache@^4.1.3: version "4.1.5" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== @@ -18336,6 +18350,11 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +memjs@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/memjs/-/memjs-1.3.0.tgz#b7959b4ff3770e4c785463fd147f1e4fafd47a24" + integrity sha512-y/V9a0auepA9Lgyr4QieK6K2FczjHucEdTpSS+hHVNmVEkYxruXhkHu8n6DSRQ4HXHEE3cc6Sf9f88WCJXGXsQ== + memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" @@ -18453,9 +18472,9 @@ merge2@^1.2.3, merge2@^1.3.0: integrity sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw== merge@^2.1.0: - version "2.1.0" - resolved "https://registry.npmjs.org/merge/-/merge-2.1.0.tgz#91fff62458ba2eca378dd395fa85f1690bf87f60" - integrity sha512-TcuhVDV+e6X457MQAm7xIb19rWhZuEDEho7RrwxMpQ/3GhD5sDlnP188gjQQuweXHy9igdke5oUtVOXX1X8Sxg== + version "2.1.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-2.1.1.tgz#59ef4bf7e0b3e879186436e8481c06a6c162ca98" + integrity sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w== meros@^1.1.2: version "1.1.4" @@ -19019,6 +19038,20 @@ mv@~2: ncp "~2.0.0" rimraf "~2.4.0" +mysql2@^2.2.5: + version "2.2.5" + resolved "https://registry.npmjs.org/mysql2/-/mysql2-2.2.5.tgz#72624ffb4816f80f96b9c97fedd8c00935f9f340" + integrity sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g== + dependencies: + denque "^1.4.1" + generate-function "^2.3.1" + iconv-lite "^0.6.2" + long "^4.0.0" + lru-cache "^6.0.0" + named-placeholders "^1.1.2" + seq-queue "^0.0.5" + sqlstring "^2.3.2" + mz@^2.7.0: version "2.7.0" resolved "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -19028,6 +19061,13 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +named-placeholders@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.2.tgz#ceb1fbff50b6b33492b5cf214ccf5e39cef3d0e8" + integrity sha512-wiFWqxoLL3PGVReSZpjLVxyJ1bRqe+KKJVbr4hGs1KWfTZTQyezHFBbuKj9hsizHyGV2ne7EMjHdxEGAybD5SA== + dependencies: + lru-cache "^4.1.3" + nan@2.14.1, nan@^2.12.1, nan@^2.14.0: version "2.14.1" resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" @@ -21689,7 +21729,7 @@ qs@6.7.0: resolved "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== -qs@^6.5.1: +qs@^6.10.1, qs@^6.5.1: version "6.10.1" resolved "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz#4931482fa8d647a5aab799c5271d2133b981fb6a" integrity sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg== @@ -22063,6 +22103,11 @@ react-hook-form@^6.15.4: resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-6.15.4.tgz#328003e1ccc096cd158899ffe7e3b33735a9b024" integrity sha512-K+Sw33DtTMengs8OdqFJI3glzNl1wBzSefD/ksQw/hJf9CnOHQAU6qy82eOrh0IRNt2G53sjr7qnnw1JDjvx1w== +react-hook-form@^7.1.1: + version "7.2.1" + resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.2.1.tgz#99b3540dd2314499df12e9a53c70587ad63a806c" + integrity sha512-QopAubhVofqQrwlWLr9aK0DF8tNU8fnU8sJIlw1Tb3tGkEvP9yeaA+cx1hlxYni8xBswtHruL1WcDEa6CYQDow== + react-hot-loader@^4.12.21: version "4.13.0" resolved "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.13.0.tgz#c27e9408581c2a678f5316e69c061b226dc6a202" @@ -23394,9 +23439,9 @@ sane@^4.0.3: walker "~1.0.5" sanitize-html@^2.3.2: - version "2.3.2" - resolved "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.3.2.tgz#a1954aea877a096c408aca7b0c260bef6e4fc402" - integrity sha512-p7neuskvC8pSurUjdVmbWPXmc9A4+QpOXIL+4gwFC+av5h+lYCXFT8uEneqsFQg/wEA1IH+cKQA60AaQI6p3cg== + version "2.3.3" + resolved "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.3.3.tgz#3db382c9a621cce4c46d90f10c64f1e9da9e8353" + integrity sha512-DCFXPt7Di0c6JUnlT90eIgrjs6TsJl/8HYU3KLdmrVclFN4O0heTcVbJiMa23OKVr6aR051XYtsgd8EWwEBwUA== dependencies: deepmerge "^4.2.2" escape-string-regexp "^4.0.0" @@ -23570,6 +23615,11 @@ sentence-case@^3.0.4: tslib "^2.0.3" upper-case-first "^2.0.2" +seq-queue@^0.0.5: + version "0.0.5" + resolved "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz#d56812e1c017a6e4e7c3e3a37a1da6d78dd3c93e" + integrity sha1-1WgS4cAXpuTnw+Ojeh2m143TyT4= + serialize-error@^8.0.1, serialize-error@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz#3a069970c712f78634942ddd50fbbc0eaebe2f67" @@ -24175,6 +24225,11 @@ sqlite3@^5.0.0: optionalDependencies: node-gyp "3.x" +sqlstring@^2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.2.tgz#cdae7169389a1375b18e885f2e60b3e460809514" + integrity sha512-vF4ZbYdKS8OnoJAWBmMxCQDkiEBkGQYU7UZPtL8flbDRSNkhaXvRJ279ZtI6M+zDaQovVU4tuRgzK5fVhvFAhg== + ssh2-streams@~0.4.10: version "0.4.10" resolved "https://registry.npmjs.org/ssh2-streams/-/ssh2-streams-0.4.10.tgz#48ef7e8a0e39d8f2921c30521d56dacb31d23a34" @@ -24330,9 +24385,9 @@ store2@^2.7.1: integrity sha512-tWEpK0snS2RPUq1i3R6OahfJNjWCQYNxq0+by1amCSuw0mXtymJpzmZIeYpA1UAa+7B0grCpNYIbDcd7AgTbFg== storybook-dark-mode@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-1.0.3.tgz#8d58a874b6107ff1a7f29ebb0e6726b8036eee08" - integrity sha512-mjHLrv/dwtqKmbOoQ2CMtGKDttWSnUybutujsIPxLcEC77EujjWiRBFv46LtXAZEyZLm8sGFUz0s6HJJfJ3tSw== + version "1.0.8" + resolved "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-1.0.8.tgz#bbd64b382fd62d38685fdd769e2cac4e32ec293d" + integrity sha512-uY6yTSd1vYE0YwlON50u+iIoNF/fmMj59ww1cpd/naUcmOmCjwawViKFG5YjichwdR/yJ5ybWRUF0tnRQfaSiw== dependencies: fast-deep-equal "^3.0.0" memoizerific "^1.11.3" @@ -25744,9 +25799,9 @@ typescript@^4.0.3, typescript@^4.1.3, typescript@~4.1.3: integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== ua-parser-js@^0.7.18: - version "0.7.21" - resolved "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" - integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ== + version "0.7.28" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31" + integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6"