diff --git a/.changeset/cost-insights-five-baboons-attack.md b/.changeset/cost-insights-five-baboons-attack.md deleted file mode 100644 index f2c070acb4..0000000000 --- a/.changeset/cost-insights-five-baboons-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': minor ---- - -make change ratio optional diff --git a/.changeset/cost-insights-smooth-toes-draw.md b/.changeset/cost-insights-smooth-toes-draw.md new file mode 100644 index 0000000000..e106cbda9c --- /dev/null +++ b/.changeset/cost-insights-smooth-toes-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +fix for query parameters with null groups diff --git a/.changeset/five-knives-flash.md b/.changeset/five-knives-flash.md deleted file mode 100644 index 9076f6f456..0000000000 --- a/.changeset/five-knives-flash.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/create-app': patch -'@backstage/integration': patch -'@backstage/plugin-catalog-import': patch -'@backstage/plugin-github-actions': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. diff --git a/.changeset/flat-keys-happen.md b/.changeset/flat-keys-happen.md deleted file mode 100644 index ce01f86bcf..0000000000 --- a/.changeset/flat-keys-happen.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/cli': patch -'@backstage/core': patch ---- - -Fix some transitive dependency warnings in yarn diff --git a/.changeset/friendly-readers-promise.md b/.changeset/friendly-readers-promise.md deleted file mode 100644 index 30607d5db2..0000000000 --- a/.changeset/friendly-readers-promise.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-react': patch ---- - -Fetch relations in batches in `useRelatedEntities` diff --git a/.changeset/fuzzy-dancers-complain.md b/.changeset/fuzzy-dancers-complain.md deleted file mode 100644 index a23e6bc3df..0000000000 --- a/.changeset/fuzzy-dancers-complain.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/core': patch -'@backstage/plugin-catalog-import': patch ---- - -Bump react-hook-form version to be the same for the entire project. diff --git a/.changeset/giant-rivers-protect.md b/.changeset/giant-rivers-protect.md new file mode 100644 index 0000000000..cbf11a47c0 --- /dev/null +++ b/.changeset/giant-rivers-protect.md @@ -0,0 +1,10 @@ +--- +'@backstage/integration': patch +'@backstage/plugin-catalog-backend': minor +--- + +Updates the `GithubCredentialsProvider` to return the token type, it can either be `token` or `app` depending on the authentication method. + +Update the `GithubOrgReaderProcessor` NOT to query for email addresses if GitHub Apps is used for authentication, this is due to inconsistencies in the GitHub API when using server to server communications and installation tokens. https://github.community/t/api-v4-unable-to-retrieve-email-resource-not-accessible-by-integration/13831/4 for more info. + +**Removes** deprecated GithubOrgReaderProcessor provider configuration(`catalog.processors.githubOrg`). If you're using the deprecated config section make sure to migrate to [integrations](https://backstage.io/docs/integrations/github/locations) instead. diff --git a/.changeset/great-ducks-bow.md b/.changeset/great-ducks-bow.md new file mode 100644 index 0000000000..ff5d3a3458 --- /dev/null +++ b/.changeset/great-ducks-bow.md @@ -0,0 +1,7 @@ +--- +'@backstage/catalog-client': patch +'@backstage/catalog-model': patch +'@backstage/plugin-catalog-backend': patch +--- + +Foundation for standard entity status values diff --git a/.changeset/khaki-rice-accept.md b/.changeset/khaki-rice-accept.md deleted file mode 100644 index 72f1c635ed..0000000000 --- a/.changeset/khaki-rice-accept.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Expose `BitbucketRepositoryParser` introduced in [#5295](https://github.com/backstage/backstage/pull/5295) diff --git a/.changeset/nice-shirts-fetch.md b/.changeset/nice-shirts-fetch.md deleted file mode 100644 index e9537e63b3..0000000000 --- a/.changeset/nice-shirts-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Added lax option to backstage-cli app:build command diff --git a/.changeset/old-horses-brake.md b/.changeset/old-horses-brake.md deleted file mode 100644 index ea41019b23..0000000000 --- a/.changeset/old-horses-brake.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Fix GithubPR built-in action `credentialsProvider.getCredentials` URL. -Adding Documentation for GitHub PR built-in action. diff --git a/.changeset/old-pigs-happen.md b/.changeset/old-pigs-happen.md new file mode 100644 index 0000000000..2af5969966 --- /dev/null +++ b/.changeset/old-pigs-happen.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-api': patch +--- + +Add a global type definition for `Symbol.observable`, fix type checking in projects that didn't already have it defined. diff --git a/.changeset/shaggy-ligers-run.md b/.changeset/shaggy-ligers-run.md new file mode 100644 index 0000000000..b6ad53e418 --- /dev/null +++ b/.changeset/shaggy-ligers-run.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add support for transforming yaml files in jest with 'yaml-jest' diff --git a/.changeset/shiny-keys-dream.md b/.changeset/shiny-keys-dream.md new file mode 100644 index 0000000000..3e28f5d431 --- /dev/null +++ b/.changeset/shiny-keys-dream.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Set admonition font size to 1rem in TechDocs to align with the rest of the document's font sizes. +Fixes #5448 and #5541. diff --git a/.changeset/silly-tables-build.md b/.changeset/silly-tables-build.md deleted file mode 100644 index cb2fd73950..0000000000 --- a/.changeset/silly-tables-build.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch -'@backstage/plugin-kubernetes-backend': patch ---- - -Adds @backstage/plugin-kubernetes-common library to share types between kubernetes frontend and backend. diff --git a/.changeset/small-sheep-smoke.md b/.changeset/small-sheep-smoke.md deleted file mode 100644 index fd917159d3..0000000000 --- a/.changeset/small-sheep-smoke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -bump `jsonschema` from 1.2.7 to 1.4.0 diff --git a/.changeset/smart-sloths-drum.md b/.changeset/smart-sloths-drum.md new file mode 100644 index 0000000000..c12fbe6734 --- /dev/null +++ b/.changeset/smart-sloths-drum.md @@ -0,0 +1,12 @@ +--- +'@backstage/create-app': patch +--- + +Added newer entity relationship cards to the default `@backstage/create-app` template: + +- `EntityDependsOnComponentsCard` +- `EntityDependsOnResourcesCard` +- `EntityHasResourcesCard` +- `EntityHasSubcomponentsCard` + +The `EntityLinksCard` was also added to the overview page. To apply these to your Backstage application, compare against the updated [EntityPage.tsx](https://github.com/backstage/backstage/blob/371760ca2493c8f63e9b44ecc57cc8488131ba5b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx) diff --git a/.changeset/spotty-pigs-bathe.md b/.changeset/spotty-pigs-bathe.md deleted file mode 100644 index 0585f335dc..0000000000 --- a/.changeset/spotty-pigs-bathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-tech-radar': patch ---- - -Update README for composability diff --git a/.changeset/stupid-feet-reply.md b/.changeset/stupid-feet-reply.md deleted file mode 100644 index d7213b1bdc..0000000000 --- a/.changeset/stupid-feet-reply.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Add support for non-organization accounts in GitHub Discovery diff --git a/.changeset/tame-masks-juggle.md b/.changeset/tame-masks-juggle.md deleted file mode 100644 index 91ee43945a..0000000000 --- a/.changeset/tame-masks-juggle.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder': patch ---- - -Close eventSource upon completion of a scaffolder task diff --git a/.changeset/techdocs-are-readable.md b/.changeset/techdocs-are-readable.md new file mode 100644 index 0000000000..b780d4f2fe --- /dev/null +++ b/.changeset/techdocs-are-readable.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Fixes #5529, a bug that prevented TechDocs from rendering pages containing malformed links. diff --git a/.changeset/ten-cups-relax.md b/.changeset/ten-cups-relax.md deleted file mode 100644 index 9e32ad3856..0000000000 --- a/.changeset/ten-cups-relax.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Make git config optional for techdocs feedback links diff --git a/.changeset/thick-cobras-switch.md b/.changeset/thick-cobras-switch.md deleted file mode 100644 index 002c724421..0000000000 --- a/.changeset/thick-cobras-switch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-proxy-backend': patch ---- - -Prefix proxy routes with `/` if not present in configuration diff --git a/.changeset/tough-walls-wash.md b/.changeset/tough-walls-wash.md deleted file mode 100644 index a45adcfbf6..0000000000 --- a/.changeset/tough-walls-wash.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@backstage/catalog-model': patch -'@backstage/cli': patch -'@backstage/config-loader': patch -'@backstage/config': patch -'@backstage/plugin-scaffolder': patch ---- - -Bump `json-schema` dependency from `0.2.5` to `0.3.0`. diff --git a/.changeset/tricky-yaks-melt.md b/.changeset/tricky-yaks-melt.md deleted file mode 100644 index 3679e6d1da..0000000000 --- a/.changeset/tricky-yaks-melt.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/techdocs-common': patch ---- - -Adding optional config to enable S3-like API for tech-docs using s3ForcePathStyle option. -This allows providers like LocalStack, Minio and Wasabi (+possibly others) to be used to host tech docs. diff --git a/.changeset/twenty-peas-deny.md b/.changeset/twenty-peas-deny.md deleted file mode 100644 index d149a9337c..0000000000 --- a/.changeset/twenty-peas-deny.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-client': patch ---- - -Allow `filter` parameter to be specified multiple times diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b07519824b..206c1a7883 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,6 +16,7 @@ /plugins/search-* @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core +/plugins/ilert @yacut /packages/search-common @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/silver-lining diff --git a/.github/stale.yml b/.github/stale.yml index 1b3792ab5e..3cd0d53720 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -9,6 +9,7 @@ exemptLabels: - plugin - help wanted - good first issue + - rfc # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 694226b8cc..7840b331e1 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -1,5 +1,6 @@ Apdex Api +automations Autoscaling Avro Bigtable @@ -32,12 +33,16 @@ GitLab Grafana GraphQL Hackathons +Helidon Heroku +hoc Hostname Iain JaCoCo JavaScript +Jira Kaewkasi +kubernetes Knex Leasot Lerna @@ -47,6 +52,8 @@ Minikube Minio Mkdocs Monorepo +microservices +microservice Namespaces OAuth Okta @@ -167,6 +174,7 @@ mailto maintainership makefile md +memcache microsite middleware minikube @@ -177,6 +185,7 @@ mkdocs monorepo monorepos msw +mysql namespace namespaced namespaces diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml index 4bd03b4d96..70afd15eee 100644 --- a/.github/workflows/changeset.yml +++ b/.github/workflows/changeset.yml @@ -20,3 +20,4 @@ jobs: version: yarn release env: GITHUB_TOKEN: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} + HUSKY: '0' diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000000..31354ec138 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..d2ae35e84b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/ADOPTERS.md b/ADOPTERS.md index 45842b1833..cc7eaa28a5 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,27 +1,31 @@ -| Organization | Contact | Description of Use | -| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | -| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | -| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | -| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | -| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | -| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | -| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | -| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | -| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | -| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | -| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | -| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | -| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | +| Organization | Contact | Description of Use | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Spotify](https://www.spotify.com) | [@leemills83](https://github.com/leemills83) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | +| [Fiverr](https://www.fiverr.com) | [@nirga](https://github.com/nirga) | Unifying separate tools that developers are using today (i.e. monitoring, dead letter queues management, etc.) into a single platform. | +| [Zalando SE](https://www.zalando.de) | [@leviferreira](https://github.com/leviferreira) | Building V2 of the Internal Development Portal. | +| [LegalZoom](https://legalzoom.com) | [@backjo](https://github.com/backjo) | Developer portal - hub for all engineering projects and metadata. | +| [Expedia Group](https://www.expediagroup.com) | [Mike Turner](mailto:miturner@expediagroup.com), [Sneha Kumar](mailto:snkumar@expediagroup.com), [@guillermomanzo](https://github.com/guillermomanzo), [Erik Lindgren](https://github.com/lindgren) | EG Common Developer Toolkit | +| [Paddle.com](https://paddle.com) | [Ioannis Georgoulas](https://github.com/geototti21) | Developer portal (Tech Docs, Service Catalog, Internal Tooling), we use vanilla Backstage FE and custom BE implementation in Go | +| [Acast.com](https://acast.com) | [Olle Lundberg](https://github.com/lndbrg) | Developer portal with tech docs, service catalog and a bunch of other internal tooling | +| [Lunar](https://lunar.app) | [Jacob Valdemar](https://github.com/JacobValdemar) | Internal developer portal for service overview and insights, API documentation, technical guides, onboarding guides and RFC's. | +| [Trendyol](https://trendyol.com) | [Erdogan Oksuz](https://github.com/erdoganoksuz) | The Developer Portal has been called `Pandora`. Provides an overview of Trendyol tech ecosystem. TechDocs, Catalog, Custom Plugins and Theme. | +| [Peloton](https://www.onepeloton.com/) | [Jim Haughwout](https://github.com/JimHaughwout) | Creating our first developer portal and tech-docs. Exploring Service Catalog, Tech Insights and Cost Insights as well. | +| [TELUS](https://telus.com) | [Seb Barre](https://github.com/sbarre) | The Go-to place to find answers about development and delivery at TELUS. | +| [Brex](https://www.brex.com/) | [Vamsi Chitters](https://github.com/vamsikc) | A centralized UI to understand how a service fits in the whole Brex architecture and manage a team’s engineering dependencies. | +| [Oriflame](https://www.oriflame.com/) | [Oriflame](https://github.com/oriflame) | Internal developer portal for services, single page apps and packages overview, API documentation, technical guides, tech-radar and more. | +| [Booz Allen Hamilton](https://www.boozallen.com/) | [Jason Miller](https://github.com/JasonMiller-BAH) | Developer portal for a full-stack software development ecosystem that accelerates consistent and repeatable Modern Software Development practices for internal innovation and investments. | +| [Netflix](https://www.netflix.com/) | [bleathem](https://github.com/bleathem) | Our Backstage implementation will be the front door to a unified experience connecting our internal platform products across important workflows with integrated knowledge and support. | +| [b.well](https://www.icanbwell.com/) | [Jacob Rosales](https://github.com/jrosales) | Foundation for our engineering portal and cloud insights. | 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/app-config.yaml b/app-config.yaml index 3c4f2d7ea5..b1c915f85a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -81,6 +81,13 @@ proxy: headers: Authorization: ${SENTRY_TOKEN} + '/ilert': + target: https://api.ilert.com + allowedMethods: ['GET', 'POST', 'PUT'] + allowedHeaders: ['Authorization'] + headers: + Authorization: ${ILERT_AUTH_HEADER} + organization: name: My Company diff --git a/cypress/package.json b/cypress/package.json index 8549386f7e..6a231326c4 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -5,7 +5,7 @@ "license": "MIT", "private": true, "dependencies": { - "cypress": "^6.4.0", + "cypress": "^7.3.0", "typescript": "^4.1.3" } } diff --git a/cypress/yarn.lock b/cypress/yarn.lock index 3c737f6ebc..baf0d92715 100644 --- a/cypress/yarn.lock +++ b/cypress/yarn.lock @@ -53,7 +53,12 @@ dependencies: any-observable "^0.3.0" -"@types/sinonjs__fake-timers@^6.0.1": +"@types/node@^14.14.31": + version "14.17.0" + resolved "https://registry.npmjs.org/@types/node/-/node-14.17.0.tgz#3ba770047723b3eeb8dc9fca02cce8a7fb6378da" + integrity sha512-w8VZUN/f7SSbvVReb9SWp6cJFevxb4/nkG65yLAya//98WgocKm5PLDAtSs5CtJJJM+kHmJjO/6mmYW4MHShZA== + +"@types/sinonjs__fake-timers@^6.0.2": version "6.0.2" resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae" integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg== @@ -117,7 +122,7 @@ any-observable@^0.3.0: resolved "https://registry.npmjs.org/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== -arch@^2.1.2: +arch@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== @@ -171,7 +176,7 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" -blob-util@2.0.2: +blob-util@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== @@ -242,10 +247,10 @@ check-more-types@^2.24.0: resolved "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA= -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== +ci-info@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.1.1.tgz#9a32fcefdf7bcdb6f0a7e1c0f8098ec57897b80a" + integrity sha512-kdRWLBIJwdsYJWYJFtAFFYxybguqeF91qpZaggjG5Nf8QKdizFG2hjqvaTXbxFIcYbSaD74KpAXv6BSm17DHEQ== cli-cursor@^1.0.2: version "1.0.2" @@ -359,18 +364,19 @@ cross-spawn@^7.0.0: shebang-command "^2.0.0" which "^2.0.1" -cypress@^6.4.0: - version "6.4.0" - resolved "https://registry.npmjs.org/cypress/-/cypress-6.4.0.tgz#432c516bf4f1a0f042a6aa1f2c3a4278fa35a8b2" - integrity sha512-SrsPsZ4IBterudkoFYBvkQmXOVxclh1/+ytbzpV8AH/D2FA+s2Qy5ISsaRzOFsbQa4KZWoi3AKwREmF1HucYkg== +cypress@^7.3.0: + version "7.3.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-7.3.0.tgz#17345b8d18681c120f033e7d8fd0f0271e9d0d51" + integrity sha512-aseRCH1tRVCrM6oEfja6fR/bo5l6e4SkHRRSATh27UeN4f/ANC8U7tGIulmrISJVy9xuOkOdbYKbUb2MNM+nrw== dependencies: "@cypress/listr-verbose-renderer" "^0.4.1" "@cypress/request" "^2.88.5" "@cypress/xvfb" "^1.2.4" - "@types/sinonjs__fake-timers" "^6.0.1" + "@types/node" "^14.14.31" + "@types/sinonjs__fake-timers" "^6.0.2" "@types/sizzle" "^2.3.2" - arch "^2.1.2" - blob-util "2.0.2" + arch "^2.2.0" + blob-util "^2.0.2" bluebird "^3.7.2" cachedir "^2.3.0" chalk "^4.1.0" @@ -378,27 +384,26 @@ cypress@^6.4.0: cli-table3 "~0.6.0" commander "^5.1.0" common-tags "^1.8.0" - dayjs "^1.9.3" - debug "^4.1.1" - eventemitter2 "^6.4.2" - execa "^4.0.2" + dayjs "^1.10.4" + debug "4.3.2" + eventemitter2 "^6.4.3" + execa "4.1.0" executable "^4.1.1" extract-zip "^1.7.0" - fs-extra "^9.0.1" + fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^2.0.0" - is-installed-globally "^0.3.2" + is-ci "^3.0.0" + is-installed-globally "~0.4.0" lazy-ass "^1.6.0" listr "^0.14.3" - lodash "^4.17.19" + lodash "^4.17.21" log-symbols "^4.0.0" minimist "^1.2.5" - moment "^2.29.1" ospath "^1.2.2" - pretty-bytes "^5.4.1" - ramda "~0.26.1" + pretty-bytes "^5.6.0" + ramda "~0.27.1" request-progress "^3.0.0" - supports-color "^7.2.0" + supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" url "^0.11.0" @@ -416,11 +421,18 @@ date-fns@^1.27.2: resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -dayjs@^1.9.3: +dayjs@^1.10.4: version "1.10.4" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== +debug@4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + debug@^2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -435,13 +447,6 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.1.1: - version "4.3.1" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" - integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== - dependencies: - ms "2.1.2" - delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -477,12 +482,12 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -eventemitter2@^6.4.2: - version "6.4.3" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" - integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== +eventemitter2@^6.4.3: + version "6.4.4" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz#aa96e8275c4dbeb017a5d0e03780c65612a1202b" + integrity sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw== -execa@^4.0.2: +execa@4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== @@ -580,7 +585,7 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" -fs-extra@^9.0.1: +fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -628,12 +633,12 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^2.0.1: - version "2.1.0" - resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.1.0.tgz#e9046a49c806ff04d6c1825e196c8f0091e8df4d" - integrity sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ== +global-dirs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" + integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== dependencies: - ini "1.3.7" + ini "2.0.0" graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.5" @@ -702,17 +707,17 @@ inherits@2, inherits@^2.0.3, inherits@~2.0.3: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@1.3.7: - version "1.3.7" - resolved "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84" - integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ== - -is-ci@^2.0.0: +ini@2.0.0: version "2.0.0" - resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + +is-ci@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz#c7e7be3c9d8eef7d0fa144390bd1e4b88dc4c994" + integrity sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ== dependencies: - ci-info "^2.0.0" + ci-info "^3.1.1" is-fullwidth-code-point@^1.0.0: version "1.0.0" @@ -731,13 +736,13 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-installed-globally@^0.3.2: - version "0.3.2" - resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" - integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== +is-installed-globally@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" + integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== dependencies: - global-dirs "^2.0.1" - is-path-inside "^3.0.1" + global-dirs "^3.0.0" + is-path-inside "^3.0.2" is-observable@^1.1.0: version "1.1.0" @@ -746,10 +751,10 @@ is-observable@^1.1.0: dependencies: symbol-observable "^1.1.0" -is-path-inside@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" - integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== +is-path-inside@^3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== is-promise@^2.1.0: version "2.2.2" @@ -879,10 +884,10 @@ lodash.once@^4.1.1: resolved "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash@^4.17.19: - version "4.17.20" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^1.0.2: version "1.0.2" @@ -953,11 +958,6 @@ mkdirp@^0.5.4: dependencies: minimist "^1.2.5" -moment@^2.29.1: - version "2.29.1" - resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -1056,10 +1056,10 @@ pify@^2.2.0: resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= -pretty-bytes@^5.4.1: - version "5.5.0" - resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" - integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== +pretty-bytes@^5.6.0: + version "5.6.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== process-nextick-args@~2.0.0: version "2.0.1" @@ -1099,10 +1099,10 @@ querystring@0.2.0: resolved "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -ramda@~0.26.1: - version "0.26.1" - resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" - integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +ramda@~0.27.1: + version "0.27.1" + resolved "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9" + integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw== readable-stream@^2.2.2: version "2.3.7" @@ -1277,13 +1277,20 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0, supports-color@^7.2.0: +supports-color@^7.1.0: version "7.2.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== dependencies: has-flag "^4.0.0" +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + symbol-observable@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" diff --git a/docs/architecture-decisions/adr002-default-catalog-file-format.md b/docs/architecture-decisions/adr002-default-catalog-file-format.md index 8523b4a111..013e2cbf39 100644 --- a/docs/architecture-decisions/adr002-default-catalog-file-format.md +++ b/docs/architecture-decisions/adr002-default-catalog-file-format.md @@ -125,8 +125,8 @@ entity, and for machines and other components to reference the entity (e.g. in URLs or from other entity specification files). Names must be unique per kind, within a given namespace (if specified), at any -point in time. Names may be reused at a later time, after an entity is deleted -from the registry. +point in time. This uniqueness constraint is also case insensitive. Names may be +reused at a later time, after an entity is deleted from the registry. Names are required to follow a certain format. Entities that do not follow those rules will not be accepted for registration in the catalog. The ruleset is @@ -139,18 +139,6 @@ follows. Example: `visits-tracking-service`, `CircleciBuildsDs_avro_gcs` -In addition to this, names are passed through a normalization function and then -compared to the same normalized form of other entity names and made sure to not -collide. This rule of uniqueness exists to avoid situations where e.g. both -`my-component` and `MyComponent` are registered side by side, which leads to -confusion and risk. The normalization function is also configurable, but the -default behavior is as follows. - -- Strip out all characters outside of the set `[a-zA-Z0-9]` -- Convert to lowercase - -Example: `CircleciBuildsDs_avro_gcs` -> `circlecibuildsdsavrogcs` - ### `namespace` The `name` of a namespace that the entity belongs to. This field is optional, diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index 8e9c499dc4..c7ac2cedc9 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -31,8 +31,8 @@ auth: providers: gitlab: development: - clientId: ${AUTH_GITLAB_APPLICATION_ID} - clientSecret: ${AUTH_GITLAB_SECRET} + clientId: ${AUTH_GITLAB_CLIENT_ID} + clientSecret: ${AUTH_GITLAB_CLIENT_SECRET} ## uncomment if using self-hosted GitLab # audience: https://gitlab.company.com ``` diff --git a/docs/auth/index.md b/docs/auth/index.md index f3783c79f3..000c57a50c 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -77,7 +77,6 @@ built-in providers: + const app = createApp({ apis, - plugins: Object.values(plugins), + components: { + SignInPage: props => ( + ( + "We have this concept of service maintainership, separate from ownership, that > we would like to make relations to individual users for." -> We feel that we want to explicitly model the team-to-global-department mapping -> as a relation, because it is core to our org setup and we frequently query for -> it. +> "We feel that we want to explicitly model the team-to-global-department +> mapping as a relation, because it is core to our org setup and we frequently +> query for it." Any processor can emit relations for entities as they are being processed, and new processors can be added when building the backend catalog using the @@ -349,3 +350,46 @@ If you want to extend the use of an established relation type in a way that has an effect outside of your organization, reach out to the Backstage maintainers or a support partner to discuss risk/impact. It may even be that one end of the relation could be considered for addition to the core. + +## Adding a New Status field + +Example intent: + +> "We would like to convey entity statuses through the catalog in a generic way, +> as an integration layer. Our monitoring and alerting system has a plugin with +> Backstage, and it would be useful if the entity's status field contained the +> current alert state close to the actual entity data for anyone to consume. We +> find the `status.items` semantics a poor fit, so we would prefer to make our +> own custom field under `status` for these purposes." + +We have not yet ventured to define any generic semantics for the `status` +object. We recommend sticking with the `status.items` mechanism where possible +(see below), since third party consumers will not be able to consume your status +information otherwise. Please reach out to the maintainers on Discord or by +making a GitHub issue describing your use case if you are interested in this +topic. + +## Adding a New Status Item Type + +Example intent: + +> "The semantics of the entity `status.items` field are fine for our needs, but +> we want to contribute our own type of status into that array instead of the +> catalog specific one." + +This is a simple, low risk way of adding your own status information to +entities. Consumers will be able to easily track and display the status together +with other types / sources. + +We recommend that any status type that are not strictly private within the +organization be namespaced to avoid collisions. Statuses emitted by Backstage +core processes will for example be prefixed with `backstage.io/`, your +organization may prefix with `my-org.net/`, and `pagerduty.com/active-alerts` +could be a sensible complete status item type for that particular external +system. + +The mechanics for how to emit custom statuses is not in place yet, so if this is +of interest to you, you might consider contacting the maintainers on Discord or +my making a GitHub issue describing your use case. +[This issue](https://github.com/backstage/backstage/issues/2292) also contains +more context. diff --git a/docs/features/software-catalog/well-known-statuses.md b/docs/features/software-catalog/well-known-statuses.md new file mode 100644 index 0000000000..d293ae5062 --- /dev/null +++ b/docs/features/software-catalog/well-known-statuses.md @@ -0,0 +1,55 @@ +--- +id: well-known-statuses +title: Well-known Status fields of Catalog Entities +sidebar_label: Well-known Statuses +# prettier-ignore +description: Lists a number of well known entity statuses, that have defined semantics. They can be attached to catalog entities and consumed by plugins as needed. +--- + +This section lists well known +[entity statuses](descriptor-format.md#common-to-all-kinds-status), that have +defined semantics. They can be attached to catalog entities and consumed by +plugins as needed. + +If you are looking to extend the statuses, see +[Extending the model](extending-the-model.md). + +## Common Fields + +The `status` object of an entity is currently left unrestricted, except for the +`items` field. Its structure is defined in the +[descriptor format](descriptor-format.md#common-to-all-kinds-status) section. + +We reserve the right to extend this model in the future. This status is in +active development and its format will change unexpectedly. Do not consume it in +your own code until such a time that this documentation has been updated. + +## Status Item Types + +This is a (non-exhaustive) list of `status.items.[].type` values that are known +to be in active use. + +### `backstage.io/catalog-processing` + +Expresses an aspect of the current status of the catalog's ingestion of this +entity. Errors that may appear here include inability to read from the remote +SCM provider, syntax errors in the YAML file, and similar. + +Note that the entity data itself may be of an older version, when errors are +present. The ingestion system keeps the old valid entity data untouched when +possible, so the errors described in this state may not seem to align with the +rest of the entity, because they pertain to a remote that could not be +successfully ingested. This is normal. + +```yaml +# Example: +status: + items: + - type: backstage.io/catalog-processing + level: error + message: 'NotFoundError: File not found' + error: + name: NotFoundError + message: File not found + stack: ... +``` diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index 5acaf0e499..ad7e6d1062 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -70,6 +70,10 @@ creating a file called `packages/backend/src/plugins/scaffolder.ts` with the following contents to get you up and running quickly. ```ts +import { + DockerContainerRunner, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { CookieCutter, createRouter, @@ -78,7 +82,6 @@ import { CreateReactAppTemplater, Templaters, } from '@backstage/plugin-scaffolder-backend'; -import { SingleHostDiscovery } from '@backstage/backend-common'; import type { PluginEnvironment } from '../types'; import Docker from 'dockerode'; import { CatalogClient } from '@backstage/catalog-client'; @@ -89,8 +92,11 @@ export default async function createPlugin({ database, reader, }: PluginEnvironment) { - const cookiecutterTemplater = new CookieCutter(); - const craTemplater = new CreateReactAppTemplater(); + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + const cookiecutterTemplater = new CookieCutter({ containerRunner }); + const craTemplater = new CreateReactAppTemplater({ containerRunner }); const templaters = new Templaters(); templaters.register('cookiecutter', cookiecutterTemplater); @@ -99,8 +105,6 @@ export default async function createPlugin({ const preparers = await Preparers.fromConfig(config, { logger }); const publishers = await Publishers.fromConfig(config, { logger }); - const dockerClient = new Docker(); - const discovery = SingleHostDiscovery.fromConfig(config); const catalogClient = new CatalogClient({ discoveryApi: discovery }); @@ -110,7 +114,6 @@ export default async function createPlugin({ publishers, logger, config, - dockerClient, database, catalogClient, reader, diff --git a/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md new file mode 100644 index 0000000000..25ff2a2f3a --- /dev/null +++ b/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2.md @@ -0,0 +1,334 @@ +--- +id: migrating-from-v1alpha1-to-v1beta2 +title: Migrating to v1beta2 templates +# prettier-ignore +description: + How to move your old templates from v1alpha1 to the more declarative v1beta2 +--- + +# What's new? + +Previously, the scaffolder was very restricted in what you could do when +creating new software components from templates. There were three scaffolding +steps which was pretty hard to extend and add new functionality to, difficult to +re-use logic between templates. There used to be a fixed pipeline of +`preparers`, `templaters`, and `publishers`, which were defined by the backend +and needed to be run for each template. This is now changed, to give the +template total control over what should be executed as part of the templating +run. This makes templates a little more declarative as you can now register +different `actions` or `functions` with the `scaffolder-backend` which you then +can decide how, and in what order, to run using the template definition YAML +file. + +We've also made some improvements, and added some helpers to work with +cookiecutter. The skeleton for a template can now be stored in a different place +to where your entity definition is: previously you needed to have your +`template.yaml` next to the skeleton source (`{{cookiecutter.component_id}}` +directory), but now that's not the case. Part of the changes with the `v1beta2` +syntax is that you can grab your template source from any repository, and re-use +them between templates. + +We've also renamed the `schema` property to `parameters` as this makes more +sense when using them as parameters to the actions or steps that you've setup +for your templates. There's the added benefit that you can now assign an array +to the `parameters` property, which will then give you multiple steps in the UI, +so you can split apart your input parameters and group them as needed rather +than having one long list of input fields. + +## The `parameters` property + +The `schema` key has now been renamed to `parameters` with a few more features. +You can pass an array now to break apart the input form into different steps in +the UI. You can also specify `ui:schema` fields that are passed along to +[`react-jsonschema-form`](https://rjsf-team.github.io/react-jsonschema-form/) +inline with the JSON schema. + +```yaml +spec: + parameters: + - title: Fill in some steps + required: + - name + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 +``` + +## The `steps` property + +`v1beta2` template syntax introduces the new `steps` property, which is an array +of `actions` that the scaffolder will run in combination with the user input +that is declared in the `schema`. Actions look like the following: + +```yaml +spec: + steps: + - id: publish # a unique id for the step, can be anything you like + name: Publish # a user friendly name for the step, this is what is shown in the frontend + action: publish:github # the action ID that has been registered with the scaffolder-backend + input: # parameters that are passed as input to the action handler function + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' # handlebars templating is supported with the values from the parameters section in the same file. + repoUrl: '{{ parameters.repoUrl }}' +``` + +# Migrating a `v1alpha1` template + +## The template definition (.yaml) + +### `parameters` + +Because of the changes to invert the control to the `template.yaml` definition +for running the workflow, we need to adjust the `schema` property and we also +now need to define what the template is actually going to do as part of the +template run. + +A simple migration would move the following yaml: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: Create a website powered with Next.js + tags: + - recommended + - react +spec: + owner: web@example.com + templater: cookiecutter + type: website + path: '.' + schema: + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Help others understand what this website is for. +``` + +To something that looks like the following: + +```yaml +apiVersion: backstage.io/v1beta2 +kind: Template +metadata: + name: react-ssr-template + title: React SSR Template + description: Create a website powered with Next.js + tags: + - recommended + - react +spec: + owner: web@example.com + type: website + parameters: + - title: Add some input + required: + - component_id + - description + properties: + component_id: + title: Name + type: string + description: Unique name of the component + description: + title: Description + type: string + description: Help others understand what this website is for. + - title: Some more additional info that was previously provided automatically + required: + - owner + - repoUrl + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com +``` + +There are a few things to note here. On the `alpha` version, the second step of +the template flow in the frontend was provided by Backstage for free, so we used +to collect the user input for the `owner` field and the `repositoryUrl` that you +were going to publish to. Now because `actions` can have any workflow they like, +it doesn't make sense to still provide these fields for every scaffolding +workflow, as you might not need these anymore. That's why we now manually add +those fields back into the template parameters that are shown to the user: + +```yaml + - title: Some more additional info that was previously provided automatically + required: + - owner + - repoUrl + properties: + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + allowedKinds: + - Group + - title: Choose a location + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com +``` + +Maybe you also don't need to publish to `github.com`, you should replace this +with your VCS provider URL that is listed in your `integrations` config instead. + +### `steps` + +So now we should have all the required information that we need from the user in +a much more extensible way. We now need to tell the scaffolder what to do with +these parameters and what to do with the user input. + +We've made templating using `cookiecutter` a little simpler. You don't need to +store the `cookiecutter` skeleton in the same directory as the `template.yaml` +definition, it can live wherever you like - maybe a shared repository somewhere +so you can re-use the skeletons but apply different actions for different +templates depending on your use case. + +We also no longer need to have a directory called +`{{cookiecutter.component_id}}`. This is because now we can't ensure that +`component_id` will be a parameter that is provided from the frontend, this +could break `cookiecutter`. If your directory structure used to look like this: + +``` +my-awesome-template + -> {{cookiecutter.component_id}} + -> file.txt + -> some_more_files.ts + -> hooks + -> post_gen_project.sh + -> template.yaml +``` + +We now recommend that you move to the following structure: + +``` +my-awesome-template + -> skeleton + -> file.txt + -> some_more_files.ts + -> template.yaml +``` + +This migration renames the skeleton folder to something more semantic, and also +drops support for `cookiecutter` hooks. We've dropped support for `cookiecutter` +hooks for now, as hopefully everything that is stored in these hooks can be +moved to `actions` instead, and for security reasons, it's more secure to run +trusted code that you ship with Backstage as an action rather than some script +that can be pulled in from anywhere which doesn't get vetted first. It's a +pretty big security risk that those scripts will be run on Backstage instances +inside your infrastructure, especially `.sh` files. + +If you really need hooks and can't find a suitable solution by using actions +please reach out to us through a ticket and we'll see what we can do to assist +:) + +You'll notice that we removed the `templater` property from the `spec` +definition in the template `yaml`, so there's no way to define that this is a +`cookiecutter` `templater`. + +We've created a built-in action that you can use which will when run, go grab a +directory from anywhere and run `cookiecutter` on top of it, and then extract +the contents into the working directory for the scaffolder. + +Adding the `steps` for a simple template should look something like the +following: + +```yaml +spec: + steps: + # this action will go use cookiecutter to template some files into the working directory + - id: template # an ID for the templating step + name: Create skeleton # A user friendly name for the action + action: fetch:cookiecutter + input: + url: ./skeleton # this is the directory for your skeleton files. + # If it's located next to the `template.yaml` then you can use a relative path, + # otherwise you can use absolute URLs that point at the VCS: https://github.com/backstage/backstage/tree/master/some_folder_somewhere + values: + # for each value that you need to pass to cookiecutter, they should be listed here and set in this values object. + # You can use the handlebars templating syntax to pull them from the input parameters listed in the same file + name: '{{ parameters.name }}' + owner: '{{ parameters.owner }}' + destination: '{{ parseRepoUrl parameters.repoUrl }}' + + # this action is for publishing the working directory to the VCS + - id: publish + name: Publish + action: publish:github + input: + allowedHosts: ['github.com'] + description: 'This is {{ parameters.name }}' + repoUrl: '{{ parameters.repoUrl }}' + + # this action will then register the created component in Backstage + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: '{{ steps.publish.output.repoContentsUrl }}' + catalogInfoPath: '/catalog-info.yaml' +``` + +### `output` + +Steps can output values, and so can the template itself. This is good for +returning values to the frontend, so we can make the buttons like +`Go to catalog` and `Go to repo` work correctly. You can add the following to +your `template.yaml` to make sure you return the right values from the steps: + +```yaml +spec: + output: + remoteUrl: '{{ steps.publish.output.remoteUrl }}' + entityRef: '{{ steps.register.output.entityRef }}' +``` + +Or you can return a `links` array with text and a URL explicitly: + +```yaml +spec: + output: + links: + - url: '{{steps.publish.output.remoteUrl}}' + text: 'Go to Repo' +``` + +## Questions? + +If you have any questions or feedback, please reach out to us on GitHub or +Discord and we will do our best to help! diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index ff145ee5af..90dd603c8f 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -106,7 +106,6 @@ return await createRouter({ publishers, logger, config, - dockerClient, database, catalogClient, reader, @@ -124,7 +123,6 @@ return await createRouter({ publishers, logger, config, - dockerClient, database, catalogClient, reader, @@ -136,11 +134,9 @@ return await createRouter({ want to have those as well as your new one, you'll need to do the following: ```ts - -import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend`; +import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend'; const builtInActions = createBuiltinActions({ - dockerClient, integrations, catalogClient, templaters, @@ -155,7 +151,6 @@ return await createRouter({ publishers, logger, config, - dockerClient, database, catalogClient, reader, diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index 520db2245c..51f07d5cde 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -63,6 +63,7 @@ Create a file called `techdocs.ts` inside `packages/backend/src/plugins/` and add the following ```typescript +import { DockerContainerRunner } from '@backstage/backend-common'; import { createRouter, Generators, @@ -84,9 +85,14 @@ export default async function createPlugin({ reader, }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + // Generators are used for generating documentation sites. const generators = await Generators.fromConfig(config, { logger, + containerRunner, }); // Publisher is used for @@ -97,14 +103,13 @@ export default async function createPlugin({ discovery, }); - // Docker client (conditionally) used by the generators, based on techdocs.generators config. - const dockerClient = new Docker(); + // checks if the publisher is working and logs the result + await publisher.getReadiness(); return await createRouter({ preparers, generators, publisher, - dockerClient, logger, config, discovery, diff --git a/docs/integrations/github/email.png b/docs/integrations/github/email.png new file mode 100644 index 0000000000..763cfbcd58 Binary files /dev/null and b/docs/integrations/github/email.png differ diff --git a/docs/integrations/github/org.md b/docs/integrations/github/org.md index 4a1bf88932..5ee650e29c 100644 --- a/docs/integrations/github/org.md +++ b/docs/integrations/github/org.md @@ -53,6 +53,16 @@ catalog: token: ${GITHUB_TOKEN} ``` +If Backstage is configured to use GitHub Apps authentication you must grant +`Read-Only` access for `Members` under `Organization` in order to ingest users +correctly. You can modify the app's permissions under the organization settings, +`https://github.com/organizations/{ORG}/settings/apps/{APP_NAME}/permissions`. +![permissions](./permissions.png) + +**Please note that when you change permissions, the app owner will get an email +that must be approved first before the changes are applied.** +![email](./email.png) + Locations point out the specific org(s) you want to import. The `type` of these locations must be `github-org`, and the `target` must point to the exact URL of some organization. You can have several such location entries if you want, but diff --git a/docs/integrations/github/permissions.png b/docs/integrations/github/permissions.png new file mode 100644 index 0000000000..4928b184ac Binary files /dev/null and b/docs/integrations/github/permissions.png differ diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 516ba4b111..4480cd69e3 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -1,7 +1,113 @@ --- id: backend-plugin -title: Backend plugin -description: Documentation on Backend plugin +title: Backend plugins +description: Creating and Developing Backend plugins --- -## TODO +This page describes the process of creating and managing backend plugins in your +Backstage repository. + +## Creating a Backend Plugin + +A new, bare-bones backend plugin package can be created by issuing the following +command in your Backstage repository root: + +```sh +yarn create-plugin --backend +``` + +Please also see the `--help` flag for the `create-plugin` command for some +further options that are available, notably the `--scope` and `--no-private` +flags that control naming and publishing of the newly created package. Your repo +root `package.json` will probably also have some default values already set up +for these. + +You will be asked to supply a name for the plugin. This is an identifier that +will be part of the NPM package name, so make it short and containing only +lowercase characters separated by dashes, for example `carmen`, if it's a +package that adds an integration with a system named Carmen, for example. The +full NPM package name would then be something like +`@internal/plugin-carmen-backend`, depending on the other flags passed to the +`create-plugin` command, and your settings for the `create-plugin` command in +your root `package.json`. + +Creating the plugin will take a little while, so be patient. It will helpfully +run the initial installation and build commands, so that your package is ready +to be hacked on! It will be located in a new folder in your `plugins` directory, +in this example `plugins/carmen-backend`. + +For simple development purposes, a backend plugin can actually be started in a +standalone mode. You can do a first-light test of your service: + +```sh +cd plugins/carmen-backend +yarn start +``` + +This will think for a bit, and then say `Listening on :7000`. In a different +terminal window, now run + +```sh +curl localhost:7000/carmen/health +``` + +This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to kill it +again. + +## Developing your Backend Plugin + +A freshly created backend plugin does basically nothing, in terms of the overall +app. It has a small set of basic dependencies and exposes an Express router in +`src/service/router.ts`. This is where you will start adding routes and +connecting those to actual underlying functionality. But nothing in your +Backstage application / backend exposes it. + +To actually attach and run the plugin router, you will make some modifications +to your backend. + +```sh +# From the Backstage root directory +cd packages/backend +yarn add @internal/plugin-carmen-backend@^0.1.1 # Change this to match the plugin's package.json +``` + +Create a new file named `packages/backend/src/plugins/carmen.ts`, and add the +following to it + +```ts +import { createRouter } from '@internal/plugin-carmen-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin(env: PluginEnvironment) { + // Here is where you will add all of the required initialization code that + // your backend plugin needs to be able to start! + + // The env contains a lot of goodies, but our router currently only + // needs a logger + return await createRouter({ + logger: env.logger, + }); +} +``` + +And finally, wire this into the overall backend router. Edit +`packages/backend/src/index.ts`: + +```ts +import carmen from './plugins/carmen'; +// ... +async function main() { + // ... + const carmenEnv = useHotMemoize(module, () => createEnv('carmen')); + apiRouter.use('/carmen', await carmen(badgesEnv)); +``` + +After you start the backend (e.g. using `yarn start-backend` from the repo +root), you should be able to fetch data from it. + +```sh +# Note the extra /api here +curl localhost:7000/api/carmen/health +``` + +This should return `{"status":"ok"}` like before. Success! diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md index 510b697221..d54b21f7c4 100644 --- a/docs/tutorials/quickstart-app-auth.md +++ b/docs/tutorials/quickstart-app-auth.md @@ -329,12 +329,12 @@ above: ### 6. In the same file, modify createApp -Remember to modify the provider information based on the table above. +Remember to add or modify a `SignInPage` component for `createApp`, using +provider information based on the table above. ```tsx const app = createApp({ apis, - plugins: Object.values(plugins), components: { SignInPage: props => ( + +## Infrastructure as tech culture + +Before providing recommendations on getting started with Backstage, it’s helpful to have a bit of context as to why Spotify made the design decisions we did. No two companies are identical — and thus, no two Backstage implementations are the same. + +Rolling back the clock just a few years, [Spotify was challenged](https://engineering.atspotify.com/2021/05/18/a-product-story-the-lessons-of-backstage-and-spotifys-autonomous-culture/) to continue to scale our engineering team (and the number of features and components built) but retain the speed of product development. Some user research with Spotify developers highlighted a clear problem: there was simply too much non-documented institutional knowledge needed to get things done. No one could find anything and everyone was interrupting everyone else trying to figure things out. + +Spotify’s developers were facing three big challenges on a daily basis: + +- They not only needed to build software quickly, they also needed to pass along knowledge to new joiners about how best to create new components. +- They needed to somehow maintain a mental model of the systems their squad owned. (Or, if they were lucky, they found a hopefully-up-to-date spreadsheet tracking this information.) +- They needed to keep an eye on what squads around them might be building to ensure they could reuse systems when they needed to solve similar problems in the future. + +In short, Spotify developers needed to continue building industry leading features at breakneck speed, while simultaneously maintaining a mental model for all the software at Spotify (oh, and help every new joiner develop that mental model as well!). +
+ +## Three jobs: Create, manage, explore + +Around the same time, the [jobs to be done](https://hbr.org/2016/09/know-your-customers-jobs-to-be-done) framework was becoming popular and luckily, a few Spotifiers helped guide the vision for a _sense-making tool for developers_ toward using it. After user research and many failed attempts, we landed on three jobs Spotify developers needed to do consistently: +
+ +- **Create**: Spotify developers want to delight their customers with incredible features. They create new software to do that. +- **Manage**: Spotify developers are proud of their work and treat their software and data like products. That requires maintaining all the software they own on an ongoing basis. +- **Explore**: Spotify developers want to solve new, yet unsolved problems. They try to build on existing systems to help them do that. + +So: make software, maintain the software you own throughout its lifecycle, and integrate with other people’s software. + +Within today’s complex development environments, there are barriers both big and small that get in the way of those three jobs. Backstage provides the building blocks for removing those barriers, streamlining your development cycle, and letting developers do what they really want to do: build great features. Let’s take a closer look at each of these jobs. + +![Backstage Software Templates rocket](assets/21-05-20/create.gif) + +### Create + +**Job:** You’re an engineer, ready to start building a new microservice. Do you just pick whatever framework you want? How do you reserve capacity to run your service in production? What about managing CI/CD? + +**Tool:** At Spotify, we use **Backstage Software Templates** to simplify all of this, reducing time–to–Hello World to just a few clicks. Instead of researching Spring Boot versus Helidon, opening a Jira ticket, rummaging through docs, and configuring CI automations, you just choose a template and your project is automatically set up in the repo for you, with the CI already running your first build. + +**Result:** By making it easier to start new projects, your engineers get to the good part of coding features faster. And your organization’s best practices are built into the templates, encouraging standards and reducing complexity in your tech ecosystem. + +![Backstage Service Catalog system](assets/21-05-20/manage.gif) + +### Manage + +**Job:** You’re on a small team that owns a dozen services. Whenever you update and deploy those services, you’re switching between your CI, the AWS console, a security dashboard, and a CLI so you can try to figure out which Kubernetes cluster your service ended up on. In other words, you have a lot of open windows and tabs, and each step means switching to a new interface. + +**Tool:** All of your team’s software components are organized together on one page in the **Backstage Service Catalog**. Go to any service’s page from there and its CI/CD status, [Kubernetes deployment status](https://backstage.io/blog/2021/01/12/new-backstage-feature-kubernetes-for-service-owners), documentation, security checks — and everything else related to that service — is grouped together in one seamless interface that shows you only the info you want. + +**Result**: One page in Backstage has everything you need to manage the software you own. No more context-switching. No more digging through your cloud provider’s obscure admin features. Outside the repo and your IDE, everything you need to manage your service is right inside Backstage. + +![Backstage explorer](assets/21-05-20/explore.gif) + +### Explore + +**Job:** You're building a new mobile feature that needs to ensure a user is paying for the premium version of your product — but someone must’ve already built a library that handles that, right? A company-wide email and a few calls for help on Slack yield no response, so you resign yourself to building the capability yourself. Turns out someone did build the library you needed. They were just on vacation so they didn’t see your messages. How do you enable better discovery and collaboration across your org? + +**Tools:** At Spotify, anyone can find everyone else’s software — because everything is centralized inside Backstage, organized by the **Backstage Service Catalog** and accessible by **search**. Go to any library or services page, and you’ll find the owners and documentation, even its API and how to extend it if need be. + +**Result:** One place for everything, one place to search. Developers can more easily share components, build on top of each other’s work, and discover tools, libraries, frameworks, documentation, system designs, org charts, and more. + +## Sounds great. Where do I start? + +After talking with companies who have already adopted Backstage, we’ve seen a few common strategies for getting started. The different strategies are based on the size of your engineering org (which often also corresponds with how fast you’re growing). + +![200 engineers](assets/21-05-20/200-engineers.png) +
+ +### My org has ~200 engineers (and is growing fast) + +You’re big enough to start feeling the pain, and only getting bigger. Onboarding and discoverability are your biggest challenges. + +**Pain points:** + +- This size seems to be the tipping point — where complexity is taking hold, collaboration starts breaking down, and ad hoc solutions stop working. +- Often this is also when you’re growing so fast (doubling in size every 6–12 months) that there are now more new engineers than old engineers. +- New engineers can’t find anything, so they ask around, which pulls all your other engineers off-task with interruptions. +- Logjams form. One company told us how it was taking 3–4 days for pull requests to get merged. + +**Recommendation — Explore, then create:** + +- New engineers need an easy way to find out how to do things, so you’re not just adding bodies, you’re adding happy, productive contributors. +- To streamline onboarding, start with centralizing your documentation and making your tools and software components discoverable in Backstage. +- At this size, you might not have a dedicated platform/infra team in place yet, but Backstage can provide the framework for centralizing and sharing knowledge — from managing compliance requirements to finding the right API documentation. +- This allows both new and old engineers to collaborate more efficiently, easily discover best practices, and cuts down on duplicated work (e.g., a new team doesn’t end up rebuilding a database that already exists but nobody can find). + +![1,000 engineers](assets/21-05-20/1000-engineers.png) + +### My org has ~1,000 engineers + +You’re officially big. Lots and lots of teams are managing lots and lots of software — and the frustration of switching between all the various tools to do that is growing exponentially. + +**Pain points:** + +- Fragmentation and entropy are real threats to productivity. From security requirements to cluster management to a thousand data endpoints, there’s too much to keep track of, leading to cognitive overload. +- A death by a thousand cuts: constantly logging into new interfaces — from your cloud provider’s console to some brand new security tool then back to your CI/CD — is bogging your engineers down with too much context-switching and a lot of noise. +- Every day, it’s getting more and more difficult for one team to manage their own microservices, data, and documentation, let alone share their knowledge with other teams. + +**Recommendation — Manage, then explore, then create:** + +- Backstage will allow your teams to get a handle on the software they own, since everything they need to manage it is in one place. +- From CI/CD status to documentation to deciphering a monorepo, Backstage makes it easy to manage a service (or other software component) on a day-to-day basis. +- The service catalog also helps your developers visualize your existing software ecosystem, beyond the software they own. And with Backstage Software Templates, every new software component is already added to the catalog. +- Even at this scale, a small platform team should be all it takes to build and maintain your own version of Backstage. At Spotify, we have a 4-person team responsible for our internal version of Backstage, which is used by all of Spotify’s 1,600+ developers. + +![More than 1,000 engineers](assets/21-05-20/1000-plus-engineers.png) + +### My org has 1,000+ engineers + +Integrating infrastructure of this size and complexity can seem overwhelming. It’s an even bigger challenge to bring this level of change to a well-established culture with ingrained processes. + +**Pain points:** + +- When you’re this large, you’ve incorporated a lot of technology and processes, as well as a lot of partners, each with their own technologies and processes. +- You’re focussed on “replacing cruft” and bringing discoverability and order to your vast ecosystem of components and tools. +- Getting your tools adopted by your engineers while modernizing your tech stack and coordinating with your infra teams to improve your engineering practices across the whole company… it’s a lot. + +**Recommendation — Create, then manage, then explore:** + +- The fastest way to bring change to your ecosystem is to start at the beginning of the chain with Backstage Software Templates. +- With every new component created with your templates, you’re establishing best practices and rewarding your developers with a streamlined experience focused on their needs — all the while building up your new service catalog. +- The more the templates ease the process of starting up a project, the more your engineers will adopt them, and the faster the other benefits of Backstage will build up, transforming productivity across your organization. ([That’s how we did it at Spotify](https://open.spotify.com/episode/7iuQ3ew1Wwpuiq6LbBKzCl).) + +## More questions about adopting Backstage? + +[Contact the Backstage team at Spotify.](https://calendly.com/spotify-backstage) We’ll share more about what we’ve learned from our experience here at Spotify — and from other companies who are already using Backstage to transform their developer experience. diff --git a/microsite/blog/assets/21-05-20/1000-engineers.png b/microsite/blog/assets/21-05-20/1000-engineers.png new file mode 100644 index 0000000000..b7c9413e99 Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/1000-plus-engineers.png b/microsite/blog/assets/21-05-20/1000-plus-engineers.png new file mode 100644 index 0000000000..63d49dd63e Binary files /dev/null and b/microsite/blog/assets/21-05-20/1000-plus-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/200-engineers.png b/microsite/blog/assets/21-05-20/200-engineers.png new file mode 100644 index 0000000000..d0f9bd73be Binary files /dev/null and b/microsite/blog/assets/21-05-20/200-engineers.png differ diff --git a/microsite/blog/assets/21-05-20/create-manage-explore.gif b/microsite/blog/assets/21-05-20/create-manage-explore.gif new file mode 100644 index 0000000000..104523a886 Binary files /dev/null and b/microsite/blog/assets/21-05-20/create-manage-explore.gif differ diff --git a/microsite/blog/assets/21-05-20/create.gif b/microsite/blog/assets/21-05-20/create.gif new file mode 100644 index 0000000000..ce2c04f384 Binary files /dev/null and b/microsite/blog/assets/21-05-20/create.gif differ diff --git a/microsite/blog/assets/21-05-20/explore.gif b/microsite/blog/assets/21-05-20/explore.gif new file mode 100644 index 0000000000..76b527af44 Binary files /dev/null and b/microsite/blog/assets/21-05-20/explore.gif differ diff --git a/microsite/blog/assets/21-05-20/manage.gif b/microsite/blog/assets/21-05-20/manage.gif new file mode 100644 index 0000000000..f4cf00d9f5 Binary files /dev/null and b/microsite/blog/assets/21-05-20/manage.gif differ 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/data/plugins/ilert.yaml b/microsite/data/plugins/ilert.yaml new file mode 100644 index 0000000000..29cc4f6aa5 --- /dev/null +++ b/microsite/data/plugins/ilert.yaml @@ -0,0 +1,15 @@ +--- +title: iLert +author: iLert +authorUrl: https://www.ilert.com +category: Monitoring +description: iLert is a platform for alerting, on-call management and uptime monitoring targeted at DevOps and IT teams. +documentation: https://github.com/backstage/backstage/tree/master/plugins/ilert +iconUrl: https://avatars.githubusercontent.com/u/13230510?s=200&v=4 +npmPackageName: '@backstage/plugin-ilert' +tags: + - monitoring + - errors + - alerting + - uptime + - on-call 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/sidebars.json b/microsite/sidebars.json index c7b049a53e..6c42614a61 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -40,6 +40,7 @@ "features/software-catalog/references", "features/software-catalog/well-known-annotations", "features/software-catalog/well-known-relations", + "features/software-catalog/well-known-statuses", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", "features/software-catalog/software-catalog-api" @@ -65,7 +66,8 @@ "features/software-templates/writing-templates", "features/software-templates/builtin-actions", "features/software-templates/writing-custom-actions", - "features/software-templates/template-legacy" + "features/software-templates/template-legacy", + "features/software-templates/migrating-from-v1alpha1-to-v1beta2" ] }, { 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..b8e9cfe3ac --- /dev/null +++ b/microsite/static/img/git-release-manager-logo.svg @@ -0,0 +1 @@ +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/mkdocs.yml b/mkdocs.yml index 7826b3221d..574b04f8f7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -37,6 +37,7 @@ nav: - Entity References: 'features/software-catalog/references.md' - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' - Well-known Relations: 'features/software-catalog/well-known-relations.md' + - Well-known Statuses: 'features/software-catalog/well-known-statuses.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' - API: 'features/software-catalog/api.md' @@ -53,6 +54,8 @@ nav: - Builtin Actions: 'features/software-templates/builtin-actions.md' - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' - Writing Templates (Legacy): 'features/software-templates/legacy.md' + - Migrating from v1alpha1 to v1beta2 templates: 'features/software-templates/migrating-from-v1alpha1-to-v1beta2.md' + - Backstage Search: - Overview: 'features/search/README.md' - Search Architecture: 'features/search/architecture.md' diff --git a/package.json b/package.json index b0f79031de..ff24cafbd0 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "prettier:check": "prettier --check .", "lerna": "lerna", "storybook": "yarn workspace storybook start", - "build-storybook": "yarn workspace storybook build-storybook" + "build-storybook": "yarn workspace storybook build-storybook", + "prepare": "husky install" }, "workspaces": { "packages": [ @@ -48,8 +49,8 @@ }, "version": "1.0.0", "dependencies": { - "@microsoft/api-extractor": "7.13.2-pr1916.0", "@microsoft/api-documenter": "^7.12.16", + "@microsoft/api-extractor": "7.13.2-pr1916.0", "@microsoft/api-extractor-model": "^7.12.5" }, "devDependencies": { @@ -60,18 +61,13 @@ "command-exists": "^1.2.9", "concurrently": "^6.0.0", "fs-extra": "^9.0.0", - "husky": "^4.2.3", + "husky": "^6.0.0", "lerna": "^4.0.0", "lint-staged": "^10.1.0", "prettier": "^2.2.1", "recursive-readdir": "^2.2.2", "shx": "^0.3.2" }, - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } - }, "prettier": "@spotify/prettier-config", "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index e9d3e63ad0..09dbb534f9 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,117 @@ # example-app +## 0.2.29 + +### Patch Changes + +- Updated dependencies [7cbfcae48] +- Updated dependencies [2bfec55a6] +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] +- Updated dependencies [ca6e0ab69] +- Updated dependencies [5914a76d5] +- Updated dependencies [81d7b9c6f] +- Updated dependencies [a62cfe068] +- Updated dependencies [35e091604] +- Updated dependencies [a53f3d603] +- Updated dependencies [b203699e9] + - @backstage/plugin-cost-insights@0.10.0 + - @backstage/cli@0.6.12 + - @backstage/catalog-model@0.7.10 + - @backstage/plugin-scaffolder@0.9.5 + - @backstage/core@0.7.10 + - @backstage/plugin-api-docs@0.4.14 + - @backstage/plugin-cloudbuild@0.2.15 + - @backstage/plugin-github-actions@0.4.7 + - @backstage/plugin-techdocs@0.9.3 + - @backstage/plugin-catalog-import@0.5.7 + - @backstage/plugin-catalog@0.5.8 + +## 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 + +- Updated dependencies [6f1b82b14] +- Updated dependencies [38ca05168] +- Updated dependencies [f65adcde7] +- Updated dependencies [81c54d1f2] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [8aedbb4af] +- Updated dependencies [fc79a6dd3] +- Updated dependencies [f53fba29f] +- Updated dependencies [b2e2ec753] +- Updated dependencies [9314a8592] +- Updated dependencies [2e05277e0] +- Updated dependencies [4075c6367] +- Updated dependencies [d8b81fd28] + - @backstage/plugin-cost-insights@0.9.0 + - @backstage/plugin-catalog-import@0.5.5 + - @backstage/plugin-github-actions@0.4.5 + - @backstage/cli@0.6.10 + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-react@0.1.5 + - @backstage/theme@0.2.7 + - @backstage/plugin-kubernetes@0.4.3 + - @backstage/plugin-tech-radar@0.3.10 + - @backstage/plugin-scaffolder@0.9.3 + - @backstage/plugin-techdocs@0.9.1 + - @backstage/catalog-model@0.7.8 + ## 0.2.26 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index fb1ea02699..6cb10d13a6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,47 +1,48 @@ { "name": "example-app", - "version": "0.2.26", + "version": "0.2.29", "private": true, "bundled": true, "dependencies": { - "@backstage/catalog-model": "^0.7.7", - "@backstage/cli": "^0.6.9", - "@backstage/core": "^0.7.7", - "@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.4", - "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/plugin-circleci": "^0.2.13", - "@backstage/plugin-cloudbuild": "^0.2.13", - "@backstage/plugin-code-coverage": "^0.1.2", - "@backstage/plugin-cost-insights": "^0.8.5", - "@backstage/plugin-explore": "^0.3.4", - "@backstage/plugin-gcp-projects": "^0.2.5", - "@backstage/plugin-github-actions": "^0.4.4", - "@backstage/plugin-graphiql": "^0.2.10", - "@backstage/plugin-jenkins": "^0.4.2", - "@backstage/plugin-kafka": "^0.2.6", - "@backstage/plugin-kubernetes": "^0.4.2", - "@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.2", - "@backstage/plugin-search": "^0.3.5", - "@backstage/plugin-sentry": "^0.3.9", - "@backstage/plugin-tech-radar": "^0.3.9", - "@backstage/plugin-techdocs": "^0.9.0", - "@backstage/plugin-todo": "^0.1.0", - "@backstage/plugin-user-settings": "^0.2.8", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.10", + "@backstage/cli": "^0.6.12", + "@backstage/core": "^0.7.10", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-api-docs": "^0.4.14", + "@backstage/plugin-badges": "^0.2.1", + "@backstage/plugin-catalog": "^0.5.8", + "@backstage/plugin-catalog-import": "^0.5.7", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/plugin-circleci": "^0.2.14", + "@backstage/plugin-cloudbuild": "^0.2.15", + "@backstage/plugin-code-coverage": "^0.1.3", + "@backstage/plugin-cost-insights": "^0.10.0", + "@backstage/plugin-explore": "^0.3.5", + "@backstage/plugin-gcp-projects": "^0.2.6", + "@backstage/plugin-github-actions": "^0.4.7", + "@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.5", + "@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.3", + "@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", "@octokit/rest": "^18.5.3", "@roadiehq/backstage-plugin-buildkite": "^1.0.0", - "@roadiehq/backstage-plugin-github-insights": "^1.0.0", + "@roadiehq/backstage-plugin-github-insights": "^1.1.10", "@roadiehq/backstage-plugin-github-pull-requests": "^1.0.0", "@roadiehq/backstage-plugin-travis-ci": "^1.0.0", "history": "^5.0.0", @@ -51,22 +52,22 @@ "react-hot-loader": "^4.12.21", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.12", "@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", "@types/react-dom": "^16.9.8", "@types/zen-observable": "^0.8.0", "cross-env": "^7.0.0", - "cypress": "^4.2.0", + "cypress": "^7.3.0", "eslint-plugin-cypress": "^2.10.3", "start-server-and-test": "^1.10.11" }, 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/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 601134362d..f646992c97 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -28,6 +28,8 @@ import { import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { EntityAboutCard, + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, EntityHasComponentsCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, @@ -37,6 +39,9 @@ import { EntitySwitch, isComponentType, isKind, + EntityHasResourcesCard, + EntityOrphanWarning, + isOrphan, } from '@backstage/plugin-catalog'; import { EntityCircleCIContent, @@ -211,7 +216,15 @@ const errorsContent = ( const overviewContent = ( - + + + + + + + + + @@ -223,7 +236,7 @@ const overviewContent = ( - + @@ -257,7 +270,7 @@ const overviewContent = ( - + @@ -288,6 +301,17 @@ const serviceEntityPage = ( + + + + + + + + + + + @@ -336,6 +360,17 @@ const websiteEntityPage = ( {errorsContent} + + + + + + + + + + + @@ -466,6 +501,9 @@ const systemPage = ( + + + 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 68fab7d861..728d8fabcd 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,155 @@ # @backstage/backend-common +## 0.8.1 + +### Patch Changes + +- c7dad9218: All cache-related connection errors are now handled and logged by the cache manager. App Integrators may provide an optional error handler when instantiating the cache manager if custom error handling is needed. + + ```typescript + // Providing an error handler + const cacheManager = CacheManager.fromConfig(config, { + onError: e => { + if (isSomehowUnrecoverable(e)) { + gracefullyShutThingsDown(); + process.exit(1); + } + }, + }); + ``` + +- 65e6c4541: Remove circular dependencies +- 5001de908: Change GitlabUrlReader to SHA timestamp compare using only commits that modify given file path, if file path given +- Updated dependencies [65e6c4541] +- Updated dependencies [290405276] + - @backstage/integration@0.5.3 + - @backstage/config-loader@0.6.2 + +## 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 + +- e0bfd3d44: Refactor the `runDockerContainer(…)` function to an interface-based api. + This gives the option to replace the docker runtime in the future. + + Packages and plugins that previously used the `dockerode` as argument should be migrated to use the new `ContainerRunner` interface instead. + + ```diff + import { + - runDockerContainer, + + ContainerRunner, + PluginEndpointDiscovery, + } from '@backstage/backend-common'; + - import Docker from 'dockerode'; + + type RouterOptions = { + // ... + - dockerClient: Docker, + + containerRunner: ContainerRunner; + }; + + export async function createRouter({ + // ... + - dockerClient, + + containerRunner, + }: RouterOptions): Promise { + // ... + + + await containerRunner.runContainer({ + - await runDockerContainer({ + image: 'docker', + // ... + - dockerClient, + }); + + // ... + } + ``` + + To keep the `dockerode` based runtime, use the `DockerContainerRunner` implementation: + + ```diff + + import { + + ContainerRunner, + + DockerContainerRunner + + } from '@backstage/backend-common'; + - import { runDockerContainer } from '@backstage/backend-common'; + + + const containerRunner: ContainerRunner = new DockerContainerRunner({dockerClient}); + + await containerRunner.runContainer({ + - await runDockerContainer({ + image: 'docker', + // ... + - dockerClient, + }); + ``` + +### Patch Changes + +- 38ca05168: The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/integration@0.5.2 + - @backstage/config-loader@0.6.1 + - @backstage/config@0.1.5 + ## 0.6.3 ### Patch Changes diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 9969d0ea27..068220cad3 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,9 +63,28 @@ 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, options?: CacheManagerOptions): CacheManager; + } + // @public (undocumented) export const coloredFormat: winston.Logform.Format; +// @public (undocumented) +export interface ContainerRunner { + // (undocumented) + runContainer(opts: RunContainerOptions): Promise; +} + // @public @deprecated export const createDatabase: typeof createDatabaseClient; @@ -80,6 +100,15 @@ export function createServiceBuilder(_module: NodeModule): ServiceBuilderImpl; // @public (undocumented) export function createStatusCheckRouter(options: StatusCheckRouterOptions): Promise; +// @public (undocumented) +export class DockerContainerRunner implements ContainerRunner { + constructor({ dockerClient }: { + dockerClient: Docker; + }); + // (undocumented) + runContainer({ imageName, command, args, logStream, mountDirs, workingDir, envVars, }: RunContainerOptions): Promise; +} + // @public export function ensureDatabaseExists(dbConfig: Config, ...databases: Array): Promise; @@ -223,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; @@ -255,10 +289,15 @@ export function requestLoggingHandler(logger?: Logger): RequestHandler; export function resolvePackagePath(name: string, ...paths: string[]): string; // @public (undocumented) -export const runDockerContainer: ({ imageName, args, logStream, dockerClient, mountDirs, workingDir, envVars, createOptions, }: RunDockerContainerOptions) => Promise<{ - error: any; - statusCode: any; -}>; +export type RunContainerOptions = { + imageName: string; + command?: string | string[]; + args: string[]; + logStream?: Writable; + mountDirs?: Record; + workingDir?: string; + envVars?: Record; +}; // @public export type SearchResponse = { 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 64e3137f7c..4de9c36c8c 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.6.3", + "version": "0.8.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,10 +30,10 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.4", - "@backstage/config-loader": "^0.6.0", + "@backstage/config": "^0.1.5", + "@backstage/config-loader": "^0.6.2", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.3", "@google-cloud/storage": "^5.8.0", "@octokit/rest": "^18.5.3", "@types/cors": "^2.8.6", @@ -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.9", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@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..3b654e248b --- /dev/null +++ b/packages/backend-common/src/cache/CacheClient.ts @@ -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 { 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. + // Padded because a plugin ID is also prepended to the key. + if (wellFormedKey.length < 200) { + 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..97c1714c53 --- /dev/null +++ b/packages/backend-common/src/cache/CacheManager.test.ts @@ -0,0 +1,225 @@ +/* + * 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('attaches error handler to client', () => { + const pluginId = 'error-test'; + manager.forPlugin(pluginId).getClient(); + + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + expect(realClient.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + 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); + }); + }); + + describe('connection errors', () => { + it('uses provided logger', () => { + // Set up and inject mock logger. + const mockLogger = { child: jest.fn(), error: jest.fn() }; + mockLogger.child.mockImplementation(() => mockLogger as any); + const manager = CacheManager.fromConfig(defaultConfig(), { + logger: mockLogger as any, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-logger-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockLogger.error).toHaveBeenCalledWith(expectedError); + }); + + it('calls provided handler', () => { + // Set up and inject mock logger. + const mockHandler = jest.fn(); + const manager = CacheManager.fromConfig(defaultConfig(), { + onError: mockHandler, + }); + + // Set up a cache client using the configured manager. + manager.forPlugin('error-handler-test').getClient(); + + // Retrieve the error handler attached to the cache client. + const client = DefaultCacheClient as jest.Mock; + const mockCalls = client.mock.calls.splice(-1); + const realClient = mockCalls[0][0].client as Keyv; + const realOnError = realClient.on as jest.Mock; + const realHandler = realOnError.mock.calls.splice(-1)[0][1]; + + // Invoke the actual error handler. + const expectedError = new Error('some error'); + realHandler(expectedError); + expect(mockHandler).toHaveBeenCalledWith(expectedError); + }); + }); +}); diff --git a/packages/backend-common/src/cache/CacheManager.ts b/packages/backend-common/src/cache/CacheManager.ts new file mode 100644 index 0000000000..e6699e628e --- /dev/null +++ b/packages/backend-common/src/cache/CacheManager.ts @@ -0,0 +1,147 @@ +/* + * 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 { Logger } from 'winston'; +import { getRootLogger } from '../logging'; +import { DefaultCacheClient, CacheClient } from './CacheClient'; +import { NoStore } from './NoStore'; +import { + CacheManagerOptions, + OptionalOnError, + 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 logger: Logger; + private readonly store: keyof CacheManager['storeFactories']; + private readonly connection: string; + private readonly errorHandler: OptionalOnError; + + /** + * 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, + options: CacheManagerOptions = {}, + ): 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') || ''; + const logger = (options.logger || getRootLogger()).child({ + type: 'cacheManager', + }); + return new CacheManager(store, connectionString, logger, options.onError); + } + + private constructor( + store: string, + connectionString: string, + logger: Logger, + errorHandler: OptionalOnError, + ) { + if (!this.storeFactories.hasOwnProperty(store)) { + throw new Error(`Unknown cache store: ${store}`); + } + this.logger = logger; + this.store = store as keyof CacheManager['storeFactories']; + this.connection = connectionString; + this.errorHandler = errorHandler; + } + + /** + * Generates a PluginCacheManager 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); + + // Always provide an error handler to avoid killing the process. + concreteClient.on('error', (err: Error) => { + // In all cases, just log the error. + this.logger.error(err); + + // Invoke any custom error handler if provided. + if (typeof this.errorHandler === 'function') { + this.errorHandler(err); + } + }); + + 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..30db53d420 --- /dev/null +++ b/packages/backend-common/src/cache/types.ts @@ -0,0 +1,56 @@ +/* + * 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 { Logger } from 'winston'; +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; +}; + +export type OptionalOnError = ((err: Error) => void) | undefined; + +export type CacheManagerOptions = { + /** + * An optional logger for use by the PluginCacheManager. + */ + logger?: Logger; + + /** + * An optional handler for connection errors emitted from the underlying data + * store. + */ + onError?: OptionalOnError; +}; + +/** + * 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-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index bb76181287..c4a0fe0466 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -29,11 +29,11 @@ import path from 'path'; import { NotModifiedError } from '@backstage/errors'; import { getVoidLogger } from '../logging'; import { AzureUrlReader } from './AzureUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; const logger = getVoidLogger(); -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 16e7628ec8..e2b230a05f 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index aa78bf27d5..e06122e04b 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -28,9 +28,9 @@ import os from 'os'; import path from 'path'; import { NotModifiedError } from '@backstage/errors'; import { BitbucketUrlReader } from './BitbucketUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index f102be7283..251743cb25 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -27,9 +27,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/FetchUrlReader.test.ts b/packages/backend-common/src/reading/FetchUrlReader.test.ts index 8dc4aba29b..2363a16d1a 100644 --- a/packages/backend-common/src/reading/FetchUrlReader.test.ts +++ b/packages/backend-common/src/reading/FetchUrlReader.test.ts @@ -19,7 +19,7 @@ import { msw } from '@backstage/test-utils'; import { setupServer } from 'msw/node'; import { getVoidLogger } from '../logging'; import { FetchUrlReader } from './FetchUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; describe('FetchUrlReader', () => { const worker = setupServer(); @@ -45,7 +45,7 @@ describe('FetchUrlReader', () => { }, }), logger: getVoidLogger(), - treeResponseFactory: ReadTreeResponseFactory.create({ + treeResponseFactory: DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }), }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 67e7f27069..cbe61f2e66 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -35,9 +35,9 @@ import { GhTreeResponse, GithubUrlReader, } from './GithubUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 77e33cbd54..2b1247fe77 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -26,8 +26,8 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 9dcc92a4a6..b5592c09f5 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -24,7 +24,7 @@ import os from 'os'; import path from 'path'; import { getVoidLogger } from '../logging'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { NotModifiedError, NotFoundError } from '@backstage/errors'; import { GitLabIntegration, @@ -33,7 +33,7 @@ import { const logger = getVoidLogger(); -const treeResponseFactory = ReadTreeResponseFactory.create({ +const treeResponseFactory = DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }); @@ -190,11 +190,17 @@ describe('GitlabUrlReader', () => { default_branch: 'main', }; - const branchGitlabApiResponse = { - commit: { + const commitsGitlabApiResponse = [ + { id: 'sha123abc', }, - }; + ]; + + const specificPathCommitsGitlabApiResponse = [ + { + id: 'sha456def', + }, + ]; beforeEach(() => { worker.use( @@ -221,17 +227,29 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), - ), - rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/branchDoesNotExist', - (_, res, ctx) => res(ctx.status(404)), + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + const filepath = req.url.searchParams.get('path'); + if (filepath === 'testFilepath') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(specificPathCommitsGitlabApiResponse), + ); + } + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + if (refName === 'branchDoesNotExist') { + return res(ctx.status(404)); + } + return res(); + }, ), rest.get( 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock', @@ -243,13 +261,26 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), + 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + const filepath = req.url.searchParams.get('path'); + if (filepath === 'testFilepath') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(specificPathCommitsGitlabApiResponse), + ); + } + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + return res(); + }, ), rest.get( 'https://gitlab.mycompany.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main', @@ -351,7 +382,7 @@ describe('GitlabUrlReader', () => { ).resolves.toBe('# Test\n'); }); - it('throws a NotModifiedError when given a etag in options', async () => { + it('throws a NotModifiedError when given a etag in options matching last commit', async () => { const fnGitlab = async () => { await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', { etag: 'sha123abc', @@ -371,6 +402,29 @@ describe('GitlabUrlReader', () => { await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError); }); + it('throws a NotModifiedError when given a etag in options matching last commit affecting specified filepath', async () => { + const fnGitlab = async () => { + await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/blob/main/testFilepath', + { + etag: 'sha456def', + }, + ); + }; + + const fnHostedGitlab = async () => { + await hostedGitlabProcessor.readTree( + 'https://gitlab.mycompany.com/backstage/mock/blob/main/testFilepath', + { + etag: 'sha456def', + }, + ); + }; + + await expect(fnGitlab).rejects.toThrow(NotModifiedError); + await expect(fnHostedGitlab).rejects.toThrow(NotModifiedError); + }); + it('should not throw error when given an outdated etag in options', async () => { const response = await gitlabProcessor.readTree( 'https://gitlab.com/backstage/mock/tree/main', @@ -389,12 +443,12 @@ describe('GitlabUrlReader', () => { }); it('should throw error on missing branch', async () => { - const fnGithub = async () => { + const fnGitlab = async () => { await gitlabProcessor.readTree( 'https://gitlab.com/backstage/mock/tree/branchDoesNotExist', ); }; - await expect(fnGithub).rejects.toThrow(NotFoundError); + await expect(fnGitlab).rejects.toThrow(NotFoundError); }); }); @@ -408,11 +462,11 @@ describe('GitlabUrlReader', () => { default_branch: 'main', }; - const branchGitlabApiResponse = { - commit: { + const commitsGitlabApiResponse = [ + { id: 'sha123abc', }, - }; + ]; beforeEach(() => { worker.use( @@ -439,13 +493,18 @@ describe('GitlabUrlReader', () => { ), ), rest.get( - 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main', - (_, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(branchGitlabApiResponse), - ), + 'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/commits', + (req, res, ctx) => { + const refName = req.url.searchParams.get('ref_name'); + if (refName === 'main') { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(commitsGitlabApiResponse), + ); + } + return res(); + }, ), ); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index c93103b2da..635565c8a0 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -25,9 +25,9 @@ import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { ReadTreeResponseFactory } from './tree'; import { stripFirstDirectoryFromPath } from './tree/util'; import { + ReadTreeResponseFactory, ReaderFactory, ReadTreeOptions, ReadTreeResponse, @@ -106,25 +106,30 @@ export class GitlabUrlReader implements UrlReader { // ref is an empty string if no branch is set in provided url to readTree. const branch = ref || projectGitlabResponseJson.default_branch; - // Fetch the latest commit in the provided or default branch to compare against - // the provided sha. - const branchGitlabResponse = await fetch( + // Fetch the latest commit that modifies the the filepath in the provided or default branch + // to compare against the provided sha. + const commitsReqParams = new URLSearchParams(); + commitsReqParams.set('ref_name', branch); + if (!!filepath) { + commitsReqParams.set('path', filepath); + } + const commitsGitlabResponse = await fetch( new URL( `${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent( full_name, - )}/repository/branches/${branch}`, + )}/repository/commits?${commitsReqParams.toString()}`, ).toString(), getGitLabRequestOptions(this.integration.config), ); - if (!branchGitlabResponse.ok) { - const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`; - if (branchGitlabResponse.status === 404) { + if (!commitsGitlabResponse.ok) { + const message = `Failed to read tree (branch) from ${url}, ${commitsGitlabResponse.status} ${commitsGitlabResponse.statusText}`; + if (commitsGitlabResponse.status === 404) { throw new NotFoundError(message); } throw new Error(message); } - const commitSha = (await branchGitlabResponse.json()).commit.id; + const commitSha = (await commitsGitlabResponse.json())[0].id; if (options?.etag && options.etag === commitSha) { throw new NotModifiedError(); diff --git a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts index e201361a91..5d4b01da44 100644 --- a/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts +++ b/packages/backend-common/src/reading/GoogleGcsUrlReader.test.ts @@ -16,7 +16,7 @@ import { ConfigReader, JsonObject } from '@backstage/config'; import { getVoidLogger } from '../logging'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; import { UrlReaderPredicateTuple } from './types'; @@ -25,7 +25,7 @@ describe('GcsUrlReader', () => { return GoogleGcsUrlReader.factory({ config: new ConfigReader(config), logger: getVoidLogger(), - treeResponseFactory: ReadTreeResponseFactory.create({ + treeResponseFactory: DefaultReadTreeResponseFactory.create({ config: new ConfigReader({}), }), }); diff --git a/packages/backend-common/src/reading/UrlReaders.ts b/packages/backend-common/src/reading/UrlReaders.ts index 06c6fdcdcf..8f27d058df 100644 --- a/packages/backend-common/src/reading/UrlReaders.ts +++ b/packages/backend-common/src/reading/UrlReaders.ts @@ -22,7 +22,7 @@ import { AzureUrlReader } from './AzureUrlReader'; import { BitbucketUrlReader } from './BitbucketUrlReader'; import { GithubUrlReader } from './GithubUrlReader'; import { GitlabUrlReader } from './GitlabUrlReader'; -import { ReadTreeResponseFactory } from './tree'; +import { DefaultReadTreeResponseFactory } from './tree'; import { FetchUrlReader } from './FetchUrlReader'; import { GoogleGcsUrlReader } from './GoogleGcsUrlReader'; @@ -44,7 +44,9 @@ export class UrlReaders { */ static create({ logger, config, factories }: CreateOptions): UrlReader { const mux = new UrlReaderPredicateMux(); - const treeResponseFactory = ReadTreeResponseFactory.create({ config }); + const treeResponseFactory = DefaultReadTreeResponseFactory.create({ + config, + }); for (const factory of factories ?? []) { const tuples = factory({ config, logger: logger, treeResponseFactory }); diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts index 05ecdb0fc3..1ce7555a30 100644 --- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts +++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts @@ -15,27 +15,18 @@ */ import os from 'os'; -import { Readable } from 'stream'; import { Config } from '@backstage/config'; -import { ReadTreeResponse } from '../types'; +import { + ReadTreeResponse, + FromArchiveOptions, + ReadTreeResponseFactory, +} from '../types'; import { TarArchiveResponse } from './TarArchiveResponse'; import { ZipArchiveResponse } from './ZipArchiveResponse'; -type FromArchiveOptions = { - // A binary stream of a tar archive. - stream: Readable; - // If unset, the files at the root of the tree will be read. - // subpath must not contain the name of the top level directory. - subpath?: string; - // etag of the blob - etag: string; - // Filter passed on from the ReadTreeOptions - filter?: (path: string) => boolean; -}; - -export class ReadTreeResponseFactory { - static create(options: { config: Config }): ReadTreeResponseFactory { - return new ReadTreeResponseFactory( +export class DefaultReadTreeResponseFactory implements ReadTreeResponseFactory { + static create(options: { config: Config }): DefaultReadTreeResponseFactory { + return new DefaultReadTreeResponseFactory( options.config.getOptionalString('backend.workingDirectory') ?? os.tmpdir(), ); diff --git a/packages/backend-common/src/reading/tree/index.ts b/packages/backend-common/src/reading/tree/index.ts index 858cf15877..3126907c1e 100644 --- a/packages/backend-common/src/reading/tree/index.ts +++ b/packages/backend-common/src/reading/tree/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { ReadTreeResponseFactory } from './ReadTreeResponseFactory'; +export { DefaultReadTreeResponseFactory } from './ReadTreeResponseFactory'; diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index 4caa76841a..7ba806197d 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { Readable } from 'stream'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -import { ReadTreeResponseFactory } from './tree'; /** * A generic interface for fetching plain data from URLs. @@ -105,6 +105,23 @@ export type ReadTreeResponseFile = { content(): Promise; }; +export type FromArchiveOptions = { + // A binary stream of a tar archive. + stream: Readable; + // If unset, the files at the root of the tree will be read. + // subpath must not contain the name of the top level directory. + subpath?: string; + // etag of the blob + etag: string; + // Filter passed on from the ReadTreeOptions + filter?: (path: string) => boolean; +}; + +export interface ReadTreeResponseFactory { + fromTarArchive(options: FromArchiveOptions): Promise; + fromZipArchive(options: FromArchiveOptions): Promise; +} + /** * An options object for search operations. */ diff --git a/packages/backend-common/src/util/ContainerRunner.ts b/packages/backend-common/src/util/ContainerRunner.ts new file mode 100644 index 0000000000..7740e384e4 --- /dev/null +++ b/packages/backend-common/src/util/ContainerRunner.ts @@ -0,0 +1,31 @@ +/* + * 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 { Writable } from 'stream'; + +export type RunContainerOptions = { + imageName: string; + command?: string | string[]; + args: string[]; + logStream?: Writable; + mountDirs?: Record; + workingDir?: string; + envVars?: Record; +}; + +export interface ContainerRunner { + runContainer(opts: RunContainerOptions): Promise; +} diff --git a/packages/backend-common/src/util/docker.test.ts b/packages/backend-common/src/util/DockerContainerRunner.test.ts similarity index 89% rename from packages/backend-common/src/util/docker.test.ts rename to packages/backend-common/src/util/DockerContainerRunner.test.ts index 2ec14494da..942264af84 100644 --- a/packages/backend-common/src/util/docker.test.ts +++ b/packages/backend-common/src/util/DockerContainerRunner.test.ts @@ -13,17 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import Docker from 'dockerode'; import mockFs from 'mock-fs'; import os from 'os'; import path from 'path'; import Stream, { PassThrough } from 'stream'; -import { runDockerContainer, UserOptions } from './docker'; +import { ContainerRunner } from './ContainerRunner'; +import { DockerContainerRunner, UserOptions } from './DockerContainerRunner'; const mockDocker = new Docker() as jest.Mocked; const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir'; -describe('runDockerContainer', () => { +describe('DockerContainerRunner', () => { + let containerTaskApi: ContainerRunner; + beforeEach(() => { mockFs({ [rootDir]: { @@ -49,6 +53,8 @@ describe('runDockerContainer', () => { jest .spyOn(mockDocker, 'ping') .mockResolvedValue(Buffer.from('OK', 'utf-8')); + + containerTaskApi = new DockerContainerRunner({ dockerClient: mockDocker }); }); afterEach(() => { @@ -66,10 +72,9 @@ describe('runDockerContainer', () => { const envVarsArray = ['HOME=/tmp', 'LOG_LEVEL=debug']; it('should pull the docker container', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }); expect(mockDocker.pull).toHaveBeenCalledWith( @@ -82,13 +87,12 @@ describe('runDockerContainer', () => { }); it('should call the dockerClient run command with the correct arguments passed through', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, mountDirs, envVars, workingDir, - dockerClient: mockDocker, }); expect(mockDocker.run).toHaveBeenCalledWith( @@ -113,20 +117,18 @@ describe('runDockerContainer', () => { }); it('should ping docker to test availability', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }); expect(mockDocker.ping).toHaveBeenCalled(); }); it('should pass through the user and group id from the host machine and set the home dir', async () => { - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }); const userOptions: UserOptions = {}; @@ -153,10 +155,9 @@ describe('runDockerContainer', () => { ]); await expect( - runDockerContainer({ + containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }), ).rejects.toThrow(/Something went wrong with docker/); }); @@ -172,10 +173,9 @@ describe('runDockerContainer', () => { it('should throw with a descriptive error message including the docker error message', async () => { await expect( - runDockerContainer({ + containerTaskApi.runContainer({ imageName, args, - dockerClient: mockDocker, }), ).rejects.toThrow(new RegExp(`.+: ${dockerError}`)); }); @@ -183,11 +183,10 @@ describe('runDockerContainer', () => { it('should pass through the log stream to the docker client', async () => { const logStream = new PassThrough(); - await runDockerContainer({ + await containerTaskApi.runContainer({ imageName, args, logStream, - dockerClient: mockDocker, }); expect(mockDocker.run).toHaveBeenCalledWith( diff --git a/packages/backend-common/src/util/DockerContainerRunner.ts b/packages/backend-common/src/util/DockerContainerRunner.ts new file mode 100644 index 0000000000..a96ca1351b --- /dev/null +++ b/packages/backend-common/src/util/DockerContainerRunner.ts @@ -0,0 +1,117 @@ +/* + * 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 Docker from 'dockerode'; +import fs from 'fs-extra'; +import { PassThrough } from 'stream'; +import { ContainerRunner, RunContainerOptions } from './ContainerRunner'; + +export type UserOptions = { + User?: string; +}; + +export class DockerContainerRunner implements ContainerRunner { + private readonly dockerClient: Docker; + + constructor({ dockerClient }: { dockerClient: Docker }) { + this.dockerClient = dockerClient; + } + + async runContainer({ + imageName, + command, + args, + logStream = new PassThrough(), + mountDirs = {}, + workingDir, + envVars = {}, + }: RunContainerOptions) { + // Show a better error message when Docker is unavailable. + try { + await this.dockerClient.ping(); + } catch (e) { + throw new Error( + `This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`, + ); + } + + await new Promise((resolve, reject) => { + this.dockerClient.pull(imageName, {}, (err, stream) => { + if (err) return reject(err); + stream.pipe(logStream, { end: false }); + stream.on('end', () => resolve()); + stream.on('error', (error: Error) => reject(error)); + return undefined; + }); + }); + + const userOptions: UserOptions = {}; + if (process.getuid && process.getgid) { + // Files that are created inside the Docker container will be owned by + // root on the host system on non Mac systems, because of reasons. Mainly the fact that + // volume sharing is done using NFS on Mac and actual mounts in Linux world. + // So we set the user in the container as the same user and group id as the host. + // On Windows we don't have process.getuid nor process.getgid + userOptions.User = `${process.getuid()}:${process.getgid()}`; + } + + // Initialize volumes to mount based on mountDirs map + const Volumes: { [T: string]: object } = {}; + for (const containerDir of Object.values(mountDirs)) { + Volumes[containerDir] = {}; + } + + // Create bind volumes + const Binds: string[] = []; + for (const [hostDir, containerDir] of Object.entries(mountDirs)) { + // Need to use realpath here as Docker mounting does not like + // symlinks for binding volumes + const realHostDir = await fs.realpath(hostDir); + Binds.push(`${realHostDir}:${containerDir}`); + } + + // Create docker environment variables array + const Env = []; + for (const [key, value] of Object.entries(envVars)) { + Env.push(`${key}=${value}`); + } + + const [ + { Error: error, StatusCode: statusCode }, + ] = await this.dockerClient.run(imageName, args, logStream, { + Volumes, + HostConfig: { + Binds, + }, + ...(workingDir ? { WorkingDir: workingDir } : {}), + Entrypoint: command, + Env, + ...userOptions, + } as Docker.ContainerCreateOptions); + + if (error) { + throw new Error( + `Docker failed to run with the following error message: ${error}`, + ); + } + + if (statusCode !== 0) { + throw new Error( + `Docker container returned a non-zero exit code (${statusCode})`, + ); + } + } +} diff --git a/packages/backend-common/src/util/docker.ts b/packages/backend-common/src/util/docker.ts deleted file mode 100644 index 356d645465..0000000000 --- a/packages/backend-common/src/util/docker.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Docker from 'dockerode'; -import fs from 'fs-extra'; -import { PassThrough, Writable } from 'stream'; - -export type UserOptions = { - User?: string; -}; - -export type RunDockerContainerOptions = { - imageName: string; - args: string[]; - logStream?: Writable; - dockerClient: Docker; - mountDirs?: Record; - workingDir?: string; - envVars?: Record; - createOptions?: Docker.ContainerCreateOptions; -}; - -/** - * - * @param options the options object - * @param options.imageName the image to run - * @param options.args the arguments to pass the container - * @param options.logStream the log streamer to capture log messages - * @param options.dockerClient the dockerClient to use - * @param options.mountDirs A map of host directories to mount on the container. - * Object Key: Path on host machine, Value: Path on Docker container - * @param options.workingDir Working dir in the container - * @param options.envVars Environment variables to set in the container. e.g. {'HOME': '/tmp'} - */ -export const runDockerContainer = async ({ - imageName, - args, - logStream = new PassThrough(), - dockerClient, - mountDirs = {}, - workingDir, - envVars = {}, - createOptions = {}, -}: RunDockerContainerOptions) => { - // Show a better error message when Docker is unavailable. - try { - await dockerClient.ping(); - } catch (e) { - throw new Error( - `This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with: ${e.message}`, - ); - } - - await new Promise((resolve, reject) => { - dockerClient.pull(imageName, {}, (err, stream) => { - if (err) return reject(err); - stream.pipe(logStream, { end: false }); - stream.on('end', () => resolve()); - stream.on('error', (error: Error) => reject(error)); - return undefined; - }); - }); - - const userOptions: UserOptions = {}; - if (process.getuid && process.getgid) { - // Files that are created inside the Docker container will be owned by - // root on the host system on non Mac systems, because of reasons. Mainly the fact that - // volume sharing is done using NFS on Mac and actual mounts in Linux world. - // So we set the user in the container as the same user and group id as the host. - // On Windows we don't have process.getuid nor process.getgid - userOptions.User = `${process.getuid()}:${process.getgid()}`; - } - - // Initialize volumes to mount based on mountDirs map - const Volumes: { [T: string]: object } = {}; - for (const containerDir of Object.values(mountDirs)) { - Volumes[containerDir] = {}; - } - - // Create bind volumes - const Binds: string[] = []; - for (const [hostDir, containerDir] of Object.entries(mountDirs)) { - // Need to use realpath here as Docker mounting does not like - // symlinks for binding volumes - const realHostDir = await fs.realpath(hostDir); - Binds.push(`${realHostDir}:${containerDir}`); - } - - // Create docker environment variables array - const Env = []; - for (const [key, value] of Object.entries(envVars)) { - Env.push(`${key}=${value}`); - } - - const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run( - imageName, - args, - logStream, - { - Volumes, - HostConfig: { - Binds, - }, - ...(workingDir ? { WorkingDir: workingDir } : {}), - Env, - ...userOptions, - ...createOptions, - }, - ); - - if (error) { - throw new Error( - `Docker failed to run with the following error message: ${error}`, - ); - } - - if (statusCode !== 0) { - throw new Error( - `Docker container returned a non-zero exit code (${statusCode})`, - ); - } - - return { error, statusCode }; -}; diff --git a/packages/backend-common/src/util/index.ts b/packages/backend-common/src/util/index.ts index 04715824b4..85c5436a2e 100644 --- a/packages/backend-common/src/util/index.ts +++ b/packages/backend-common/src/util/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export { runDockerContainer } from './docker'; +export type { ContainerRunner, RunContainerOptions } from './ContainerRunner'; +export { DockerContainerRunner } from './DockerContainerRunner'; diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index ee04e2bb6b..977588b555 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,72 @@ # 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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [e0bfd3d44] +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [b219821a0] +- Updated dependencies [69eefb5ae] +- Updated dependencies [f53fba29f] +- Updated dependencies [75c8cec39] +- Updated dependencies [227439a72] +- Updated dependencies [cdb3426e5] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/plugin-scaffolder-backend@0.11.0 + - @backstage/backend-common@0.7.0 + - @backstage/plugin-techdocs-backend@0.8.0 + - @backstage/plugin-catalog-backend@0.8.2 + - @backstage/plugin-kubernetes-backend@0.3.6 + - @backstage/plugin-proxy-backend@0.2.7 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + - example-app@0.2.27 + - @backstage/plugin-app-backend@0.3.12 + - @backstage/plugin-auth-backend@0.3.9 + - @backstage/plugin-badges-backend@0.1.3 + - @backstage/plugin-code-coverage-backend@0.1.4 + - @backstage/plugin-graphql-backend@0.1.7 + - @backstage/plugin-kafka-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.10 + - @backstage/plugin-search-backend@0.1.4 + - @backstage/plugin-todo-backend@0.1.4 + ## 0.2.25 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 0422088d64..4044a6632f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.25", + "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.6.3", - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", - "@backstage/config": "^0.1.4", - "@backstage/plugin-app-backend": "^0.3.10", - "@backstage/plugin-auth-backend": "^0.3.7", - "@backstage/plugin-badges-backend": "^0.1.1", - "@backstage/plugin-catalog-backend": "^0.8.0", - "@backstage/plugin-code-coverage-backend": "^0.1.2", - "@backstage/plugin-graphql-backend": "^0.1.6", - "@backstage/plugin-kubernetes-backend": "^0.3.5", - "@backstage/plugin-kafka-backend": "^0.2.3", - "@backstage/plugin-proxy-backend": "^0.2.6", - "@backstage/plugin-rollbar-backend": "^0.1.9", - "@backstage/plugin-scaffolder-backend": "^0.10.0", - "@backstage/plugin-search-backend": "^0.1.3", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.9", + "@backstage/config": "^0.1.5", + "@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.7.0", - "@backstage/plugin-todo-backend": "^0.1.3", + "@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.25", + "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.9", + "@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/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index 7651836e2a..63a3e53c81 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -20,6 +20,7 @@ import { createRouter, NextCatalogBuilder, runPeriodically, + createNextRouter, } from '@backstage/plugin-catalog-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -36,18 +37,18 @@ export default async function createPlugin( const builder = new NextCatalogBuilder(env); const { entitiesCatalog, - locationsCatalog, locationAnalyzer, processingEngine, + locationService, } = await builder.build(); // TODO(jhaals): run and manage in background. await processingEngine.start(); - return await createRouter({ + return await createNextRouter({ entitiesCatalog, - locationsCatalog, locationAnalyzer, + locationService, logger: env.logger, config: env.config, }); diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index fca3f79270..9c7c3c12f3 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { SingleHostDiscovery } from '@backstage/backend-common'; +import { + DockerContainerRunner, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; import { CookieCutter, @@ -34,8 +37,11 @@ export default async function createPlugin({ database, reader, }: PluginEnvironment): Promise { - const cookiecutterTemplater = new CookieCutter(); - const craTemplater = new CreateReactAppTemplater(); + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + const cookiecutterTemplater = new CookieCutter({ containerRunner }); + const craTemplater = new CreateReactAppTemplater({ containerRunner }); const templaters = new Templaters(); templaters.register('cookiecutter', cookiecutterTemplater); @@ -44,8 +50,6 @@ export default async function createPlugin({ const preparers = await Preparers.fromConfig(config, { logger }); const publishers = await Publishers.fromConfig(config, { logger }); - const dockerClient = new Docker(); - const discovery = SingleHostDiscovery.fromConfig(config); const catalogClient = new CatalogClient({ discoveryApi: discovery }); @@ -55,7 +59,6 @@ export default async function createPlugin({ publishers, logger, config, - dockerClient, database, catalogClient, reader, diff --git a/packages/backend/src/plugins/techdocs.ts b/packages/backend/src/plugins/techdocs.ts index d0387cd15d..dbdc455e09 100644 --- a/packages/backend/src/plugins/techdocs.ts +++ b/packages/backend/src/plugins/techdocs.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { DockerContainerRunner } from '@backstage/backend-common'; import { createRouter, Generators, @@ -35,9 +36,14 @@ export default async function createPlugin({ reader, }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + // Generators are used for generating documentation sites. const generators = await Generators.fromConfig(config, { logger, + containerRunner, }); // Publisher is used for @@ -51,14 +57,10 @@ export default async function createPlugin({ // checks if the publisher is working and logs the result await publisher.getReadiness(); - // Docker client (conditionally) used by the generators, based on techdocs.generators config. - const dockerClient = new Docker(); - return await createRouter({ preparers, generators, publisher, - dockerClient, logger, config, 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/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index 0076202c64..e5eb166ec2 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/catalog-client +## 0.3.11 + +### Patch Changes + +- d1b1306d9: Allow `filter` parameter to be specified multiple times +- Updated dependencies [d8b81fd28] + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.3.10 ### Patch Changes diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 4b489ff2e4..1f911c84e9 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -76,6 +76,9 @@ export type CatalogListResponse = { items: T[]; }; +// @public +export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = "backstage.io/catalog-processing"; + // (No @packageDocumentation comment for this package) diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 91845e56ab..f9638dee0d 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.10", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,13 +29,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@backstage/catalog-model": "^0.7.8", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.10", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 359e3a2c60..98b378f5b0 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -18,7 +18,8 @@ import { Entity } from '@backstage/catalog-model'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { CatalogClient } from './CatalogClient'; -import { CatalogListResponse, DiscoveryApi } from './types'; +import { CatalogListResponse } from './types/api'; +import { DiscoveryApi } from './types/discovery'; const server = setupServer(); const token = 'fake-token'; diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a246b3abab..7c9b313ce7 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -31,8 +31,8 @@ import { CatalogEntitiesRequest, CatalogListResponse, CatalogRequestOptions, - DiscoveryApi, -} from './types'; +} from './types/api'; +import { DiscoveryApi } from './types/discovery'; export class CatalogClient implements CatalogApi { private readonly discoveryApi: DiscoveryApi; @@ -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-client/src/index.ts b/packages/catalog-client/src/index.ts index c5a626e25b..59c652f9fd 100644 --- a/packages/catalog-client/src/index.ts +++ b/packages/catalog-client/src/index.ts @@ -15,10 +15,4 @@ */ export { CatalogClient } from './CatalogClient'; -export type { - AddLocationRequest, - AddLocationResponse, - CatalogApi, - CatalogEntitiesRequest, - CatalogListResponse, -} from './types'; +export * from './types'; diff --git a/packages/catalog-client/src/types.ts b/packages/catalog-client/src/types/api.ts similarity index 91% rename from packages/catalog-client/src/types.ts rename to packages/catalog-client/src/types/api.ts index ef907eafa9..f9ba2e9d8d 100644 --- a/packages/catalog-client/src/types.ts +++ b/packages/catalog-client/src/types/api.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Spotify AB + * 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. @@ -81,10 +81,3 @@ export type AddLocationResponse = { location: Location; entities: Entity[]; }; - -/** - * This is a copy of the core DiscoveryApi, to avoid importing core. - */ -export type DiscoveryApi = { - getBaseUrl(pluginId: string): Promise; -}; diff --git a/packages/catalog-client/src/types/discovery.ts b/packages/catalog-client/src/types/discovery.ts new file mode 100644 index 0000000000..90eb748b2f --- /dev/null +++ b/packages/catalog-client/src/types/discovery.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. + */ + +/** + * This is a copy of the core DiscoveryApi, to avoid importing core. + */ +export type DiscoveryApi = { + getBaseUrl(pluginId: string): Promise; +}; diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts new file mode 100644 index 0000000000..c1670659ec --- /dev/null +++ b/packages/catalog-client/src/types/index.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. + */ + +export type { + AddLocationRequest, + AddLocationResponse, + CatalogApi, + CatalogEntitiesRequest, + CatalogListResponse, +} from './api'; +export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-client/src/types/status.ts b/packages/catalog-client/src/types/status.ts new file mode 100644 index 0000000000..7990c4b121 --- /dev/null +++ b/packages/catalog-client/src/types/status.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. + */ + +/** + * The entity `status.items[].type` for the status of the processing engine in + * regards to an entity. + */ +export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = + 'backstage.io/catalog-processing'; diff --git a/packages/catalog-model/CHANGELOG.md b/packages/catalog-model/CHANGELOG.md index 445da3afab..d697461487 100644 --- a/packages/catalog-model/CHANGELOG.md +++ b/packages/catalog-model/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/catalog-model +## 0.7.10 + +### Patch Changes + +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 68fdbf014: Add the `status` field to the Entity envelope + +## 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 + +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. +- Updated dependencies [d8b81fd28] + - @backstage/config@0.1.5 + ## 0.7.7 ### Patch Changes diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index c37c86b01f..7b5542fcd6 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -7,6 +7,7 @@ import { JsonObject } from '@backstage/config'; import { JSONSchema7 } from 'json-schema'; import { JsonValue } from '@backstage/config'; +import { SerializedError } from '@backstage/errors'; import * as yup from 'yup'; // @public (undocumented) @@ -112,6 +113,7 @@ export type Entity = { metadata: EntityMeta; spec?: JsonObject; relations?: EntityRelation[]; + status?: UNSTABLE_EntityStatus; }; // @public @@ -486,6 +488,7 @@ interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } @@ -514,17 +517,34 @@ export interface TemplateEntityV1beta2 extends Entity { id?: string; name?: string; action: string; - parameters?: JsonObject; + input?: JsonObject; }>; output?: { [name: string]: string; }; + owner?: string; }; } // @public (undocumented) export const templateEntityV1beta2Validator: KindValidator; +// @alpha +export type UNSTABLE_EntityStatus = { + items?: UNSTABLE_EntityStatusItem[]; +}; + +// @alpha +export type UNSTABLE_EntityStatusItem = { + type: string; + level: UNSTABLE_EntityStatusLevel; + message: string; + error?: SerializedError; +}; + +// @alpha +export type UNSTABLE_EntityStatusLevel = 'info' | 'warning' | 'error'; + // @public (undocumented) interface UserEntityV1alpha1 extends Entity { // (undocumented) diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index edd9b8fcf9..88fd33ac0d 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -33,3 +33,4 @@ spec: lifecycle: experimental owner: team-a system: artist-engagement-portal + dependsOn: ['resource:artists-db'] diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 941877aec5..97ce0e6568 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.7.7", + "version": "0.7.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.5", + "@backstage/errors": "^0.1.1", "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.8", "ajv": "^7.0.3", @@ -39,7 +40,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.12", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/catalog-model/src/entity/Entity.test.ts b/packages/catalog-model/src/entity/Entity.test.ts new file mode 100644 index 0000000000..9f3bfc6813 --- /dev/null +++ b/packages/catalog-model/src/entity/Entity.test.ts @@ -0,0 +1,629 @@ +/* + * 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 Ajv from 'ajv'; +import { AnyValidateFunction } from 'ajv/dist/core'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { Entity } from './Entity'; + +describe('Entity', () => { + let entity: any; + let validate: AnyValidateFunction; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: 'e01199ab-08cc-44c2-8e19-5c29ded82521', + etag: 'lsndfkjsndfkjnsdfkjnsd==', + generation: 13, + name: 'test', + namespace: 'ns', + labels: { + 'backstage.io/custom': 'ValueStuff', + }, + annotations: { + 'example.com/bindings': 'are-secret', + }, + tags: ['java', 'data'], + links: [ + { + url: 'https://example.com', + title: 'Website', + icon: 'website', + }, + ], + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'me', + }, + relations: [ + { type: 't', target: { kind: 'k', namespace: 'ns', name: 'n' } }, + ], + status: { + items: [ + { + type: 't', + level: 'error', + message: 'm', + error: { name: 'n', message: 'm', code: '1', stack: 's' }, + }, + ], + }, + }; + + const ajv = new Ajv({ allowUnionTypes: true }); + ajv.addSchema([entityMetaSchema, commonSchema], undefined, undefined, true); + validate = ajv.compile(entitySchema); + }); + + function check(data: unknown): () => true { + return () => { + const result = validate(data); + if (result === true) { + return true; + } + + const [error] = validate.errors || []; + if (!error) { + throw new TypeError('Unknown error'); + } + + throw new TypeError( + `${error.dataPath || ''} ${error.message}${ + error.params + ? ` - ${Object.entries(error.params) + .map(([key, val]) => `${key}: ${val}`) + .join(', ')}` + : '' + }`, + ); + }; + } + + it('happy path: accepts valid data', () => { + expect(check(entity)).not.toThrow(); + }); + + // + // apiVersion and kind + // + + it('rejects wrong root type', () => { + expect(check((7 as unknown) as Entity)).toThrow(/object/); + }); + + it('rejects missing apiVersion', () => { + delete entity.apiVersion; + expect(check(entity)).toThrow(/apiVersion/); + }); + + it('rejects bad apiVersion type', () => { + entity.apiVersion = 7; + expect(check(entity)).toThrow(/apiVersion/); + }); + + it('rejects empty apiVersion', () => { + entity.apiVersion = ''; + expect(check(entity)).toThrow(/apiVersion/); + }); + + it('rejects missing kind', () => { + delete entity.kind; + expect(check(entity)).toThrow(/kind/); + }); + + it('rejects bad kind type', () => { + entity.kind = 7; + expect(check(entity)).toThrow(/kind/); + }); + + it('rejects empty kind', () => { + entity.kind = ''; + expect(check(entity)).toThrow(/kind/); + }); + + // + // metadata + // + + it('rejects missing metadata', () => { + delete entity.metadata; + expect(check(entity)).toThrow(/metadata/); + }); + + it('rejects bad metadata type', () => { + entity.metadata = 7; + expect(check(entity)).toThrow(/metadata/); + }); + + it('accepts missing uid', () => { + delete entity.metadata.uid; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad uid type', () => { + entity.metadata.uid = 7; + expect(check(entity)).toThrow(/uid/); + }); + + it('rejects empty uid', () => { + entity.metadata.uid = ''; + expect(check(entity)).toThrow(/uid/); + }); + + it('accepts missing etag', () => { + delete entity.metadata.etag; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad etag type', () => { + entity.metadata.etag = 7; + expect(check(entity)).toThrow(/etag/); + }); + + it('rejects empty etag', () => { + entity.metadata.etag = ''; + expect(check(entity)).toThrow(/etag/); + }); + + it('accepts missing generation', () => { + delete entity.metadata.generation; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad generation type', () => { + entity.metadata.generation = 'a'; + expect(check(entity)).toThrow(/generation/); + }); + + it('rejects zero generation', () => { + entity.metadata.generation = 0; + expect(check(entity)).toThrow(/generation/); + }); + + it('rejects non-integer generation', () => { + entity.metadata.generation = 1.5; + expect(check(entity)).toThrow(/generation/); + }); + + it('rejects missing name', () => { + delete entity.metadata.name; + expect(check(entity)).toThrow(/name/); + }); + + it('rejects bad name type', () => { + entity.metadata.name = 7; + expect(check(entity)).toThrow(/name/); + }); + + it('accepts missing namespace', () => { + delete entity.metadata.namespace; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad namespace type', () => { + entity.metadata.namespace = 7; + expect(check(entity)).toThrow(/namespace/); + }); + + it('accepts missing description', () => { + delete entity.metadata.description; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad description type', () => { + entity.metadata.description = 7; + expect(check(entity)).toThrow(/description/); + }); + + it('accepts missing labels', () => { + delete entity.metadata.labels; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty labels', () => { + entity.metadata.labels = {}; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad labels type', () => { + entity.metadata.labels = 7; + expect(check(entity)).toThrow(/labels/); + }); + + it('accepts missing annotations', () => { + delete entity.metadata.annotations; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty annotations object', () => { + entity.metadata.annotations = {}; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad annotations type', () => { + entity.metadata.annotations = 7; + expect(check(entity)).toThrow(/annotations/); + }); + + it('rejects bad tags type', () => { + entity.metadata.tags = 7; + expect(check(entity)).toThrow(/tags/); + }); + + it('accepts empty tags', () => { + entity.metadata.tags = []; + expect(check(entity)).not.toThrow(); + }); + + it('rejects empty tag', () => { + entity.metadata.tags[0] = ''; + expect(check(entity)).toThrow(/tags/); + }); + + it('rejects bad tag type', () => { + entity.metadata.tags[0] = 7; + expect(check(entity)).toThrow(/tags/); + }); + + it('accepts missing links', () => { + delete entity.metadata.links; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty links', () => { + entity.metadata.links = []; + expect(check(entity)).not.toThrow(); + }); + + it('rejects empty links.url', () => { + entity.metadata.links[0].url = ''; + expect(check(entity)).toThrow(/links/); + }); + + it('rejects missing links.url', () => { + delete entity.metadata.links[0].url; + expect(check(entity)).toThrow(/links/); + }); + + it('rejects bad links.url type', () => { + entity.metadata.links[0].url = 7; + expect(check(entity)).toThrow(/links/); + }); + + it('rejects empty links.title', () => { + entity.metadata.links[0].title = ''; + expect(check(entity)).toThrow(/links/); + }); + + it('accepts missing links.title', () => { + delete entity.metadata.links[0].title; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad links.title type', () => { + entity.metadata.links[0].title = 7; + expect(check(entity)).toThrow(/links/); + }); + + it('rejects empty links.icon', () => { + entity.metadata.links[0].icon = ''; + expect(check(entity)).toThrow(/links/); + }); + + it('accepts missing links.icon', () => { + delete entity.metadata.links[0].icon; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad links.icon type', () => { + entity.metadata.links[0].icon = 7; + expect(check(entity)).toThrow(/links/); + }); + + it('accepts unknown metadata field', () => { + entity.metadata.unknown = 7; + expect(check(entity)).not.toThrow(); + }); + + // + // spec + // + + it('accepts missing spec', () => { + delete entity.spec; + expect(check(entity)).not.toThrow(); + }); + + it('rejects non-object spec', () => { + entity.spec = 7; + expect(check(entity)).toThrow(/spec/); + }); + + it('accepts unknown spec field', () => { + entity.spec.unknown = 7; + expect(check(entity)).not.toThrow(); + }); + + // + // Relations + // + + it('accepts missing relations', () => { + delete entity.relations; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty relations', () => { + entity.relations = []; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad relations type', () => { + entity.relations = 7; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.type', () => { + delete entity.relations[0].type; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.type', () => { + entity.relations[0].type = ''; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.type type', () => { + entity.relations[0].type = 7; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target', () => { + delete entity.relations[0].target; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target', () => { + entity.relations[0].target = ''; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target type', () => { + entity.relations[0].target = 7; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target.kind', () => { + delete entity.relations[0].target.kind; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target.kind', () => { + entity.relations[0].target.kind = ''; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target.kind type', () => { + entity.relations[0].target.kind = 7; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target.namespace', () => { + delete entity.relations[0].target.namespace; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target.namespace', () => { + entity.relations[0].target.namespace = ''; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target.namespace type', () => { + entity.relations[0].target.namespace = 7; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects missing relations.target.name', () => { + delete entity.relations[0].target.name; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects empty relations.target.name', () => { + entity.relations[0].target.name = ''; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects wrong relations.target.name type', () => { + entity.relations[0].target.name = 7; + expect(check(entity)).toThrow(/relations/); + }); + + it('rejects unknown relation field', () => { + entity.relations[0].unknown = 7; + expect(check(entity)).toThrow(/unknown/); + }); + + // + // Status + // + + it('accepts missing status', () => { + delete entity.status; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty status', () => { + entity.status = {}; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad status type', () => { + entity.status = 7; + expect(check(entity)).toThrow(/status/); + }); + + it('accepts missing status.items', () => { + delete entity.status.items; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty status.items', () => { + entity.status.items = []; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad status.items type', () => { + entity.status.items = 7; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects bad status.items item type', () => { + entity.status.items[0] = 7; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects missing status.items.type', () => { + delete entity.status.items[0].type; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects empty status.items.type', () => { + entity.status.items[0].type = ''; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects bad status.items.type type', () => { + entity.status.items[0].type = 7; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects missing status.items.level', () => { + delete entity.status.items[0].level; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects empty status.items.level', () => { + entity.status.items[0].level = ''; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects bad status.items.level type', () => { + entity.status.items[0].level = 7; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects bad status.items.level enum', () => { + entity.status.items[0].level = 'unknown'; + expect(check(entity)).toThrow(/status/); + }); + + it('rejects missing status.items.message', () => { + delete entity.status.items[0].message; + expect(check(entity)).toThrow(/status/); + }); + + it('accepts empty status.items.message', () => { + entity.status.items[0].message = ''; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad status.items.message type', () => { + entity.status.items[0].message = 7; + expect(check(entity)).toThrow(/status/); + }); + + it('accepts missing status.items.error', () => { + delete entity.status.items[0].error; + expect(check(entity)).not.toThrow(); + }); + + it('rejects missing status.items.error.name', () => { + delete entity.status.items[0].error.name; + expect(check(entity)).toThrow(/name/); + }); + + it('rejects empty status.items.error.name', () => { + entity.status.items[0].error.name = ''; + expect(check(entity)).toThrow(/name/); + }); + + it('rejects bad status.items.error.name type', () => { + entity.status.items[0].error.name = 7; + expect(check(entity)).toThrow(/name/); + }); + + it('rejects missing status.items.error.message', () => { + delete entity.status.items[0].error.message; + expect(check(entity)).toThrow(/message/); + }); + + it('accepts empty status.items.error.message', () => { + entity.status.items[0].error.message = ''; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad status.items.error.message type', () => { + entity.status.items[0].error.message = 7; + expect(check(entity)).toThrow(/message/); + }); + + it('accepts missing status.items.error.code', () => { + delete entity.status.items[0].error.code; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty status.items.error.code', () => { + entity.status.items[0].error.code = ''; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad status.items.error.code type', () => { + entity.status.items[0].error.code = 7; + expect(check(entity)).toThrow(/code/); + }); + + it('accepts missing status.items.error.stack', () => { + delete entity.status.items[0].error.stack; + expect(check(entity)).not.toThrow(); + }); + + it('accepts empty status.items.error.stack', () => { + entity.status.items[0].error.stack = ''; + expect(check(entity)).not.toThrow(); + }); + + it('rejects bad status.items.error.stack type', () => { + entity.status.items[0].error.stack = 7; + expect(check(entity)).toThrow(/stack/); + }); + + it('accepts unknown status.items field', () => { + entity.status.items[0].unknown = 7; + expect(check(entity)).not.toThrow(); + }); + + it('accepts unknown status.items.error field', () => { + entity.status.items[0].error.unknown = 7; + expect(check(entity)).not.toThrow(); + }); +}); diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 7abe1f9c0c..3b5a0621bb 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -16,6 +16,7 @@ import { JsonObject } from '@backstage/config'; import { EntityName } from '../types'; +import { UNSTABLE_EntityStatus } from './EntityStatus'; /** * The format envelope that's common to all versions/kinds of entity. @@ -48,6 +49,14 @@ export type Entity = { * The relations that this entity has with other entities. */ relations?: EntityRelation[]; + + /** + * The current status of the entity, as claimed by various sources. + * + * The keys are implementation defined and the values can be any JSON object + * with semantics that match that implementation. + */ + status?: UNSTABLE_EntityStatus; }; /** diff --git a/packages/catalog-model/src/entity/EntityStatus.ts b/packages/catalog-model/src/entity/EntityStatus.ts new file mode 100644 index 0000000000..67090f2bc2 --- /dev/null +++ b/packages/catalog-model/src/entity/EntityStatus.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 { SerializedError } from '@backstage/errors'; + +/** + * The current status of the entity, as claimed by various sources. + * @alpha + */ +export type UNSTABLE_EntityStatus = { + /** + * Specific status item on a well known format. + */ + items?: UNSTABLE_EntityStatusItem[]; +}; + +/** + * A specific status item on a well known format. + * @alpha + */ +export type UNSTABLE_EntityStatusItem = { + /** + * The type of status as a unique key per source. + */ + type: string; + /** + * The level / severity of the status item. If the level is "error", the + * processing of the entity may be entirely blocked. In this case the status + * entry may apply to a different, newer version of the data than what is + * being returned in the catalog response. + */ + level: UNSTABLE_EntityStatusLevel; + /** + * A brief message describing the status, intended for human consumption. + */ + message: string; + /** + * An optional serialized error object related to the status. + */ + error?: SerializedError; +}; + +/** + * Each entity status item has a level, describing its severity. + * @alpha + */ +export type UNSTABLE_EntityStatusLevel = + | 'info' // Only informative data + | 'warning' // Warnings were found + | 'error'; // Errors were found diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 572df63557..2ccd205d85 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -15,10 +15,10 @@ */ export { + EDIT_URL_ANNOTATION, ENTITY_DEFAULT_NAMESPACE, ENTITY_META_GENERATED_FIELDS, VIEW_URL_ANNOTATION, - EDIT_URL_ANNOTATION, } from './constants'; export type { Entity, @@ -27,6 +27,11 @@ export type { EntityRelation, EntityRelationSpec, } from './Entity'; +export type { + UNSTABLE_EntityStatus, + UNSTABLE_EntityStatusItem, + UNSTABLE_EntityStatusLevel, +} from './EntityStatus'; export * from './policies'; export { compareEntityToRef, diff --git a/packages/catalog-model/src/entity/util.ts b/packages/catalog-model/src/entity/util.ts index 2c63cc4c49..116913c85c 100644 --- a/packages/catalog-model/src/entity/util.ts +++ b/packages/catalog-model/src/entity/util.ts @@ -42,11 +42,9 @@ export function generateEntityEtag(): string { * the next version of this entity. * * Significance, in this case, means that we do not compare generated fields - * such as uid, etag and generation, and we only check that no new annotations - * are added or existing annotations were changed (since they are effectively - * merged when doing updates). + * such as uid, etag and generation. * - * Note that this comparison does NOT take state, relations or similar into + * Note that this comparison does NOT take status, relations or similar into * account. It only compares the actual input entity data, i.e. metadata and * spec. * @@ -86,7 +84,9 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean { // Remove things that we explicitly do not compare delete e1.relations; + delete e1.status; delete e2.relations; + delete e2.status; return !lodash.isEqual(e1, e2); } 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/TemplateEntityV1alpha1.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts index e3d29aad4f..bfb27b4ed6 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.test.ts @@ -48,6 +48,7 @@ describe('templateEntityV1alpha1Validator', () => { }, }, }, + owner: 'team-a@example.com', }, }; }); @@ -90,4 +91,16 @@ describe('templateEntityV1alpha1Validator', () => { (entity as any).spec.templater = ''; await expect(validator.check(entity)).rejects.toThrow(/templater/); }); + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index 72479c7efa..64721e3739 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -33,6 +33,7 @@ export interface TemplateEntityV1alpha1 extends Entity { templater: string; path?: string; schema: JSONSchema; + owner?: string; }; } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts index 60f3de9e59..458bdcd0aa 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.test.ts @@ -51,7 +51,7 @@ describe('templateEntityV1beta2Validator', () => { id: 'fetch', name: 'Fetch', action: 'fetch:plan', - parameters: { + input: { url: './template', }, }, @@ -59,6 +59,7 @@ describe('templateEntityV1beta2Validator', () => { output: { fetchUrl: '{{ steps.fetch.output.targetUrl }}', }, + owner: 'team-b@example.com', }, }; }); @@ -121,4 +122,16 @@ describe('templateEntityV1beta2Validator', () => { delete (entity as any).spec.steps[0].action; await expect(validator.check(entity)).rejects.toThrow(/action/); }); + it('accepts missing owner', async () => { + delete (entity as any).spec.owner; + await expect(validator.check(entity)).resolves.toBe(true); + }); + it('rejects empty owner', async () => { + (entity as any).spec.owner = ''; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); + it('rejects wrong type owner', async () => { + (entity as any).spec.owner = 5; + await expect(validator.check(entity)).rejects.toThrow(/owner/); + }); }); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index c78e7f85d5..7a100de4bb 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -38,9 +38,10 @@ export interface TemplateEntityV1beta2 extends Entity { id?: string; name?: string; action: string; - parameters?: JsonObject; + input?: JsonObject; }>; output?: { [name: string]: string }; + owner?: string; }; } 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/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json index 803e025796..b8c43dda9e 100644 --- a/packages/catalog-model/src/schema/Entity.schema.json +++ b/packages/catalog-model/src/schema/Entity.schema.json @@ -62,6 +62,9 @@ "items": { "$ref": "common#relation" } + }, + "status": { + "$ref": "common#status" } } } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json index 53109ac7ee..5a0c0efa73 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json @@ -85,6 +85,11 @@ "schema": { "type": "object", "description": "The JSONSchema describing the inputs for the template." + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json index 1e0b7d0ded..a3d8e6ba86 100644 --- a/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json +++ b/packages/catalog-model/src/schema/kinds/Template.v1beta2.schema.json @@ -125,7 +125,7 @@ "type": "string", "description": "The name of the action to execute." }, - "parameters": { + "input": { "type": "object", "description": "A templated object describing the inputs to the action." } @@ -168,6 +168,11 @@ "additionalProperties": { "type": "string" } + }, + "owner": { + "type": "string", + "description": "The user (or group) owner of the template", + "minLength": 1 } } } diff --git a/packages/catalog-model/src/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json index 84ae8a4147..a9082d53e4 100644 --- a/packages/catalog-model/src/schema/shared/common.schema.json +++ b/packages/catalog-model/src/schema/shared/common.schema.json @@ -13,15 +13,18 @@ "properties": { "kind": { "type": "string", - "description": "The kind field of the entity." + "description": "The kind field of the entity.", + "minLength": 1 }, "namespace": { "type": "string", - "description": "The metadata.namespace field of the entity." + "description": "The metadata.namespace field of the entity.", + "minLength": 1 }, "name": { "type": "string", - "description": "The metadata.name field of the entity." + "description": "The metadata.name field of the entity.", + "minLength": 1 } } }, @@ -29,7 +32,7 @@ "$id": "#relation", "type": "object", "description": "A directed relation from one entity to another.", - "required": ["type", "source", "target"], + "required": ["type", "target"], "additionalProperties": false, "properties": { "type": { @@ -38,13 +41,83 @@ "pattern": "^\\w+$", "description": "The type of relation." }, - "source": { - "$ref": "#reference" - }, "target": { "$ref": "#reference" } } + }, + "status": { + "$id": "#status", + "type": "object", + "description": "The current status of the entity, as claimed by various sources.", + "required": [], + "additionalProperties": true, + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#statusItem" + } + } + } + }, + "statusItem": { + "$id": "#statusItem", + "type": "object", + "description": "A specific status item on a well known format.", + "required": ["type", "level", "message"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "level": { + "$ref": "#statusLevel", + "description": "The status level / severity of the status item." + }, + "message": { + "type": "string", + "description": "A brief message describing the status, intended for human consumption." + }, + "error": { + "$ref": "#error", + "description": "An optional serialized error object related to the status." + } + } + }, + "statusLevel": { + "$id": "#statusLevel", + "type": "string", + "description": "A status level / severity.", + "enum": ["info", "warning", "error"] + }, + "error": { + "$id": "#error", + "type": "object", + "description": "A serialized error object.", + "required": ["name", "message"], + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "examples": ["Error", "InputError"], + "description": "The type name of the error", + "minLength": 1 + }, + "message": { + "type": "string", + "description": "The message of the error" + }, + "code": { + "type": "string", + "description": "An error code associated with the error" + }, + "stack": { + "type": "string", + "description": "An error stack trace" + } + } } } } diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index af452b3c9d..c9826ccd6d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/cli +## 0.6.12 + +### Patch Changes + +- 2bfec55a6: Update `fork-ts-checker-webpack-plugin` +- Updated dependencies [290405276] + - @backstage/config-loader@0.6.2 + +## 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 + +- f65adcde7: Fix some transitive dependency warnings in yarn +- fc79a6dd3: Added lax option to backstage-cli app:build command +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. +- Updated dependencies [d8b81fd28] + - @backstage/config-loader@0.6.1 + - @backstage/config@0.1.5 + ## 0.6.9 ### Patch Changes diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 94cf059cbd..4a2ae7119c 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -89,6 +89,7 @@ async function getConfig() { '\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve( './jestFileTransform.js', ), + '\\.(yaml)$': require.resolve('yaml-jest'), }, // A bit more opinionated diff --git a/packages/cli/package.json b/packages/cli/package.json index 46e99b762b..12d8373ba5 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.9", + "version": "0.6.12", "private": false, "publishConfig": { "access": "public" @@ -31,8 +31,8 @@ "@babel/core": "^7.4.4", "@babel/plugin-transform-modules-commonjs": "^7.4.4", "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.4", - "@backstage/config-loader": "^0.6.0", + "@backstage/config": "^0.1.5", + "@backstage/config-loader": "^0.6.2", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", "@lerna/project": "^4.0.0", @@ -75,7 +75,7 @@ "eslint-plugin-react-hooks": "^4.0.0", "express": "^4.17.1", "file-loader": "^6.2.0", - "fork-ts-checker-webpack-plugin": "^4.0.5", + "fork-ts-checker-webpack-plugin": "^6.2.9", "fs-extra": "^9.0.0", "handlebars": "^4.7.3", "html-webpack-plugin": "^4.3.0", @@ -113,16 +113,17 @@ "webpack-dev-server": "3.11.0", "webpack-node-externals": "^2.5.0", "yaml": "^1.10.0", + "yaml-jest": "^1.0.5", "yml-loader": "^2.1.0", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.6.3", - "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.6", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", - "@backstage/theme": "^0.2.6", + "@backstage/backend-common": "^0.8.1", + "@backstage/config": "^0.1.5", + "@backstage/core": "^0.7.10", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", + "@backstage/theme": "^0.2.7", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", "@types/fs-extra": "^9.0.1", @@ -143,7 +144,7 @@ "del": "^5.1.0", "mock-fs": "^4.13.0", "nodemon": "^2.0.2", - "ts-node": "^8.6.2" + "ts-node": "^9.1.1" }, "files": [ "asset-types", 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/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 8837554f22..4cc18f2083 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -97,15 +97,18 @@ export async function createConfig( if (checksEnabled) { plugins.push( new ForkTsCheckerWebpackPlugin({ - tsconfig: paths.targetTsConfig, - eslint: true, - eslintOptions: { - parserOptions: { - project: paths.targetTsConfig, - tsconfigRootDir: paths.targetPath, + typescript: { + configFile: paths.targetTsConfig, + }, + eslint: { + files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], + options: { + parserOptions: { + project: paths.targetTsConfig, + tsconfigRootDir: paths.targetPath, + }, }, }, - reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], }), ); } @@ -291,15 +294,18 @@ export async function createBackendConfig( ...(checksEnabled ? [ new ForkTsCheckerWebpackPlugin({ - tsconfig: paths.targetTsConfig, - eslint: true, - eslintOptions: { - parserOptions: { - project: paths.targetTsConfig, - tsconfigRootDir: paths.targetPath, + typescript: { + configFile: paths.targetTsConfig, + }, + eslint: { + files: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], + options: { + parserOptions: { + project: paths.targetTsConfig, + tsconfigRootDir: paths.targetPath, + }, }, }, - reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'], }), ] : []), diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 49eda9e0eb..1d2766eab4 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -31,7 +31,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { "@backstage/cli": "^{{version '@backstage/cli'}}", @@ -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/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index cb8ef123ce..75c94a46a5 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/config-loader +## 0.6.2 + +### Patch Changes + +- 290405276: Updated dependencies + +## 0.6.1 + +### Patch Changes + +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. +- Updated dependencies [d8b81fd28] + - @backstage/config@0.1.5 + ## 0.6.0 ### Minor Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 55d86314af..f7df2ddea0 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.6.0", + "version": "0.6.2", "private": false, "publishConfig": { "access": "public", @@ -31,12 +31,12 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.1", + "@backstage/config": "^0.1.5", "@types/json-schema": "^7.0.6", "ajv": "^7.0.3", "fs-extra": "^9.0.0", "json-schema": "^0.3.0", - "json-schema-merge-allof": "^0.7.0", + "json-schema-merge-allof": "^0.8.1", "typescript-json-schema": "^0.49.0", "yaml": "^1.9.2", "yup": "^0.29.3" diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index c190207547..5cababf94e 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/config +## 0.1.5 + +### Patch Changes + +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. + ## 0.1.4 ### Patch Changes diff --git a/packages/config/package.json b/packages/config/package.json index 8f08a5dcfc..400e4548cd 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config", "description": "Config API used by Backstage core, backend, and CLI", - "version": "0.1.4", + "version": "0.1.5", "private": false, "publishConfig": { "access": "public", diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index fab82a77e5..76af4a3060 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,28 @@ # @backstage/core-api +## 0.2.19 + +### Patch Changes + +- 61c3f927c: Updated the `Observable` type to provide interoperability with `Symbol.observable`, making it compatible with at least `zen-observable` and `RxJS 7`. + + In cases where this change breaks tests that mocked the `Observable` type, the following addition to the mock should fix the breakage: + + ```ts + [Symbol.observable]() { + return this; + }, + ``` + +- 65e6c4541: Remove circular dependencies + +## 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 a55df67343..cf80fb0d72 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.19", "private": false, "publishConfig": { "access": "public", @@ -38,17 +38,17 @@ "prop-types": "^15.7.2", "react": "^16.12.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@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-api/src/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts index b9776ed037..fc4f1ef166 100644 --- a/packages/core-api/src/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IconComponent } from '../../icons'; +import { IconComponent } from '../../icons/types'; import { Observable } from '../../types'; import { ApiRef, createApiRef } from '../system'; diff --git a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts index 19da7a3765..f18829d99c 100644 --- a/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublishSubject } from '../../../lib'; +import { PublishSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; import { AlertApi, AlertMessage } from '../../definitions'; diff --git a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts index 43b52a254b..837c5f54db 100644 --- a/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts +++ b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -15,7 +15,7 @@ */ import { AppThemeApi, AppTheme } from '../../definitions'; -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; const STORAGE_KEY = 'theme'; diff --git a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index 51548708d0..5993ede367 100644 --- a/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PublishSubject } from '../../../lib'; +import { PublishSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; import { ErrorApi, ErrorContext } from '../../definitions'; diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts index e616ece5ae..f0710cb466 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; type RequestQueueEntry = { diff --git a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts index 23e9e1ddc4..a0a01d1bc9 100644 --- a/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -21,7 +21,7 @@ import { AuthRequesterOptions, } from '../../definitions'; import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; -import { BehaviorSubject } from '../../../lib'; +import { BehaviorSubject } from '../../../lib/subjects'; import { Observable } from '../../../types'; /** diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index cf3aaac931..df0f65f33f 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -15,13 +15,12 @@ */ import { ComponentType } from 'react'; -import { IconComponent, IconComponentMap, IconKey } from '../icons'; +import { IconComponent, IconComponentMap, IconKey } from '../icons/types'; import { AnyExternalRoutes, BackstagePlugin } from '../plugin/types'; -import { ExternalRouteRef, RouteRef } from '../routing'; -import { AnyApiFactory } from '../apis'; +import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing/types'; +import { AnyApiFactory } from '../apis/system'; import { AppTheme, ProfileInfo } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; -import { SubRouteRef } from '../routing/types'; export type BootErrorPageProps = { step: 'load-config' | 'load-chunk'; diff --git a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 1c7e92daa8..1a1bab0eb9 100644 --- a/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { AuthRequester } from '../../apis'; import { + AuthRequester, OAuthRequestApi, AuthProvider, DiscoveryApi, diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts index 6cc9cf5237..af3d8bc9b6 100644 --- a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts +++ b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { SessionState } from '../../apis'; +import { SessionState } from '../../apis/definitions'; import { Observable } from '../../types'; import { BehaviorSubject } from '../subjects'; diff --git a/packages/core-api/src/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts index f57afd6760..332824e438 100644 --- a/packages/core-api/src/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -15,7 +15,7 @@ */ import { Observable } from '../../types'; -import { SessionState } from '../../apis'; +import { SessionState } from '../../apis/definitions'; export type GetSessionOptions = { optional?: boolean; diff --git a/packages/core-api/src/lib/subjects.ts b/packages/core-api/src/lib/subjects.ts index 9ebde6ebbc..5239e460af 100644 --- a/packages/core-api/src/lib/subjects.ts +++ b/packages/core-api/src/lib/subjects.ts @@ -53,6 +53,10 @@ export class PublishSubject ZenObservable.SubscriptionObserver >(); + [Symbol.observable]() { + return this; + } + get closed() { return this.isClosed; } @@ -148,6 +152,10 @@ export class BehaviorSubject ZenObservable.SubscriptionObserver >(); + [Symbol.observable]() { + return this; + } + get closed() { return this.isClosed; } diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 7ce2b2afbd..4603b03a30 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -23,7 +23,7 @@ import { ParamKeys, OptionalParams, } from './types'; -import { IconComponent } from '../icons'; +import { IconComponent } from '../icons/types'; // TODO(Rugvip): Remove this in the next breaking release, it's exported but unused export type RouteRefConfig = { diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 1fabd39cc4..0089e29e4b 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IconComponent } from '../icons'; +import { IconComponent } from '../icons/types'; import { getOrCreateGlobalSingleton } from '../lib/globalObject'; export type AnyParams = { [param in string]: string } | undefined; diff --git a/packages/core-api/src/types.ts b/packages/core-api/src/types.ts index 13a8a72570..ab0aa56a1b 100644 --- a/packages/core-api/src/types.ts +++ b/packages/core-api/src/types.ts @@ -39,9 +39,17 @@ export type Subscription = { /** * Value indicating whether the subscription is closed. */ - readonly closed: Boolean; + readonly closed: boolean; }; +// Declares the global well-known Symbol.observable +// We get the actual runtime polyfill from zen-observable +declare global { + interface SymbolConstructor { + readonly observable: symbol; + } +} + /** * Observable sequence of values and errors, see TC39. * @@ -51,12 +59,14 @@ export type Subscription = { * using many different observable implementations, such as zen-observable or RxJS 5. */ export type Observable = { + [Symbol.observable](): Observable; + /** * Subscribes to this observable to start receiving new values. */ subscribe(observer: Observer): Subscription; subscribe( - onNext: (value: T) => void, + onNext?: (value: T) => void, onError?: (error: Error) => void, onComplete?: () => void, ): Subscription; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 974dcd3b91..97427f27e9 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,39 @@ # @backstage/core +## 0.7.10 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- 5da6a561d: Fix a bug where users are asked to log-in on every page refresh. This is specific to people with only one sign-in provider in their SignInPage component. +- Updated dependencies [61c3f927c] +- Updated dependencies [65e6c4541] + - @backstage/core-api@0.2.19 + +## 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 + +- f65adcde7: Fix some transitive dependency warnings in yarn +- 80888659b: Bump react-hook-form version to be the same for the entire project. +- Updated dependencies [7b8272fb7] +- Updated dependencies [d8b81fd28] + - @backstage/theme@0.2.7 + - @backstage/config@0.1.5 + ## 0.7.7 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 21303d52fc..11f8037c06 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.7", + "version": "0.7.10", "private": false, "publishConfig": { "access": "public", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.4", - "@backstage/core-api": "^0.2.17", + "@backstage/config": "^0.1.5", + "@backstage/core-api": "^0.2.19", "@backstage/errors": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@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", @@ -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", @@ -65,16 +66,16 @@ "react-sparklines": "^1.7.0", "react-syntax-highlighter": "^15.4.3", "react-text-truncate": "^0.16.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "remark-gfm": "^1.0.0", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@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/SimpleStepper/SimpleStepperFooter.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx index a49e20913c..750ee55c10 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx @@ -15,7 +15,7 @@ */ import React, { useContext, ReactNode, PropsWithChildren } from 'react'; import { Button, makeStyles } from '@material-ui/core'; -import { StepActions } from './SimpleStepperStep'; +import { StepActions } from './types'; import { VerticalStepperContext } from './SimpleStepper'; const useStyles = makeStyles(theme => ({ diff --git a/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx index da467a5981..bdf3e8c59d 100644 --- a/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx +++ b/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx @@ -22,6 +22,7 @@ import { makeStyles, } from '@material-ui/core'; import { SimpleStepperFooter } from './SimpleStepperFooter'; +import { StepProps } from './types'; const useStyles = makeStyles(theme => ({ end: { @@ -29,30 +30,6 @@ const useStyles = makeStyles(theme => ({ }, })); -export type StepActions = { - showNext?: boolean; - canNext?: () => boolean; - onNext?: () => void; - nextStep?: (current: number, last: number) => number; - nextText?: string; - - showBack?: boolean; - backText?: string; - onBack?: () => void; - - showRestart?: boolean; - canRestart?: () => boolean; - onRestart?: () => void; - restartText?: string; -}; - -export type StepProps = { - title: string; - children: React.ReactElement; - end?: boolean; - actions?: StepActions; -}; - export const SimpleStepperStep = ({ title, children, diff --git a/packages/core/src/components/SimpleStepper/types.ts b/packages/core/src/components/SimpleStepper/types.ts new file mode 100644 index 0000000000..0a488ff356 --- /dev/null +++ b/packages/core/src/components/SimpleStepper/types.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +export type StepActions = { + showNext?: boolean; + canNext?: () => boolean; + onNext?: () => void; + nextStep?: (current: number, last: number) => number; + nextText?: string; + + showBack?: boolean; + backText?: string; + onBack?: () => void; + + showRestart?: boolean; + canRestart?: () => boolean; + onRestart?: () => void; + restartText?: string; +}; + +export type StepProps = { + title: string; + children: React.ReactElement; + end?: boolean; + actions?: StepActions; +}; diff --git a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx index 6b7b39329b..8d32e63688 100644 --- a/packages/core/src/components/TabbedLayout/RoutedTabs.tsx +++ b/packages/core/src/components/TabbedLayout/RoutedTabs.tsx @@ -16,7 +16,8 @@ import React, { useMemo } from 'react'; import { Helmet } from 'react-helmet'; import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router'; -import { Content, HeaderTabs } from '../../layout'; +import { Content } from '../../layout/Content'; +import { HeaderTabs } from '../../layout/HeaderTabs'; import { SubRoute } from './types'; export function useSelectedSubRoute( 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/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index daefe40c73..6a8dcf454e 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,18 +14,18 @@ * limitations under the License. */ +import { configApiRef, SignInPageProps, useApi } from '@backstage/core-api'; +import { Button, Grid, Typography } from '@material-ui/core'; import React, { useEffect, useState } from 'react'; -import { Page } from '../Page'; -import { Header } from '../Header'; +import { Progress } from '../../components/Progress'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; -import { Grid, Button, Typography } from '@material-ui/core'; -import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; -import { useSignInProviders, getSignInProviders } from './providers'; -import { IdentityProviders, SignInProviderConfig } from './types'; -import { Progress } from '../../components/Progress'; -import { GridItem, useStyles } from './styles'; +import { Header } from '../Header'; import { InfoCard } from '../InfoCard'; +import { Page } from '../Page'; +import { getSignInProviders, useSignInProviders } from './providers'; +import { GridItem, useStyles } from './styles'; +import { IdentityProviders, SignInProviderConfig } from './types'; type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; @@ -87,34 +87,63 @@ export const SingleSignInPage = ({ const authApi = useApi(provider.apiRef); const configApi = useApi(configApiRef); - const [retry, setRetry] = useState<{} | boolean | undefined>(auto); + const [autoShowPopup, setAutoShowPopup] = useState(auto ?? false); + // Defaults to true so that an initial check for existing user session is made + const [retry, setRetry] = useState<{} | boolean | undefined>(true); const [error, setError] = useState(); + // The SignIn component takes some time to decide whether the user is logged-in or not. + // showLoginPage is used to prevent a glitch-like experience where the sign-in page is + // displayed for a split second when the user is already logged-in. + const [showLoginPage, setShowLoginPage] = useState(false); + useEffect(() => { const login = async () => { - const identity = await authApi.getBackstageIdentity({ - instantPopup: true, - }); + try { + let identity; + // Do an initial check if any logged-in session exists + identity = await authApi.getBackstageIdentity({ + optional: true, + }); - const profile = await authApi.getProfile(); - onResult({ - userId: identity!.id, - profile: profile!, - getIdToken: () => { - return authApi.getBackstageIdentity().then(i => i!.idToken); - }, - signOut: async () => { - await authApi.signOut(); - }, - }); + // If no session exists, show the sign-in page + if (!identity && autoShowPopup) { + // Unless auto is set to true, this step should not happen. + // When user intentionally clicks the Sign In button, autoShowPopup is set to true + identity = await authApi.getBackstageIdentity({ + instantPopup: true, + }); + } + + if (!identity) { + setShowLoginPage(true); + return; + } + + const profile = await authApi.getProfile(); + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => { + return authApi.getBackstageIdentity().then(i => i!.idToken); + }, + signOut: async () => { + await authApi.signOut(); + }, + }); + } catch (err) { + // User closed the sign-in modal + setError(err); + setShowLoginPage(true); + } }; if (retry) { - login().catch(setError); + login(); } - }, [onResult, authApi, retry]); + }, [onResult, authApi, retry, autoShowPopup]); - return ( + return showLoginPage ? (
@@ -133,7 +162,10 @@ export const SingleSignInPage = ({ @@ -150,6 +182,8 @@ export const SingleSignInPage = ({ + ) : ( + ); }; diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 430e7f003a..2e62d43436 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,206 @@ # @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 + +- 38ca05168: The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. +- e0bfd3d44: The `scaffolder-backend` and `techdocs-backend` plugins have been updated. + In order to update, you need to apply the following changes to your existing backend application: + + `@backstage/plugin-techdocs-backend`: + + ```diff + // packages/backend/src/plugin/techdocs.ts + + + import { DockerContainerRunner } from '@backstage/backend-common'; + + export default async function createPlugin({ + logger, + config, + discovery, + reader, + }: PluginEnvironment): Promise { + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + + const dockerClient = new Docker(); + + const containerRunner = new DockerContainerRunner({ dockerClient }); + + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + + containerRunner, + }); + + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + + // checks if the publisher is working and logs the result + await publisher.getReadiness(); + + - // Docker client (conditionally) used by the generators, based on techdocs.generators config. + - const dockerClient = new Docker(); + + return await createRouter({ + preparers, + generators, + publisher, + - dockerClient, + logger, + config, + discovery, + }); + } + ``` + + `@backstage/plugin-scaffolder-backend`: + + ```diff + // packages/backend/src/plugin/scaffolder.ts + + - import { SingleHostDiscovery } from '@backstage/backend-common'; + + import { + + DockerContainerRunner, + + SingleHostDiscovery, + + } from '@backstage/backend-common'; + + export default async function createPlugin({ + logger, + config, + database, + reader, + }: PluginEnvironment): Promise { + + const dockerClient = new Docker(); + + const containerRunner = new DockerContainerRunner({ dockerClient }); + + + const cookiecutterTemplater = new CookieCutter({ containerRunner }); + - const cookiecutterTemplater = new CookieCutter(); + + const craTemplater = new CreateReactAppTemplater({ containerRunner }); + - const craTemplater = new CreateReactAppTemplater(); + const templaters = new Templaters(); + + templaters.register('cookiecutter', cookiecutterTemplater); + templaters.register('cra', craTemplater); + + const preparers = await Preparers.fromConfig(config, { logger }); + const publishers = await Publishers.fromConfig(config, { logger }); + + - const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + - dockerClient, + database, + catalogClient, + reader, + }); + } + ``` + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [e0bfd3d44] +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [f65adcde7] +- Updated dependencies [80888659b] +- Updated dependencies [b219821a0] +- Updated dependencies [7b8272fb7] +- Updated dependencies [8aedbb4af] +- Updated dependencies [fc79a6dd3] +- Updated dependencies [69eefb5ae] +- Updated dependencies [75c8cec39] +- Updated dependencies [b2e2ec753] +- Updated dependencies [227439a72] +- Updated dependencies [9314a8592] +- Updated dependencies [2e05277e0] +- Updated dependencies [4075c6367] +- Updated dependencies [cdb3426e5] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/plugin-scaffolder-backend@0.11.0 + - @backstage/backend-common@0.7.0 + - @backstage/plugin-techdocs-backend@0.8.0 + - @backstage/plugin-catalog-import@0.5.5 + - @backstage/plugin-github-actions@0.4.5 + - @backstage/cli@0.6.10 + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-backend@0.8.2 + - @backstage/theme@0.2.7 + - @backstage/plugin-tech-radar@0.3.10 + - @backstage/plugin-scaffolder@0.9.3 + - @backstage/plugin-techdocs@0.9.1 + - @backstage/plugin-proxy-backend@0.2.7 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + - @backstage/plugin-app-backend@0.3.12 + - @backstage/plugin-auth-backend@0.3.9 + - @backstage/plugin-rollbar-backend@0.1.10 + ## 0.3.20 ### Patch Changes @@ -776,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: @@ -818,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 071a0a5ebd..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.20", + "version": "0.3.22", "private": false, "publishConfig": { "access": "public" @@ -41,7 +41,7 @@ "@types/inquirer": "^7.3.1", "@types/react-dev-utils": "^9.0.4", "@types/recursive-readdir": "^2.2.0", - "ts-node": "^8.6.2" + "ts-node": "^9.1.1" }, "peerDependencies": { "@backstage/backend-common": "*", diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index 442e90944e..275b3865f9 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -72,7 +72,7 @@ scaffolder: catalog: rules: - - allow: [Component, System, API, Group, User, Template, Location] + - allow: [Component, System, API, Group, User, Resource, Location] locations: # Backstage example components - type: url @@ -86,11 +86,13 @@ catalog: - type: url target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.yaml + # Backstage example resources + - type: url + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-resources.yaml + # Backstage example organization groups - type: url target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/acme/org.yaml - rules: - - allow: [Group, User] # Backstage example templates - type: url diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 97ff521a89..6038f7c8b9 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -38,7 +38,7 @@ "@types/node": "^14.14.32", "@types/react-dom": "^16.9.8", "cross-env": "^7.0.0", - "cypress": "^4.2.0", + "cypress": "^7.3.0", "eslint-plugin-cypress": "^2.10.3", "start-server-and-test": "^1.10.11" }, diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index a302bb6dd7..4264c4a4ee 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -26,10 +26,15 @@ import { } from '@backstage/plugin-api-docs'; import { EntityAboutCard, + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, EntitySystemDiagramCard, EntityHasComponentsCard, + EntityHasResourcesCard, + EntityHasSubcomponentsCard, EntityHasSystemsCard, EntityLayout, + EntityLinksCard, EntitySwitch, isComponentType, isKind, @@ -78,6 +83,12 @@ const overviewContent = ( + + + + + + ); @@ -102,6 +113,17 @@ const serviceEntityPage = ( + + + + + + + + + + + @@ -118,6 +140,17 @@ const websiteEntityPage = ( {cicdContent} + + + + + + + + + + + @@ -224,6 +257,9 @@ const systemPage = ( + + + diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts index a3416c5080..333ffa11df 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts @@ -1,4 +1,7 @@ -import { SingleHostDiscovery } from '@backstage/backend-common'; +import { + DockerContainerRunner, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; import { CookieCutter, @@ -6,7 +9,7 @@ import { createRouter, Preparers, Publishers, - Templaters + Templaters, } from '@backstage/plugin-scaffolder-backend'; import Docker from 'dockerode'; import { Router } from 'express'; @@ -18,8 +21,11 @@ export default async function createPlugin({ database, reader, }: PluginEnvironment): Promise { - const cookiecutterTemplater = new CookieCutter(); - const craTemplater = new CreateReactAppTemplater(); + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + + const cookiecutterTemplater = new CookieCutter({ containerRunner }); + const craTemplater = new CreateReactAppTemplater({ containerRunner }); const templaters = new Templaters(); templaters.register('cookiecutter', cookiecutterTemplater); @@ -28,8 +34,6 @@ export default async function createPlugin({ const preparers = await Preparers.fromConfig(config, { logger }); const publishers = await Publishers.fromConfig(config, { logger }); - const dockerClient = new Docker(); - const discovery = SingleHostDiscovery.fromConfig(config); const catalogClient = new CatalogClient({ discoveryApi: discovery }); @@ -39,9 +43,8 @@ export default async function createPlugin({ publishers, logger, config, - dockerClient, database, catalogClient, - reader + reader, }); } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts index 231a7e7fd7..906d86d4a2 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/techdocs.ts @@ -1,9 +1,9 @@ +import { DockerContainerRunner } from '@backstage/backend-common'; import { createRouter, - - Generators, Preparers, - - Publisher + Generators, + Preparers, + Publisher, } from '@backstage/plugin-techdocs-backend'; import Docker from 'dockerode'; import { Router } from 'express'; @@ -21,9 +21,14 @@ export default async function createPlugin({ reader, }); + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + // Generators are used for generating documentation sites. const generators = await Generators.fromConfig(config, { logger, + containerRunner, }); // Publisher is used for @@ -37,14 +42,10 @@ export default async function createPlugin({ // checks if the publisher is working and logs the result await publisher.getReadiness(); - // Docker client (conditionally) used by the generators, based on techdocs.generators config. - const dockerClient = new Docker(); - return await createRouter({ preparers, generators, publisher, - dockerClient, logger, config, discovery, diff --git a/packages/dev-utils/CHANGELOG.md b/packages/dev-utils/CHANGELOG.md index 3e8b8a96a0..91585424dd 100644 --- a/packages/dev-utils/CHANGELOG.md +++ b/packages/dev-utils/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/dev-utils +## 0.1.15 + +### Patch Changes + +- 5e7b36e74: Fix a bug where an instance of the SCM integrations API wasn't being provided properly to the dev app. +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] +- Updated dependencies [61c3f927c] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/test-utils@0.1.12 + +## 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..563a5102d7 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.15", "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.10", + "@backstage/catalog-model": "^0.7.10", + "@backstage/integration-react": "^0.1.2", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/test-utils": "^0.1.12", "@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.12", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 1785fcbe38..06975df20f 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -154,7 +154,7 @@ class DevAppBuilder { } const app = createApp({ - apis: this.apis, + apis, plugins: this.plugins, bindRoutes: ({ bind }) => { for (const plugin of this.plugins ?? []) { diff --git a/packages/docgen/package.json b/packages/docgen/package.json index bb00620967..a7d7615e19 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -30,7 +30,7 @@ "commander": "^6.1.0", "fs-extra": "^9.0.0", "github-slugger": "^1.3.0", - "ts-node": "^8.6.2", + "ts-node": "^9.1.1", "typescript": "^4.0.3" }, "devDependencies": { diff --git a/packages/e2e-test/package.json b/packages/e2e-test/package.json index 6cd740c7b7..77a3e3960a 100644 --- a/packages/e2e-test/package.json +++ b/packages/e2e-test/package.json @@ -34,7 +34,7 @@ "handlebars": "^4.7.3", "pgtools": "^0.3.0", "tree-kill": "^1.2.2", - "ts-node": "^8.6.2", + "ts-node": "^9.1.1", "zombie": "^6.1.4" }, "nodemonConfig": { 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 9f33a25ac8..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", @@ -29,15 +29,15 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "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/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 6c65643409..6fefbe2670 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/integration +## 0.5.3 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies + +## 0.5.2 + +### Patch Changes + +- 38ca05168: The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. +- Updated dependencies [d8b81fd28] + - @backstage/config@0.1.5 + ## 0.5.1 ### Patch Changes diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 5387eb12db..31cbc8b5d8 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -115,6 +115,9 @@ export class GithubCredentialsProvider { }): Promise; } +// @public (undocumented) +export type GithubCredentialType = 'app' | 'token'; + // @public (undocumented) export class GitHubIntegration implements ScmIntegration { constructor(integrationConfig: GitHubIntegrationConfig); diff --git a/packages/integration/package.json b/packages/integration/package.json index b7c8bee957..ba5876aae5 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.5.1", + "version": "0.5.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.2", + "@backstage/config": "^0.1.5", "cross-fetch": "^3.0.6", "git-url-parse": "^11.4.4", "@octokit/rest": "^18.5.3", @@ -37,9 +37,9 @@ "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.6.7", - "@backstage/config-loader": "^0.6.0", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/config-loader": "^0.6.2", + "@backstage/test-utils": "^0.1.12", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", "msw": "^0.21.2" diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 0cba8e17c9..0f44958ab6 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -20,11 +20,8 @@ import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; import { defaultScmResolveUrl } from './helpers'; -import { - ScmIntegration, - ScmIntegrationRegistry, - ScmIntegrationsGroup, -} from './types'; +import { ScmIntegration, ScmIntegrationsGroup } from './types'; +import { ScmIntegrationRegistry } from './registry'; type IntegrationsByType = { azure: ScmIntegrationsGroup; diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index d295677c55..271d4b9920 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -81,13 +81,13 @@ describe('GithubCredentialsProvider tests', () => { }, } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); - const { token, headers } = await github.getCredentials({ + const { token, headers, type } = await github.getCredentials({ url: 'https://github.com/backstage/foobar', }); const { token: accessToken2 } = await github.getCredentials({ url: 'https://github.com/backstage/foobar', }); - + expect(type).toEqual('app'); expect(token).toEqual('secret_token'); expect(token).toEqual(accessToken2); expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); @@ -102,6 +102,7 @@ describe('GithubCredentialsProvider tests', () => { Authorization: 'Bearer hardcoded_token', }, token: 'hardcoded_token', + type: 'token', }); }); @@ -260,6 +261,6 @@ describe('GithubCredentialsProvider tests', () => { githubProvider.getCredentials({ url: 'https://github.com/backstage', }), - ).resolves.toEqual({ headers: undefined, token: undefined }); + ).resolves.toEqual({ headers: undefined, token: undefined, type: 'token' }); }); }); diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 0bf217c824..9bc09deab3 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -192,9 +192,12 @@ export class GithubAppCredentialsMux { } } +export type GithubCredentialType = 'app' | 'token'; + export type GithubCredentials = { headers?: { [name: string]: string }; token?: string; + type: GithubCredentialType; }; // TODO: Possibly move this to a backend only package so that it's not used in the frontend by mistake @@ -226,8 +229,10 @@ export class GithubCredentialsProvider { const owner = parsed.owner || parsed.name; const repo = parsed.owner ? parsed.name : undefined; + let type: GithubCredentialType = 'app'; let token = await this.githubAppCredentialsMux.getAppToken(owner, repo); if (!token) { + type = 'token'; token = this.token; } @@ -238,6 +243,7 @@ export class GithubCredentialsProvider { } : undefined, token, + type, }; } } diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 99d2f56d0f..ee92b7d934 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -21,4 +21,5 @@ export { export type { GitHubIntegrationConfig } from './config'; export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; export { GithubCredentialsProvider } from './GithubCredentialsProvider'; +export type { GithubCredentialType } from './GithubCredentialsProvider'; export { GitHubIntegration } from './GitHubIntegration'; diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index d15eea0832..8742114008 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -21,8 +21,5 @@ export * from './gitlab'; export * from './googleGcs'; export { defaultScmResolveUrl } from './helpers'; export { ScmIntegrations } from './ScmIntegrations'; -export type { - ScmIntegration, - ScmIntegrationRegistry, - ScmIntegrationsGroup, -} from './types'; +export type { ScmIntegration, ScmIntegrationsGroup } from './types'; +export type { ScmIntegrationRegistry } from './registry'; diff --git a/packages/integration/src/registry.ts b/packages/integration/src/registry.ts new file mode 100644 index 0000000000..8f38bb8d5b --- /dev/null +++ b/packages/integration/src/registry.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ScmIntegration, ScmIntegrationsGroup } from './types'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; + +/** + * Holds all registered SCM integrations, of all types. + */ +export interface ScmIntegrationRegistry + extends ScmIntegrationsGroup { + azure: ScmIntegrationsGroup; + bitbucket: ScmIntegrationsGroup; + github: ScmIntegrationsGroup; + gitlab: ScmIntegrationsGroup; + + /** + * Resolves an absolute or relative URL in relation to a base URL. + * + * This method is adapted for use within SCM systems, so relative URLs are + * within the context of the root of the hierarchy pointed to by the base + * URL. + * + * For example, if the base URL is `/folder/a.yaml`, i.e. + * within the file tree of a certain repo, an absolute path of `/b.yaml` does + * not resolve to `https://hostname/b.yaml` but rather to + * `/b.yaml` inside the file tree of that same repo. + * + * @param options.url The (absolute or relative) URL or path to resolve + * @param options.base The base URL onto which this resolution happens + * @param options.lineNumber The line number in the target file to link to, starting with 1. Only applicable when linking to files. + */ + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + + /** + * Resolves the edit URL for a file within the SCM system. + * + * Most SCM systems have a web interface that allows viewing and editing files + * in the repository. The returned URL directly jumps into the edit mode for + * the file. + * If this is not possible, the integration can fall back to a URL to view + * the file in the web interface. + * + * @param url The absolute URL to the file that should be edited. + */ + resolveEditUrl(url: string): string; +} diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index f06bda285a..30c298c57f 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -15,10 +15,6 @@ */ import { Config } from '@backstage/config'; -import { AzureIntegration } from './azure/AzureIntegration'; -import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; -import { GitHubIntegration } from './github/GitHubIntegration'; -import { GitLabIntegration } from './gitlab/GitLabIntegration'; /** * Encapsulates a single SCM integration. @@ -95,52 +91,6 @@ export interface ScmIntegrationsGroup { byHost(host: string): T | undefined; } -/** - * Holds all registered SCM integrations, of all types. - */ -export interface ScmIntegrationRegistry - extends ScmIntegrationsGroup { - azure: ScmIntegrationsGroup; - bitbucket: ScmIntegrationsGroup; - github: ScmIntegrationsGroup; - gitlab: ScmIntegrationsGroup; - - /** - * Resolves an absolute or relative URL in relation to a base URL. - * - * This method is adapted for use within SCM systems, so relative URLs are - * within the context of the root of the hierarchy pointed to by the base - * URL. - * - * For example, if the base URL is `/folder/a.yaml`, i.e. - * within the file tree of a certain repo, an absolute path of `/b.yaml` does - * not resolve to `https://hostname/b.yaml` but rather to - * `/b.yaml` inside the file tree of that same repo. - * - * @param options.url The (absolute or relative) URL or path to resolve - * @param options.base The base URL onto which this resolution happens - * @param options.lineNumber The line number in the target file to link to, starting with 1. Only applicable when linking to files. - */ - resolveUrl(options: { - url: string; - base: string; - lineNumber?: number; - }): string; - - /** - * Resolves the edit URL for a file within the SCM system. - * - * Most SCM systems have a web interface that allows viewing and editing files - * in the repository. The returned URL directly jumps into the edit mode for - * the file. - * If this is not possible, the integration can fall back to a URL to view - * the file in the web interface. - * - * @param url The absolute URL to the file that should be edited. - */ - resolveEditUrl(url: string): string; -} - export type ScmIntegrationsFactory = (options: { config: Config; }) => ScmIntegrationsGroup; diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 1788502e45..6f57a9ed3f 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,71 @@ # @backstage/techdocs-common +## 0.6.2 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/integration@0.5.3 + +## 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 + +- e0bfd3d44: Migrate the package to use the `ContainerRunner` interface instead of `runDockerContainer(…)`. + It also no longer provides the `ContainerRunner` as an input to the `GeneratorBase#run(…)` function, but expects it as a constructor parameter instead. + + If you use the `TechdocsGenerator` you need to update the usage: + + ```diff + + const containerRunner = new DockerContainerRunner({ dockerClient }); + + - const generator = new TechdocsGenerator(logger, config); + + const techdocsGenerator = new TechdocsGenerator({ + + logger, + + containerRunner, + + config, + + }); + + await this.generator.run({ + inputDir: preparedDir, + outputDir, + - dockerClient: this.dockerClient, + parsedLocationAnnotation, + etag: newEtag, + }); + ``` + +### Patch Changes + +- e9e56b01a: Adding optional config to enable S3-like API for tech-docs using s3ForcePathStyle option. + This allows providers like LocalStack, Minio and Wasabi (+possibly others) to be used to host tech docs. +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/integration@0.5.2 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.5.1 ### Patch 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/api-report.md b/packages/techdocs-common/api-report.md index a53fb9e681..3fff030eee 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -6,7 +6,7 @@ import { AzureIntegrationConfig } from '@backstage/integration'; import { Config } from '@backstage/config'; -import Docker from 'dockerode'; +import { ContainerRunner } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { EntityName } from '@backstage/catalog-model'; import express from 'express'; @@ -50,8 +50,9 @@ export type GeneratorBuilder = { // @public (undocumented) export class Generators implements GeneratorBuilder { // (undocumented) - static fromConfig(config: Config, { logger }: { + static fromConfig(config: Config, { logger, containerRunner, }: { logger: Logger; + containerRunner: ContainerRunner; }): Promise; // (undocumented) get(entity: Entity): GeneratorBase; @@ -151,9 +152,13 @@ export type RemoteProtocol = 'url' | 'dir' | 'github' | 'gitlab' | 'file' | 'azu // @public (undocumented) export class TechdocsGenerator implements GeneratorBase { - constructor(logger: Logger, config: Config); + constructor({ logger, containerRunner, config, }: { + logger: Logger; + containerRunner: ContainerRunner; + config: Config; + }); // (undocumented) - run({ inputDir, outputDir, dockerClient, parsedLocationAnnotation, etag, }: GeneratorRunOptions): Promise; + run({ inputDir, outputDir, parsedLocationAnnotation, etag, }: GeneratorRunOptions): Promise; } // @public diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index e749b33919..bdeba7a789 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.5.1", + "version": "0.6.2", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,17 +38,15 @@ "dependencies": { "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", - "@backstage/backend-common": "^0.6.0", - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.1", + "@backstage/catalog-model": "^0.7.10", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.3", "@google-cloud/storage": "^5.6.0", - "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "cross-fetch": "^3.0.6", - "dockerode": "^3.2.1", "express": "^4.17.1", "fs-extra": "^9.0.1", "git-url-parse": "^11.4.4", @@ -62,14 +60,16 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.12", "@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/generate/generators.test.ts b/packages/techdocs-common/src/stages/generate/generators.test.ts index 032aafbf49..610a3e8ad5 100644 --- a/packages/techdocs-common/src/stages/generate/generators.test.ts +++ b/packages/techdocs-common/src/stages/generate/generators.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { ContainerRunner, getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { Generators } from './generators'; import { TechdocsGenerator } from './techdocs'; @@ -30,6 +30,10 @@ const mockEntity = { }; describe('generators', () => { + const containerRunner: jest.Mocked = { + runContainer: jest.fn(), + }; + it('should return error if no generator is registered', async () => { const generators = new Generators(); @@ -40,7 +44,11 @@ describe('generators', () => { it('should return correct registered generator', async () => { const generators = new Generators(); - const techdocs = new TechdocsGenerator(logger, new ConfigReader({})); + const techdocs = new TechdocsGenerator({ + logger, + containerRunner, + config: new ConfigReader({}), + }); generators.register('techdocs', techdocs); diff --git a/packages/techdocs-common/src/stages/generate/generators.ts b/packages/techdocs-common/src/stages/generate/generators.ts index 57c4017b15..4a3af72931 100644 --- a/packages/techdocs-common/src/stages/generate/generators.ts +++ b/packages/techdocs-common/src/stages/generate/generators.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ContainerRunner } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; @@ -30,11 +31,18 @@ export class Generators implements GeneratorBuilder { static async fromConfig( config: Config, - { logger }: { logger: Logger }, + { + logger, + containerRunner, + }: { logger: Logger; containerRunner: ContainerRunner }, ): Promise { const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); + const techdocsGenerator = new TechdocsGenerator({ + logger, + containerRunner, + config, + }); generators.register('techdocs', techdocsGenerator); return generators; diff --git a/packages/techdocs-common/src/stages/generate/techdocs.ts b/packages/techdocs-common/src/stages/generate/techdocs.ts index 0dfa952a17..1a6b0a581c 100644 --- a/packages/techdocs-common/src/stages/generate/techdocs.ts +++ b/packages/techdocs-common/src/stages/generate/techdocs.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { runDockerContainer } from '@backstage/backend-common'; +import { ContainerRunner } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import path from 'path'; import { PassThrough } from 'stream'; @@ -48,20 +48,29 @@ const createStream = (): [string[], PassThrough] => { export class TechdocsGenerator implements GeneratorBase { private readonly logger: Logger; + private readonly containerRunner: ContainerRunner; private readonly options: TechdocsGeneratorOptions; - constructor(logger: Logger, config: Config) { + constructor({ + logger, + containerRunner, + config, + }: { + logger: Logger; + containerRunner: ContainerRunner; + config: Config; + }) { this.logger = logger; this.options = { runGeneratorIn: config.getOptionalString('techdocs.generators.techdocs') ?? 'docker', }; + this.containerRunner = containerRunner; } public async run({ inputDir, outputDir, - dockerClient, parsedLocationAnnotation, etag, }: GeneratorRunOptions): Promise { @@ -100,7 +109,7 @@ export class TechdocsGenerator implements GeneratorBase { ); break; case 'docker': - await runDockerContainer({ + await this.containerRunner.runContainer({ imageName: 'spotify/techdocs', args: ['build', '-d', '/output'], logStream, @@ -109,7 +118,6 @@ export class TechdocsGenerator implements GeneratorBase { // Set the home directory inside the container as something that applications can // write to, otherwise they will just fail trying to write to / envVars: { HOME: '/tmp' }, - dockerClient, }); this.logger.info( `Successfully generated docs from ${inputDir} into ${outputDir} using techdocs-container`, diff --git a/packages/techdocs-common/src/stages/generate/types.ts b/packages/techdocs-common/src/stages/generate/types.ts index fda41ed442..249b809592 100644 --- a/packages/techdocs-common/src/stages/generate/types.ts +++ b/packages/techdocs-common/src/stages/generate/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import Docker from 'dockerode'; import { Writable } from 'stream'; import { ParsedLocationAnnotation } from '../../helpers'; @@ -23,7 +22,6 @@ import { ParsedLocationAnnotation } from '../../helpers'; * * @param {string} inputDir The directory of the uncompiled documentation, with the values from the frontend * @param {string} outputDir Directory to store generated docs in. Usually - a newly created temporary directory. - * @param {Docker} dockerClient A docker client to run any generator on top of your directory * @param {ParsedLocationAnnotation} parsedLocationAnnotation backstage.io/techdocs-ref annotation of an entity * @param {string} etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json. * @param {Writable} [logStream] A dedicated log stream @@ -31,7 +29,6 @@ import { ParsedLocationAnnotation } from '../../helpers'; export type GeneratorRunOptions = { inputDir: string; outputDir: string; - dockerClient: Docker; parsedLocationAnnotation?: ParsedLocationAnnotation; etag?: string; logStream?: Writable; diff --git a/packages/techdocs-common/src/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts index 4c6325f305..91cfcc72ff 100644 --- a/packages/techdocs-common/src/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -18,7 +18,9 @@ import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; import { parseReferenceAnnotation } from '../../helpers'; -import { CommonGitPreparer, DirectoryPreparer, UrlPreparer } from '../prepare'; +import { DirectoryPreparer } from './dir'; +import { CommonGitPreparer } from './commonGit'; +import { UrlPreparer } from './url'; import { PreparerBase, PreparerBuilder, RemoteProtocol } from './types'; type factoryOptions = { 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..7fe7efe490 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/test-utils +## 0.1.12 + +### Patch Changes + +- 61c3f927c: Updated `MockErrorApi` to work with new `Observable` type in `@backstage/core`. +- Updated dependencies [61c3f927c] +- Updated dependencies [65e6c4541] + - @backstage/core-api@0.2.19 + +## 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..429d616572 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.12", "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.19", "@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.12", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts index 7bf50314d0..b90646201a 100644 --- a/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts +++ b/packages/test-utils/src/testUtils/apis/ErrorApi/MockErrorApi.ts @@ -32,6 +32,10 @@ type Waiter = { const nullObservable = { subscribe: () => ({ unsubscribe: () => {}, closed: true }), + + [Symbol.observable]() { + return this; + }, }; export class MockErrorApi implements ErrorApi { 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/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 22964d6b25..e70364016d 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/theme +## 0.2.7 + +### Patch Changes + +- 7b8272fb7: Remove extra bottom padding in InfoCard content + ## 0.2.6 ### Patch Changes diff --git a/packages/theme/package.json b/packages/theme/package.json index 87821b6751..f93c793ff8 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.2.6", + "version": "0.2.7", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.6.9" + "@backstage/cli": "^0.6.10" }, "files": [ "dist" diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index e7bb7e58b6..4f5627eec9 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -257,6 +257,9 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { // etc) end up at the bottom of the card instead of just below the body // contents. flexGrow: 1, + '&:last-child': { + paddingBottom: undefined, + }, }, }, MuiCardActions: { diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index f268ba1c4d..9c8a29eeec 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/plugin-api-docs +## 0.4.14 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + +## 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 5c9f9ca7ee..5bb574e592 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.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ }, "dependencies": { "@asyncapi/react-component": "^0.23.0", - "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/theme": "^0.2.7", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,16 +45,16 @@ "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/api-docs/src/config.ts b/plugins/api-docs/src/config.ts index a90189a037..c2a806d8b2 100644 --- a/plugins/api-docs/src/config.ts +++ b/plugins/api-docs/src/config.ts @@ -16,7 +16,7 @@ import { ApiEntity } from '@backstage/catalog-model'; import { createApiRef } from '@backstage/core'; -import { ApiDefinitionWidget } from './components'; +import { ApiDefinitionWidget } from './components/ApiDefinitionCard/ApiDefinitionWidget'; export const apiDocsConfigRef = createApiRef({ id: 'plugin.api-docs.config', diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 85fab4455d..ede16b6a5e 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,24 @@ # @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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/config-loader@0.6.1 + - @backstage/config@0.1.5 + ## 0.3.11 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index c6a1dd5a6b..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.11", + "version": "0.3.13", "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.6.2", - "@backstage/config-loader": "^0.6.0", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.0", + "@backstage/config-loader": "^0.6.1", + "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.7", + "@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 47616b68c7..6573f7ee2e 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,46 @@ # @backstage/plugin-auth-backend +## 0.3.11 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] +- Updated dependencies [61c3f927c] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/test-utils@0.1.12 + +## 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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/backend-common@0.7.0 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + ## 0.3.8 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 4af8a1736d..ba8fcdc794 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.8", + "version": "0.3.11", "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.6.2", - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.6", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.1", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.10", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/test-utils": "^0.1.10", + "@backstage/test-utils": "^0.1.12", "@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.7", + "@backstage/cli": "^0.6.12", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 6cf6a94964..5648ca5965 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -23,7 +23,7 @@ import { AuthProviderConfig, } from '../../providers/types'; import { InputError } from '@backstage/errors'; -import { TokenIssuer } from '../../identity'; +import { TokenIssuer } from '../../identity/types'; import { verifyNonce } from './helpers'; import { postMessageResponse, ensuresXRequestedWith } from '../flow'; import { OAuthHandlers, OAuthStartRequest, OAuthRefreshRequest } from './types'; diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index a00644a90d..a714674447 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -39,7 +39,7 @@ import { PassportDoneCallback, } from '../../lib/passport'; import { AuthProviderFactory, RedirectInfo } from '../types'; -import { TokenIssuer } from '../../identity'; +import { TokenIssuer } from '../../identity/types'; type PrivateInfo = { refreshToken: string; diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index c3a91b3b77..979380da9e 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -28,7 +28,7 @@ import { } from '../../lib/passport'; import { AuthProviderRouteHandlers, AuthProviderFactory } from '../types'; import { postMessageResponse } from '../../lib/flow'; -import { TokenIssuer } from '../../identity'; +import { TokenIssuer } from '../../identity/types'; type SamlInfo = { fullProfile: any; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 1a4e7b118a..700af3ebef 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -19,7 +19,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; -import { TokenIssuer } from '../identity'; +import { TokenIssuer } from '../identity/types'; export type AuthProviderConfig = { /** diff --git a/plugins/badges-backend/CHANGELOG.md b/plugins/badges-backend/CHANGELOG.md index 69785a645f..465170c239 100644 --- a/plugins/badges-backend/CHANGELOG.md +++ b/plugins/badges-backend/CHANGELOG.md @@ -1,5 +1,42 @@ # @backstage/plugin-badges-backend +## 0.1.5 + +### Patch Changes + +- a286a6397: Fix generated badge links +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + +## 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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/backend-common@0.7.0 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + ## 0.1.2 ### Patch Changes diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json index eb2a93fe67..3bbabde739 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.2", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.2", - "@backstage/catalog-client": "^0.3.6", - "@backstage/catalog-model": "^0.7.6", - "@backstage/config": "^0.1.3", + "@backstage/backend-common": "^0.8.1", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.10", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", "@types/express": "^4.17.6", "badge-maker": "^3.3.0", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.7", + "@backstage/cli": "^0.6.12", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" }, diff --git a/plugins/badges-backend/src/badges.test.ts b/plugins/badges-backend/src/badges.test.ts index 15be96154e..1d5e57141d 100644 --- a/plugins/badges-backend/src/badges.test.ts +++ b/plugins/badges-backend/src/badges.test.ts @@ -65,4 +65,28 @@ describe('BadgeFactories', () => { expect(badge.kind).toEqual('entity'); } }); + + it('returns valid link for entity', () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'service', + metadata: { + name: 'test', + }, + }; + + const context: BadgeContext = { + badgeUrl: '/dummy/url', + config, + entity, + }; + + expect.assertions(Object.keys(badgeFactories).length); + for (const badgeFactory of Object.values(badgeFactories)) { + const badge = badgeFactory.createBadge(context); + expect(badge.link).toContain( + 'http://localhost/catalog/default/service/test', + ); + } + }); }); diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts index f8637dfe88..ccd5f55855 100644 --- a/plugins/badges-backend/src/badges.ts +++ b/plugins/badges-backend/src/badges.ts @@ -24,8 +24,8 @@ function appTitle(context: BadgeContext): string { function entityUrl(context: BadgeContext): string { const e = context.entity!; - const entityUri = `${e.kind}/${ - e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE + const entityUri = `${e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE}/${ + e.kind }/${e.metadata.name}`; const catalogUrl = `${context.config.getString('app.baseUrl')}/catalog`; return `${catalogUrl}/${entityUri}`.toLowerCase(); 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 8f7b5865a1..aba90d257b 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,26 +20,26 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.7", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.6", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 950392f732..f2b9288dd7 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,10 +20,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.2", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -33,16 +33,16 @@ "qs": "^6.9.6", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 ed87e4dbc5..5815ac5f78 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,64 @@ # @backstage/plugin-catalog-backend +## 0.9.1 + +### Patch Changes + +- 50a5348b7: Fix error handling in `LdapOrgReaderProcessor`, and support complex paging options +- 1b8e28aed: Resolve the `target` for glob `file` locations correctly +- dcd5a93a9: Correctly add `/project-slug` annotation for new catalog-info.yaml PRs based on SCM integration. +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 62579ced6: Skip adding entries to the `entities_search` table if their `key` exceeds a length limit. +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/integration@0.5.3 + +## 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 + +- b219821a0: Expose `BitbucketRepositoryParser` introduced in [#5295](https://github.com/backstage/backstage/pull/5295) +- 227439a72: Add support for non-organization accounts in GitHub Discovery +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/integration@0.5.2 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.8.1 ### 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 99df0acd09..2f0164ace5 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.1", + "version": "0.9.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,11 +30,12 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.6.3", - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.1", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.10", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.3", "@backstage/plugin-search-backend-node": "^0.1.4", "@backstage/search-common": "^0.1.1", "@octokit/graphql": "^4.5.8", @@ -63,8 +64,8 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/test-utils": "^0.1.9", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@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/database/search.test.ts b/plugins/catalog-backend/src/database/search.test.ts index c6793cbdf4..656da8b6f5 100644 --- a/plugins/catalog-backend/src/database/search.test.ts +++ b/plugins/catalog-backend/src/database/search.test.ts @@ -101,6 +101,12 @@ describe('search', () => { expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]); }); + it('skips very large keys', () => { + const input = [{ key: 'a'.repeat(10000), value: 'foo' }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([]); + }); + it('skips very large values', () => { const input = [{ key: 'foo', value: 'a'.repeat(10000) }]; const output = mapToRows(input, 'eid'); diff --git a/plugins/catalog-backend/src/database/search.ts b/plugins/catalog-backend/src/database/search.ts index 9e58d2468b..67b6d14b4f 100644 --- a/plugins/catalog-backend/src/database/search.ts +++ b/plugins/catalog-backend/src/database/search.ts @@ -31,6 +31,7 @@ const SPECIAL_KEYS = [ // The maximum length allowed for search values. These columns are indexed, and // database engines do not like to index on massive values. For example, // postgres will balk after 8191 byte line sizes. +const MAX_KEY_LENGTH = 200; const MAX_VALUE_LENGTH = 200; type Kv = { @@ -136,7 +137,7 @@ export function mapToRows( result.push({ entity_id: entityId, key, value: null }); } else { const value = String(rawValue).toLowerCase(); - if (value.length <= MAX_VALUE_LENGTH) { + if (key.length <= MAX_KEY_LENGTH && value.length <= MAX_VALUE_LENGTH) { result.push({ entity_id: entityId, key, value }); } } diff --git a/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts index bc81cf14f8..cacdf38ffd 100644 --- a/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts +++ b/plugins/catalog-backend/src/ingestion/LocationAnalyzer.ts @@ -16,6 +16,8 @@ import { Logger } from 'winston'; import parseGitUrl from 'git-url-parse'; +import { Entity } from '@backstage/catalog-model'; +import { ScmIntegrationRegistry } from '@backstage/integration'; import { AnalyzeLocationRequest, AnalyzeLocationResponse, @@ -24,25 +26,50 @@ import { export class RepoLocationAnalyzer implements LocationAnalyzer { private readonly logger: Logger; + private readonly scmIntegrations: ScmIntegrationRegistry; - constructor(logger: Logger) { + constructor(logger: Logger, scmIntegrations: ScmIntegrationRegistry) { this.logger = logger; + this.scmIntegrations = scmIntegrations; } async analyzeLocation( request: AnalyzeLocationRequest, ): Promise { - const { owner, name, source } = parseGitUrl(request.location.target); - const entity = { + const { owner, name } = parseGitUrl(request.location.target); + const entity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: name, - // Probably won't handle properly self-hosted git providers with custom url - annotations: { [`${source}/project-slug`]: `${owner}/${name}` }, }, spec: { type: 'other', lifecycle: 'unknown' }, }; + const integration = this.scmIntegrations.byUrl(request.location.target); + let annotationPrefix; + switch (integration?.type) { + case 'azure': + annotationPrefix = 'dev.azure.com'; + break; + case 'bitbucket': + annotationPrefix = 'bitbucket.org'; + break; + case 'github': + annotationPrefix = 'github.com'; + break; + case 'gitlab': + annotationPrefix = 'gitlab.com'; + break; + default: + break; + } + + if (annotationPrefix) { + entity.metadata.annotations = { + [`${annotationPrefix}/project-slug`]: `${owner}/${name}`, + }; + } + this.logger.debug(`entity created for ${request.location.target}`); return { existingEntityFiles: [], diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts index cb60c119b6..3a8c061892 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.test.ts @@ -21,6 +21,7 @@ import { GroupEntity, ResourceEntity, SystemEntity, + TemplateEntity, UserEntity, } from '@backstage/catalog-model'; import { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; @@ -520,5 +521,47 @@ describe('BuiltinKindsEntityProcessor', () => { }, }); }); + it('generates relations for template entities', async () => { + const entity: TemplateEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { name: 'n' }, + spec: { + schema: { + properties: { + description: { + title: 'd', + type: 'string', + description: 'des', + }, + }, + }, + templater: 'cookiecutter', + path: '.', + type: 'service', + owner: 'o', + }, + }; + + await processor.postProcessEntity(entity, location, emit); + + expect(emit).toBeCalledTimes(2); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Group', namespace: 'default', name: 'o' }, + type: 'ownerOf', + target: { kind: 'Template', namespace: 'default', name: 'n' }, + }, + }); + expect(emit).toBeCalledWith({ + type: 'relation', + relation: { + source: { kind: 'Template', namespace: 'default', name: 'n' }, + type: 'ownedBy', + target: { kind: 'Group', namespace: 'default', name: 'o' }, + }, + }); + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index 404d7fd30d..f9acae6b4f 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -46,6 +46,7 @@ import { resourceEntityV1alpha1Validator, SystemEntity, systemEntityV1alpha1Validator, + TemplateEntity, templateEntityV1alpha1Validator, templateEntityV1beta2Validator, UserEntity, @@ -131,6 +132,19 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { } } + /* + * Emit relations for the Template kind + */ + if (entity.kind === 'Template') { + const template = entity as TemplateEntity; + doEmit( + template.spec.owner, + { defaultKind: 'Group', defaultNamespace: selfRef.namespace }, + RELATION_OWNED_BY, + RELATION_OWNER_OF, + ); + } + /* * Emit relations for the Component kind */ diff --git a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts index 0155f93916..d51f22f538 100644 --- a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.test.ts @@ -79,6 +79,14 @@ describe('FileReaderProcessor', () => { expect(emit).toBeCalledTimes(2); expect(emit.mock.calls[0][0].entity).toEqual({ kind: 'Component' }); + expect(emit.mock.calls[0][0].location).toEqual({ + type: 'file', + target: expect.stringMatching(/^[^*]*$/), + }); expect(emit.mock.calls[1][0].entity).toEqual({ kind: 'API' }); + expect(emit.mock.calls[1][0].location).toEqual({ + type: 'file', + target: expect.stringMatching(/^[^*]*$/), + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts index db069913a2..8c4c522abb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/FileReaderProcessor.ts @@ -16,11 +16,12 @@ import { LocationSpec } from '@backstage/catalog-model'; import fs from 'fs-extra'; +import g from 'glob'; +import path from 'path'; +import { promisify } from 'util'; import * as result from './results'; import { CatalogProcessor, CatalogProcessorEmit } from './types'; import { parseEntityYaml } from './util/parse'; -import { promisify } from 'util'; -import g from 'glob'; const glob = promisify(g); @@ -41,7 +42,12 @@ export class FileReaderProcessor implements CatalogProcessor { for (const fileMatch of fileMatches) { const data = await fs.readFile(fileMatch); - for (const parseResult of parseEntityYaml(data, location)) { + // The normalize converts to native slashes; the glob library returns + // forward slashes even on windows + for (const parseResult of parseEntityYaml(data, { + type: 'file', + target: path.normalize(fileMatch), + })) { emit(parseResult); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts index 19a08d219f..dc6a2e5ec2 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.test.ts @@ -13,10 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +jest.mock('@octokit/graphql'); import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; +import { + ScmIntegrations, + GithubCredentialsProvider, +} from '@backstage/integration'; import { GithubOrgReaderProcessor, parseUrl } from './GithubOrgReaderProcessor'; +import { graphql } from '@octokit/graphql'; +import { ConfigReader } from '@backstage/config'; describe('GithubOrgReaderProcessor', () => { describe('parseUrl', () => { @@ -29,34 +35,27 @@ describe('GithubOrgReaderProcessor', () => { }); describe('implementation', () => { - it('rejects unknown types', async () => { - const processor = new GithubOrgReaderProcessor({ - providers: [ - { - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }, - ], - logger: getVoidLogger(), - }); - const location: LocationSpec = { - type: 'not-github-org', - target: 'https://github.com', - }; - await expect( - processor.readLocation(location, false, () => {}), - ).resolves.toBeFalsy(); + const logger = getVoidLogger(); + const integrations = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + }, + ], + }, + }), + ); + + beforeEach(() => { + jest.resetAllMocks(); }); - it('rejects unknown targets', async () => { + it('rejects unknown targets from integrations', async () => { const processor = new GithubOrgReaderProcessor({ - providers: [ - { - target: 'https://github.com', - apiBaseUrl: 'https://api.github.com', - }, - ], - logger: getVoidLogger(), + integrations, + logger, }); const location: LocationSpec = { type: 'github-org', @@ -68,5 +67,101 @@ describe('GithubOrgReaderProcessor', () => { /There is no GitHub Org provider that matches https:\/\/not.github.com\/apa/, ); }); + + it('should not query for email addresses when GitHub Apps is used for authentication', async () => { + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'app', + }); + + const mockClient = jest.fn(); + + mockClient + .mockResolvedValueOnce({ + organization: { + membersWithRole: { pageInfo: { hasNextPage: false }, nodes: [{}] }, + }, + }) + .mockResolvedValueOnce({ + organization: { + teams: { + pageInfo: { hasNextPage: false }, + nodes: [ + { members: { pageInfo: { hasNextPage: false }, nodes: [{}] } }, + ], + }, + }, + }); + + (graphql.defaults as jest.Mock).mockReturnValue(mockClient); + + jest.spyOn(GithubCredentialsProvider, 'create').mockReturnValue({ + getCredentials: mockGetCredentials, + } as any); + + const processor = new GithubOrgReaderProcessor({ + integrations, + logger, + }); + const location: LocationSpec = { + type: 'github-org', + target: 'https://github.com/backstage', + }; + + await processor.readLocation(location, false, () => {}); + + expect(mockClient).toHaveBeenCalledWith( + expect.stringContaining('@include(if: $email)'), + expect.objectContaining({ email: false }), + ); + }); + + it('should query for email addresses when token is used for authentication', async () => { + const mockGetCredentials = jest.fn().mockReturnValue({ + headers: { token: 'blah' }, + type: 'token', + }); + + const mockClient = jest.fn(); + + mockClient + .mockResolvedValueOnce({ + organization: { + membersWithRole: { pageInfo: { hasNextPage: false }, nodes: [{}] }, + }, + }) + .mockResolvedValueOnce({ + organization: { + teams: { + pageInfo: { hasNextPage: false }, + nodes: [ + { members: { pageInfo: { hasNextPage: false }, nodes: [{}] } }, + ], + }, + }, + }); + + (graphql.defaults as jest.Mock).mockReturnValue(mockClient); + + jest.spyOn(GithubCredentialsProvider, 'create').mockReturnValue({ + getCredentials: mockGetCredentials, + } as any); + + const processor = new GithubOrgReaderProcessor({ + integrations, + logger, + }); + const location: LocationSpec = { + type: 'github-org', + target: 'https://github.com/backstage', + }; + + await processor.readLocation(location, false, () => {}); + + expect(mockClient).toHaveBeenCalledWith( + expect.stringContaining('@include(if: $email)'), + expect.objectContaining({ email: true }), + ); + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts index 6a83d705f6..6a2b4bd8b1 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubOrgReaderProcessor.ts @@ -16,34 +16,38 @@ import { LocationSpec } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { + GithubCredentialsProvider, + GithubCredentialType, + ScmIntegrations, +} from '@backstage/integration'; import { graphql } from '@octokit/graphql'; import { Logger } from 'winston'; -import { - getOrganizationTeams, - getOrganizationUsers, - ProviderConfig, - readGithubConfig, -} from './github'; +import { getOrganizationTeams, getOrganizationUsers } from './github'; 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 }) { - this.providers = options.providers; + constructor(options: { integrations: ScmIntegrations; logger: Logger }) { + this.integrations = options.integrations; this.logger = options.logger; } @@ -56,30 +60,14 @@ 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, tokenType } = 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(); this.logger.info('Reading GitHub users and groups'); - const { users } = await getOrganizationUsers(client, org); + const { users } = await getOrganizationUsers(client, org, tokenType); const { groups, groupMemberUsers } = await getOrganizationTeams( client, org, @@ -112,6 +100,33 @@ export class GithubOrgReaderProcessor implements CatalogProcessor { return true; } + + private async createClient( + orgUrl: string, + ): Promise<{ client: GraphQL; tokenType: GithubCredentialType }> { + const gitHubConfig = this.integrations.github.byUrl(orgUrl)?.config; + + if (!gitHubConfig) { + throw new Error( + `There is no GitHub Org provider that matches ${orgUrl}. Please add a configuration for an integration.`, + ); + } + + const credentialsProvider = GithubCredentialsProvider.create(gitHubConfig); + const { + headers, + type: tokenType, + } = await credentialsProvider.getCredentials({ + url: orgUrl, + }); + + const client = graphql.defaults({ + baseUrl: gitHubConfig.apiBaseUrl, + headers, + }); + + return { client, tokenType }; + } } /* diff --git a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts index d863142169..474e813590 100644 --- a/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/LdapOrgReaderProcessor.ts @@ -69,7 +69,11 @@ export class LdapOrgReaderProcessor implements CatalogProcessor { // Be lazy and create the client each time; even though it's pretty // inefficient, we usually only do this once per entire refresh loop and // don't have to worry about timeouts and reconnects etc. - const client = await LdapClient.create(provider.target, provider.bind); + const client = await LdapClient.create( + this.logger, + provider.target, + provider.bind, + ); const { users, groups } = await readLdapOrg( client, provider.users, diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts index 88bddc93fb..c5094230f8 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.test.ts @@ -65,7 +65,9 @@ describe('github', () => { graphqlMsw.query('users', (_req, res, ctx) => res(ctx.data(input))), ); - await expect(getOrganizationUsers(graphql, 'a')).resolves.toEqual(output); + await expect( + getOrganizationUsers(graphql, 'a', 'token'), + ).resolves.toEqual(output); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/github/github.ts b/plugins/catalog-backend/src/ingestion/processors/github/github.ts index f6cfcb8901..2cc97d2ec5 100644 --- a/plugins/catalog-backend/src/ingestion/processors/github/github.ts +++ b/plugins/catalog-backend/src/ingestion/processors/github/github.ts @@ -15,6 +15,7 @@ */ import { GroupEntity, UserEntity } from '@backstage/catalog-model'; +import { GithubCredentialType } from '@backstage/integration'; import { graphql } from '@octokit/graphql'; // Graphql types @@ -77,13 +78,20 @@ export type Connection = { export async function getOrganizationUsers( client: typeof graphql, org: string, + tokenType: GithubCredentialType, ): Promise<{ users: UserEntity[] }> { const query = ` - query users($org: String!, $cursor: String) { + query users($org: String!, $email: Boolean!, $cursor: String) { organization(login: $org) { membersWithRole(first: 100, after: $cursor) { pageInfo { hasNextPage, endCursor } - nodes { avatarUrl, bio, email, login, name } + nodes { + avatarUrl, + bio, + email @include(if: $email), + login, + name + } } } }`; @@ -119,7 +127,7 @@ export async function getOrganizationUsers( query, r => r.organization?.membersWithRole, mapper, - { org }, + { org, email: tokenType === 'token' }, ); return { users }; diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts index bf0d0f740e..0557f21003 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/client.ts @@ -15,6 +15,7 @@ */ import ldap, { Client, SearchEntry, SearchOptions } from 'ldapjs'; +import { Logger } from 'winston'; import { BindConfig } from './config'; import { errorString } from './util'; import { @@ -31,8 +32,20 @@ import { export class LdapClient { private vendor: Promise | undefined; - static async create(target: string, bind?: BindConfig): Promise { + static async create( + logger: Logger, + target: string, + bind?: BindConfig, + ): Promise { const client = ldap.createClient({ url: target }); + + // We want to have a catch-all error handler at the top, since the default + // behavior of the client is to blow up the entire process when it fails, + // unless an error handler is set. + client.on('error', (err: ldap.Error) => { + logger.warn(`LDAP client threw an error, ${errorString(err)}`); + }); + if (!bind) { return new LdapClient(client); } @@ -92,7 +105,7 @@ export class LdapClient { }); }); } catch (e) { - throw new Error(`LDAP search at ${dn} failed, ${e.message}`); + throw new Error(`LDAP search at DN "${dn}" failed, ${e.message}`); } } diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts index 607581e153..755c173d19 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.test.ts @@ -105,7 +105,10 @@ describe('readLdapConfig', () => { scope: 'base', attributes: ['*'], filter: 'f', - paged: true, + paged: { + pageSize: 7, + pagePause: true, + }, }, set: { p: 'v' }, map: { @@ -153,7 +156,10 @@ describe('readLdapConfig', () => { scope: 'base', attributes: ['*'], filter: 'f', - paged: true, + paged: { + pageSize: 7, + pagePause: true, + }, }, set: { p: 'v' }, map: { diff --git a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts index aca02e6e10..417657b244 100644 --- a/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts +++ b/plugins/catalog-backend/src/ingestion/processors/ldap/config.ts @@ -180,11 +180,32 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { if (!c) { return {}; } + + const paged = readOptionsPagedConfig(c); + return { scope: c.getOptionalString('scope') as SearchOptions['scope'], filter: formatFilter(c.getOptionalString('filter')), attributes: c.getOptionalStringArray('attributes'), - paged: c.getOptionalBoolean('paged'), + ...(paged !== undefined ? { paged } : undefined), + }; + } + + function readOptionsPagedConfig(c: Config): SearchOptions['paged'] { + const pagedConfig = c.getOptional('paged'); + if (pagedConfig === undefined) { + return undefined; + } + + if (pagedConfig === true || pagedConfig === false) { + return pagedConfig; + } + + const pageSize = c.getOptionalNumber('paged.pageSize'); + const pagePause = c.getOptionalBoolean('paged.pagePause'); + return { + ...(pageSize !== undefined ? { pageSize } : undefined), + ...(pagePause !== undefined ? { pagePause } : undefined), }; } @@ -258,7 +279,7 @@ export function readLdapConfig(config: Config): LdapProviderConfig[] { } function formatFilter(filter?: string): string | undefined { - // Remove extra whitespaces between blocks to support multiline filters from the configuration + // Remove extra whitespace between blocks to support multiline filters from the configuration return filter?.replace(/\s*(\(|\))/g, '$1')?.trim(); } diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts index 55cb8e2029..b7b6dfa297 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingEngine.ts @@ -15,6 +15,7 @@ */ import { stringifyEntityRef } from '@backstage/catalog-model'; +import { serializeError } from '@backstage/errors'; import { Logger } from 'winston'; import { ProcessingDatabase } from './database/types'; import { Stitcher } from './Stitcher'; @@ -118,7 +119,27 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { for (const error of result.errors) { this.logger.warn(error.message); } + const errorsString = JSON.stringify( + result.errors.map(e => serializeError(e)), + ); + + // If the result was marked as not OK, it signals that some part of the + // processing pipeline threw an exception. This can happen both as part of + // non-catastrophic things such as due to validation errors, as well as if + // something fatal happens inside the processing for other reasons. In any + // case, this means we can't trust that anything in the output is okay. So + // just store the errors and trigger a stich so that they become visible to + // the outside. if (!result.ok) { + await this.processingDatabase.transaction(async tx => { + await this.processingDatabase.updateProcessedEntityErrors(tx, { + id, + errors: errorsString, + }); + }); + await this.stitcher.stitch( + new Set([stringifyEntityRef(unprocessedEntity)]), + ); return; } @@ -128,7 +149,7 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine { id, processedEntity: result.completedEntity, state: result.state, - errors: JSON.stringify(result.errors), + errors: errorsString, relations: result.relations, deferredEntities: result.deferredEntities, }); diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts index 8fbb19e536..9b2d6550d1 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts @@ -97,11 +97,8 @@ export class DefaultCatalogProcessingOrchestrator async process( request: EntityProcessingRequest, ): Promise { - const { entity } = request; - - const result = await this.processSingleEntity(entity); - - return result; + // TODO: implement dryRun/eager + return this.processSingleEntity(request.entity); } private async processSingleEntity( 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/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts index f9bb97477c..88806d0ace 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.test.ts @@ -17,16 +17,17 @@ import { v4 as uuid } from 'uuid'; import { DatabaseManager } from './database/DatabaseManager'; import { DefaultLocationStore } from './DefaultLocationStore'; -/* eslint-disable */ -xdescribe('DefaultLocationStore', () => { - const createLocationStore = async () => { - const db = await DatabaseManager.createTestDatabase(); - const connection = { applyMutation: jest.fn() }; - const store = new DefaultLocationStore(db); - await store.connect(connection); - return { store, connection }; - }; +const createLocationStore = async () => { + const knex = await DatabaseManager.createTestDatabaseConnection(); + await DatabaseManager.createDatabase(knex); + const connection = { applyMutation: jest.fn() }; + const store = new DefaultLocationStore(knex); + await store.connect(connection); + return { store, connection }; +}; + +describe('DefaultLocationStore', () => { it('should do a full sync with the locations on connect', async () => { const { connection } = await createLocationStore(); @@ -39,13 +40,11 @@ xdescribe('DefaultLocationStore', () => { describe('listLocations', () => { it('lists empty locations when there is no locations', async () => { const { store } = await createLocationStore(); - expect(await store.listLocations()).toEqual([]); }); it('lists locations that are added to the db', async () => { const { store } = await createLocationStore(); - await store.createLocation({ target: 'https://github.com/backstage/demo/blob/master/catalog-info.yml', @@ -53,7 +52,6 @@ xdescribe('DefaultLocationStore', () => { }); const listLocations = await store.listLocations(); - expect(listLocations).toHaveLength(1); expect(listLocations).toEqual( expect.arrayContaining([ @@ -76,7 +74,6 @@ xdescribe('DefaultLocationStore', () => { type: 'url', }; await store.createLocation(spec); - await expect(() => store.createLocation(spec)).rejects.toThrow( new RegExp(`Location ${spec.type}:${spec.target} already exists`), ); @@ -84,7 +81,6 @@ xdescribe('DefaultLocationStore', () => { it('calls apply mutation when adding a new location', async () => { const { store, connection } = await createLocationStore(); - await store.createLocation({ target: 'https://github.com/backstage/demo/blob/master/catalog-info.yml', @@ -110,9 +106,7 @@ xdescribe('DefaultLocationStore', () => { describe('deleteLocation', () => { it('throws if the location does not exist', async () => { const { store } = await createLocationStore(); - const id = uuid(); - await expect(() => store.deleteLocation(id)).rejects.toThrow( new RegExp(`Found no location with ID ${id}`), ); diff --git a/plugins/catalog-backend/src/next/DefaultLocationStore.ts b/plugins/catalog-backend/src/next/DefaultLocationStore.ts index f6267df6a5..fe1eb30467 100644 --- a/plugins/catalog-backend/src/next/DefaultLocationStore.ts +++ b/plugins/catalog-backend/src/next/DefaultLocationStore.ts @@ -15,7 +15,6 @@ */ import { LocationSpec, Location } from '@backstage/catalog-model'; -import { Database } from '../database'; import { LocationStore, EntityProvider, @@ -23,80 +22,96 @@ import { } from './types'; import { v4 as uuid } from 'uuid'; import { locationSpecToLocationEntity } from './util'; -import { ConflictError } from '@backstage/errors'; +import { ConflictError, NotFoundError } from '@backstage/errors'; +import { Knex } from 'knex'; + +type DbLocationsRow = { + id: string; + type: string; + target: string; +}; export class DefaultLocationStore implements LocationStore, EntityProvider { private _connection: EntityProviderConnection | undefined; - constructor(private readonly db: Database) {} + constructor(private readonly db: Knex) {} getProviderName(): string { return 'DefaultLocationStore'; } async createLocation(spec: LocationSpec): Promise { - return this.db.transaction(async tx => { + const location = await this.db.transaction(async tx => { // Attempt to find a previous location matching the spec - const previousLocations = await this.listLocations(); + const previousLocations = await this.locations(tx); + // TODO: when location id's are a compilation of spec target we can remove this full + // lookup of locations first and just grab the by that instead. const previousLocation = previousLocations.some( l => spec.type === l.type && spec.target === l.target, ); - if (previousLocation) { throw new ConflictError( `Location ${spec.type}:${spec.target} already exists`, ); } - // TODO: id should really be type and target combined and not a uuid. - const location = await this.db.addLocation(tx, { + const inner: DbLocationsRow = { id: uuid(), type: spec.type, target: spec.target, - }); + }; - await this.connection.applyMutation({ - type: 'delta', - added: [locationSpecToLocationEntity(location)], - removed: [], - }); + await tx('locations').insert(inner); - return location; + return inner; }); + + await this.connection.applyMutation({ + type: 'delta', + added: [locationSpecToLocationEntity(location)], + removed: [], + }); + + return location; } async listLocations(): Promise { - const dbLocations = await this.db.locations(); - return ( - dbLocations - // TODO(blam): We should create a mutation to remove this location for everyone - // eventually when it's all done and dusted - .filter(({ type }) => type !== 'bootstrap') - .map(item => ({ - id: item.id, - target: item.target, - type: item.type, - })) - ); + return await this.locations(); } - getLocation(id: string): Promise { - return this.db.location(id); + async getLocation(id: string): Promise { + const items = await this.db('locations') + .where({ id }) + .select(); + + if (!items.length) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + return items[0]; } - deleteLocation(id: string): Promise { + async deleteLocation(id: string): Promise { if (!this.connection) { throw new Error('location store is not initialized'); } - return this.db.transaction(async tx => { - const location = await this.db.location(id); - await this.db.removeLocation(tx, id); - await this.connection.applyMutation({ - type: 'delta', - added: [], - removed: [locationSpecToLocationEntity(location)], - }); + const deleted = await this.db.transaction(async tx => { + const [location] = await tx('locations') + .where({ id }) + .select(); + + if (!location) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + + await tx('locations').where({ id }).del(); + return location; + }); + + await this.connection.applyMutation({ + type: 'delta', + added: [], + removed: [locationSpecToLocationEntity(deleted)], }); } @@ -110,13 +125,31 @@ export class DefaultLocationStore implements LocationStore, EntityProvider { async connect(connection: EntityProviderConnection): Promise { this._connection = connection; - const locations = await this.listLocations(); + + const locations = await this.locations(); + const entities = locations.map(location => { return locationSpecToLocationEntity(location); }); + await this.connection.applyMutation({ type: 'full', entities, }); } + + private async locations(dbOrTx: Knex.Transaction | Knex = this.db) { + const locations = await dbOrTx('locations').select(); + return ( + locations + // TODO(blam): We should create a mutation to remove this location for everyone + // eventually when it's all done and dusted + .filter(({ type }) => type !== 'bootstrap') + .map(item => ({ + id: item.id, + target: item.target, + type: item.type, + })) + ); + } } diff --git a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts index 0158cc9fc9..e10d13c129 100644 --- a/plugins/catalog-backend/src/next/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/next/NextCatalogBuilder.ts @@ -63,11 +63,12 @@ import { } from '../ingestion/processors/PlaceholderProcessor'; import { defaultEntityDataParser } from '../ingestion/processors/util/parse'; import { LocationAnalyzer } from '../ingestion/types'; -import { CatalogProcessingEngine } from '../next/types'; +import { CatalogProcessingEngine, LocationService } from '../next/types'; import { ConfigLocationEntityProvider } from './ConfigLocationEntityProvider'; import { DefaultProcessingDatabase } from './database/DefaultProcessingDatabase'; import { DefaultCatalogProcessingEngine } from './DefaultCatalogProcessingEngine'; import { DefaultCatalogProcessingOrchestrator } from './DefaultCatalogProcessingOrchestrator'; +import { DefaultLocationService } from './DefaultLocationService'; import { DefaultLocationStore } from './DefaultLocationStore'; import { NextEntitiesCatalog } from './NextEntitiesCatalog'; import { Stitcher } from './Stitcher'; @@ -233,6 +234,7 @@ export class NextCatalogBuilder { locationsCatalog: LocationsCatalog; locationAnalyzer: LocationAnalyzer; processingEngine: CatalogProcessingEngine; + locationService: LocationService; }> { const { config, database, logger } = this.env; @@ -261,7 +263,7 @@ export class NextCatalogBuilder { }); const entitiesCatalog = new NextEntitiesCatalog(dbClient); - const locationStore = new DefaultLocationStore(db); + const locationStore = new DefaultLocationStore(dbClient); const stitcher = new Stitcher(dbClient, logger); const configLocationProvider = new ConfigLocationEntityProvider(config); const processingEngine = new DefaultCatalogProcessingEngine( @@ -273,13 +275,17 @@ export class NextCatalogBuilder { ); const locationsCatalog = new DatabaseLocationsCatalog(db); - const locationAnalyzer = new RepoLocationAnalyzer(logger); - + const locationAnalyzer = new RepoLocationAnalyzer(logger, integrations); + const locationService = new DefaultLocationService( + locationStore, + orchestrator, + ); return { entitiesCatalog, locationsCatalog, locationAnalyzer, processingEngine, + locationService, }; } diff --git a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts index e87bb01e1f..13cbf39c7b 100644 --- a/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/next/NextEntitiesCatalog.ts @@ -18,37 +18,128 @@ import { Knex } from 'knex'; import { DbFinalEntitiesRow } from './Stitcher'; import { EntitiesCatalog } from '../catalog'; import { EntitiesRequest, EntitiesResponse } from '../catalog/types'; +import { DbRefreshStateRow } from './database/DefaultProcessingDatabase'; +import { + DbEntitiesSearchRow, + DbPageInfo, + EntityPagination, +} from '../database/types'; +import { InputError } from '@backstage/errors'; + +function parsePagination( + input?: EntityPagination, +): { limit?: number; offset?: number } { + if (!input) { + return {}; + } + + let { limit, offset } = input; + + if (input.after !== undefined) { + let cursor; + try { + const json = Buffer.from(input.after, 'base64').toString('utf8'); + cursor = JSON.parse(json); + } catch { + throw new InputError('Malformed after cursor, could not be parsed'); + } + if (cursor.limit !== undefined) { + if (!Number.isInteger(cursor.limit)) { + throw new InputError('Malformed after cursor, limit was not an number'); + } + limit = cursor.limit; + } + if (cursor.offset !== undefined) { + if (!Number.isInteger(cursor.offset)) { + throw new InputError('Malformed after cursor, offset was not a number'); + } + offset = cursor.offset; + } + } + + return { limit, offset }; +} + +function stringifyPagination(input: { limit: number; offset: number }) { + const json = JSON.stringify({ limit: input.limit, offset: input.offset }); + const base64 = Buffer.from(json, 'utf8').toString('base64'); + return base64; +} export class NextEntitiesCatalog implements EntitiesCatalog { constructor(private readonly database: Knex) {} async entities(request?: EntitiesRequest): Promise { - if (request?.fields) { - throw new Error('Fields extraction is not implemented'); - } - if (request?.pagination) { - throw new Error('Pagination is not implemented'); - } - if (request?.filter) { - throw new Error('Filters are not implemented'); + const db = this.database; + + let entitiesQuery = db('final_entities'); + + for (const singleFilter of request?.filter?.anyOf ?? []) { + entitiesQuery = entitiesQuery.orWhere(function singleFilterFn() { + for (const { key, matchValueIn } of singleFilter.allOf) { + // NOTE(freben): This used to be a set of OUTER JOIN, which may seem to + // make a lot of sense. However, it had abysmal performance on sqlite + // when datasets grew large, so we're using IN instead. + const matchQuery = db('search') + .select('entity_id') + .where(function keyFilter() { + this.andWhere({ key: key.toLowerCase() }); + if (matchValueIn) { + if (matchValueIn.length === 1) { + this.andWhere({ value: matchValueIn[0].toLowerCase() }); + } else if (matchValueIn.length > 1) { + this.andWhere( + 'value', + 'in', + matchValueIn.map(v => v.toLowerCase()), + ); + } + } + }); + this.andWhere('entity_id', 'in', matchQuery); + } + }); } - const dbResponse = await this.database( - 'final_entities', - ).select(); + // TODO: move final_entities to use entity_Ref + entitiesQuery = entitiesQuery + .select('final_entities.*') + .orderBy('entity_id', 'asc'); - const entities = dbResponse.map(e => JSON.parse(e.final_entity)); + const { limit, offset } = parsePagination(request?.pagination); + if (limit !== undefined) { + entitiesQuery = entitiesQuery.limit(limit + 1); + } + if (offset !== undefined) { + entitiesQuery = entitiesQuery.offset(offset); + } + + let rows = await entitiesQuery; + + let pageInfo: DbPageInfo; + if (limit === undefined || rows.length <= limit) { + pageInfo = { hasNextPage: false }; + } else { + rows = rows.slice(0, -1); + pageInfo = { + hasNextPage: true, + endCursor: stringifyPagination({ + limit, + offset: (offset ?? 0) + limit, + }), + }; + } return { - entities, - pageInfo: { - hasNextPage: false, - }, + entities: rows.map(e => JSON.parse(e.final_entity)), + pageInfo, }; } - async removeEntityByUid(_uid: string): Promise { - throw new Error('Not implemented'); + async removeEntityByUid(uid: string): Promise { + await this.database('refresh_state') + .where('entity_id', uid) + .delete(); } async batchAddOrUpdateEntities(): Promise { diff --git a/plugins/catalog-backend/src/next/NextRouter.ts b/plugins/catalog-backend/src/next/NextRouter.ts new file mode 100644 index 0000000000..b4660edea0 --- /dev/null +++ b/plugins/catalog-backend/src/next/NextRouter.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorHandler } from '@backstage/backend-common'; +import { + analyzeLocationSchema, + locationSpecSchema, +} from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { NotFoundError } from '@backstage/errors'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import yn from 'yn'; +import { EntitiesCatalog } from '../catalog'; +import { LocationAnalyzer } from '../ingestion/types'; +import { + basicEntityFilter, + parseEntityFilterParams, + parseEntityPaginationParams, + parseEntityTransformParams, +} from '../service/request'; +import { LocationService } from './types'; +import { disallowReadonlyMode, validateRequestBody } from '../service/util'; + +export interface RouterOptions { + entitiesCatalog?: EntitiesCatalog; + locationAnalyzer?: LocationAnalyzer; + locationService: LocationService; + logger: Logger; + config: Config; +} + +export async function createNextRouter( + options: RouterOptions, +): Promise { + const { + entitiesCatalog, + locationAnalyzer, + locationService, + config, + logger, + } = options; + + const router = Router(); + router.use(express.json()); + + const readonlyEnabled = + config.getOptionalBoolean('catalog.readonly') || false; + if (readonlyEnabled) { + logger.info('Catalog is running in readonly mode'); + } + + if (entitiesCatalog) { + router + .get('/entities', async (req, res) => { + const { entities, pageInfo } = await entitiesCatalog.entities({ + filter: parseEntityFilterParams(req.query), + fields: parseEntityTransformParams(req.query), + pagination: parseEntityPaginationParams(req.query), + }); + + // Add a Link header to the next page + if (pageInfo.hasNextPage) { + const url = new URL(`http://ignored${req.url}`); + url.searchParams.delete('offset'); + url.searchParams.set('after', pageInfo.endCursor); + res.setHeader('link', `<${url.pathname}${url.search}>; rel="next"`); + } + + // TODO(freben): encode the pageInfo in the response + res.json(entities); + }) + .get('/entities/by-uid/:uid', async (req, res) => { + const { uid } = req.params; + const { entities } = await entitiesCatalog.entities({ + filter: basicEntityFilter({ 'metadata.uid': uid }), + }); + if (!entities.length) { + throw new NotFoundError(`No entity with uid ${uid}`); + } + res.status(200).json(entities[0]); + }) + .delete('/entities/by-uid/:uid', async (req, res) => { + const { uid } = req.params; + await entitiesCatalog.removeEntityByUid(uid); + res.status(204).end(); + }) + .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { + const { kind, namespace, name } = req.params; + const { entities } = await entitiesCatalog.entities({ + filter: basicEntityFilter({ + kind: kind, + 'metadata.namespace': namespace, + 'metadata.name': name, + }), + }); + if (!entities.length) { + throw new NotFoundError( + `No entity named '${name}' found, with kind '${kind}' in namespace '${namespace}'`, + ); + } + res.status(200).json(entities[0]); + }); + } + + if (locationService) { + router + .post('/locations', async (req, res) => { + const input = await validateRequestBody(req, locationSpecSchema); + const dryRun = yn(req.query.dryRun, { default: false }); + + // when in dryRun addLocation is effectively a read operation so we don't + // need to disallow readonly + if (!dryRun) { + disallowReadonlyMode(readonlyEnabled); + } + + const output = await locationService.createLocation(input, dryRun); + res.status(201).json(output); + }) + .get('/locations', async (_req, res) => { + const locations = await locationService.listLocations(); + res.status(200).json(locations.map(l => ({ data: l }))); + }) + + .get('/locations/:id', async (req, res) => { + const { id } = req.params; + const output = await locationService.getLocation(id); + res.status(200).json(output); + }) + .delete('/locations/:id', async (req, res) => { + disallowReadonlyMode(readonlyEnabled); + + const { id } = req.params; + await locationService.deleteLocation(id); + res.status(204).end(); + }); + } + + if (locationAnalyzer) { + router.post('/analyze-location', async (req, res) => { + const input = await validateRequestBody(req, analyzeLocationSchema); + const output = await locationAnalyzer.analyzeLocation(input); + res.status(200).json(output); + }); + } + + router.use(errorHandler()); + return router; +} diff --git a/plugins/catalog-backend/src/next/Stitcher.ts b/plugins/catalog-backend/src/next/Stitcher.ts index bdc028d923..0281591fb1 100644 --- a/plugins/catalog-backend/src/next/Stitcher.ts +++ b/plugins/catalog-backend/src/next/Stitcher.ts @@ -14,8 +14,13 @@ * limitations under the License. */ -import { Entity, parseEntityRef } from '@backstage/catalog-model'; -import { ConflictError } from '@backstage/errors'; +import { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from '@backstage/catalog-client'; +import { + Entity, + parseEntityRef, + UNSTABLE_EntityStatusItem, +} from '@backstage/catalog-model'; +import { ConflictError, SerializedError } from '@backstage/errors'; import { createHash } from 'crypto'; import stableStringify from 'fast-json-stable-stringify'; import { Knex } from 'knex'; @@ -41,6 +46,11 @@ function generateStableHash(entity: Entity) { .digest('hex'); } +/** + * Performs the act of stitching - to take all of the various outputs from the + * ingestion process, and stitching them together into the final entity JSON + * shape. + */ export class Stitcher { constructor( private readonly database: Knex, @@ -84,14 +94,14 @@ export class Stitcher { relationTarget: 'relations.target_entity_ref', }) .from('refresh_state') - .leftJoin('incoming_references', {}) + .where({ 'refresh_state.entity_ref': entityRef }) + .crossJoin(tx.raw('incoming_references')) .leftOuterJoin('final_entities', { 'final_entities.entity_id': 'refresh_state.entity_id', }) .leftOuterJoin('relations', { 'relations.source_entity_ref': 'refresh_state.entity_ref', }) - .where({ 'refresh_state.entity_ref': entityRef }) .orderBy('relationType', 'asc') .orderBy('relationTarget', 'asc'); @@ -109,7 +119,7 @@ export class Stitcher { const { entityId, processedEntity, - // errors, + errors, incomingReferenceCount, previousHash, } = result[0]; @@ -129,6 +139,7 @@ export class Stitcher { // it const entity = JSON.parse(processedEntity) as Entity; const isOrphan = Number(incomingReferenceCount) === 0; + let statusItems: UNSTABLE_EntityStatusItem[] = []; if (isOrphan) { this.logger.debug(`${entityRef} is an orphan`); @@ -137,6 +148,17 @@ export class Stitcher { ['backstage.io/orphan']: 'true', }; } + if (errors) { + const parsedErrors = JSON.parse(errors) as SerializedError[]; + if (Array.isArray(parsedErrors) && parsedErrors.length) { + statusItems = parsedErrors.map(e => ({ + type: ENTITY_STATUS_CATALOG_PROCESSING_TYPE, + level: 'error', + message: e.toString(), + error: e, + })); + } + } // TODO: entityRef is lower case and should be uppercase in the final // result @@ -146,6 +168,12 @@ export class Stitcher { type: row.relationType!, target: parseEntityRef(row.relationTarget!), })); + if (statusItems.length) { + entity.status = { + ...entity.status, + items: [...(entity.status?.items ?? []), ...statusItems], + }; + } // If the output entity was actually not changed, just abort const hash = generateStableHash(entity); diff --git a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts index dc5e6036f5..cd3ddf037b 100644 --- a/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts +++ b/plugins/catalog-backend/src/next/database/DefaultProcessingDatabase.ts @@ -123,6 +123,20 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ); } + async updateProcessedEntityErrors( + txOpaque: Transaction, + options: UpdateProcessedEntityOptions, + ): Promise { + const tx = txOpaque as Knex.Transaction; + const { id, errors } = options; + + await tx('refresh_state') + .update({ + errors, + }) + .where('entity_id', id); + } + private deduplicateRelations(rows: DbRelationsRow[]): DbRelationsRow[] { return lodash.uniqBy( rows, @@ -185,7 +199,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { ), -- All the nodes that can be reached upwards from the descendants ancestors(root_id, via_entity_ref, to_entity_ref) AS ( - SELECT NULL, entity_ref, entity_ref + SELECT CAST(NULL as INT), entity_ref, entity_ref FROM descendants UNION SELECT @@ -235,7 +249,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { .withRecursive('ancestors', function ancestors(outer) { return outer .select({ - root_id: tx.raw('NULL', []), + root_id: tx.raw('CAST(NULL as INT)', []), via_entity_ref: 'entity_ref', to_entity_ref: 'entity_ref', }) @@ -376,8 +390,8 @@ export class DefaultProcessingDatabase implements ProcessingDatabase { .update({ next_update_at: tx.client.config.client === 'sqlite3' - ? tx.raw(`datetime('now', ?)`, [`10 seconds`]) // TODO: test this in sqlite3 - : tx.raw(`now() + interval '30 seconds'`), + ? tx.raw(`datetime('now', ?)`, [`100 seconds`]) + : tx.raw(`now() + interval '100 seconds'`), }); return { diff --git a/plugins/catalog-backend/src/next/database/types.ts b/plugins/catalog-backend/src/next/database/types.ts index d7a1dcd20b..f65c796a00 100644 --- a/plugins/catalog-backend/src/next/database/types.ts +++ b/plugins/catalog-backend/src/next/database/types.ts @@ -34,6 +34,11 @@ export type UpdateProcessedEntityOptions = { deferredEntities: Entity[]; }; +export type UpdateProcessedEntityErrorsOptions = { + id: string; + errors?: string; +}; + export type RefreshStateItem = { id: string; entityRef: string; @@ -80,10 +85,18 @@ export interface ProcessingDatabase { ): Promise; /** - * Updates the + * Updates a processed entity */ updateProcessedEntity( txOpaque: Transaction, options: UpdateProcessedEntityOptions, ): Promise; + + /** + * Updates only the errors of a processed entity + */ + updateProcessedEntityErrors( + txOpaque: Transaction, + options: UpdateProcessedEntityErrorsOptions, + ): Promise; } diff --git a/plugins/catalog-backend/src/next/index.ts b/plugins/catalog-backend/src/next/index.ts index d4eeca4c81..76867e05c8 100644 --- a/plugins/catalog-backend/src/next/index.ts +++ b/plugins/catalog-backend/src/next/index.ts @@ -15,3 +15,4 @@ */ export { NextCatalogBuilder } from './NextCatalogBuilder'; +export { createNextRouter } from './NextRouter'; diff --git a/plugins/catalog-backend/src/next/search.test.ts b/plugins/catalog-backend/src/next/search.test.ts index 2e0b07b4a9..be896319b7 100644 --- a/plugins/catalog-backend/src/next/search.test.ts +++ b/plugins/catalog-backend/src/next/search.test.ts @@ -104,6 +104,12 @@ describe('search', () => { expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]); }); + it('skips very large keys', () => { + const input = [{ key: 'a'.repeat(10000), value: 'foo' }]; + const output = mapToRows(input, 'eid'); + expect(output).toEqual([]); + }); + it('skips very large values', () => { const input = [{ key: 'foo', value: 'a'.repeat(10000) }]; const output = mapToRows(input, 'eid'); diff --git a/plugins/catalog-backend/src/next/search.ts b/plugins/catalog-backend/src/next/search.ts index 383b29fd70..0681bffa17 100644 --- a/plugins/catalog-backend/src/next/search.ts +++ b/plugins/catalog-backend/src/next/search.ts @@ -43,6 +43,7 @@ const SPECIAL_KEYS = [ // The maximum length allowed for search values. These columns are indexed, and // database engines do not like to index on massive values. For example, // postgres will balk after 8191 byte line sizes. +const MAX_KEY_LENGTH = 200; const MAX_VALUE_LENGTH = 200; type Kv = { @@ -145,7 +146,7 @@ export function mapToRows(input: Kv[], entityId: string): DbSearchRow[] { result.push({ entity_id: entityId, key, value: null }); } else { const value = String(rawValue).toLocaleLowerCase('en-US'); - if (value.length <= MAX_VALUE_LENGTH) { + if (key.length <= MAX_KEY_LENGTH && value.length <= MAX_VALUE_LENGTH) { result.push({ entity_id: entityId, key, value }); } } 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-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index b8d8b3ee61..f742e3c0db 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -228,6 +228,7 @@ export class CatalogBuilder { locationAnalyzer: LocationAnalyzer; }> { const { config, database, logger } = this.env; + const integrations = ScmIntegrations.fromConfig(config); const policy = this.buildEntityPolicy(); const processors = this.buildProcessors(); @@ -255,7 +256,7 @@ export class CatalogBuilder { locationReader, logger, ); - const locationAnalyzer = new RepoLocationAnalyzer(logger); + const locationAnalyzer = new RepoLocationAnalyzer(logger, integrations); return { entitiesCatalog, diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index 899bf8a406..c04cc8e3d2 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,28 @@ # @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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.2.7 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 3ab6d64dc8..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.7", + "version": "0.2.9", "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.6.0", - "@backstage/catalog-model": "^0.7.1", - "@backstage/config": "^0.1.4", + "@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", "cross-fetch": "^3.0.6", @@ -42,8 +42,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.5", - "@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", @@ -52,7 +52,7 @@ "eslint-plugin-graphql": "^4.0.0", "msw": "^0.21.2", "supertest": "^6.1.3", - "ts-node": "^8.10.2" + "ts-node": "^9.1.1" }, "files": [ "dist" diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 4b0d264a52..d5ecc45b51 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,56 @@ # @backstage/plugin-catalog-import +## 0.5.7 + +### Patch Changes + +- ca6e0ab69: Make UI errors friendlier when importing existing components +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + +## 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 + +- 38ca05168: The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. +- 80888659b: Bump react-hook-form version to be the same for the entire project. +- 8aedbb4af: Fixes a typo and minor wording changes to the catalog import UI +- Updated dependencies [38ca05168] +- Updated dependencies [f65adcde7] +- Updated dependencies [81c54d1f2] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/integration@0.5.2 + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-react@0.1.5 + - @backstage/theme@0.2.7 + - @backstage/catalog-model@0.7.8 + - @backstage/catalog-client@0.3.11 + ## 0.5.4 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index ccc2dd24a7..b90954ea99 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.4", + "version": "0.5.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,13 +30,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.6", - "@backstage/catalog-client": "^0.3.9", - "@backstage/core": "^0.7.7", - "@backstage/integration": "^0.5.0", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.10", + "@backstage/catalog-client": "^0.3.11", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", + "@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", "@material-ui/lab": "4.0.0-alpha.45", @@ -49,17 +49,17 @@ "react-hook-form": "^6.15.4", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/ImportStepper/defaults.tsx b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx index 354ff6974f..a8c9b41abb 100644 --- a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx +++ b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx @@ -275,7 +275,7 @@ export function defaultGenerateStepper( } /> - WARNING: This may fail is no CODEOWNERS file is found at + WARNING: This may fail if no CODEOWNERS file is found at the target location. 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-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx index 49bd776db5..09f6230fdc 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx @@ -108,7 +108,7 @@ export const StepInitAnalyzeUrl = ({ } } } catch (e) { - setError(e.message); + setError(e.data?.error?.message ?? e.message); setSubmitted(false); } }, diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx index c34d5f374b..d1f8be7065 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx @@ -176,10 +176,9 @@ export const StepPrepareCreatePullRequest = ({ return ( <> - You entered a link to a {analyzeResult.integrationType} repository but - we didn't found a catalog-info.yaml. Use this form to - create a Pull Request that creates an initial{' '} - catalog-info.yaml. + You entered a link to a {analyzeResult.integrationType} repository but a{' '} + catalog-info.yaml could not be found. Use this form to open + a Pull Request that creates one. diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index 122647a67f..c50b72d059 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,33 @@ # @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 + +- 81c54d1f2: Fetch relations in batches in `useRelatedEntities` +- Updated dependencies [f65adcde7] +- Updated dependencies [80888659b] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/core@0.7.8 + - @backstage/catalog-model@0.7.8 + - @backstage/catalog-client@0.3.11 + ## 0.1.4 ### Patch Changes diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 57a9479a3c..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.4", + "version": "0.1.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,25 +28,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.3", + "@backstage/catalog-client": "^0.3.11", + "@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", "react": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.6", - "@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..02aacab77f 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,44 @@ # @backstage/plugin-catalog +## 0.5.8 + +### Patch Changes + +- a53f3d603: - Added `RelatedEntitesCard` as a base implementation of displaying entities that are related to another entity. + - Added `HasResourcesCard` to display resources that are part of a system. + - Added `DependsOnComponentsCard` to display components that are dependencies of a component. + - Added `DependsOnResourcesCard` to display resources that are dependencies of a component. + - Refactored `HasComponentsCard` to use base `RelatedEntitiesCard`. Card remains backwards compatible. + - Refactored `HasSubcomponentsCard` to use base `RelatedEntitiesCard`. Card remains backwards compatible. + - Refactored `HasSystemsCard` to use base `RelatedEntitiesCard`. Card remains backwards compatible. + - Updated the example app to take advantage of these new components. +- b203699e9: Display warning when Entity has orphan annotation. +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + +## 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 4f54735611..6cbf033a86 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.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,13 +31,13 @@ }, "dependencies": { "@backstage/catalog-client": "^0.3.10", - "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.7", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/integration": "^0.5.3", + "@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", "@material-ui/lab": "4.0.0-alpha.45", @@ -49,17 +49,17 @@ "react-helmet": "6.1.0", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx new file mode 100644 index 0000000000..8ea7638c8e --- /dev/null +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { Entity, RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { DependsOnComponentsCard } from './DependsOnComponentsCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no dependencies', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Components')).toBeInTheDocument(); + expect( + getByText(/No component is a dependency of this component/i), + ).toBeInTheDocument(); + }); + + it('shows dependency components', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Component', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_DEPENDS_ON, + }, + ], + }; + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'v1', + kind: 'Component', + metadata: { + namespace: 'my-namespace', + name: 'target-name', + }, + spec: {}, + }, + ], + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Components')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.tsx new file mode 100644 index 0000000000..f837bb02ff --- /dev/null +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import React from 'react'; +import { + asComponentEntities, + componentEntityColumns, + componentEntityHelpLink, + RelatedEntitiesCard, +} from '../RelatedEntitiesCard'; + +type Props = { + variant?: 'gridItem'; +}; + +export const DependsOnComponentsCard = ({ variant = 'gridItem' }: Props) => { + return ( + + ); +}; diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/index.ts b/plugins/catalog/src/components/DependsOnComponentsCard/index.ts new file mode 100644 index 0000000000..f46faf8198 --- /dev/null +++ b/plugins/catalog/src/components/DependsOnComponentsCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DependsOnComponentsCard } from './DependsOnComponentsCard'; diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx new file mode 100644 index 0000000000..8f425ba6aa --- /dev/null +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { Entity, RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { DependsOnResourcesCard } from './DependsOnResourcesCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no dependencies', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Resources')).toBeInTheDocument(); + expect( + getByText(/No resource is a dependency of this component/i), + ).toBeInTheDocument(); + }); + + it('shows dependency resources', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'my-component', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Resource', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_DEPENDS_ON, + }, + ], + }; + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'v1', + kind: 'Resource', + metadata: { + namespace: 'my-namespace', + name: 'target-name', + }, + spec: {}, + }, + ], + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Resources')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.tsx new file mode 100644 index 0000000000..7b64654f20 --- /dev/null +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RELATION_DEPENDS_ON } from '@backstage/catalog-model'; +import React from 'react'; +import { + asResourceEntities, + componentEntityHelpLink, + RelatedEntitiesCard, + resourceEntityColumns, +} from '../RelatedEntitiesCard'; + +type Props = { + variant?: 'gridItem'; +}; + +export const DependsOnResourcesCard = ({ variant = 'gridItem' }: Props) => { + return ( + + ); +}; diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/index.ts b/plugins/catalog/src/components/DependsOnResourcesCard/index.ts new file mode 100644 index 0000000000..20062f51dd --- /dev/null +++ b/plugins/catalog/src/components/DependsOnResourcesCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DependsOnResourcesCard } from './DependsOnResourcesCard'; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx new file mode 100644 index 0000000000..d9f266cce7 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import { DeleteEntityDialog } from './DeleteEntityDialog'; +import { ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model'; +import { + AlertApi, + alertApiRef, + ApiProvider, + ApiRegistry, +} from '@backstage/core'; +import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { screen, waitFor } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; + +describe('DeleteEntityDialog', () => { + const alertApi: jest.Mocked = { + post: jest.fn(), + alert$: jest.fn(), + }; + + const catalogClient: jest.Mocked = { + removeEntityByUid: jest.fn(), + } as any; + const apis = ApiRegistry.with(catalogApiRef, catalogClient).with( + alertApiRef, + alertApi, + ); + + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: '123', + name: 'n', + namespace: 'ns', + annotations: { + [ORIGIN_LOCATION_ANNOTATION]: 'url:http://example.com', + }, + }, + spec: {}, + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('can cancel', async () => { + const onClose = jest.fn(); + + await renderInTestApp( + + {}} + entity={entity} + /> + , + ); + + userEvent.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(onClose).toBeCalled(); + }); + }); + + it('can delete', async () => { + const onConfirm = jest.fn(); + + await renderInTestApp( + + {}} + onConfirm={onConfirm} + entity={entity} + /> + , + ); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(catalogClient.removeEntityByUid).toBeCalledWith('123'); + expect(onConfirm).toBeCalled(); + }); + }); + + it('handles error', async () => { + const onConfirm = jest.fn(); + + await renderInTestApp( + + {}} + onConfirm={onConfirm} + entity={entity} + /> + , + ); + + catalogClient.removeEntityByUid.mockRejectedValue(new Error('no no no')); + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(catalogClient.removeEntityByUid).toBeCalledWith('123'); + expect(alertApi.post).toBeCalledWith({ message: 'no no no' }); + }); + }); +}); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.tsx new file mode 100644 index 0000000000..26b6b29650 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.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 { Entity } from '@backstage/catalog-model'; +import { alertApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Button, Dialog, DialogActions, DialogTitle } from '@material-ui/core'; +import React, { useState } from 'react'; + +type Props = { + open: boolean; + onClose: () => any; + onConfirm: () => any; + entity: Entity; +}; + +export const DeleteEntityDialog = ({ + open, + onClose, + onConfirm, + entity, +}: Props) => { + const [busy, setBusy] = useState(false); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); + + const onDelete = async () => { + setBusy(true); + try { + const uid = entity.metadata.uid; + await catalogApi.removeEntityByUid(uid!); + onConfirm(); + } catch (err) { + alertApi.post({ message: err.message }); + } finally { + setBusy(false); + } + }; + + return ( + + + Are you sure you want to delete this entity? + + + + + + + ); +}; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx new file mode 100644 index 0000000000..0b3b8de504 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; + +import { + CatalogApi, + catalogApiRef, + catalogRouteRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { EntityOrphanWarning } from './EntityOrphanWarning'; + +describe('', () => { + const catalogClient: jest.Mocked = { + removeEntityByUid: jest.fn(), + } as any; + const apis = ApiRegistry.with(catalogApiRef, catalogClient); + + it('renders EntityOrphanWarning if the entity is orphan', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + description: 'This is the description', + annotations: { 'backstage.io/orphan': 'true' }, + }, + + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + const { getByText } = await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/create': catalogRouteRef, + }, + }, + ); + expect( + getByText( + 'This entity is not referenced by any location and is therefore not receiving updates. Click here to delete.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.tsx new file mode 100644 index 0000000000..070c36c56c --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/EntityOrphanWarning.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 { Entity } from '@backstage/catalog-model'; +import { useRouteRef } from '@backstage/core'; +import { catalogRouteRef, useEntity } from '@backstage/plugin-catalog-react'; +import { Alert } from '@material-ui/lab'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { DeleteEntityDialog } from './DeleteEntityDialog'; + +export const isOrphan = (entity: Entity) => + entity?.metadata?.annotations?.['backstage.io/orphan'] === 'true'; + +/** + * Displays a warning alert if the entity is marked as orphan with the ability to delete said entity. + */ +export const EntityOrphanWarning = () => { + const navigate = useNavigate(); + const catalogLink = useRouteRef(catalogRouteRef); + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const { entity } = useEntity(); + + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + navigate(catalogLink()); + }; + + return ( + <> + setConfirmationDialogOpen(true)}> + This entity is not referenced by any location and is therefore not + receiving updates. Click here to delete. + + setConfirmationDialogOpen(false)} + /> + + ); +}; diff --git a/plugins/catalog/src/components/EntityOrphanWarning/index.ts b/plugins/catalog/src/components/EntityOrphanWarning/index.ts new file mode 100644 index 0000000000..b64f8c1232 --- /dev/null +++ b/plugins/catalog/src/components/EntityOrphanWarning/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { EntityOrphanWarning, isOrphan } from './EntityOrphanWarning'; diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx index b6eae81f30..e2fbb6ff70 100644 --- a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.tsx @@ -14,79 +14,30 @@ * limitations under the License. */ -import { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; -import { - CodeSnippet, - InfoCard, - Link, - Progress, - WarningPanel, -} from '@backstage/core'; -import { Typography } from '@material-ui/core'; -import { - EntityTable, - useEntity, - useRelatedEntities, -} from '@backstage/plugin-catalog-react'; +import { RELATION_HAS_PART } from '@backstage/catalog-model'; import React from 'react'; +import { + asComponentEntities, + componentEntityColumns, + componentEntityHelpLink, + RelatedEntitiesCard, +} from '../RelatedEntitiesCard'; type Props = { variant?: 'gridItem'; }; -const columns = [ - EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), - EntityTable.columns.createOwnerColumn(), - EntityTable.columns.createSpecTypeColumn(), - EntityTable.columns.createSpecLifecycleColumn(), - EntityTable.columns.createMetadataDescriptionColumn(), -]; - export const HasComponentsCard = ({ variant = 'gridItem' }: Props) => { - const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities(entity, { - type: RELATION_HAS_PART, - kind: 'Component', - }); - - if (loading) { - return ( - - - - ); - } - - if (error || !entities) { - return ( - - } - /> - - ); - } - return ( - - - No component is part of this system. - - - - Learn how to change this. - - - - } - columns={columns} - entities={entities as ComponentEntity[]} + title="Components" + entityKind="Component" + relationType={RELATION_HAS_PART} + columns={componentEntityColumns} + emptyMessage="No component is part of this system" + emptyHelpLink={componentEntityHelpLink} + asRenderableEntities={asComponentEntities} /> ); }; diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx new file mode 100644 index 0000000000..38c8a3b5f5 --- /dev/null +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { HasResourcesCard } from './HasResourcesCard'; + +describe('', () => { + const catalogApi: jest.Mocked = { + getLocationById: jest.fn(), + getEntityByName: jest.fn(), + getEntities: jest.fn(), + addLocation: jest.fn(), + getLocationByEntity: jest.fn(), + removeEntityByUid: jest.fn(), + } as any; + let Wrapper: React.ComponentType; + + beforeEach(() => { + const apis = ApiRegistry.with(catalogApiRef, catalogApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('Resources')).toBeInTheDocument(); + expect( + getByText(/No resource is part of this system/i), + ).toBeInTheDocument(); + }); + + it('shows related resources', async () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Resource', + namespace: 'my-namespace', + name: 'target-name', + }, + type: RELATION_HAS_PART, + }, + ], + }; + catalogApi.getEntities.mockResolvedValue({ + items: [ + { + apiVersion: 'v1', + kind: 'Resource', + metadata: { + name: 'target-name', + namespace: 'my-namespace', + }, + spec: {}, + }, + ], + }); + + const { getByText } = await renderInTestApp( + + + + + , + ); + + await waitFor(() => { + expect(getByText('Resources')).toBeInTheDocument(); + expect(getByText(/target-name/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.tsx new file mode 100644 index 0000000000..be923d30a1 --- /dev/null +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RELATION_HAS_PART } from '@backstage/catalog-model'; +import React from 'react'; +import { + asResourceEntities, + RelatedEntitiesCard, + resourceEntityColumns, + resourceEntityHelpLink, +} from '../RelatedEntitiesCard'; + +type Props = { + variant?: 'gridItem'; +}; + +export const HasResourcesCard = ({ variant = 'gridItem' }: Props) => { + return ( + + ); +}; diff --git a/plugins/catalog/src/components/HasResourcesCard/index.ts b/plugins/catalog/src/components/HasResourcesCard/index.ts new file mode 100644 index 0000000000..48b1d419aa --- /dev/null +++ b/plugins/catalog/src/components/HasResourcesCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { HasResourcesCard } from './HasResourcesCard'; diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx index 41244ba3d4..bf4aeb18cc 100644 --- a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.tsx @@ -14,79 +14,29 @@ * limitations under the License. */ -import { ComponentEntity, RELATION_HAS_PART } from '@backstage/catalog-model'; -import { - CodeSnippet, - InfoCard, - Link, - Progress, - WarningPanel, -} from '@backstage/core'; -import { Typography } from '@material-ui/core'; -import { - EntityTable, - useEntity, - useRelatedEntities, -} from '@backstage/plugin-catalog-react'; +import { RELATION_HAS_PART } from '@backstage/catalog-model'; import React from 'react'; +import { + asComponentEntities, + componentEntityColumns, + RelatedEntitiesCard, +} from '../RelatedEntitiesCard'; type Props = { variant?: 'gridItem'; }; -const columns = [ - EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), - EntityTable.columns.createOwnerColumn(), - EntityTable.columns.createSpecTypeColumn(), - EntityTable.columns.createSpecLifecycleColumn(), - EntityTable.columns.createMetadataDescriptionColumn(), -]; - export const HasSubcomponentsCard = ({ variant = 'gridItem' }: Props) => { - const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities(entity, { - type: RELATION_HAS_PART, - kind: 'Component', - }); - - if (loading) { - return ( - - - - ); - } - - if (error || !entities) { - return ( - - } - /> - - ); - } - return ( - - - No subcomponent is part of this component. - - - - Learn how to change this. - - - - } - columns={columns} - entities={entities as ComponentEntity[]} + title="Subcomponents" + entityKind="Component" + relationType={RELATION_HAS_PART} + columns={componentEntityColumns} + asRenderableEntities={asComponentEntities} + emptyMessage="No subcomponent is part of this component" + emptyHelpLink="https://backstage.io/docs/features/software-catalog/descriptor-format#specsubcomponentof-optional" /> ); }; diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx index ea4de474de..b6c5bb7ca2 100644 --- a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.tsx @@ -14,76 +14,30 @@ * limitations under the License. */ -import { RELATION_HAS_PART, SystemEntity } from '@backstage/catalog-model'; -import { - CodeSnippet, - InfoCard, - Link, - Progress, - WarningPanel, -} from '@backstage/core'; -import { Typography } from '@material-ui/core'; -import { - EntityTable, - useEntity, - useRelatedEntities, -} from '@backstage/plugin-catalog-react'; +import { RELATION_HAS_PART } from '@backstage/catalog-model'; import React from 'react'; +import { + asSystemEntities, + RelatedEntitiesCard, + systemEntityColumns, + systemEntityHelpLink, +} from '../RelatedEntitiesCard'; type Props = { variant?: 'gridItem'; }; -const columns = [ - EntityTable.columns.createEntityRefColumn({ defaultKind: 'system' }), - EntityTable.columns.createOwnerColumn(), - EntityTable.columns.createMetadataDescriptionColumn(), -]; - export const HasSystemsCard = ({ variant = 'gridItem' }: Props) => { - const { entity } = useEntity(); - const { entities, loading, error } = useRelatedEntities(entity, { - type: RELATION_HAS_PART, - }); - - if (loading) { - return ( - - - - ); - } - - if (error || !entities) { - return ( - - } - /> - - ); - } - return ( - - - No system is part of this domain. - - - - Learn how to change this. - - - - } - columns={columns} - entities={entities as SystemEntity[]} + title="Systems" + entityKind="System" + relationType={RELATION_HAS_PART} + columns={systemEntityColumns} + asRenderableEntities={asSystemEntities} + emptyMessage="No system is part of this domain" + emptyHelpLink={systemEntityHelpLink} /> ); }; diff --git a/plugins/catalog/src/components/RelatedEntitiesCard/RelatedEntitiesCard.tsx b/plugins/catalog/src/components/RelatedEntitiesCard/RelatedEntitiesCard.tsx new file mode 100644 index 0000000000..f80f138515 --- /dev/null +++ b/plugins/catalog/src/components/RelatedEntitiesCard/RelatedEntitiesCard.tsx @@ -0,0 +1,92 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { + InfoCard, + Link, + Progress, + ResponseErrorPanel, + TableColumn, +} from '@backstage/core'; +import { Typography } from '@material-ui/core'; +import { + EntityTable, + useEntity, + useRelatedEntities, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; + +type Props = { + variant?: 'gridItem'; + title: string; + columns: TableColumn[]; + entityKind: string; + relationType: string; + emptyMessage: string; + emptyHelpLink: string; + asRenderableEntities: (entities: Entity[]) => T[]; +}; + +export function RelatedEntitiesCard({ + variant = 'gridItem', + title, + columns, + entityKind, + relationType, + emptyMessage, + emptyHelpLink, + asRenderableEntities, +}: Props) { + const { entity } = useEntity(); + const { entities, loading, error } = useRelatedEntities(entity, { + type: relationType, + kind: entityKind, + }); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + ); + } + + return ( + + {emptyMessage} + + Learn how to change this. + + + } + columns={columns} + entities={asRenderableEntities(entities || [])} + /> + ); +} diff --git a/plugins/catalog/src/components/RelatedEntitiesCard/index.ts b/plugins/catalog/src/components/RelatedEntitiesCard/index.ts new file mode 100644 index 0000000000..3104004dd6 --- /dev/null +++ b/plugins/catalog/src/components/RelatedEntitiesCard/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 { RelatedEntitiesCard } from './RelatedEntitiesCard'; +export * from './presets'; diff --git a/plugins/catalog/src/components/RelatedEntitiesCard/presets.ts b/plugins/catalog/src/components/RelatedEntitiesCard/presets.ts new file mode 100644 index 0000000000..9d3586a7e9 --- /dev/null +++ b/plugins/catalog/src/components/RelatedEntitiesCard/presets.ts @@ -0,0 +1,57 @@ +/* + * 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 { + ComponentEntity, + Entity, + ResourceEntity, + SystemEntity, +} from '@backstage/catalog-model'; +import { TableColumn } from '@backstage/core'; +import { EntityTable } from '@backstage/plugin-catalog-react'; + +export const componentEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'component' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecTypeColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; +export const componentEntityHelpLink: string = + 'https://backstage.io/docs/features/software-catalog/descriptor-format#kind-component'; +export const asComponentEntities = (entities: Entity[]): ComponentEntity[] => + entities as ComponentEntity[]; + +export const resourceEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'resource' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createSpecTypeColumn(), + EntityTable.columns.createSpecLifecycleColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; +export const resourceEntityHelpLink: string = + 'https://backstage.io/docs/features/software-catalog/descriptor-format#kind-resource'; +export const asResourceEntities = (entities: Entity[]): ResourceEntity[] => + entities as ResourceEntity[]; + +export const systemEntityColumns: TableColumn[] = [ + EntityTable.columns.createEntityRefColumn({ defaultKind: 'system' }), + EntityTable.columns.createOwnerColumn(), + EntityTable.columns.createMetadataDescriptionColumn(), +]; +export const systemEntityHelpLink: string = + 'https://backstage.io/docs/features/software-catalog/descriptor-format#kind-system'; +export const asSystemEntities = (entities: Entity[]): SystemEntity[] => + entities as SystemEntity[]; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 7ab7b95dd7..f7506b534a 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 { @@ -25,9 +26,13 @@ export { catalogPlugin, catalogPlugin as plugin, EntityAboutCard, + EntityDependsOnComponentsCard, + EntityDependsOnResourcesCard, EntityHasComponentsCard, + EntityHasResourcesCard, EntityHasSubcomponentsCard, EntityHasSystemsCard, EntityLinksCard, EntitySystemDiagramCard, } from './plugin'; +export * from './components/EntityOrphanWarning'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 20c7a865db..3f4de6c67d 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -115,6 +115,37 @@ export const EntityHasSubcomponentsCard = catalogPlugin.provide( }), ); +export const EntityHasResourcesCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/HasResourcesCard').then(m => m.HasResourcesCard), + }, + }), +); + +export const EntityDependsOnComponentsCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/DependsOnComponentsCard').then( + m => m.DependsOnComponentsCard, + ), + }, + }), +); + +export const EntityDependsOnResourcesCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/DependsOnResourcesCard').then( + m => m.DependsOnResourcesCard, + ), + }, + }), +); + export const EntitySystemDiagramCard = catalogPlugin.provide( createComponentExtension({ component: { 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 20c757eda0..e3f40f29c8 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,10 +31,10 @@ "postpack": "backstage-cli postpack" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -47,15 +47,15 @@ "react-lazylog": "^4.5.2", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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..8a21e9b872 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/plugin-cloudbuild +## 0.2.15 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + +## 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 cb2a08fce1..795807f45f 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.15", "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.3", - "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/core": "^0.7.10", + "@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", @@ -44,15 +44,15 @@ "react-lazylog": "^4.5.3", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/cloudbuild/src/components/Cards/Cards.tsx b/plugins/cloudbuild/src/components/Cards/Cards.tsx index 75ae33027e..e7b3d54de1 100644 --- a/plugins/cloudbuild/src/components/Cards/Cards.tsx +++ b/plugins/cloudbuild/src/components/Cards/Cards.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ import React, { useEffect } from 'react'; -import { useWorkflowRuns } from '../useWorkflowRuns'; -import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; +import { WorkflowRunsTable } from '../WorkflowRunsTable'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; diff --git a/plugins/cloudbuild/src/components/Router.tsx b/plugins/cloudbuild/src/components/Router.tsx index 0f71efcf86..bd2299f8e9 100644 --- a/plugins/cloudbuild/src/components/Router.tsx +++ b/plugins/cloudbuild/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from '../plugin'; +import { rootRouteRef, buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { CLOUDBUILD_ANNOTATION } from './useProjectName'; diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index 43cbbefe24..886d772dd5 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -19,26 +19,14 @@ import RetryIcon from '@material-ui/icons/Replay'; import GoogleIcon from '@material-ui/icons/CloudCircle'; import { Link as RouterLink, generatePath } from 'react-router-dom'; import { Table, TableColumn } from '@backstage/core'; -import { useWorkflowRuns } from '../useWorkflowRuns'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; import { useProjectName } from '../useProjectName'; import { Entity } from '@backstage/catalog-model'; -import { Substitutions } from '../../api/types'; -import { buildRouteRef } from '../../plugin'; +import { buildRouteRef } from '../../routes'; import moment from 'moment'; -export type WorkflowRun = { - id: string; - message: string; - url?: string; - googleUrl?: string; - status: string; - substitutions: Substitutions; - createTime: string; - rerun: () => void; -}; - const generatedColumns: TableColumn[] = [ { title: 'Status', diff --git a/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts b/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts index 4e21cc67c2..e190aa55bc 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts +++ b/plugins/cloudbuild/src/components/WorkflowRunsTable/index.ts @@ -14,4 +14,3 @@ * limitations under the License. */ export { WorkflowRunsTable, WorkflowRunsTableView } from './WorkflowRunsTable'; -export type { WorkflowRun } from './WorkflowRunsTable'; diff --git a/plugins/cloudbuild/src/components/useWorkflowRuns.ts b/plugins/cloudbuild/src/components/useWorkflowRuns.ts index b6d6c2c620..ac7400cbf3 100644 --- a/plugins/cloudbuild/src/components/useWorkflowRuns.ts +++ b/plugins/cloudbuild/src/components/useWorkflowRuns.ts @@ -15,10 +15,23 @@ */ import { useState } from 'react'; import { useAsyncRetry } from 'react-use'; -import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable'; import { cloudbuildApiRef } from '../api/CloudbuildApi'; import { useApi, errorApiRef } from '@backstage/core'; -import { ActionsListWorkflowRunsForRepoResponseData } from '../api/types'; +import { + ActionsListWorkflowRunsForRepoResponseData, + Substitutions, +} from '../api/types'; + +export type WorkflowRun = { + id: string; + message: string; + url?: string; + googleUrl?: string; + status: string; + substitutions: Substitutions; + createTime: string; + rerun: () => void; +}; export function useWorkflowRuns({ projectId }: { projectId: string }) { const api = useApi(cloudbuildApiRef); diff --git a/plugins/cloudbuild/src/plugin.ts b/plugins/cloudbuild/src/plugin.ts index f5aaf4696a..f0db5702e6 100644 --- a/plugins/cloudbuild/src/plugin.ts +++ b/plugins/cloudbuild/src/plugin.ts @@ -15,23 +15,13 @@ */ import { createPlugin, - createRouteRef, createApiFactory, googleAuthApiRef, createRoutableExtension, createComponentExtension, } from '@backstage/core'; import { cloudbuildApiRef, CloudbuildClient } from './api'; - -export const rootRouteRef = createRouteRef({ - path: '', - title: 'Google Cloudbuild', -}); - -export const buildRouteRef = createRouteRef({ - path: ':id', - title: 'Cloudbuild Run', -}); +import { rootRouteRef } from './routes'; export const cloudbuildPlugin = createPlugin({ id: 'cloudbuild', diff --git a/plugins/cloudbuild/src/routes.ts b/plugins/cloudbuild/src/routes.ts new file mode 100644 index 0000000000..610d708b44 --- /dev/null +++ b/plugins/cloudbuild/src/routes.ts @@ -0,0 +1,26 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Google Cloudbuild', +}); + +export const buildRouteRef = createRouteRef({ + path: ':id', + title: 'Cloudbuild Run', +}); diff --git a/plugins/code-coverage-backend/CHANGELOG.md b/plugins/code-coverage-backend/CHANGELOG.md index d7a0f136f5..3322186cbe 100644 --- a/plugins/code-coverage-backend/CHANGELOG.md +++ b/plugins/code-coverage-backend/CHANGELOG.md @@ -1,5 +1,30 @@ # @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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/backend-common@0.7.0 + - @backstage/integration@0.5.2 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + ## 0.1.3 ### Patch Changes diff --git a/plugins/code-coverage-backend/package.json b/plugins/code-coverage-backend/package.json index c2bebbf293..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.3", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -19,12 +19,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.3", - "@backstage/catalog-client": "^0.3.10", - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.9", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.2", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "express": "^4.17.1", @@ -36,7 +36,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.9", + "@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 f5c36811cc..b1a2696861 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,12 +20,12 @@ "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.7", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/styles": "^4.11.0", @@ -35,16 +35,16 @@ "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 a017529400..2332d9ef67 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,9 +21,9 @@ }, "dependencies": { "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.7", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@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", @@ -31,15 +31,15 @@ "zen-observable": "^0.8.15", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 c86a71f8c9..d60d8c709c 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,46 @@ # @backstage/plugin-cost-insights +## 0.10.0 + +### Minor Changes + +- 7cbfcae48: support jsx in alert titles + +### Patch Changes + +- 5914a76d5: Added example client +- Updated dependencies [65e6c4541] +- Updated dependencies [5da6a561d] + - @backstage/core@0.7.10 + +## 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 + +- 6f1b82b14: make change ratio optional + +### Patch Changes + +- Updated dependencies [f65adcde7] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [d8b81fd28] + - @backstage/core@0.7.8 + - @backstage/theme@0.2.7 + - @backstage/config@0.1.5 + ## 0.8.5 ### Patch Changes diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index 3fee89e77a..2311937890 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -25,9 +25,7 @@ yarn add @backstage/plugin-cost-insights 1. Configure `app-config.yaml`. See [Configuration](#configuration). -2. Create a CostInsights client. Clients must implement the CostInsightsApi interface. See the [API file](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts) for required methods and documentation. - -**Note:** We've briefly explored using the AWS Cost Explorer API to implement a CostInsights client. Learn more about our findings [here](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/contrib/aws-cost-explorer-api.md). +2. Create a CostInsights client. Clients must implement the [CostInsightsApi](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts) interface. Create your own or [use a template](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/example/templates/CostInsightsClient.ts) to get started. ```ts // path/to/CostInsightsClient.ts @@ -36,7 +34,9 @@ import { CostInsightsApi } from '@backstage/plugin-cost-insights'; export class CostInsightsClient implements CostInsightsApi { ... } ``` -3. Import the client and the CostInsights plugin API to your Backstage instance. +**Note:** We've briefly explored using the AWS Cost Explorer API to implement a Cost Insights client. Learn more about our findings [here](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/contrib/aws-cost-explorer-api.md). + +3. Import the client and the Cost Insights plugin API to your Backstage instance. ```ts // packages/app/src/api.ts diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 39e3fe1791..b8cb87e3fe 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.8.5", + "version": "0.10.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,9 +30,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/config": "^0.1.5", + "@backstage/core": "^0.7.10", + "@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", @@ -49,18 +49,18 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "recharts": "^1.8.5", "regression": "^2.0.1", "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx index 70ae44bcdc..b686076e17 100644 --- a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx @@ -43,4 +43,22 @@ describe('', () => { expect(rendered.getByText(alert.title)).toBeInTheDocument(); expect(rendered.getByText(alert.subtitle)).toBeInTheDocument(); }); + + it('renders custom title elements', async () => { + const rendered = await renderInTestApp( + + Foo, + subtitle: Bar, + }} + avatar={
1
} + /> +
, + ); + + expect(rendered.getByText('Foo')).toBeInTheDocument(); + expect(rendered.getByText('Bar')).toBeInTheDocument(); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index 093ca1ff0c..f9fe985e43 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -41,14 +41,33 @@ describe('', () => { onAccept={jest.fn()} />, ); - expect(getByText(mockAlert.title)).toBeInTheDocument(); - expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); + expect(getByText(mockAlert.title as string)).toBeInTheDocument(); + expect(getByText(mockAlert.subtitle as string)).toBeInTheDocument(); expect(getByText('View Instructions')).toBeInTheDocument(); expect(queryByText('Snooze')).not.toBeInTheDocument(); expect(queryByText('Accept')).not.toBeInTheDocument(); expect(queryByText('Dismiss')).not.toBeInTheDocument(); }); + it('renders custom title elements', () => { + const { getByText } = renderInContext( + Foo, + subtitle: Bar, + }} + number={1} + onSnooze={jest.fn()} + onDismiss={jest.fn()} + onAccept={jest.fn()} + />, + ); + + expect(getByText('Foo')).toBeInTheDocument(); + expect(getByText('Bar')).toBeInTheDocument(); + }); + it('Hides instructions button if url is not provided', () => { const alert: Alert = { ...mockAlert, diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx index ab39e0364e..754bc6db5c 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx @@ -50,8 +50,8 @@ describe('', () => { , ); [mockSnoozed, mockAccepted, mockDismissed].forEach(a => { - expect(getByText(a.title)).toBeInTheDocument(); - expect(getByText(a.subtitle)).toBeInTheDocument(); + expect(getByText(a.title as string)).toBeInTheDocument(); + expect(getByText(a.subtitle as string)).toBeInTheDocument(); expect(getByRole('img', { name: a.status })).toBeInTheDocument(); }); }); diff --git a/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx index 4878462f62..8b386f338c 100644 --- a/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx @@ -17,6 +17,7 @@ import React from 'react'; import pluralize from 'pluralize'; import { KubernetesMigrationAlertCard } from '../components'; +import { Lifecycle } from '@backstage/core'; import { CostInsightsApi } from '../../api'; import { Alert, @@ -87,11 +88,13 @@ export class KubernetesMigrationAlert implements KubernetesMigrationApi { } get title() { - return `Consider migrating ${pluralize( - 'service', - this.data.services.length, - true, - )} to Kubernetes.`; + return ( + + Consider migrating{' '} + {pluralize('service', this.data.services.length, true)} to Kubernetes{' '} + + + ); } get element() { diff --git a/plugins/cost-insights/src/example/templates/CostInsightsClient.ts b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts new file mode 100644 index 0000000000..f7e4686961 --- /dev/null +++ b/plugins/cost-insights/src/example/templates/CostInsightsClient.ts @@ -0,0 +1,101 @@ +/* + * 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. + */ + +/* + This is a copy-pastable client template to get up and running quickly. + API Reference: + https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/api/CostInsightsApi.ts +*/ + +// IMPORTANT: Remove the lines below to enable type checking and linting +// @ts-nocheck +/* eslint-disable import/no-extraneous-dependencies */ + +import { + CostInsightsApi, + ProductInsightsOptions, + Alert, + Cost, + Entity, + Group, + MetricData, + Project, +} from '@backstage/plugin-cost-insights'; + +export class CostInsightsClient implements CostInsightsApi { + + async getLastCompleteBillingDate(): Promise { + return '2021-01-01'; // YYYY-MM-DD + } + + async getUserGroups(userId: string): Promise { + return []; + } + + async getGroupProjects(group: string): Promise { + return []; + } + + async getAlerts(group: string): Promise { + return []; + } + + async getDailyMetricData(metric: string, intervals: string): Promise { + return { + id: 'remove-me', + format: 'number', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getGroupDailyCost(group: string, intervals: string): Promise { + return { + id: 'remove-me', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getProjectDailyCost(project: string, intervals: string): Promise { + return { + id: 'remove-me', + aggregation: [], + change: { + ratio: 0, + amount: 0 + } + } + } + + async getProductInsights(options: ProductInsightsOptions): Promise { + return { + id: 'remove-me', + aggregation: [0, 0], + change: { + ratio: 0, + amount: 0 + }, + entities: {} + } + } +} diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 84c37a7c15..a8b041a67e 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -34,8 +34,8 @@ import { Maybe } from './Maybe'; */ export type Alert = { - title: string; - subtitle: string; + title: string | JSX.Element; + subtitle: string | JSX.Element; element?: JSX.Element; status?: AlertStatus; url?: string; diff --git a/plugins/cost-insights/src/utils/history.test.ts b/plugins/cost-insights/src/utils/history.test.ts index e4310e13c6..dff6015aca 100644 --- a/plugins/cost-insights/src/utils/history.test.ts +++ b/plugins/cost-insights/src/utils/history.test.ts @@ -61,7 +61,9 @@ describe.each` params | expected ${''} | ${{}} ${'?foo=bar'} | ${{}} + ${'?group'} | ${{ group: null }} ${'?project'} | ${{ project: null }} + ${'?project&group'} | ${{ group: null, project: null }} ${'?group=some-group'} | ${{ group: 'some-group' }} ${'?group=some-group&project'} | ${{ group: 'some-group', project: null }} ${'?group=some-group&project=some-project'} | ${{ group: 'some-group', project: 'some-project' }} @@ -72,9 +74,3 @@ describe.each` expect(pageFilters).toMatchObject(expected); }); }); - -describe('invalidate', () => { - it("should throw an error if param values don't match schema", async () => { - await expect(validate('?group')).rejects.toThrowError(); - }); -}); diff --git a/plugins/cost-insights/src/utils/history.ts b/plugins/cost-insights/src/utils/history.ts index 7d45cfdad2..9e13db3c7a 100644 --- a/plugins/cost-insights/src/utils/history.ts +++ b/plugins/cost-insights/src/utils/history.ts @@ -23,7 +23,7 @@ import { ConfigContextProps } from '../hooks/useConfig'; const schema = yup .object() .shape({ - group: yup.string(), + group: yup.string().nullable(), project: yup.string().nullable(), }) .required(); 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 be61bc1fd9..63fe3448a6 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,11 +30,11 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/plugin-explore-react": "^0.0.4", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@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", "@material-ui/lab": "4.0.0-alpha.45", @@ -42,15 +42,15 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/src/components/ExplorePage/ExploreTabs.tsx b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx index 0cf28f9f1c..0415dd97ae 100644 --- a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx +++ b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx @@ -16,6 +16,7 @@ import { TabbedLayout } from '@backstage/core'; import React from 'react'; import { DomainExplorerContent } from '../DomainExplorerContent'; +import { GroupsExplorerContent } from '../GroupsExplorerContent'; import { ToolExplorerContent } from '../ToolExplorerContent'; export const ExploreTabs = () => ( @@ -23,6 +24,9 @@ export const ExploreTabs = () => ( + + + diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.test.tsx new file mode 100644 index 0000000000..022387faca --- /dev/null +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.test.tsx @@ -0,0 +1,69 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + catalogApiRef, + CatalogApi, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { GroupsDiagram } from './GroupsDiagram'; + +describe('', () => { + beforeAll(() => { + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + it('shows groups', async () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'group-a', + namespace: 'my-namespace', + }, + spec: { + type: 'organization', + }, + }, + ] as Entity[], + }), + }; + + const { getByText } = await renderInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect(getByText('my-namespace/group-a')).toBeInTheDocument(); + }); +}); diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx new file mode 100644 index 0000000000..99157b878f --- /dev/null +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsDiagram.tsx @@ -0,0 +1,190 @@ +/* + * 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 { + RELATION_CHILD_OF, + stringifyEntityRef, + parseEntityRef, +} from '@backstage/catalog-model'; +import { + catalogApiRef, + entityRouteRef, + getEntityRelations, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-react'; +import { + DependencyGraph, + DependencyGraphTypes, + Progress, + useApi, + ResponseErrorPanel, + Link, + useRouteRef, + configApiRef, +} from '@backstage/core'; +import { makeStyles, Typography } from '@material-ui/core'; +import ZoomOutMap from '@material-ui/icons/ZoomOutMap'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { BackstageTheme } from '@backstage/theme'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + organizationNode: { + fill: 'coral', + stroke: theme.palette.border, + }, + groupNode: { + fill: 'yellowgreen', + stroke: theme.palette.border, + }, +})); + +function RenderNode(props: DependencyGraphTypes.RenderNodeProps) { + const classes = useStyles(); + const catalogEntityRoute = useRouteRef(entityRouteRef); + + if (props.node.id === 'root') { + return ( + + + + {props.node.name} + + + ); + } + + const ref = parseEntityRef(props.node.id); + + return ( + + + + + {props.node.name} + + + + ); +} + +/** + * Dynamically generates a diagram of groups registered in the catalog. + */ +export function GroupsDiagram() { + const nodes = new Array<{ id: string; kind: string; name: string }>(); + const edges = new Array<{ from: string; to: string; label: string }>(); + + const configApi = useApi(configApiRef); + const catalogApi = useApi(catalogApiRef); + const organizationName = + configApi.getOptionalString('organization.name') ?? 'Backstage'; + const { loading, error, value: catalogResponse } = useAsync(() => { + return catalogApi.getEntities({ + filter: { + kind: ['Group'], + }, + }); + }, [catalogApi]); + + if (loading) { + return ; + } else if (error) { + return ; + } + + // the root of this diagram is the organization + nodes.push({ + id: 'root', + kind: 'Organization', + name: organizationName, + }); + + for (const catalogItem of catalogResponse?.items || []) { + const currentItemId = stringifyEntityRef(catalogItem); + + nodes.push({ + id: stringifyEntityRef(catalogItem), + kind: catalogItem.kind, + name: formatEntityRefTitle(catalogItem, { defaultKind: 'Group' }), + }); + + // Edge to parent + const catalogItemRelations_childOf = getEntityRelations( + catalogItem, + RELATION_CHILD_OF, + ); + + // if no parent is found, link the node to the root + if (catalogItemRelations_childOf.length === 0) { + edges.push({ + from: currentItemId, + to: 'root', + label: '', + }); + } + + catalogItemRelations_childOf.forEach(relation => { + edges.push({ + from: currentItemId, + to: stringifyEntityRef(relation), + label: '', + }); + }); + } + + return ( + <> + + + Use pinch & zoom + to move around the diagram. + + + ); +} diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx new file mode 100644 index 0000000000..38c4678d5a --- /dev/null +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -0,0 +1,103 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { GroupsExplorerContent } from '../GroupsExplorerContent'; + +describe('', () => { + const catalogApi: jest.Mocked = { + addLocation: jest.fn(_a => new Promise(() => {})), + getEntities: jest.fn(), + getOriginLocationByEntity: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + beforeEach(() => { + jest.resetAllMocks(); + + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + it('renders a groups diagram', async () => { + const entities: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'group-a', + namespace: 'my-namespace', + }, + spec: { + type: 'organization', + }, + }, + ]; + catalogApi.getEntities.mockResolvedValue({ items: entities }); + + const { getByText } = await renderInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + await waitFor(() => { + expect(getByText('my-namespace/group-a')).toBeInTheDocument(); + }); + }); + + it('renders a friendly error if it cannot collect domains', async () => { + const catalogError = new Error('Network timeout'); + catalogApi.getEntities.mockRejectedValueOnce(catalogError); + + const { getByText } = await renderInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + await waitFor(() => + expect(getByText(/Warning: Network timeout/)).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx new file mode 100644 index 0000000000..c4d46206e4 --- /dev/null +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx @@ -0,0 +1,30 @@ +/* + * 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 { Content, ContentHeader, SupportButton } from '@backstage/core'; +import React from 'react'; +import { GroupsDiagram } from './GroupsDiagram'; + +export const GroupsExplorerContent = () => { + return ( + + + Explore your groups. + + + + + ); +}; diff --git a/plugins/explore/src/components/GroupsExplorerContent/index.ts b/plugins/explore/src/components/GroupsExplorerContent/index.ts new file mode 100644 index 0000000000..4d88f40dde --- /dev/null +++ b/plugins/explore/src/components/GroupsExplorerContent/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { GroupsExplorerContent } from './GroupsExplorerContent'; 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 ba7e1a381e..159739cc58 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,11 +31,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.7", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -44,15 +44,15 @@ "p-limit": "^3.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 0930c791e4..919b5bba15 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,23 +30,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "^6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/CHANGELOG.md b/plugins/git-release-manager/CHANGELOG.md new file mode 100644 index 0000000000..65018d7925 --- /dev/null +++ b/plugins/git-release-manager/CHANGELOG.md @@ -0,0 +1,11 @@ +# @backstage/plugin-git-release-manager + +## 0.1.2 + +### Patch Changes + +- f915a342d: [ImgBot] Optimize images +- Updated dependencies [65e6c4541] +- Updated dependencies [5da6a561d] + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 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..994593a808 --- /dev/null +++ b/plugins/git-release-manager/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-git-release-manager", + "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.10", + "@backstage/integration": "^0.5.3", + "@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.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", + "@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..04fe5bbe34 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..ba3ee536f6 --- /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:{' '} + + + + + Lengthiest 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 6896a7f877..b1cb9d3ad2 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,51 @@ # @backstage/plugin-github-actions +## 0.4.7 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + +## 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 + +- 38ca05168: The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. +- Updated dependencies [38ca05168] +- Updated dependencies [f65adcde7] +- Updated dependencies [81c54d1f2] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [d8b81fd28] + - @backstage/integration@0.5.2 + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-react@0.1.5 + - @backstage/theme@0.2.7 + - @backstage/catalog-model@0.7.8 + ## 0.4.4 ### Patch Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 89b569bd95..f23010b862 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.4", + "version": "0.4.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,11 +32,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/core": "^0.7.7", - "@backstage/integration": "^0.5.1", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", + "@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", @@ -47,15 +47,15 @@ "react-lazylog": "^4.5.3", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 4ce667eb93..c2eaaf885e 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -34,8 +34,8 @@ import { import ExternalLinkIcon from '@material-ui/icons/Launch'; import React, { useEffect } from 'react'; import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; -import { useWorkflowRuns } from '../useWorkflowRuns'; -import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; +import { WorkflowRunsTable } from '../WorkflowRunsTable'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; const useStyles = makeStyles({ diff --git a/plugins/github-actions/src/components/Router.tsx b/plugins/github-actions/src/components/Router.tsx index 834fcae43c..52cd66375f 100644 --- a/plugins/github-actions/src/components/Router.tsx +++ b/plugins/github-actions/src/components/Router.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Routes, Route } from 'react-router'; -import { rootRouteRef, buildRouteRef } from '../plugin'; +import { rootRouteRef, buildRouteRef } from '../routes'; import { WorkflowRunDetails } from './WorkflowRunDetails'; import { WorkflowRunsTable } from './WorkflowRunsTable'; import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName'; diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts index 3e2a5c4382..124b8e8a87 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts +++ b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts @@ -16,7 +16,7 @@ import { useApi, useRouteRefParams } from '@backstage/core'; import { useAsync } from 'react-use'; import { githubActionsApiRef } from '../../api'; -import { buildRouteRef } from '../../plugin'; +import { buildRouteRef } from '../../routes'; export const useWorkflowRunsDetails = ({ hostname, diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx index f04ec90323..bec2503562 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx +++ b/plugins/github-actions/src/components/WorkflowRunsTable/WorkflowRunsTable.tsx @@ -32,32 +32,14 @@ import { configApiRef, useApi, } from '@backstage/core'; -import { useWorkflowRuns } from '../useWorkflowRuns'; +import { useWorkflowRuns, WorkflowRun } from '../useWorkflowRuns'; import { WorkflowRunStatus } from '../WorkflowRunStatus'; import SyncIcon from '@material-ui/icons/Sync'; -import { buildRouteRef } from '../../plugin'; +import { buildRouteRef } from '../../routes'; import { useProjectName } from '../useProjectName'; import { Entity } from '@backstage/catalog-model'; import { readGitHubIntegrationConfigs } from '@backstage/integration'; -export type WorkflowRun = { - workflowName: string; - id: string; - message: string; - url?: string; - githubUrl?: string; - source: { - branchName: string; - commit: { - hash: string; - url?: string; - }; - }; - status: string; - conclusion: string; - onReRunClick: () => void; -}; - const generatedColumns: TableColumn[] = [ { title: 'ID', diff --git a/plugins/github-actions/src/components/WorkflowRunsTable/index.ts b/plugins/github-actions/src/components/WorkflowRunsTable/index.ts index 4e21cc67c2..e190aa55bc 100644 --- a/plugins/github-actions/src/components/WorkflowRunsTable/index.ts +++ b/plugins/github-actions/src/components/WorkflowRunsTable/index.ts @@ -14,4 +14,3 @@ * limitations under the License. */ export { WorkflowRunsTable, WorkflowRunsTableView } from './WorkflowRunsTable'; -export type { WorkflowRun } from './WorkflowRunsTable'; diff --git a/plugins/github-actions/src/components/useWorkflowRuns.ts b/plugins/github-actions/src/components/useWorkflowRuns.ts index 0690632a7b..f62b03f506 100644 --- a/plugins/github-actions/src/components/useWorkflowRuns.ts +++ b/plugins/github-actions/src/components/useWorkflowRuns.ts @@ -15,10 +15,27 @@ */ import { useState } from 'react'; import { useAsyncRetry } from 'react-use'; -import { WorkflowRun } from './WorkflowRunsTable/WorkflowRunsTable'; import { githubActionsApiRef } from '../api/GithubActionsApi'; import { useApi, errorApiRef } from '@backstage/core'; +export type WorkflowRun = { + workflowName: string; + id: string; + message: string; + url?: string; + githubUrl?: string; + source: { + branchName: string; + commit: { + hash: string; + url?: string; + }; + }; + status: string; + conclusion: string; + onReRunClick: () => void; +}; + export function useWorkflowRuns({ hostname, owner, diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index f66e96a981..a34428e18c 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -17,25 +17,13 @@ import { configApiRef, createPlugin, - createRouteRef, createApiFactory, githubAuthApiRef, createRoutableExtension, createComponentExtension, } from '@backstage/core'; import { githubActionsApiRef, GithubActionsClient } from './api'; - -// TODO(freben): This is just a demo route for now -export const rootRouteRef = createRouteRef({ - path: '', - title: 'GitHub Actions', -}); - -export const buildRouteRef = createRouteRef({ - path: ':id', - params: ['id'], - title: 'GitHub Actions Workflow Run', -}); +import { rootRouteRef } from './routes'; export const githubActionsPlugin = createPlugin({ id: 'github-actions', diff --git a/plugins/github-actions/src/routes.ts b/plugins/github-actions/src/routes.ts new file mode 100644 index 0000000000..e576379d6c --- /dev/null +++ b/plugins/github-actions/src/routes.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouteRef } from '@backstage/core'; + +// TODO(freben): This is just a demo route for now +export const rootRouteRef = createRouteRef({ + path: '', + title: 'GitHub Actions', +}); + +export const buildRouteRef = createRouteRef({ + path: ':id', + params: ['id'], + title: 'GitHub Actions Workflow Run', +}); diff --git a/plugins/github-deployments/CHANGELOG.md b/plugins/github-deployments/CHANGELOG.md index 23c8943349..ab766a789c 100644 --- a/plugins/github-deployments/CHANGELOG.md +++ b/plugins/github-deployments/CHANGELOG.md @@ -1,5 +1,36 @@ # @backstage/plugin-github-deployments +## 0.1.6 + +### Patch Changes + +- fa69982fb: Support GHE by properly parsing enterprise instance hosts +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + +## 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 049e02de4c..c245a4e3ba 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.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,10 +20,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", + "@backstage/errors": "^0.1.1", + "@backstage/integration": "^0.5.3", + "@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", "@material-ui/lab": "4.0.0-alpha.45", @@ -31,15 +34,15 @@ "luxon": "^1.26.0", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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..d7e7e2fca6 100644 --- a/plugins/github-deployments/src/api/index.ts +++ b/plugins/github-deployments/src/api/index.ts @@ -13,9 +13,46 @@ * 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' && location.type !== 'url') { + return 'https://api.github.com'; + } + + const config = scmIntegrationsApi.github.byUrl(location.target); + + if (!config) { + throw new InputError( + `No matching GitHub integration configuration for host ${ + new URL(location.target).hostname + }, please check your integrations config`, + ); + } + + if (!config.config.apiBaseUrl) { + throw new InputError( + `No apiBaseUrl available for host ${ + new URL(location.target).hostname + }, please check your integrations config`, + ); + } + + return config?.config.apiBaseUrl; +}; + export type GithubDeployment = { environment: string; state: string; @@ -30,12 +67,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 +85,7 @@ export const githubDeploymentsApiRef = createApiRef({ export type Options = { githubAuthApi: OAuthApi; + scmIntegrationsApi: ScmIntegrationRegistry; }; const deploymentsQuery = ` @@ -79,19 +120,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 +140,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..68f5b5df43 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,147 @@ 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:https://my-github-1.com/org/repo/tree/master/', + }; + }); + + 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:https://my-github-2.com/org/repo/blob/master/catalog-info.yaml', + }; + }); + + 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:https://my-github-3.com/org/repo/blob/master/catalog-info.yaml', + }; + }); + + it('shows no apiBaseUrl error', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect( + await rendered.findByText( + 'Warning: No apiBaseUrl available for host 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:https://my-github-unknown.com/org/repo/blob/master/catalog-info.yaml', + }; + }); + + it('shows no matching host error', async () => { + const rendered = await renderInTestApp( + + + , + ); + + expect( + await rendered.findByText( + 'Warning: No matching GitHub integration configuration for host my-github-unknown.com, 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:https://my-github-1.com/org/repo/tree/master/', + 'backstage.io/managed-by-location': + 'url:https://my-github-1.com/org/repo/blob/master/catalog-info.yaml', + }; + }); + + 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 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 d1897b018c..f07caf0fbe 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,23 +31,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 9ce1603b57..36a850b901 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,8 +31,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@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", @@ -40,15 +40,15 @@ "graphql": "15.5.0", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 dfcddfbcf9..2604a15230 100644 --- a/plugins/graphql/CHANGELOG.md +++ b/plugins/graphql/CHANGELOG.md @@ -1,5 +1,26 @@ # @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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/config@0.1.5 + - @backstage/plugin-catalog-graphql@0.2.8 + ## 0.1.6 ### Patch Changes diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index b5046e13fc..619df1d33b 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.6", + "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.6.0", - "@backstage/config": "^0.1.4", - "@backstage/plugin-catalog-graphql": "^0.2.7", + "@backstage/backend-common": "^0.8.0", + "@backstage/config": "^0.1.5", + "@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.5", + "@backstage/cli": "^0.6.11", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/ilert/.eslintrc.js b/plugins/ilert/.eslintrc.js new file mode 100644 index 0000000000..4d51616e98 --- /dev/null +++ b/plugins/ilert/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + quotes: ['error', 'single'], + }, +}; diff --git a/plugins/ilert/CHANGELOG.md b/plugins/ilert/CHANGELOG.md new file mode 100644 index 0000000000..678ede204a --- /dev/null +++ b/plugins/ilert/CHANGELOG.md @@ -0,0 +1,13 @@ +# @backstage/plugin-ilert + +## 0.1.2 + +### Patch Changes + +- f915a342d: [ImgBot] Optimize images +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 diff --git a/plugins/ilert/README.md b/plugins/ilert/README.md new file mode 100644 index 0000000000..da019d6bb6 --- /dev/null +++ b/plugins/ilert/README.md @@ -0,0 +1,128 @@ +# @backstage/plugin-ilert + +## Introduction + +[iLert](https://www.ilert.com) is a platform for alerting, on-call management and uptime monitoring. It helps teams to reduce response times to critical incidents by extending monitoring tools with reliable alerting, automatic escalations, on-call schedules and other features to support the incident response process, such as informing stakeholders or creating tickets in external incident management tools. + +## Overview + +This plugin gives an overview about ongoing iLert incidents, on-call and uptime monitor status. +See who is on-call, which incidents are active and trigger incidents directly from backstage for the configured alert sources. + +In detail this plugin provides: + +- Information details about the person on-call (all escalation levels of the current time) +- A way to override the current on-call person +- A list of active incidents +- A way to trigger a new incident +- A way to reassign/acknowledge/resolve an incident +- A way to trigger an incident action +- A way to trigger an immediate maintenance +- A way to disable/enable an alert source +- A list of uptime monitors + +## Setup instructions + +Install the plugin: + +```bash +# From the Backstage repository root +cd packages/app +yarn add @backstage/plugin-ilert +``` + +Add it to the `EntityPage.tsx`: + +```ts +import { + isPluginApplicableToEntity as isILertAvailable, + EntityILertCard, +} from '@backstage/plugin-ilert'; + +// ... + + + + + + +; +// ... +``` + +> To force an iLert card for each entity just add the `` component. An instruction card will appear if no integration key is set. + +## Add iLert explorer to the app sidebar + +Modify your app routes in [`App.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/App.tsx) to include the Router component exported by the plugin - for example: + +```tsx +import { ILertPage } from '@backstage/plugin-ilert'; + + // ... + } /> + // ... +; +``` + +Modify your sidebar in [`Root.tsx`](https://github.com/backstage/backstage/blob/master/packages/app/src/components/Root/Root.tsx) to include the icon component exported by the plugin - for example: + +```tsx +import { ILertIcon } from '@backstage/plugin-ilert'; + + // ... + + // ... +; +``` + +## Client configuration + +If you want to override the default URL for api calls and detail pages, you can add it to `app-config.yaml`. + +In `app-config.yaml`: + +```yaml +ilert: + baseUrl: https://my-org.ilert.com/ +``` + +## Providing the Authorization Header + +In order to make the API calls, you need to provide a new proxy config which will redirect to the [iLert API](https://api.ilert.com/api-docs/) endpoint. It needs an [Authorization Header](https://api.ilert.com/api-docs/#section/Authentication). + +Add the proxy configuration in `app-config.yaml` + +```yaml +proxy: + ... + '/ilert': + target: https://api.ilert.com + allowedMethods: ['GET', 'POST', 'PUT'] + allowedHeaders: ['Authorization'] + headers: + Authorization: ${ILERT_AUTH_HEADER} +``` + +Then start the backend, passing the authorization header (bearer token or basic auth) as environment variable: + +```bash +$ ILERT_AUTH_HEADER='' yarn start +``` + +## Integration Key + +The information displayed for each entity is based on the alert source integration key. + +### Adding the integration key to the entity annotation + +If you want to use this plugin for an entity, you need to label it with the below annotation: + +```yml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example + annotations: + ilert.com/integration-key: [INTEGRATION_KEY] +``` diff --git a/plugins/ilert/config.d.ts b/plugins/ilert/config.d.ts new file mode 100644 index 0000000000..6bf3f569ab --- /dev/null +++ b/plugins/ilert/config.d.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. + */ +export interface Config { + ilert?: { + /** + * Domain used by users to access iLert web UI. + * Example: https://my-app.ilert.com/ + * @visibility frontend + */ + baseUrl: string; + + /** + * Path to use for requests via the proxy, defaults to /ilert/api + * @visibility frontend + */ + proxyPath?: string; + }; +} diff --git a/plugins/ilert/dev/index.tsx b/plugins/ilert/dev/index.tsx new file mode 100644 index 0000000000..737666e650 --- /dev/null +++ b/plugins/ilert/dev/index.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. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { ilertPlugin } from '../src/plugin'; +import { ILertPage } from '../src'; + +createDevApp() + .registerPlugin(ilertPlugin) + .addPage({ + element: , + title: 'Root Page', + }) + .render(); diff --git a/plugins/ilert/package.json b/plugins/ilert/package.json new file mode 100644 index 0000000000..b3bc3c6f87 --- /dev/null +++ b/plugins/ilert/package.json @@ -0,0 +1,56 @@ +{ + "name": "@backstage/plugin-ilert", + "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/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", + "@backstage/errors": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.4", + "@backstage/theme": "^0.2.7", + "@date-io/luxon": "1.x", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", + "@material-ui/pickers": "^3.3.10", + "humanize-duration": "^3.26.0", + "luxon": "^1.26.0", + "react": "^16.13.1", + "react-dom": "^16.13.1", + "react-use": "^17.2.4" + }, + "devDependencies": { + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", + "@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", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/ilert/src/api/client.ts b/plugins/ilert/src/api/client.ts new file mode 100644 index 0000000000..ed1e671bc0 --- /dev/null +++ b/plugins/ilert/src/api/client.ts @@ -0,0 +1,535 @@ +/* + * 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, createApiRef, DiscoveryApi } from '@backstage/core'; +import { AuthenticationError, ResponseError } from '@backstage/errors'; +import { + AlertSource, + EscalationPolicy, + Incident, + IncidentAction, + IncidentResponder, + OnCall, + Schedule, + UptimeMonitor, + User, +} from '../types'; +import { + ILertApi, + GetIncidentsOpts, + GetIncidentsCountOpts, + Options, + EventRequest, +} from './types'; +import { DateTime as dt } from 'luxon'; + +export const ilertApiRef = createApiRef({ + id: 'plugin.ilert.service', + description: 'Used to make requests towards iLert API', +}); + +const DEFAULT_PROXY_PATH = '/ilert'; +const JSON_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +export class ILertClient implements ILertApi { + private readonly discoveryApi: DiscoveryApi; + private readonly proxyPath: string; + private readonly baseUrl: string; + + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi) { + const baseUrl: string = + configApi.getOptionalString('ilert.baseUrl') ?? 'https://app.ilert.com'; + + return new ILertClient({ + discoveryApi: discoveryApi, + baseUrl, + proxyPath: + configApi.getOptionalString('ilert.proxyPath') ?? DEFAULT_PROXY_PATH, + }); + } + + constructor(opts: Options) { + this.discoveryApi = opts.discoveryApi; + this.baseUrl = opts.baseUrl; + this.proxyPath = opts.proxyPath; + } + + private async fetch(input: string, init?: RequestInit): Promise { + const apiUrl = await this.apiUrl(); + + const response = await fetch(`${apiUrl}${input}`, init); + if (response.status === 401) { + throw new AuthenticationError( + 'This request requires HTTP authentication.', + ); + } + if (!response.ok || response.status >= 400) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } + + async fetchIncidents(opts?: GetIncidentsOpts): Promise { + const init = { + headers: JSON_HEADERS, + }; + const query = new URLSearchParams(); + if (opts?.maxResults !== undefined) { + query.append('max-results', String(opts.maxResults)); + } + if (opts?.startIndex !== undefined) { + query.append('start-index', String(opts.startIndex)); + } + if (opts?.alertSources !== undefined && Array.isArray(opts.alertSources)) { + opts.alertSources.forEach((a: number) => { + if (a) { + query.append('alert-source', String(a)); + } + }); + } + + if (opts?.states !== undefined && Array.isArray(opts.states)) { + opts.states.forEach(state => { + query.append('state', state); + }); + } + const response = await this.fetch( + `/api/v1/incidents?${query.toString()}`, + init, + ); + + return response; + } + + async fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise { + const init = { + headers: JSON_HEADERS, + }; + const query = new URLSearchParams(); + if (opts && opts.states && Array.isArray(opts.states)) { + opts.states.forEach(state => { + query.append('state', state); + }); + } + const response = await this.fetch( + `/api/v1/incidents/count?${query.toString()}`, + init, + ); + + return response && response.count ? response.count : 0; + } + + async fetchIncident(id: number): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(id)}`, + init, + ); + + return response; + } + + async fetchIncidentResponders( + incident: Incident, + ): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/responders`, + init, + ); + + return response; + } + + async fetchIncidentActions(incident: Incident): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/actions`, + init, + ); + + return response; + } + + async acceptIncident( + incident: Incident, + userName: string, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: incident.alertSource?.integrationKey || '', + incidentKey: incident.incidentKey, + summary: `from ${userName} via Backstage plugin`, + eventType: 'ACCEPT', + }), + }; + + await this.fetch('/api/v1/events', init); + return this.fetchIncident(incident.id); + } + + async resolveIncident( + incident: Incident, + userName: string, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: incident.alertSource?.integrationKey || '', + incidentKey: incident.incidentKey, + summary: `from ${userName} via Backstage plugin`, + eventType: 'RESOLVE', + }), + }; + + await this.fetch('/api/v1/events', init); + return this.fetchIncident(incident.id); + } + + async assignIncident( + incident: Incident, + responder: IncidentResponder, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + }; + + const query = new URLSearchParams(); + switch (responder.group) { + case 'ESCALATION_POLICY': + query.append('policy-id', String(responder.id)); + break; + case 'ON_CALL_SCHEDULE': + query.append('schedule-id', String(responder.id)); + break; + default: + query.append('user-id', String(responder.id)); + break; + } + + const response = await this.fetch( + `/api/v1/incidents/${encodeURIComponent( + incident.id, + )}/assign?${query.toString()}`, + init, + ); + + return response; + } + + async triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + webhookId: action.webhookId, + extensionId: action.extensionId, + type: action.type, + name: action.name, + }), + }; + + await this.fetch( + `/api/v1/incidents/${encodeURIComponent(incident.id)}/actions`, + init, + ); + } + + async createIncident(eventRequest: EventRequest): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + apiKey: eventRequest.integrationKey, + summary: eventRequest.summary, + details: eventRequest.details, + eventType: 'ALERT', + links: [ + { + href: eventRequest.source, + text: 'Backstage Url', + }, + ], + customDetails: { + userName: eventRequest.userName, + }, + }), + }; + + const response = await this.fetch('/api/v1/events', init); + return response; + } + + async fetchUptimeMonitors(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/uptime-monitors', init); + + return response; + } + + async fetchUptimeMonitor(id: number): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response: UptimeMonitor = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(id)}`, + init, + ); + + return response; + } + + async pauseUptimeMonitor( + uptimeMonitor: UptimeMonitor, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...uptimeMonitor, paused: true }), + }; + + const response = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(uptimeMonitor.id)}`, + init, + ); + + return response; + } + + async resumeUptimeMonitor( + uptimeMonitor: UptimeMonitor, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...uptimeMonitor, paused: false }), + }; + + const response = await this.fetch( + `/api/v1/uptime-monitors/${encodeURIComponent(uptimeMonitor.id)}`, + init, + ); + + return response; + } + + async fetchAlertSources(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/alert-sources', init); + + return response; + } + + async fetchAlertSource( + idOrIntegrationKey: number | string, + ): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(idOrIntegrationKey)}`, + init, + ); + + return response; + } + + async fetchAlertSourceOnCalls(alertSource: AlertSource): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch( + `/api/v1/on-calls?policies=${encodeURIComponent( + alertSource.escalationPolicy.id, + )}&expand=user&expand=escalationPolicy&timezone=${dt.local().zoneName}`, + init, + ); + + return response; + } + + async enableAlertSource(alertSource: AlertSource): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...alertSource, active: true }), + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(alertSource.id)}`, + init, + ); + + return response; + } + + async disableAlertSource(alertSource: AlertSource): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ ...alertSource, active: false }), + }; + + const response = await this.fetch( + `/api/v1/alert-sources/${encodeURIComponent(alertSource.id)}`, + init, + ); + + return response; + } + + async addImmediateMaintenance( + alertSourceId: number, + minutes: number, + ): Promise { + const init = { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ + start: dt.utc().toISO(), + end: dt.utc().plus({ minutes }).toISO(), + description: `Immediate maintenance window for ${minutes} minutes. Backstage — iLert plugin.`, + createdBy: 'Backstage', + timezone: dt.local().zoneName, + alertSources: [{ id: alertSourceId }], + }), + }; + + const response = await this.fetch('/api/v1/maintenance-windows', init); + + return response; + } + + async fetchOnCallSchedules(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/schedules', init); + + return response; + } + + async fetchUsers(): Promise { + const init = { + headers: JSON_HEADERS, + }; + + const response = await this.fetch('/api/v1/users', init); + + return response; + } + + async overrideShift( + scheduleId: number, + userId: number, + start: string, + end: string, + ): Promise { + const init = { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify({ user: { id: userId }, start, end }), + }; + + const response = await this.fetch( + `/api/v1/schedules/${encodeURIComponent(scheduleId)}/overrides`, + init, + ); + + return response; + } + + getIncidentDetailsURL(incident: Incident): string { + return `${this.baseUrl}/incident/view.jsf?id=${encodeURIComponent( + incident.id, + )}`; + } + + getAlertSourceDetailsURL(alertSource: AlertSource | null): string { + if (!alertSource) { + return ''; + } + return `${this.baseUrl}/source/view.jsf?id=${encodeURIComponent( + alertSource.id, + )}`; + } + + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string { + return `${this.baseUrl}/policy/view.jsf?id=${encodeURIComponent( + escalationPolicy.id, + )}`; + } + + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string { + return `${this.baseUrl}/uptime/view.jsf?id=${encodeURIComponent( + uptimeMonitor.id, + )}`; + } + + getScheduleDetailsURL(schedule: Schedule): string { + return `${this.baseUrl}/schedule/view.jsf?id=${encodeURIComponent( + schedule.id, + )}`; + } + + getUserPhoneNumber(user: User | null) { + return user?.mobile?.number || user?.landline?.number || ''; + } + + getUserInitials(user: User | null) { + if (!user) { + return ''; + } + if (!user.firstName && !user.lastName) { + return user.username; + } + return `${user.firstName} ${user.lastName} (${user.username})`; + } + + private async apiUrl() { + const proxyUrl = await this.discoveryApi.getBaseUrl('proxy'); + return proxyUrl + this.proxyPath; + } +} diff --git a/plugins/ilert/src/api/index.ts b/plugins/ilert/src/api/index.ts new file mode 100644 index 0000000000..65c3571bff --- /dev/null +++ b/plugins/ilert/src/api/index.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 { ILertClient, ilertApiRef } from './client'; +export type { + ILertApi, + GetIncidentsCountOpts, + GetIncidentsOpts, + TableState, +} from './types'; diff --git a/plugins/ilert/src/api/types.ts b/plugins/ilert/src/api/types.ts new file mode 100644 index 0000000000..ab9cca3dc6 --- /dev/null +++ b/plugins/ilert/src/api/types.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 { DiscoveryApi } from '@backstage/core'; +import { + AlertSource, + Incident, + User, + IncidentStatus, + UptimeMonitor, + EscalationPolicy, + Schedule, + IncidentResponder, + IncidentAction, + OnCall, +} from '../types'; + +export type TableState = { + page: number; + pageSize: number; +}; + +export type GetIncidentsOpts = { + maxResults?: number; + startIndex?: number; + states?: IncidentStatus[]; + alertSources?: number[]; +}; + +export type GetIncidentsCountOpts = { + states?: IncidentStatus[]; +}; + +export type EventRequest = { + integrationKey: string; + summary: string; + details: string; + userName: string; + source: string; +}; + +export interface ILertApi { + fetchIncidents(opts?: GetIncidentsOpts): Promise; + fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; + fetchIncident(id: number): Promise; + fetchIncidentResponders(incident: Incident): Promise; + fetchIncidentActions(incident: Incident): Promise; + acceptIncident(incident: Incident, userName: string): Promise; + resolveIncident(incident: Incident, userName: string): Promise; + assignIncident( + incident: Incident, + responder: IncidentResponder, + ): Promise; + createIncident(eventRequest: EventRequest): Promise; + triggerIncidentAction( + incident: Incident, + action: IncidentAction, + ): Promise; + + fetchUptimeMonitors(): Promise; + pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + fetchUptimeMonitor(id: number): Promise; + + fetchAlertSources(): Promise; + fetchAlertSource(idOrIntegrationKey: number | string): Promise; + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; + enableAlertSource(alertSource: AlertSource): Promise; + disableAlertSource(alertSource: AlertSource): Promise; + + addImmediateMaintenance( + alertSourceId: number, + minutes: number, + ): Promise; + + fetchOnCallSchedules(): Promise; + fetchUsers(): Promise; + + overrideShift( + scheduleId: number, + userId: number, + start: string, + end: string, + ): Promise; + + getIncidentDetailsURL(incident: Incident): string; + getAlertSourceDetailsURL(alertSource: AlertSource | null): string; + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string; + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string; + getScheduleDetailsURL(schedule: Schedule): string; + getUserPhoneNumber(user: User | null): string; + getUserInitials(user: User | null): string; +} + +export type Options = { + discoveryApi: DiscoveryApi; + + /** + * URL used by users to access iLert web UI. + * Example: https://my-org.ilert.com/ + */ + baseUrl: string; + + /** + * Path to use for requests via the proxy, defaults to /ilert/api + */ + proxyPath: string; +}; diff --git a/plugins/ilert/src/assets/ilert.icon.svg b/plugins/ilert/src/assets/ilert.icon.svg new file mode 100644 index 0000000000..5109286355 --- /dev/null +++ b/plugins/ilert/src/assets/ilert.icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx b/plugins/ilert/src/components/AlertSource/AlertSourceLink.tsx new file mode 100644 index 0000000000..f7f6dbb78c --- /dev/null +++ b/plugins/ilert/src/components/AlertSource/AlertSourceLink.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 React from 'react'; +import { useApi, Link } from '@backstage/core'; +import Grid from '@material-ui/core/Grid'; +import { AlertSource } from '../../types'; +import { ilertApiRef } from '../../api'; +import { makeStyles } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; + +const useStyles = makeStyles({ + root: { + display: 'flex', + maxWidth: '100%', + }, + image: { + height: 22, + paddingRight: 4, + }, + link: { + lineHeight: '22px', + }, +}); + +export const AlertSourceLink = ({ + alertSource, +}: { + alertSource: AlertSource | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + + if (!alertSource) { + return null; + } + + return ( + + + {alertSource.name} + + + + {alertSource.name} + + + + ); +}; diff --git a/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx b/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx new file mode 100644 index 0000000000..6b3d463d2a --- /dev/null +++ b/plugins/ilert/src/components/Errors/MissingAuthorizationHeaderError.tsx @@ -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 React from 'react'; +import { EmptyState } from '@backstage/core'; +import { Button } from '@material-ui/core'; + +export const MissingAuthorizationHeaderError = () => ( + + Read More + + } + /> +); diff --git a/plugins/ilert/src/components/Errors/index.ts b/plugins/ilert/src/components/Errors/index.ts new file mode 100644 index 0000000000..f20de48b90 --- /dev/null +++ b/plugins/ilert/src/components/Errors/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MissingAuthorizationHeaderError } from './MissingAuthorizationHeaderError'; diff --git a/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx b/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.tsx new file mode 100644 index 0000000000..9e7ff102f8 --- /dev/null +++ b/plugins/ilert/src/components/EscalationPolicy/EscalationPolicyLink.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 React from 'react'; +import { useApi, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { EscalationPolicy } from '../../types'; +import { ilertApiRef } from '../../api'; + +const useStyles = makeStyles({ + link: { + lineHeight: '22px', + }, +}); + +export const EscalationPolicyLink = ({ + escalationPolicy, +}: { + escalationPolicy: EscalationPolicy | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!escalationPolicy) { + return null; + } + + return ( + + {escalationPolicy.name} + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCard.tsx b/plugins/ilert/src/components/ILertCard/ILertCard.tsx new file mode 100644 index 0000000000..bbc82c2b2d --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCard.tsx @@ -0,0 +1,142 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { AuthenticationError } from '@backstage/errors'; +import { ResponseErrorPanel } from '@backstage/core'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; +import Divider from '@material-ui/core/Divider'; +import { makeStyles } from '@material-ui/core/styles'; +import { ILERT_INTEGRATION_KEY_ANNOTATION } from '../../constants'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useIncidents } from '../../hooks/useIncidents'; +import { IncidentsTable } from '../IncidentsPage'; +import { IncidentNewModal } from '../Incident/IncidentNewModal'; +import { ILertCardActionsHeader } from './ILertCardActionsHeader'; +import { useAlertSource } from '../../hooks/useAlertSource'; +import { useILertEntity } from '../../hooks'; +import { ILertCardHeaderStatus } from './ILertCardHeaderStatus'; +import { ILertCardMaintenanceModal } from './ILertCardMaintenanceModal'; +import { ILertCardEmptyState } from './ILertCardEmptyState'; +import { ILertCardOnCall } from './ILertCardOnCall'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[ILERT_INTEGRATION_KEY_ANNOTATION]); + +const useStyles = makeStyles({ + content: { + paddingLeft: '0 !important', + paddingRight: '0 !important', + paddingBottom: '0 !important', + paddingTop: '0 !important', + '& div div': { + boxShadow: 'none !important', + }, + }, +}); + +export const ILertCard = () => { + const classes = useStyles(); + const { integrationKey, name } = useILertEntity(); + const [ + { alertSource, uptimeMonitor }, + { setAlertSource, refetchAlertSource }, + ] = useAlertSource(integrationKey); + const [ + { tableState, states, incidents, incidentsCount, isLoading, error }, + { + onIncidentStatesChange, + onChangePage, + onChangeRowsPerPage, + onIncidentChanged, + refetchIncidents, + setIsLoading, + }, + ] = useIncidents(false, true, alertSource); + + const [ + isNewIncidentModalOpened, + setIsNewIncidentModalOpened, + ] = React.useState(false); + const [ + isMaintenanceModalOpened, + setIsMaintenanceModalOpened, + ] = React.useState(false); + + if (error) { + if (error instanceof AuthenticationError) { + return ; + } + + return ; + } + + if (!integrationKey) { + return ; + } + + return ( + <> + + + } + action={} + /> + + + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx b/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx new file mode 100644 index 0000000000..c19c31839a --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardActionsHeader.tsx @@ -0,0 +1,203 @@ +/* + * 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 { + HeaderIconLinkRow, + IconLinkVerticalProps, + useApi, + alertApiRef, +} from '@backstage/core'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; +import BuildIcon from '@material-ui/icons/Build'; +import PauseIcon from '@material-ui/icons/Pause'; +import PlayArrowIcon from '@material-ui/icons/PlayArrow'; +import TimelineIcon from '@material-ui/icons/Timeline'; +import WebIcon from '@material-ui/icons/Web'; +import Typography from '@material-ui/core/Typography'; +import { ilertApiRef } from '../../api'; +import { AlertSource, UptimeMonitor } from '../../types'; + +export const ILertCardActionsHeader = ({ + alertSource, + setAlertSource, + setIsNewIncidentModalOpened, + setIsMaintenanceModalOpened, + uptimeMonitor, +}: { + alertSource: AlertSource | null; + setAlertSource: (alertSource: AlertSource) => void; + setIsNewIncidentModalOpened: (isOpen: boolean) => void; + setIsMaintenanceModalOpened: (isOpen: boolean) => void; + uptimeMonitor: UptimeMonitor | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const [isLoading, setIsLoading] = React.useState(false); + const [isDisableModalOpened, setIsDisableModalOpened] = React.useState(false); + + const handleCreateNewIncident = () => { + setIsNewIncidentModalOpened(true); + }; + + const handleEnableAlertSource = async () => { + try { + if (!alertSource) { + return; + } + setIsLoading(true); + const newAlertSource = await ilertApi.enableAlertSource(alertSource); + alertApi.post({ message: 'Alert source enabled.' }); + setIsLoading(false); + setAlertSource(newAlertSource); + } catch (err) { + setIsLoading(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + const handleDisableAlertSource = async () => { + try { + if (!alertSource) { + return; + } + setIsDisableModalOpened(false); + setIsLoading(true); + const newAlertSource = await ilertApi.disableAlertSource(alertSource); + alertApi.post({ message: 'Alert source disabled.' }); + setIsLoading(false); + setAlertSource(newAlertSource); + } catch (err) { + setIsLoading(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleDisableAlertSourceWarningOpen = () => { + setIsDisableModalOpened(true); + }; + + const handleDisableAlertSourceWarningClose = () => { + setIsDisableModalOpened(false); + }; + + const handleMaintenanceAlertSource = () => { + setIsMaintenanceModalOpened(true); + }; + + const alertSourceLink: IconLinkVerticalProps = { + label: 'Alert Source', + href: ilertApi.getAlertSourceDetailsURL(alertSource), + icon: , + }; + + const createIncidentLink: IconLinkVerticalProps = { + label: 'Create Incident', + onClick: handleCreateNewIncident, + icon: , + color: 'secondary', + disabled: + !alertSource || + alertSource.status === 'DISABLED' || + alertSource.status === 'IN_MAINTENANCE', + }; + + const enableAlertSourceLink: IconLinkVerticalProps = { + label: 'Enable', + onClick: handleEnableAlertSource, + icon: , + disabled: !alertSource || isLoading, + }; + + const disableAlertSourceLink: IconLinkVerticalProps = { + label: 'Disable', + onClick: handleDisableAlertSourceWarningOpen, + icon: , + disabled: !alertSource || isLoading, + }; + + const maintenanceAlertSourceLink: IconLinkVerticalProps = { + label: 'Immediate maintenance', + onClick: handleMaintenanceAlertSource, + icon: , + disabled: !alertSource || isLoading, + }; + + const uptimeMonitorReportLink: IconLinkVerticalProps = { + label: 'Uptime Report', + href: uptimeMonitor ? uptimeMonitor.shareUrl : '', + icon: , + disabled: !alertSource || !uptimeMonitor || isLoading, + }; + + const links: IconLinkVerticalProps[] = [ + alertSourceLink, + createIncidentLink, + alertSource && alertSource.active + ? disableAlertSourceLink + : enableAlertSourceLink, + ]; + + if (alertSource && alertSource.integrationType === 'MONITOR') { + links.push(uptimeMonitorReportLink); + } + + if (alertSource && alertSource.status !== 'IN_MAINTENANCE') { + links.push(maintenanceAlertSourceLink); + } + + return ( + <> + + + + Disable alert source + + + + + Do you really want to disable this alert source? A disabled alert + source cannot create new incidents. + + + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx b/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx new file mode 100644 index 0000000000..955d514564 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardEmptyState.tsx @@ -0,0 +1,84 @@ +/* + * 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 { CodeSnippet } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; +import Button from '@material-ui/core/Button'; +import Divider from '@material-ui/core/Divider'; +import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; + +const ENTITY_YAML = `apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example + description: example.com + annotations: + ilert.com/integration-key: [INTEGRATION_KEY] +spec: + type: website + lifecycle: production + owner: guest`; + +const useStyles = makeStyles(theme => ({ + code: { + borderRadius: 6, + margin: theme.spacing(2, 0), + background: theme.palette.type === 'dark' ? '#444' : '#fff', + }, + header: { + display: 'inline-block', + padding: theme.spacing(2, 2, 2, 2.5), + }, +})); + +export const ILertCardEmptyState = () => { + const classes = useStyles(); + + return ( + + + + + + No integration key defined for this entity. You can add integration + key to your entity YAML as shown in the highlighted example below: + +
+ +
+ +
+
+ ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx b/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx new file mode 100644 index 0000000000..2c1b44551e --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardHeaderStatus.tsx @@ -0,0 +1,46 @@ +/* + * 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 Chip from '@material-ui/core/Chip'; +import { withStyles } from '@material-ui/core/styles'; +import { AlertSource } from '../../types'; + +const MaintenanceChip = withStyles({ + root: { + backgroundColor: '#92949c', + color: 'white', + marginTop: 8, + }, +})(Chip); + +export const ILertCardHeaderStatus = ({ + alertSource, +}: { + alertSource: AlertSource | null; +}) => { + if (!alertSource) { + return null; + } + + switch (alertSource.status) { + case 'IN_MAINTENANCE': + return ; + case 'DISABLED': + return ; + default: + return null; + } +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx b/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx new file mode 100644 index 0000000000..54d465a83c --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardMaintenanceModal.tsx @@ -0,0 +1,138 @@ +/* + * 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 { alertApiRef, useApi } from '@backstage/core'; +import Button from '@material-ui/core/Button'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import MenuItem from '@material-ui/core/MenuItem'; +import TextField from '@material-ui/core/TextField'; +import { ilertApiRef } from '../../api'; +import { AlertSource } from '../../types'; + +export const ILertCardMaintenanceModal = ({ + alertSource, + refetchAlertSource, + isModalOpened, + setIsModalOpened, +}: { + alertSource: AlertSource | null; + refetchAlertSource: () => void; + isModalOpened: boolean; + setIsModalOpened: (isModalOpened: boolean) => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const [minutes, setMinutes] = React.useState(5); + + const handleClose = () => { + setIsModalOpened(false); + }; + + const handleImmediateMaintenance = () => { + if (!alertSource) { + return; + } + setIsModalOpened(false); + setTimeout(async () => { + try { + await ilertApi.addImmediateMaintenance(alertSource.id, minutes); + alertApi.post({ message: 'Maintenance started.' }); + refetchAlertSource(); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }, 250); + }; + + const handleMinutesChange = (event: any) => { + setMinutes(event.target.value); + }; + + const minuteOptions = [ + { + value: 5, + label: '5 minutes', + }, + { + value: 10, + label: '10 minutes', + }, + { + value: 15, + label: '15 minutes', + }, + { + value: 30, + label: '30 minutes', + }, + { + value: 60, + label: '60 minutes', + }, + ]; + + if (!alertSource) { + return null; + } + + return ( + + + New maintenance window + + + + Keep your alert sources quiet, when your systems are under + maintenance. + + + {minuteOptions.map(option => ( + + {option.label} + + ))} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx new file mode 100644 index 0000000000..8f28790375 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCall.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListSubheader from '@material-ui/core/ListSubheader'; +import Typography from '@material-ui/core/Typography'; +import ReplayIcon from '@material-ui/icons/Replay'; +import { makeStyles } from '@material-ui/core/styles'; +import { AlertSource } from '../../types'; +import { useAlertSourceOnCalls } from '../../hooks/useAlertSourceOnCalls'; +import { Progress } from '@backstage/core'; +import { ILertCardOnCallEmptyState } from './ILertCardOnCallEmptyState'; +import { ILertCardOnCallItem } from './ILertCardOnCallItem'; + +const useStyles = makeStyles(theme => ({ + repeatText: { + fontStyle: 'italic', + }, + repeatIcon: { + marginLeft: theme.spacing(1), + }, +})); + +export const ILertCardOnCall = ({ + alertSource, +}: { + alertSource: AlertSource | null; +}) => { + const classes = useStyles(); + const [{ onCalls, isLoading }, {}] = useAlertSourceOnCalls(alertSource); + + if (isLoading) { + return ; + } + + if (!alertSource || !onCalls) { + return null; + } + + const repeatInfo = () => { + if ( + !alertSource || + !alertSource.escalationPolicy || + !alertSource.escalationPolicy.repeating || + !alertSource.escalationPolicy.frequency + ) { + return null; + } + + return ( + + + + + + {`Repeat ${alertSource.escalationPolicy.frequency} times`} + + } + /> + + ); + }; + + if (!onCalls.length) { + return ( + ON CALL}> + + + ); + } + + if (onCalls.length === 1) { + ON CALL}> + + ; + } + + return ( + ON CALL}> + {onCalls.map((onCall, index) => ( + + ))} + {repeatInfo()} + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx new file mode 100644 index 0000000000..2d43aeccb8 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallEmptyState.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import Typography from '@material-ui/core/Typography'; + +const useStyles = makeStyles({ + text: { + fontStyle: 'italic', + }, +}); + +export const ILertCardOnCallEmptyState = () => { + const classes = useStyles(); + + return ( + + + + Nobody + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx new file mode 100644 index 0000000000..994666084b --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/ILertCardOnCallItem.tsx @@ -0,0 +1,169 @@ +/* + * 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 Divider from '@material-ui/core/Divider'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import Avatar from '@material-ui/core/Avatar'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import Tooltip from '@material-ui/core/Tooltip'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import EmailIcon from '@material-ui/icons/Email'; +import PhoneIcon from '@material-ui/icons/Phone'; +import { makeStyles } from '@material-ui/core/styles'; +import { OnCall } from '../../types'; +import { useApi } from '@backstage/core'; +import { ilertApiRef } from '../../api'; +import { DateTime as dt } from 'luxon'; + +const useStyles = makeStyles({ + listItemPrimary: { + fontWeight: 'bold', + }, + fistItemLine: { + position: 'absolute', + bottom: 0, + height: '50%', + left: 36, + }, + lastItemLine: { + position: 'absolute', + top: 0, + height: '50%', + left: 36, + }, + itemLine: { + position: 'absolute', + top: 0, + bottom: 0, + height: '100%', + left: 36, + }, +}); + +export const ILertCardOnCallItem = ({ + onCall, + fistItem = false, + lastItem = false, +}: { + onCall: OnCall; + fistItem?: boolean; + lastItem?: boolean; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!onCall || !onCall.user) { + return null; + } + + const phoneNumber = ilertApi.getUserPhoneNumber(onCall.user); + const escalationRepeating = onCall.escalationPolicy.repeating; + const escalationSeconds = + onCall.escalationPolicy.escalationRules[onCall.escalationLevel - 1] + .escalationTimeout; + const escalationHoursOnly = Math.floor(escalationSeconds / 60); + const escalationMinutesOnly = escalationSeconds % 60; + + let escalationText = ''; + if (!lastItem || (lastItem && escalationRepeating)) { + escalationText = 'escalate'; + if (escalationSeconds === 0) { + escalationText += ' immediately'; + } else { + escalationText += ' after'; + if (escalationHoursOnly > 0) { + escalationText += ` ${escalationHoursOnly} ${ + escalationHoursOnly === 1 ? 'hour' : 'hours' + }`; + } + if (escalationMinutesOnly > 0 || escalationSeconds === 0) { + escalationText += ` ${escalationMinutesOnly} ${ + escalationMinutesOnly === 1 ? 'minute' : 'minutes' + }`; + } + } + } + + return ( + + {fistItem ? ( + + ) : null} + {lastItem ? ( + + ) : null} + {!fistItem && !lastItem ? ( + + ) : null} + + + {onCall.escalationLevel} + + + {onCall.schedule ? ( + + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + + ) : ( + + {ilertApi.getUserInitials(onCall.user)} + + } + secondary={escalationText} + /> + )} + + {phoneNumber ? ( + + + + + + ) : null} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/ILertCard/index.ts b/plugins/ilert/src/components/ILertCard/index.ts new file mode 100644 index 0000000000..4ae259bfc5 --- /dev/null +++ b/plugins/ilert/src/components/ILertCard/index.ts @@ -0,0 +1,16 @@ +/* + * 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 * from './ILertCard'; diff --git a/plugins/ilert/src/components/ILertPage/ILertPage.tsx b/plugins/ilert/src/components/ILertPage/ILertPage.tsx new file mode 100644 index 0000000000..40934bfdfb --- /dev/null +++ b/plugins/ilert/src/components/ILertPage/ILertPage.tsx @@ -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 React from 'react'; +import { + Page, + Header, + HeaderTabs, + HeaderLabel, + Content, +} from '@backstage/core'; +import { IncidentsPage } from '../IncidentsPage'; +import { UptimeMonitorsPage } from '../UptimeMonitorsPage'; +import { OnCallSchedulesPage } from '../OnCallSchedulesPage'; + +export const ILertPage = () => { + const [selectedTab, setSelectedTab] = React.useState(0); + const tabs = [ + { label: 'Who is on call?' }, + { label: 'Incidents' }, + { label: 'Uptime Monitors' }, + ]; + const renderTab = () => { + switch (selectedTab) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + default: + return null; + } + }; + + return ( + +
+ + +
+ setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + {renderTab()} +
+ ); +}; + +export default ILertPage; diff --git a/plugins/ilert/src/components/ILertPage/index.ts b/plugins/ilert/src/components/ILertPage/index.ts new file mode 100644 index 0000000000..5de46b63d4 --- /dev/null +++ b/plugins/ilert/src/components/ILertPage/index.ts @@ -0,0 +1,16 @@ +/* + * 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 * from './ILertPage'; diff --git a/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx new file mode 100644 index 0000000000..5b06cc4eb6 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentActionsMenu.tsx @@ -0,0 +1,203 @@ +/* + * 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 { + alertApiRef, + Progress, + useApi, + identityApiRef, + Link, +} from '@backstage/core'; +import { IconButton, Menu, MenuItem, Typography } from '@material-ui/core'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; +import { ilertApiRef } from '../../api'; +import { Incident, IncidentAction } from '../../types'; +import { IncidentAssignModal } from './IncidentAssignModal'; +import { useIncidentActions } from '../../hooks/useIncidentActions'; + +export const IncidentActionsMenu = ({ + incident, + onIncidentChanged, + setIsLoading, +}: { + incident: Incident; + onIncidentChanged?: (incident: Incident) => void; + setIsLoading?: (isLoading: boolean) => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const identityApi = useApi(identityApiRef); + const userName = identityApi.getUserId(); + const [anchorEl, setAnchorEl] = React.useState(null); + const callback = onIncidentChanged || ((_: Incident): void => {}); + const setProcessing = setIsLoading || ((_: boolean): void => {}); + const [ + isAssignIncidentModalOpened, + setIsAssignIncidentModalOpened, + ] = React.useState(false); + + const [{ incidentActions, isLoading }] = useIncidentActions( + incident, + Boolean(anchorEl), + ); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const handleAccept = async (): Promise => { + try { + handleCloseMenu(); + setProcessing(true); + const newIncident = await ilertApi.acceptIncident(incident, userName); + alertApi.post({ message: 'Incident accepted.' }); + + callback(newIncident); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleResolve = async (): Promise => { + try { + handleCloseMenu(); + setProcessing(true); + const newIncident = await ilertApi.resolveIncident(incident, userName); + alertApi.post({ message: 'Incident resolved.' }); + + callback(newIncident); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleAssign = () => { + handleCloseMenu(); + setIsAssignIncidentModalOpened(true); + }; + + const handleTriggerAction = (action: IncidentAction) => async () => { + try { + handleCloseMenu(); + setProcessing(true); + await ilertApi.triggerIncidentAction(incident, action); + alertApi.post({ message: 'Incident action triggered.' }); + setProcessing(false); + } catch (err) { + setProcessing(false); + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const actions: React.ReactNode[] = incidentActions.map(a => { + const successTrigger = a.history + ? a.history.find(h => h.success) + : undefined; + const triggeredBy = + successTrigger && successTrigger.actor + ? `${successTrigger.actor.firstName} ${successTrigger.actor.lastName}` + : ''; + return ( + + + {triggeredBy ? `${a.name} (by ${triggeredBy})` : a.name} + + + ); + }); + + return ( + <> + + + + + {incident.status === 'PENDING' ? ( + + + Accept + + + ) : null} + + {incident.status !== 'RESOLVED' ? ( + + + Resolve + + + ) : null} + + {incident.status !== 'RESOLVED' ? ( + + + Assign + + + ) : null} + + {isLoading ? ( + + + + ) : ( + actions + )} + + + + + View in iLert + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx b/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx new file mode 100644 index 0000000000..edac5e4e0e --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentAssignModal.tsx @@ -0,0 +1,183 @@ +/* + * 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 { alertApiRef, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { useAssignIncident } from '../../hooks/useAssignIncident'; +import { Typography } from '@material-ui/core'; +import { ilertApiRef } from '../../api'; +import { Incident } from '../../types'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + }, + formControl: { + minWidth: 120, + width: '100%', + }, + option: { + fontSize: 15, + '& > span': { + marginRight: 10, + fontSize: 18, + }, + }, + optionWrapper: { + display: 'flex', + width: '100%', + }, + sourceImage: { + height: 22, + paddingRight: 4, + }, +})); + +export const IncidentAssignModal = ({ + incident, + isModalOpened, + setIsModalOpened, + onIncidentChanged, +}: { + incident: Incident | null; + isModalOpened: boolean; + setIsModalOpened: (open: boolean) => void; + onIncidentChanged?: (incident: Incident) => void; +}) => { + const [ + { incidentRespondersList, incidentResponder, isLoading }, + { setIsLoading, setIncidentResponder, setIncidentRespondersList }, + ] = useAssignIncident(incident, isModalOpened); + const callback = onIncidentChanged || ((_: Incident): void => {}); + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const classes = useStyles(); + + const handleClose = () => { + setIncidentRespondersList([]); + setIsModalOpened(false); + }; + + const handleAssign = () => { + if (!incident || !incidentResponder) { + return; + } + setIsLoading(true); + setIncidentRespondersList([]); + setTimeout(async () => { + try { + const newIncident = await ilertApi.assignIncident( + incident, + incidentResponder, + ); + callback(newIncident); + alertApi.post({ message: 'Incident assigned.' }); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + setIsLoading(false); + setIsModalOpened(false); + }, 250); + }; + + const canAssign = !!incidentResponder; + + return ( + + + Select responder to assign + + + + + This action will assign the incident to the selected responder. + + + { + setIncidentResponder(newValue); + }} + autoHighlight + groupBy={option => { + switch (option.group) { + case 'SUGGESTED': + return 'Suggested responders'; + case 'USER': + return 'Users'; + case 'ESCALATION_POLICY': + return 'Escalation policies'; + case 'ON_CALL_SCHEDULE': + return 'Schedules'; + default: + return ''; + } + }} + getOptionLabel={a => a.name} + renderOption={a => ( +
+ {a.name} +
+ )} + renderInput={params => ( + + )} + /> +
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentLink.tsx b/plugins/ilert/src/components/Incident/IncidentLink.tsx new file mode 100644 index 0000000000..7bfe08b7a9 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentLink.tsx @@ -0,0 +1,44 @@ +/* + * 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 { useApi, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Incident } from '../../types'; +import { ilertApiRef } from '../../api'; + +const useStyles = makeStyles({ + link: { + lineHeight: '22px', + }, +}); + +export const IncidentLink = ({ incident }: { incident: Incident | null }) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!incident) { + return null; + } + + return ( + + #{incident.id} + + ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentNewModal.tsx b/plugins/ilert/src/components/Incident/IncidentNewModal.tsx new file mode 100644 index 0000000000..8ff33e6319 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentNewModal.tsx @@ -0,0 +1,228 @@ +/* + * 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 { alertApiRef, identityApiRef, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { useNewIncident } from '../../hooks/useNewIncident'; +import { Typography } from '@material-ui/core'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import { ilertApiRef } from '../../api'; +import { AlertSource } from '../../types'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + }, + formControl: { + minWidth: 120, + width: '100%', + }, + option: { + fontSize: 15, + '& > span': { + marginRight: 10, + fontSize: 18, + }, + }, + optionWrapper: { + display: 'flex', + width: '100%', + }, + sourceImage: { + height: 22, + paddingRight: 4, + }, +})); + +export const IncidentNewModal = ({ + isModalOpened, + setIsModalOpened, + refetchIncidents, + initialAlertSource, + entityName, +}: { + isModalOpened: boolean; + setIsModalOpened: (open: boolean) => void; + refetchIncidents: () => void; + initialAlertSource?: AlertSource | null; + entityName?: string; +}) => { + const [ + { alertSources, alertSource, summary, details, isLoading }, + { setAlertSource, setSummary, setDetails, setIsLoading }, + ] = useNewIncident(isModalOpened, initialAlertSource); + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const identityApi = useApi(identityApiRef); + const userName = identityApi.getUserId(); + const source = window.location.toString(); + const classes = useStyles(); + const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); + + const handleClose = () => { + setIsModalOpened(false); + }; + + let integrationKey = ''; + if (initialAlertSource && initialAlertSource.integrationKey) { + integrationKey = initialAlertSource.integrationKey; + } else if (alertSource && alertSource.integrationKey) { + integrationKey = alertSource.integrationKey; + } + const handleCreate = () => { + if (!integrationKey) { + return; + } + setIsLoading(true); + setTimeout(async () => { + try { + await ilertApi.createIncident({ + integrationKey, + summary, + details, + userName, + source, + }); + alertApi.post({ message: 'Incident created.' }); + refetchIncidents(); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + setIsModalOpened(false); + }, 250); + }; + + const canCreate = !!integrationKey && !!summary; + + return ( + + + {entityName ? ( +
+ This action will trigger an incident for{' '} + "{entityName}". +
+ ) : ( + 'New incident' + )} +
+ + + + Please describe the problem you want to report. Be as descriptive as + possible. Your signed in user and a reference to the current page + will automatically be amended to the alarm so that the receiver can + reach out to you if necessary. + + + {!initialAlertSource ? ( + { + setAlertSource(newValue); + }} + autoHighlight + getOptionLabel={a => a.name} + renderOption={a => ( +
+ {a.name} + {a.name} +
+ )} + renderInput={params => ( + + )} + /> + ) : null} + { + setSummary(event.target.value); + }} + /> + { + setDetails(event.target.value); + }} + /> +
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/IncidentStatus.tsx b/plugins/ilert/src/components/Incident/IncidentStatus.tsx new file mode 100644 index 0000000000..931a472ec6 --- /dev/null +++ b/plugins/ilert/src/components/Incident/IncidentStatus.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 React from 'react'; +import { StatusError, StatusOK } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Tooltip from '@material-ui/core/Tooltip'; +import { ACCEPTED, Incident, PENDING, RESOLVED } from '../../types'; + +const useStyles = makeStyles({ + denseListIcon: { + marginRight: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export const incidentStatusLabels = { + [RESOLVED]: 'Resolved', + [ACCEPTED]: 'Accepted', + [PENDING]: 'Pending', +} as Record; + +export const IncidentStatus = ({ incident }: { incident: Incident }) => { + const classes = useStyles(); + + return ( + +
+ {incident.status === 'PENDING' ? : } +
+
+ ); +}; diff --git a/plugins/ilert/src/components/Incident/index.ts b/plugins/ilert/src/components/Incident/index.ts new file mode 100644 index 0000000000..df8bab5d95 --- /dev/null +++ b/plugins/ilert/src/components/Incident/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './IncidentActionsMenu'; +export * from './IncidentStatus'; diff --git a/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx b/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx new file mode 100644 index 0000000000..782848645e --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/IncidentsPage.tsx @@ -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 React from 'react'; +import { + Content, + ContentHeader, + SupportButton, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import Button from '@material-ui/core/Button'; +import AddIcon from '@material-ui/icons/Add'; +import { IncidentsTable } from './IncidentsTable'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useIncidents } from '../../hooks/useIncidents'; +import { IncidentNewModal } from '../Incident/IncidentNewModal'; + +export const IncidentsPage = () => { + const [ + { tableState, states, incidents, incidentsCount, isLoading, error }, + { + onIncidentStatesChange, + onChangePage, + onChangeRowsPerPage, + onIncidentChanged, + refetchIncidents, + setIsLoading, + }, + ] = useIncidents(true); + + const [isModalOpened, setIsModalOpened] = React.useState(false); + + const handleCreateNewIncidentClick = () => { + setIsModalOpened(true); + }; + + if (error) { + if (error instanceof AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx b/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx new file mode 100644 index 0000000000..087b2bfd40 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/IncidentsTable.tsx @@ -0,0 +1,262 @@ +/* + * 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 { Table, TableColumn, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { ilertApiRef, TableState } from '../../api'; +import { Incident, IncidentStatus } from '../../types'; +import { StatusChip } from './StatusChip'; +import { AlertSourceLink } from '../AlertSource/AlertSourceLink'; +import { TableTitle } from './TableTitle'; +import Typography from '@material-ui/core/Typography'; +import { DateTime as dt, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; +import { IncidentActionsMenu } from '../Incident/IncidentActionsMenu'; +import { IncidentLink } from '../Incident/IncidentLink'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const IncidentsTable = ({ + incidents, + incidentsCount, + tableState, + states, + isLoading, + onIncidentChanged, + setIsLoading, + onIncidentStatesChange, + onChangePage, + onChangeRowsPerPage, + compact, +}: { + incidents: Incident[]; + incidentsCount: number; + tableState: TableState; + states: IncidentStatus[]; + isLoading: boolean; + onIncidentChanged: (incident: Incident) => void; + setIsLoading: (isLoading: boolean) => void; + onIncidentStatesChange: (states: IncidentStatus[]) => void; + onChangePage: (page: number) => void; + onChangeRowsPerPage: (pageSize: number) => void; + compact?: boolean; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + const xsColumnStyle = { + width: '5%', + maxWidth: '5%', + }; + const smColumnStyle = { + width: '10%', + maxWidth: '10%', + }; + const mdColumnStyle = { + width: '15%', + maxWidth: '15%', + }; + const lgColumnStyle = { + width: '20%', + maxWidth: '20%', + }; + const xlColumnStyle = { + width: '30%', + maxWidth: '30%', + }; + + const idColumn: TableColumn = { + title: 'ID', + field: 'id', + highlight: true, + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => , + }; + const summaryColumn: TableColumn = { + title: 'Summary', + field: 'summary', + cellStyle: !compact ? xlColumnStyle : undefined, + headerStyle: !compact ? xlColumnStyle : undefined, + render: rowData => {(rowData as Incident).summary}, + }; + const sourceColumn: TableColumn = { + title: 'Source', + field: 'source', + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + ), + }; + const durationColumn: TableColumn = { + title: 'Duration', + field: 'reportTime', + type: 'datetime', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + {(rowData as Incident).status !== 'RESOLVED' + ? humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as Incident).reportTime), + dt.now(), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + ) + : humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as Incident).reportTime), + dt.fromISO((rowData as Incident).resolvedOn), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + )} + + ), + }; + const assignedToColumn: TableColumn = { + title: 'Assigned to', + field: 'assignedTo', + cellStyle: !compact ? mdColumnStyle : lgColumnStyle, + headerStyle: !compact ? mdColumnStyle : lgColumnStyle, + render: rowData => ( + + {ilertApi.getUserInitials((rowData as Incident).assignedTo)} + + ), + }; + const priorityColumn: TableColumn = { + title: 'Priority', + field: 'priority', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + {(rowData as Incident).priority === 'HIGH' ? 'High' : 'Low'} + + ), + }; + const statusColumn: TableColumn = { + title: 'Status', + field: 'status', + cellStyle: xsColumnStyle, + headerStyle: xsColumnStyle, + render: rowData => , + }; + const actionsColumn: TableColumn = { + title: '', + field: '', + cellStyle: xsColumnStyle, + headerStyle: xsColumnStyle, + render: rowData => ( + + ), + }; + + const columns: TableColumn[] = compact + ? [ + summaryColumn, + durationColumn, + assignedToColumn, + statusColumn, + actionsColumn, + ] + : [ + idColumn, + summaryColumn, + sourceColumn, + durationColumn, + assignedToColumn, + priorityColumn, + statusColumn, + actionsColumn, + ]; + let tableStyle: React.CSSProperties = {}; + if (compact) { + tableStyle = { + width: '100%', + maxWidth: '100%', + minWidth: '0', + height: 'calc(100% - 10px)', + boxShadow: 'none !important', + borderRadius: 'none !important', + }; + } else { + tableStyle = { + width: '100%', + maxWidth: '100%', + }; + } + + return ( + + No incidents right now + + } + title={ + !compact ? ( + + ) : ( + + INCIDENTS + + ) + } + page={tableState.page} + totalCount={incidentsCount} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangeRowsPerPage} + // localization={{ header: { actions: undefined } }} + columns={columns} + data={incidents} + isLoading={isLoading} + /> + ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx b/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx new file mode 100644 index 0000000000..9713b16a55 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/StatusChip.tsx @@ -0,0 +1,57 @@ +/* + * 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 { Chip, withStyles } from '@material-ui/core'; +import { Incident, PENDING, ACCEPTED, RESOLVED } from '../../types'; +import { incidentStatusLabels } from '../Incident/IncidentStatus'; + +const ResolvedChip = withStyles({ + root: { + backgroundColor: '#4caf50', + color: 'white', + margin: 0, + }, +})(Chip); + +const AcceptedChip = withStyles({ + root: { + backgroundColor: '#ffb74d', + color: 'white', + margin: 0, + }, +})(Chip); +const PendingChip = withStyles({ + root: { + backgroundColor: '#d32f2f', + color: 'white', + margin: 0, + }, +})(Chip); + +export const StatusChip = ({ incident }: { incident: Incident }) => { + const label = `${incidentStatusLabels[incident.status]}`; + + switch (incident.status) { + case RESOLVED: + return ; + case ACCEPTED: + return ; + case PENDING: + return ; + default: + return ; + } +}; diff --git a/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx b/plugins/ilert/src/components/IncidentsPage/TableTitle.tsx new file mode 100644 index 0000000000..f35981dec2 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/TableTitle.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 { PENDING, ACCEPTED, RESOLVED, IncidentStatus } from '../../types'; +import { incidentStatusLabels } from '../Incident/IncidentStatus'; +import FormControl from '@material-ui/core/FormControl'; +import ListItemText from '@material-ui/core/ListItemText'; +import Select from '@material-ui/core/Select'; +import Typography from '@material-ui/core/Typography'; +import MenuItem from '@material-ui/core/MenuItem'; +import Checkbox from '@material-ui/core/Checkbox'; +import { makeStyles } from '@material-ui/core/styles'; + +const ITEM_HEIGHT = 48; +const ITEM_PADDING_TOP = 8; +const MenuProps = { + PaperProps: { + style: { + maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, + width: 250, + }, + }, +}; + +const useStyles = makeStyles({ + root: { + display: 'flex', + }, + label: { + marginTop: 8, + marginRight: 4, + }, + formControl: { + minWidth: 120, + maxWidth: 300, + }, + grow: { + flexGrow: 1, + }, +}); + +export const TableTitle = ({ + incidentStates, + onIncidentStatesChange, +}: { + incidentStates: IncidentStatus[]; + onIncidentStatesChange: (states: IncidentStatus[]) => void; +}) => { + const classes = useStyles(); + const handleIncidentStatusSelectChange = (event: any) => { + onIncidentStatesChange(event.target.value); + }; + + return ( +
+ + Status: + + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/IncidentsPage/index.ts b/plugins/ilert/src/components/IncidentsPage/index.ts new file mode 100644 index 0000000000..7159247c68 --- /dev/null +++ b/plugins/ilert/src/components/IncidentsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './IncidentsPage'; +export * from './IncidentsTable'; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx new file mode 100644 index 0000000000..31da9c1097 --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesGrid.tsx @@ -0,0 +1,171 @@ +/* + * 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 { useApi, ItemCardGrid, Progress, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import CardHeader from '@material-ui/core/CardHeader'; +import Typography from '@material-ui/core/Typography'; +import { Schedule } from '../../types'; +import { ilertApiRef } from '../../api'; +import { OnCallShiftItem } from './OnCallShiftItem'; + +const useStyles = makeStyles(() => ({ + card: { + margin: 16, + width: 'calc(100% - 32px)', + }, + + cardHeader: { + maxWidth: '100%', + }, + + cardContent: { + marginLeft: 80, + borderLeft: '1px #808289 solid', + position: 'relative', + }, + + indicatorNext: { + position: 'absolute', + top: 'calc(40% - 10px)', + left: -6, + width: 12, + height: 12, + background: '#92949c !important', + borderRadius: '50%', + }, + + indicatorCurrent: { + position: 'absolute', + top: 'calc(40% - 10px)', + left: -6, + width: 12, + height: 12, + background: '#ffb74d !important', + color: '#ffb74d !important', + borderRadius: '50%', + '&::after': { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: '50%', + animation: '$ripple 1.2s infinite ease-in-out', + border: '1px solid currentColor', + content: '""', + }, + }, + '@keyframes ripple': { + '0%': { + transform: 'scale(.8)', + opacity: 1, + }, + '100%': { + transform: 'scale(2.4)', + opacity: 0, + }, + }, + + beforeText: { + position: 'absolute', + top: 'calc(31% - 10px)', + left: -78, + width: 65, + height: 20, + textAlign: 'center', + color: '#808289', + }, + + marginBottom: { + marginBottom: 16, + }, + + link: { + fontSize: '1.5rem', + fontWeight: 700, + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + display: 'block', + }, +})); + +export const OnCallSchedulesGrid = ({ + onCallSchedules, + isLoading, + refetchOnCallSchedules, +}: { + onCallSchedules: Schedule[]; + isLoading: boolean; + refetchOnCallSchedules: () => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (isLoading) { + return ; + } + return ( + + {!onCallSchedules?.length + ? null + : onCallSchedules.map((schedule, index) => ( + + + {schedule.name} + + } + /> + + +
+ + + On call now + + + + +
+ + + Next on call + + + + ))} + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.tsx new file mode 100644 index 0000000000..bb0cd0218e --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallSchedulesPage.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 { + Content, + ContentHeader, + SupportButton, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { OnCallSchedulesGrid } from './OnCallSchedulesGrid'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useOnCallSchedules } from '../../hooks/useOnCallSchedules'; + +export const OnCallSchedulesPage = () => { + const [ + { onCallSchedules, isLoading, error }, + { refetchOnCallSchedules }, + ] = useOnCallSchedules(); + + if (error) { + if (error instanceof AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.tsx new file mode 100644 index 0000000000..46f6963445 --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/OnCallShiftItem.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 Button from '@material-ui/core/Button'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import RepeatIcon from '@material-ui/icons/Repeat'; +import { Shift } from '../../types'; +import { DateTime as dt } from 'luxon'; +import { makeStyles } from '@material-ui/core/styles'; +import { ShiftOverrideModal } from '../Shift/ShiftOverrideModal'; + +const useStyles = makeStyles({ + button: { + marginTop: 4, + padding: 0, + lineHeight: 1.8, + '& span': { + lineHeight: 1.8, + fontSize: '0.65rem', + }, + '& svg': { + fontSize: '0.85rem !important', + }, + }, +}); + +export const OnCallShiftItem = ({ + scheduleId, + shift, + refetchOnCallSchedules, +}: { + scheduleId: number; + shift: Shift; + refetchOnCallSchedules: () => void; +}) => { + const classes = useStyles(); + const [isModalOpened, setIsModalOpened] = React.useState(false); + + const handleOverride = () => { + setIsModalOpened(true); + }; + + if (!shift || !shift.start) { + return ( + + + + Nobody + + + + ); + } + + return ( + + {shift && shift.user ? ( + + + {`${shift.user.firstName} ${shift.user.lastName} (${shift.user.username})`} + + + ) : null} + + + {`${dt.fromISO(shift.start).toFormat('D MMM, HH:mm')} - ${dt + .fromISO(shift.end) + .toFormat('D MMM, HH:mm')}`} + + + + + + + + ); +}; diff --git a/plugins/ilert/src/components/OnCallSchedulesPage/index.ts b/plugins/ilert/src/components/OnCallSchedulesPage/index.ts new file mode 100644 index 0000000000..a52accc2bb --- /dev/null +++ b/plugins/ilert/src/components/OnCallSchedulesPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './OnCallSchedulesPage'; +export * from './OnCallSchedulesGrid'; diff --git a/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx b/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx new file mode 100644 index 0000000000..47e13f9b7e --- /dev/null +++ b/plugins/ilert/src/components/Shift/ShiftOverrideModal.tsx @@ -0,0 +1,196 @@ +/* + * 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 { alertApiRef, useApi } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { Typography } from '@material-ui/core'; +import { ilertApiRef } from '../../api'; +import { useShiftOverride } from '../../hooks/useShiftOverride'; +import { Shift } from '../../types'; +import { DateTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; +import LuxonUtils from '@date-io/luxon'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + }, + formControl: { + minWidth: 120, + width: '100%', + }, + option: { + fontSize: 15, + '& > span': { + marginRight: 10, + fontSize: 18, + }, + }, + optionWrapper: { + display: 'flex', + width: '100%', + }, + sourceImage: { + height: 22, + paddingRight: 4, + }, + grow: { + flexGrow: 1, + }, +})); + +export const ShiftOverrideModal = ({ + scheduleId, + shift, + refetchOnCallSchedules, + isModalOpened, + setIsModalOpened, +}: { + scheduleId: number; + shift: Shift; + refetchOnCallSchedules: () => void; + isModalOpened: boolean; + setIsModalOpened: (isModalOpened: boolean) => void; +}) => { + const [ + { isLoading, users, user, start, end }, + { setUser, setStart, setEnd, setIsLoading }, + ] = useShiftOverride(shift, isModalOpened); + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const classes = useStyles(); + + const handleClose = () => { + setIsModalOpened(false); + }; + + const handleOverride = () => { + if (!shift || !shift.user) { + return; + } + setIsLoading(true); + setTimeout(async () => { + try { + const success = await ilertApi.overrideShift( + scheduleId, + user.id, + start, + end, + ); + if (success) { + alertApi.post({ message: 'Shift overridden.' }); + refetchOnCallSchedules(); + } + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + setIsModalOpened(false); + }, 250); + }; + + if (!shift) { + return null; + } + + return ( + + Shift override + + + { + setUser(newValue); + }} + autoHighlight + getOptionLabel={a => ilertApi.getUserInitials(a)} + renderOption={a => ( +
+ {ilertApi.getUserInitials(a)} +
+ )} + renderInput={params => ( + + )} + /> + { + setStart(date ? date.toISO() : ''); + }} + /> + { + setEnd(date ? date.toISO() : ''); + }} + /> +
+
+ + + + +
+ ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx new file mode 100644 index 0000000000..36671bff90 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorActionsMenu.tsx @@ -0,0 +1,135 @@ +/* + * 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 { alertApiRef, useApi, Link } from '@backstage/core'; +import { IconButton, Menu, MenuItem, Typography } from '@material-ui/core'; +import MoreVertIcon from '@material-ui/icons/MoreVert'; + +import { ilertApiRef } from '../../api'; +import { UptimeMonitor } from '../../types'; + +export const UptimeMonitorActionsMenu = ({ + uptimeMonitor, + onUptimeMonitorChanged, +}: { + uptimeMonitor: UptimeMonitor; + onUptimeMonitorChanged?: (uptimeMonitor: UptimeMonitor) => void; +}) => { + const ilertApi = useApi(ilertApiRef); + const alertApi = useApi(alertApiRef); + const [anchorEl, setAnchorEl] = React.useState(null); + const callback = onUptimeMonitorChanged || ((_: UptimeMonitor): void => {}); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const handlePause = async (): Promise => { + try { + const newUptimeMonitor = await ilertApi.pauseUptimeMonitor(uptimeMonitor); + handleCloseMenu(); + alertApi.post({ message: 'Uptime monitor paused.' }); + + callback(newUptimeMonitor); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleResume = async (): Promise => { + try { + const newUptimeMonitor = await ilertApi.resumeUptimeMonitor( + uptimeMonitor, + ); + handleCloseMenu(); + alertApi.post({ message: 'Uptime monitor resumed.' }); + + callback(newUptimeMonitor); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }; + + const handleOpenReport = async (): Promise => { + try { + const um = await ilertApi.fetchUptimeMonitor(uptimeMonitor.id); + handleCloseMenu(); + window.open(um.shareUrl, '_blank'); + } catch (err) { + alertApi.post({ message: err, severity: 'error' }); + } + }; + + return ( + <> + + + + + {uptimeMonitor.paused ? ( + + + Resume + + + ) : null} + + {!uptimeMonitor.paused ? ( + + + Pause + + + ) : null} + + + + + View Report + + + + + + + + View in iLert + + + + + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.tsx new file mode 100644 index 0000000000..507e0208e2 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/UptimeMonitorLink.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 React from 'react'; +import { useApi, Link } from '@backstage/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { UptimeMonitor } from '../../types'; +import { ilertApiRef } from '../../api'; + +const useStyles = makeStyles({ + link: { + lineHeight: '22px', + }, +}); + +export const UptimeMonitorLink = ({ + uptimeMonitor, +}: { + uptimeMonitor: UptimeMonitor | null; +}) => { + const ilertApi = useApi(ilertApiRef); + const classes = useStyles(); + + if (!uptimeMonitor) { + return null; + } + + return ( + + #{uptimeMonitor.id} + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitor/index.ts b/plugins/ilert/src/components/UptimeMonitor/index.ts new file mode 100644 index 0000000000..f30d1ef15a --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitor/index.ts @@ -0,0 +1,16 @@ +/* + * 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 * from './UptimeMonitorActionsMenu'; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.tsx new file mode 100644 index 0000000000..dd17b1758b --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/StatusChip.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 Chip from '@material-ui/core/Chip'; +import { withStyles } from '@material-ui/core/styles'; +import { UptimeMonitor } from '../../types'; + +const UpChip = withStyles({ + root: { + backgroundColor: '#4caf50', + color: 'white', + margin: 0, + }, +})(Chip); + +const DownChip = withStyles({ + root: { + backgroundColor: '#d32f2f', + color: 'white', + margin: 0, + }, +})(Chip); + +const UnknownChip = withStyles({ + root: { + backgroundColor: '#92949c', + color: 'white', + margin: 0, + }, +})(Chip); + +export const uptimeMonitorStatusLabels = { + ['up']: 'Up', + ['down']: 'Down', + ['unknown']: 'Unknown', +} as Record; + +export const StatusChip = ({ + uptimeMonitor, +}: { + uptimeMonitor: UptimeMonitor; +}) => { + let label = `${uptimeMonitorStatusLabels[uptimeMonitor.status]}`; + + if (uptimeMonitor.paused) { + label = 'Paused'; + return ; + } + + switch (uptimeMonitor.status) { + case 'up': + return ; + case 'down': + return ; + case 'unknown': + return ; + default: + return ; + } +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx new file mode 100644 index 0000000000..3fdecd65e9 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorCheckType.tsx @@ -0,0 +1,39 @@ +/* + * 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 { UptimeMonitor } from '../../types'; +import Typography from '@material-ui/core/Typography'; + +export const UptimeMonitorCheckType = ({ + uptimeMonitor, +}: { + uptimeMonitor: UptimeMonitor; +}) => { + switch (uptimeMonitor.region) { + case 'EU': + return ( + {`${uptimeMonitor.checkType.toUpperCase()} 🇩🇪`} + ); + default: + return ( + {`${uptimeMonitor.checkType.toUpperCase()} 🇺🇸`} + ); + } +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.tsx new file mode 100644 index 0000000000..9769fa408d --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsPage.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 { + Content, + ContentHeader, + SupportButton, + ResponseErrorPanel, +} from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { UptimeMonitorsTable } from './UptimeMonitorsTable'; +import { MissingAuthorizationHeaderError } from '../Errors'; +import { useUptimeMonitors } from '../../hooks/useUptimeMonitors'; + +export const UptimeMonitorsPage = () => { + const [ + { tableState, uptimeMonitors, isLoading, error }, + { onChangePage, onChangeRowsPerPage, onUptimeMonitorChanged }, + ] = useUptimeMonitors(); + + if (error) { + if (error instanceof AuthenticationError) { + return ( + + + + ); + } + + return ( + + + + ); + } + + return ( + + + + This helps you to bring iLert into your developer portal. + + + + + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx new file mode 100644 index 0000000000..2173f8e1b7 --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/UptimeMonitorsTable.tsx @@ -0,0 +1,176 @@ +/* + * 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 { makeStyles } from '@material-ui/core/styles'; +import { Table, TableColumn } from '@backstage/core'; +import { TableState } from '../../api'; +import { UptimeMonitor } from '../../types'; +import { StatusChip } from './StatusChip'; +import Typography from '@material-ui/core/Typography'; +import { DateTime as dt, Interval } from 'luxon'; +import humanizeDuration from 'humanize-duration'; +import { EscalationPolicyLink } from '../EscalationPolicy/EscalationPolicyLink'; +import { UptimeMonitorCheckType } from './UptimeMonitorCheckType'; +import { UptimeMonitorActionsMenu } from '../UptimeMonitor/UptimeMonitorActionsMenu'; +import { UptimeMonitorLink } from '../UptimeMonitor/UptimeMonitorLink'; + +const useStyles = makeStyles(theme => ({ + empty: { + padding: theme.spacing(2), + display: 'flex', + justifyContent: 'center', + }, +})); + +export const UptimeMonitorsTable = ({ + uptimeMonitors, + tableState, + isLoading, + onChangePage, + onChangeRowsPerPage, + onUptimeMonitorChanged, +}: { + uptimeMonitors: UptimeMonitor[]; + tableState: TableState; + isLoading: boolean; + onChangePage: (page: number) => void; + onChangeRowsPerPage: (pageSize: number) => void; + onUptimeMonitorChanged: (uptimeMonitor: UptimeMonitor) => void; +}) => { + const classes = useStyles(); + + const smColumnStyle = { + width: '5%', + maxWidth: '5%', + }; + const mdColumnStyle = { + width: '10%', + maxWidth: '10%', + }; + const lgColumnStyle = { + width: '15%', + maxWidth: '15%', + }; + + const columns: TableColumn[] = [ + { + title: 'ID', + field: 'id', + highlight: true, + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Name', + field: 'name', + render: rowData => ( + {(rowData as UptimeMonitor).name} + ), + }, + { + title: 'Check Type', + field: 'checkType', + cellStyle: lgColumnStyle, + headerStyle: lgColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Last state change', + field: 'lastStatusChange', + type: 'datetime', + cellStyle: mdColumnStyle, + headerStyle: mdColumnStyle, + render: rowData => ( + + {humanizeDuration( + Interval.fromDateTimes( + dt.fromISO((rowData as UptimeMonitor).lastStatusChange), + dt.now(), + ) + .toDuration() + .valueOf(), + { units: ['h', 'm', 's'], largest: 2, round: true }, + )} + + ), + }, + { + title: 'Escalation policy', + field: 'assignedTo', + cellStyle: lgColumnStyle, + headerStyle: lgColumnStyle, + render: rowData => ( + + ), + }, + { + title: 'Status', + field: 'status', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + ), + }, + { + title: '', + field: '', + cellStyle: smColumnStyle, + headerStyle: smColumnStyle, + render: rowData => ( + + ), + }, + ]; + + return ( +
+ No uptime monitor + + } + page={tableState.page} + onChangePage={onChangePage} + onChangeRowsPerPage={onChangeRowsPerPage} + localization={{ header: { actions: undefined } }} + isLoading={isLoading} + columns={columns} + data={uptimeMonitors} + /> + ); +}; diff --git a/plugins/ilert/src/components/UptimeMonitorsPage/index.ts b/plugins/ilert/src/components/UptimeMonitorsPage/index.ts new file mode 100644 index 0000000000..d1a8809a4e --- /dev/null +++ b/plugins/ilert/src/components/UptimeMonitorsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './UptimeMonitorsPage'; +export * from './UptimeMonitorsTable'; diff --git a/plugins/ilert/src/components/index.ts b/plugins/ilert/src/components/index.ts new file mode 100644 index 0000000000..81f7e02e23 --- /dev/null +++ b/plugins/ilert/src/components/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 * from './ILertPage'; +export * from './ILertCard'; diff --git a/plugins/ilert/src/constants.ts b/plugins/ilert/src/constants.ts new file mode 100644 index 0000000000..bba644785f --- /dev/null +++ b/plugins/ilert/src/constants.ts @@ -0,0 +1,16 @@ +/* + * 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 ILERT_INTEGRATION_KEY_ANNOTATION = 'ilert.com/integration-key'; diff --git a/plugins/ilert/src/hooks/index.ts b/plugins/ilert/src/hooks/index.ts new file mode 100644 index 0000000000..6e7e207112 --- /dev/null +++ b/plugins/ilert/src/hooks/index.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 { useEntity } from '@backstage/plugin-catalog-react'; + +import { ILERT_INTEGRATION_KEY_ANNOTATION } from '../constants'; + +export function useILertEntity() { + const { entity } = useEntity(); + const integrationKey = + entity.metadata.annotations?.[ILERT_INTEGRATION_KEY_ANNOTATION] || ''; + const name = entity.metadata.name; + const identifier = `${entity.kind}:${ + entity.metadata.namespace || 'default' + }/${entity.metadata.name}`; + + return { integrationKey, name, identifier }; +} diff --git a/plugins/ilert/src/hooks/useAlertSource.ts b/plugins/ilert/src/hooks/useAlertSource.ts new file mode 100644 index 0000000000..15cc213b48 --- /dev/null +++ b/plugins/ilert/src/hooks/useAlertSource.ts @@ -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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { AlertSource, UptimeMonitor } from '../types'; + +export const useAlertSource = (integrationKey: string) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [alertSource, setAlertSource] = React.useState( + null, + ); + const [isAlertSourceLoading, setIsAlertSourceLoading] = React.useState(false); + const [ + uptimeMonitor, + setUptimeMonitor, + ] = React.useState(null); + const [isUptimeMonitorLoading, setIsUptimeMonitorLoading] = React.useState( + false, + ); + + const fetchAlertSourceCall = async () => { + try { + if (!integrationKey) { + return; + } + setIsAlertSourceLoading(true); + const data = await ilertApi.fetchAlertSource(integrationKey); + setAlertSource(data || null); + setIsAlertSourceLoading(false); + } catch (e) { + setIsAlertSourceLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { + error: alertSourceError, + retry: alertSourceRetry, + } = useAsyncRetry(fetchAlertSourceCall, [integrationKey]); + + const fetchUptimeMonitorCall = async () => { + try { + if (!alertSource || alertSource.integrationType !== 'MONITOR') { + return; + } + setIsUptimeMonitorLoading(true); + const data = await ilertApi.fetchUptimeMonitor(alertSource.id); + setUptimeMonitor(data || null); + setIsUptimeMonitorLoading(false); + } catch (e) { + setIsUptimeMonitorLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { + error: uptimeMonitorError, + retry: uptimeMonitorRetry, + } = useAsyncRetry(fetchUptimeMonitorCall, [alertSource]); + + const retry = () => { + alertSourceRetry(); + uptimeMonitorRetry(); + }; + + return [ + { + alertSource, + uptimeMonitor, + error: alertSourceError || uptimeMonitorError, + isLoading: isAlertSourceLoading || isUptimeMonitorLoading, + }, + { + retry, + setIsLoading: setIsAlertSourceLoading, + refetchAlertSource: fetchAlertSourceCall, + setAlertSource, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts b/plugins/ilert/src/hooks/useAlertSourceOnCalls.ts new file mode 100644 index 0000000000..0fbb190a02 --- /dev/null +++ b/plugins/ilert/src/hooks/useAlertSourceOnCalls.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 React from 'react'; +import { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { AlertSource, OnCall } from '../types'; + +export const useAlertSourceOnCalls = (alertSource?: AlertSource | null) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [onCallsList, setOnCallsList] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchAlertSourceOnCallsCall = async () => { + try { + if (!alertSource) { + return; + } + setIsLoading(true); + const data = await ilertApi.fetchAlertSourceOnCalls(alertSource); + setOnCallsList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { error, retry } = useAsyncRetry(fetchAlertSourceOnCallsCall, [ + alertSource, + ]); + + return [ + { + onCalls: onCallsList, + error, + isLoading, + }, + { + retry, + setIsLoading, + refetchAlertSourceOnCalls: fetchAlertSourceOnCallsCall, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useAssignIncident.ts b/plugins/ilert/src/hooks/useAssignIncident.ts new file mode 100644 index 0000000000..8f0f663d2b --- /dev/null +++ b/plugins/ilert/src/hooks/useAssignIncident.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { Incident, IncidentResponder } from '../types'; + +export const useAssignIncident = (incident: Incident | null, open: boolean) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [incidentRespondersList, setIncidentRespondersList] = React.useState< + IncidentResponder[] + >([]); + const [ + incidentResponder, + setIncidentResponder, + ] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + if (!incident || !open) { + return; + } + const data = await ilertApi.fetchIncidentResponders(incident); + if (data && Array.isArray(data)) { + const groups = [ + 'SUGGESTED', + 'USER', + 'ESCALATION_POLICY', + 'ON_CALL_SCHEDULE', + ]; + data.sort((a, b) => groups.indexOf(a.group) - groups.indexOf(b.group)); + setIncidentRespondersList(data); + } + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [incident, open]); + + return [ + { + incidentRespondersList, + incidentResponder, + error, + isLoading, + }, + { + setIncidentRespondersList, + setIncidentResponder, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useIncidentActions.ts b/plugins/ilert/src/hooks/useIncidentActions.ts new file mode 100644 index 0000000000..e23341ed9e --- /dev/null +++ b/plugins/ilert/src/hooks/useIncidentActions.ts @@ -0,0 +1,62 @@ +/* + * 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { Incident, IncidentAction } from '../types'; + +export const useIncidentActions = ( + incident: Incident | null, + open: boolean, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [incidentActionsList, setIncidentActionsList] = React.useState< + IncidentAction[] + >([]); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + if (!incident || !open) { + return; + } + const data = await ilertApi.fetchIncidentActions(incident); + setIncidentActionsList(data); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [incident, open]); + + return [ + { + incidentActions: incidentActionsList, + error, + isLoading, + }, + { + setIncidentActionsList, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useIncidents.ts b/plugins/ilert/src/hooks/useIncidents.ts new file mode 100644 index 0000000000..5f130fb20c --- /dev/null +++ b/plugins/ilert/src/hooks/useIncidents.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 React from 'react'; +import { GetIncidentsOpts, ilertApiRef, TableState } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { + ACCEPTED, + PENDING, + Incident, + IncidentStatus, + AlertSource, +} from '../types'; + +export const useIncidents = ( + paging: boolean, + singleSource?: boolean, + alertSource?: AlertSource | null, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [tableState, setTableState] = React.useState({ + page: 0, + pageSize: 10, + }); + const [states, setStates] = React.useState([ + ACCEPTED, + PENDING, + ]); + const [incidentsList, setIncidentsList] = React.useState([]); + const [incidentsCount, setIncidentsCount] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchIncidentsCall = async () => { + try { + if (singleSource && !alertSource) { + return; + } + setIsLoading(true); + const opts: GetIncidentsOpts = { + states, + alertSources: alertSource ? [alertSource.id] : [], + }; + if (paging) { + opts.maxResults = tableState.pageSize; + opts.startIndex = tableState.page * tableState.pageSize; + } + const data = await ilertApi.fetchIncidents(opts); + setIncidentsList(data || []); + setIsLoading(false); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + setIsLoading(false); + throw e; + } + }; + + const fetchIncidentsCountCall = async () => { + try { + const count = await ilertApi.fetchIncidentsCount({ states }); + setIncidentsCount(count || 0); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + const fetchIncidents = useAsyncRetry(fetchIncidentsCall, [ + tableState, + states, + singleSource, + alertSource, + ]); + + const refetchIncidents = () => { + setTableState({ ...tableState, page: 0 }); + Promise.all([fetchIncidentsCall(), fetchIncidentsCountCall()]); + }; + + const fetchIncidentsCount = useAsyncRetry(fetchIncidentsCountCall, [states]); + + const error = fetchIncidents.error || fetchIncidentsCount.error; + const retry = () => { + fetchIncidents.retry(); + fetchIncidentsCount.retry(); + }; + + const onIncidentChanged = (newIncident: Incident) => { + let shouldRefetchIncidents = false; + setIncidentsList( + incidentsList.reduce((acc: Incident[], incident: Incident) => { + if (newIncident.id === incident.id) { + if (states.includes(newIncident.status)) { + acc.push(newIncident); + } else { + shouldRefetchIncidents = true; + } + return acc; + } + acc.push(incident); + return acc; + }, []), + ); + if (shouldRefetchIncidents) { + refetchIncidents(); + } + }; + + const onChangePage = (page: number) => { + setTableState({ ...tableState, page }); + }; + const onChangeRowsPerPage = (p: number) => { + setTableState({ ...tableState, pageSize: p }); + }; + const onIncidentStatesChange = (s: IncidentStatus[]) => { + setStates(s); + }; + + return [ + { + tableState, + states, + incidents: incidentsList, + incidentsCount, + error, + isLoading, + }, + { + setTableState, + setStates, + setIncidentsList, + setIsLoading, + retry, + onIncidentChanged, + refetchIncidents, + onChangePage, + onChangeRowsPerPage, + onIncidentStatesChange, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useNewIncident.ts b/plugins/ilert/src/hooks/useNewIncident.ts new file mode 100644 index 0000000000..6fbcfbb5a5 --- /dev/null +++ b/plugins/ilert/src/hooks/useNewIncident.ts @@ -0,0 +1,78 @@ +/* + * 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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { AlertSource } from '../types'; + +export const useNewIncident = ( + open: boolean, + initialAlertSource?: AlertSource | null, +) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [alertSourcesList, setAlertSourcesList] = React.useState( + [], + ); + const [alertSource, setAlertSource] = React.useState( + null, + ); + const [summary, setSummary] = React.useState(''); + const [details, setDetails] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchAlertSources = useAsyncRetry(async () => { + try { + if (!open || initialAlertSource) { + return; + } + const count = await ilertApi.fetchAlertSources(); + setAlertSourcesList(count || 0); + } catch (e) { + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [open]); + + const error = fetchAlertSources.error; + const retry = () => { + fetchAlertSources.retry(); + }; + + return [ + { + alertSources: alertSourcesList, + alertSource: initialAlertSource ? initialAlertSource : alertSource, + summary, + details, + error, + isLoading, + }, + { + setAlertSourcesList, + setAlertSource, + setSummary, + setDetails, + setIsLoading, + retry, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useOnCallSchedules.ts b/plugins/ilert/src/hooks/useOnCallSchedules.ts new file mode 100644 index 0000000000..ce76a0770c --- /dev/null +++ b/plugins/ilert/src/hooks/useOnCallSchedules.ts @@ -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 React from 'react'; +import { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { Schedule } from '../types'; + +export const useOnCallSchedules = () => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [onCallSchedulesList, setOnCallSchedulesList] = React.useState< + Schedule[] + >([]); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchOnCallSchedulesCall = async () => { + try { + setIsLoading(true); + const data = await ilertApi.fetchOnCallSchedules(); + setOnCallSchedulesList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }; + + const { error, retry } = useAsyncRetry(fetchOnCallSchedulesCall, []); + + return [ + { + onCallSchedules: onCallSchedulesList, + error, + isLoading, + }, + { + retry, + setIsLoading, + refetchOnCallSchedules: fetchOnCallSchedulesCall, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useShiftOverride.ts b/plugins/ilert/src/hooks/useShiftOverride.ts new file mode 100644 index 0000000000..136d20d286 --- /dev/null +++ b/plugins/ilert/src/hooks/useShiftOverride.ts @@ -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 { ilertApiRef } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { User, Shift } from '../types'; + +export const useShiftOverride = (s: Shift, isModalOpened: boolean) => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [shift, setShift] = React.useState(s); + const [usersList, setUsersList] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + if (!isModalOpened) { + return; + } + setIsLoading(true); + const data = await ilertApi.fetchUsers(); + setUsersList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [isModalOpened]); + + const setUser = (user: User) => { + setShift({ ...shift, user }); + }; + + const setStart = (start: string) => { + setShift({ ...shift, start }); + }; + + const setEnd = (end: string) => { + setShift({ ...shift, end }); + }; + + return [ + { + shift, + users: usersList, + user: shift.user, + start: shift.start, + end: shift.end, + error, + isLoading, + }, + { + retry, + setIsLoading, + setUser, + setStart, + setEnd, + }, + ] as const; +}; diff --git a/plugins/ilert/src/hooks/useUptimeMonitors.ts b/plugins/ilert/src/hooks/useUptimeMonitors.ts new file mode 100644 index 0000000000..fa1576642a --- /dev/null +++ b/plugins/ilert/src/hooks/useUptimeMonitors.ts @@ -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 { ilertApiRef, TableState } from '../api'; +import { useApi, errorApiRef } from '@backstage/core'; +import { AuthenticationError } from '@backstage/errors'; +import { useAsyncRetry } from 'react-use'; +import { UptimeMonitor } from '../types'; + +export const useUptimeMonitors = () => { + const ilertApi = useApi(ilertApiRef); + const errorApi = useApi(errorApiRef); + + const [tableState, setTableState] = React.useState({ + page: 0, + pageSize: 10, + }); + const [uptimeMonitorsList, setUptimeMonitorsList] = React.useState< + UptimeMonitor[] + >([]); + const [isLoading, setIsLoading] = React.useState(false); + + const { error, retry } = useAsyncRetry(async () => { + try { + setIsLoading(true); + const data = await ilertApi.fetchUptimeMonitors(); + setUptimeMonitorsList(data || []); + setIsLoading(false); + } catch (e) { + setIsLoading(false); + if (!(e instanceof AuthenticationError)) { + errorApi.post(e); + } + throw e; + } + }, [tableState]); + + const onUptimeMonitorChanged = (newUptimeMonitor: UptimeMonitor) => { + setUptimeMonitorsList( + uptimeMonitorsList.map( + (uptimeMonitor: UptimeMonitor): UptimeMonitor => { + if (newUptimeMonitor.id === uptimeMonitor.id) { + return newUptimeMonitor; + } + + return uptimeMonitor; + }, + ), + ); + }; + + const onChangePage = (page: number) => { + setTableState({ ...tableState, page }); + }; + const onChangeRowsPerPage = (pageSize: number) => { + setTableState({ ...tableState, pageSize }); + }; + + return [ + { + tableState, + uptimeMonitors: uptimeMonitorsList, + error, + isLoading, + }, + { + setTableState, + setUptimeMonitorsList, + retry, + onUptimeMonitorChanged, + onChangePage, + onChangeRowsPerPage, + setIsLoading, + }, + ] as const; +}; diff --git a/plugins/ilert/src/index.ts b/plugins/ilert/src/index.ts new file mode 100644 index 0000000000..2e5301f151 --- /dev/null +++ b/plugins/ilert/src/index.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 { IconComponent } from '@backstage/core'; +import ILertIconComponent from './assets/ilert.icon.svg'; + +export { + ilertPlugin, + ilertPlugin as plugin, + ILertPage, + EntityILertCard, +} from './plugin'; +export { + ILertPage as Router, + isPluginApplicableToEntity, + isPluginApplicableToEntity as isILertAvailable, + ILertCard, +} from './components'; +export * from './api'; +export * from './route-refs'; +export const ILertIcon: IconComponent = ILertIconComponent; diff --git a/plugins/ilert/src/plugin.test.ts b/plugins/ilert/src/plugin.test.ts new file mode 100644 index 0000000000..89a0cb231f --- /dev/null +++ b/plugins/ilert/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 { ilertPlugin } from './plugin'; + +describe('plugin-ilert', () => { + it('should export plugin', () => { + expect(ilertPlugin).toBeDefined(); + }); +}); diff --git a/plugins/ilert/src/plugin.ts b/plugins/ilert/src/plugin.ts new file mode 100644 index 0000000000..546b77911d --- /dev/null +++ b/plugins/ilert/src/plugin.ts @@ -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 { + configApiRef, + createApiFactory, + createPlugin, + discoveryApiRef, + identityApiRef, + createRoutableExtension, + createComponentExtension, +} from '@backstage/core'; +import { ILertClient, ilertApiRef } from './api'; +import { iLertRouteRef } from './route-refs'; + +export const ilertPlugin = createPlugin({ + id: 'ilert', + apis: [ + createApiFactory({ + api: ilertApiRef, + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, configApi }) => + ILertClient.fromConfig(configApi, discoveryApi), + }), + ], + routes: { + root: iLertRouteRef, + }, +}); + +export const ILertPage = ilertPlugin.provide( + createRoutableExtension({ + component: () => import('./components').then(m => m.ILertPage), + mountPoint: iLertRouteRef, + }), +); + +export const EntityILertCard = ilertPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/ILertCard').then(m => m.ILertCard), + }, + }), +); diff --git a/plugins/ilert/src/route-refs.tsx b/plugins/ilert/src/route-refs.tsx new file mode 100644 index 0000000000..b63ba15315 --- /dev/null +++ b/plugins/ilert/src/route-refs.tsx @@ -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 { createRouteRef } from '@backstage/core'; +import ILertIcon from './assets/ilert.icon.svg'; + +export const iLertRouteRef = createRouteRef({ + icon: ILertIcon, + path: '/ilert', + title: 'iLert', +}); diff --git a/plugins/ilert/src/setupTests.ts b/plugins/ilert/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/ilert/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/ilert/src/types.ts b/plugins/ilert/src/types.ts new file mode 100644 index 0000000000..d7746c9d9b --- /dev/null +++ b/plugins/ilert/src/types.ts @@ -0,0 +1,337 @@ +/* + * 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 interface Incident { + id: number; + summary: string; + details: string; + reportTime: string; + resolvedOn: string; + status: IncidentStatus; + priority: IncidentPriority; + incidentKey: string; + alertSource: AlertSource | null; + assignedTo: User | null; + logEntries: LogEntry[]; + links: Link[]; + images: Image[]; + subscribers: Subscriber[]; + commentText: string; + commentPublishToSubscribers: boolean; +} + +export const PENDING = 'PENDING'; +export const ACCEPTED = 'ACCEPTED'; +export const RESOLVED = 'RESOLVED'; +export type IncidentStatus = typeof PENDING | typeof ACCEPTED | typeof RESOLVED; +export type IncidentPriority = 'HIGH' | 'LOW'; + +export interface Link { + href: string; + text: string; +} + +export interface Image { + src: string; + href: string; + alt: string; +} + +export type SubscriberType = 'TEAM' | 'USER'; + +export interface Subscriber { + id: number; + name: string; + type: SubscriberType; +} + +export interface LogEntry { + id: number; + timestamp: string; + logEntryType: string; + text: string; + incidentId?: number; + iconName?: string; + iconClass?: string; + filterTypes?: string[]; +} + +export interface User { + id: number; + username: string; + firstName: string; + lastName: string; + email: string; + mobile: Phone; + landline: Phone; + timezone?: string; + language?: Language; + role?: UserRole; + notificationPreferences?: any[]; + position: string; + department: string; +} + +export type UserRole = + | 'USER' + | 'ADMIN' + | 'STAKEHOLDER' + | 'ACCOUNT_OWNER' + | 'RESPONDER'; +export type Language = 'de' | 'en'; +export interface Phone { + regionCode: string; + number: string; +} + +export interface AlertSource { + id: number; + name: string; + status: AlertSourceStatus; + escalationPolicy: EscalationPolicy; + integrationType: AlertSourceIntegrationType; + integrationKey?: string; + iconUrl?: string; + lightIconUrl?: string; + darkIconUrl?: string; + incidentCreation?: AlertSourceIncidentCreation; + incidentPriorityRule?: AlertSourceIncidentPriorityRule; + emailFiltered?: boolean; + emailResolveFiltered?: boolean; + active?: boolean; + emailPredicates?: AlertSourceEmailPredicate[]; + emailResolvePredicates?: AlertSourceEmailPredicate[]; + filterOperator?: AlertSourceFilterOperator; + resolveFilterOperator?: AlertSourceFilterOperator; + supportHours?: AlertSourceSupportHours; + heartbeat?: AlertSourceHeartbeat; + autotaskMetadata?: AlertSourceAutotaskMetadata; + autoResolutionTimeout?: string; + teams: TeamShort[]; +} + +export interface TeamShort { + id: number; + name: string; +} + +export interface TeamMember { + user: User; + role: 'STAKEHOLDER' | 'RESPONDER' | 'USER' | 'ADMIN'; +} + +export type AlertSourceStatus = + | 'PENDING' + | 'ALL_ACCEPTED' + | 'ALL_RESOLVED' + | 'IN_MAINTENANCE' + | 'DISABLED'; +export type AlertSourceIntegrationType = + | 'NAGIOS' + | 'ICINGA' + | 'EMAIL' + | 'SMS' + | 'API' + | 'CRN' + | 'HEARTBEAT' + | 'PRTG' + | 'PINGDOM' + | 'CLOUDWATCH' + | 'AWSPHD' + | 'STACKDRIVER' + | 'INSTANA' + | 'ZABBIX' + | 'SOLARWINDS' + | 'PROMETHEUS' + | 'NEWRELIC' + | 'GRAFANA' + | 'GITHUB' + | 'DATADOG' + | 'UPTIMEROBOT' + | 'APPDYNAMICS' + | 'DYNATRACE' + | 'TOPDESK' + | 'STATUSCAKE' + | 'MONITOR' + | 'TOOL' + | 'CHECKMK' + | 'AUTOTASK' + | 'AWSBUDGET' + | 'KENTIXAM' + | 'CONSUL' + | 'ZAMMAD' + | 'SIGNALFX' + | 'SPLUNK' + | 'KUBERNETES' + | 'SEMATEXT' + | 'SENTRY' + | 'SUMOLOGIC' + | 'RAYGUN' + | 'MXTOOLBOX' + | 'ESWATCHER' + | 'AMAZONSNS' + | 'KAPACITOR' + | 'CORTEXXSOAR' + | string; +export type AlertSourceIncidentCreation = + | 'ONE_INCIDENT_PER_EMAIL' + | 'ONE_INCIDENT_PER_EMAIL_SUBJECT' + | 'ONE_PENDING_INCIDENT_ALLOWED' + | 'ONE_OPEN_INCIDENT_ALLOWED' + | 'OPEN_RESOLVE_ON_EXTRACTION'; +export type AlertSourceFilterOperator = 'AND' | 'OR'; +export type AlertSourceIncidentPriorityRule = + | 'HIGH' + | 'LOW' + | 'HIGH_DURING_SUPPORT_HOURS' + | 'LOW_DURING_SUPPORT_HOURS'; +export interface AlertSourceEmailPredicate { + field: 'EMAIL_FROM' | 'EMAIL_SUBJECT' | 'EMAIL_BODY'; + criteria: + | 'CONTAINS_ANY_WORDS' + | 'CONTAINS_NOT_WORDS' + | 'CONTAINS_STRING' + | 'CONTAINS_NOT_STRING' + | 'IS_STRING' + | 'IS_NOT_STRING' + | 'MATCHES_REGEX' + | 'MATCHES_NOT_REGEX'; + value: string; +} +export type AlertSourceTimeZone = + | 'Europe/Berlin' + | 'America/New_York' + | 'America/Los_Angeles' + | 'Asia/Istanbul'; +export interface AlertSourceSupportDay { + start: string; + end: string; +} +export interface AlertSourceSupportHours { + timezone: AlertSourceTimeZone; + autoRaiseIncidents: boolean; + supportDays: { + MONDAY: AlertSourceSupportDay; + TUESDAY: AlertSourceSupportDay; + WEDNESDAY: AlertSourceSupportDay; + THURSDAY: AlertSourceSupportDay; + FRIDAY: AlertSourceSupportDay; + SATURDAY: AlertSourceSupportDay; + SUNDAY: AlertSourceSupportDay; + }; +} +export interface AlertSourceHeartbeat { + summary: string; + intervalSec: number; + status: 'OVERDUE' | 'ON_TIME' | 'NEVER_RECEIVED'; +} + +export interface AlertSourceAutotaskMetadata { + userName: string; + secret: string; + apiIntegrationCode: string; + webServer: string; +} + +export interface EscalationPolicy { + id: number; + name: string; + escalationRules: EscalationRule[]; + newEscalationRule: EscalationRule; + repeating?: boolean; + frequency?: number; + teams: TeamShort[]; +} + +export interface EscalationRule { + user: User | null; + schedule: Schedule | null; + escalationTimeout: number; +} + +export interface Schedule { + id: number; + name: string; + timezone: string; + startsOn: string; + currentShift: Shift; + nextShift: Shift; + shifts: Shift[]; + overrides: Shift[]; + teams: TeamShort[]; +} + +export interface Shift { + user: User; + start: string; + end: string; +} + +export interface UptimeMonitor { + id: number; + name: string; + region: 'EU' | 'US'; + checkType: 'http' | 'tcp' | 'udp' | 'ping'; + checkParams: UptimeMonitorCheckParams; + intervalSec: number; + timeoutMs: number; + createIncidentAfterFailedChecks: number; + paused: boolean; + embedUrl: string; + shareUrl: string; + status: string; + lastStatusChange: string; + escalationPolicy: EscalationPolicy; + teams: TeamShort[]; +} + +export interface UptimeMonitorCheckParams { + host?: string; + port?: number; + url?: string; +} + +export interface IncidentResponder { + group: 'SUGGESTED' | 'USER' | 'ESCALATION_POLICY' | 'ON_CALL_SCHEDULE'; + id: number; + name: string; + disabled: boolean; +} + +export interface IncidentAction { + name: string; + type: string; + webhookId: string; + extensionId?: string; + history?: IncidentActionHistory[]; +} + +export interface IncidentActionHistory { + id: string; + webhookId: string; + incidentId: number; + actor: User; + success: boolean; +} + +export interface OnCall { + user: User; + escalationPolicy: EscalationPolicy; + schedule?: Schedule; + start: string; + end: string; + escalationLevel: number; +} 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 d1cabd2143..829d493034 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,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -44,15 +44,15 @@ "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 f29fb62a91..b71ac92e5a 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,27 @@ # @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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.2.3 ### Patch Changes diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index ef64ebfbf5..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.3", + "version": "0.2.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", - "@backstage/catalog-model": "^0.7.5", - "@backstage/config": "^0.1.4", + "@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", "express-promise-router": "^4.1.0", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@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 0963d3178f..b1664c823e 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,26 +20,26 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 8bcac8a60b..265b7eb414 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,29 @@ # @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 + +- f53fba29f: Adds @backstage/plugin-kubernetes-common library to share types between kubernetes frontend and backend. +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.3.5 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 3c8380b661..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.5", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,9 +31,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.3", - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@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", "@kubernetes/client-node": "^0.14.0", @@ -53,7 +53,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.9", + "@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 13f1a03d8e..cca63f6389 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,38 @@ # @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 + +- f53fba29f: Adds @backstage/plugin-kubernetes-common library to share types between kubernetes frontend and backend. +- Updated dependencies [f65adcde7] +- Updated dependencies [81c54d1f2] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [d8b81fd28] + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-react@0.1.5 + - @backstage/theme@0.2.7 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.4.2 ### Patch Changes diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 331e121d6c..9ab0af3f29 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.4.2", + "version": "0.4.4", "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.4", - "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.3", + "@backstage/catalog-model": "^0.7.9", + "@backstage/config": "^0.1.5", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", "@backstage/plugin-kubernetes-common": "^0.1.0", - "@backstage/theme": "^0.2.6", + "@backstage/theme": "^0.2.7", "@kubernetes/client-node": "^0.14.0", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,16 +46,16 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 2fce507756..8bb12bf0ce 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,11 +31,11 @@ "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.7", - "@backstage/plugin-catalog-react": "^0.1.2", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -43,15 +43,15 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 2dbb2a7eb8..5d99b98b40 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,22 +31,22 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 9ad4704630..b07279e287 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,11 +20,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.6", - "@backstage/core": "^0.7.7", - "@backstage/core-api": "^0.2.16", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@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", "@material-ui/lab": "4.0.0-alpha.45", @@ -32,15 +32,15 @@ "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 821b89a5b1..e57c53ebf3 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,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -43,15 +43,15 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 0fada2e81f..2890039b96 100644 --- a/plugins/proxy-backend/CHANGELOG.md +++ b/plugins/proxy-backend/CHANGELOG.md @@ -1,5 +1,24 @@ # @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 + +- cdb3426e5: Prefix proxy routes with `/` if not present in configuration +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/config@0.1.5 + ## 0.2.6 ### Patch Changes diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 39a6e128bd..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.6", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,8 +28,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.0", + "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", @@ -42,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.5", + "@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 bd51a7db0b..08d443fa26 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,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.5", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -42,15 +42,15 @@ "react-hook-form": "^6.15.4", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 8e20e93555..0318d3a111 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,23 @@ # @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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] + - @backstage/backend-common@0.7.0 + - @backstage/config@0.1.5 + ## 0.1.9 ### Patch Changes diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 05b02b2c39..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.9", + "version": "0.1.11", "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.6.3", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.0", + "@backstage/config": "^0.1.5", "@types/express": "^4.17.6", "axios": "^0.21.1", "camelcase-keys": "^6.2.2", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.9", + "@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 64ea010b54..a8d6380003 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,10 +31,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -44,16 +44,16 @@ "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-sparklines": "^1.7.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 f591568d44..db6044310c 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,117 @@ # @backstage/plugin-scaffolder-backend +## 0.11.3 + +### Patch Changes + +- 021eb366a: Instead of failing, warn when you need to pay for GitHub Pro. + +## 0.11.2 + +### Patch Changes + +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 65e6c4541: Remove circular dependencies +- 81d7b9c6f: Added deprecation warnings for `v1alpha1` templates +- 9962faa2b: Add branch protection for default branches of scaffolded GitHub repositories +- Updated dependencies [f7f7783a3] +- Updated dependencies [c7dad9218] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5001de908] + - @backstage/catalog-model@0.7.10 + - @backstage/backend-common@0.8.1 + - @backstage/integration@0.5.3 + +## 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 + +- e0bfd3d44: Migrate the plugin to use the `ContainerRunner` interface instead of `runDockerContainer(…)`. + It also provides the `ContainerRunner` to the individual templaters instead of to the `createRouter` function. + + To apply this change to an existing backend application, add the following to `src/plugins/scaffolder.ts`: + + ```diff + - import { SingleHostDiscovery } from '@backstage/backend-common'; + + import { + + DockerContainerRunner, + + SingleHostDiscovery, + + } from '@backstage/backend-common'; + + export default async function createPlugin({ + logger, + config, + database, + reader, + }: PluginEnvironment): Promise { + + const dockerClient = new Docker(); + + const containerRunner = new DockerContainerRunner({ dockerClient }); + + + const cookiecutterTemplater = new CookieCutter({ containerRunner }); + - const cookiecutterTemplater = new CookieCutter(); + + const craTemplater = new CreateReactAppTemplater({ containerRunner }); + - const craTemplater = new CreateReactAppTemplater(); + const templaters = new Templaters(); + + templaters.register('cookiecutter', cookiecutterTemplater); + templaters.register('cra', craTemplater); + + const preparers = await Preparers.fromConfig(config, { logger }); + const publishers = await Publishers.fromConfig(config, { logger }); + + - const dockerClient = new Docker(); + + const discovery = SingleHostDiscovery.fromConfig(config); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + + return await createRouter({ + preparers, + templaters, + publishers, + logger, + config, + - dockerClient, + database, + catalogClient, + reader, + }); + } + ``` + +### Patch Changes + +- 38ca05168: The default `@octokit/rest` dependency was bumped to `"^18.5.3"`. +- 69eefb5ae: Fix GithubPR built-in action `credentialsProvider.getCredentials` URL. + Adding Documentation for GitHub PR built-in action. +- 75c8cec39: bump `jsonschema` from 1.2.7 to 1.4.0 +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/backend-common@0.7.0 + - @backstage/integration@0.5.2 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + ## 0.10.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index bbeeecceb0..c135f97ec9 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.10.1", + "version": "0.11.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,16 +29,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.3", - "@backstage/catalog-client": "^0.3.10", - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.1", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.10", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.1", + "@backstage/integration": "^0.5.3", "@gitbeaker/core": "^28.0.2", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.5.3", - "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/git-url-parse": "^9.0.0", "azure-devops-node-api": "^10.1.1", @@ -46,7 +45,6 @@ "compression": "^1.7.4", "cors": "^2.8.5", "cross-fetch": "^3.0.6", - "dockerode": "^3.2.1", "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "^9.0.0", @@ -55,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", @@ -67,11 +64,12 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/test-utils": "^0.1.12", "@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/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 2d4b8fb4c5..0209fc50dd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -17,6 +17,7 @@ import { UrlReader } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { ScmIntegrations } from '@backstage/integration'; +import { TemplaterBuilder } from '../../stages'; import { createCatalogRegisterAction } from './catalog'; import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch'; import { @@ -26,23 +27,14 @@ import { createPublishGithubPullRequestAction, createPublishGitlabAction, } from './publish'; -import Docker from 'dockerode'; -import { TemplaterBuilder } from '../../stages'; export const createBuiltinActions = (options: { reader: UrlReader; integrations: ScmIntegrations; - dockerClient: Docker; catalogClient: CatalogApi; templaters: TemplaterBuilder; }) => { - const { - reader, - integrations, - dockerClient, - templaters, - catalogClient, - } = options; + const { reader, integrations, templaters, catalogClient } = options; return [ createFetchPlainAction({ @@ -52,7 +44,6 @@ export const createBuiltinActions = (options: { createFetchCookiecutterAction({ reader, integrations, - dockerClient, templaters, }), createPublishGithubAction({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts index 628107155d..a0f94816bd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.test.ts @@ -15,16 +15,16 @@ */ jest.mock('./helpers'); +import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import mock from 'mock-fs'; import os from 'os'; import { resolve as resolvePath } from 'path'; -import { createFetchCookiecutterAction } from './cookiecutter'; -import { ScmIntegrations } from '@backstage/integration'; -import { ConfigReader } from '@backstage/config'; -import { Templaters } from '../../../stages/templater'; import { PassThrough } from 'stream'; -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { Templaters } from '../../../stages/templater'; +import { createFetchCookiecutterAction } from './cookiecutter'; import { fetchContents } from './helpers'; -import mock from 'mock-fs'; describe('fetch:cookiecutter', () => { const integrations = ScmIntegrations.fromConfig( @@ -40,7 +40,6 @@ describe('fetch:cookiecutter', () => { const templaters = new Templaters(); const cookiecutterTemplater = { run: jest.fn() }; - const mockDockerClient = {}; const mockTmpDir = os.tmpdir(); const mockContext = { input: { @@ -67,7 +66,6 @@ describe('fetch:cookiecutter', () => { const action = createFetchCookiecutterAction({ integrations, templaters, - dockerClient: mockDockerClient as any, reader: mockReader, }); @@ -102,7 +100,6 @@ describe('fetch:cookiecutter', () => { expect(cookiecutterTemplater.run).toHaveBeenCalledWith({ workspacePath: mockTmpDir, - dockerClient: mockDockerClient, logStream: mockContext.logStream, values: mockContext.input.values, }); @@ -123,7 +120,6 @@ describe('fetch:cookiecutter', () => { expect(cookiecutterTemplater.run).toHaveBeenCalledWith({ workspacePath: mockTmpDir, - dockerClient: mockDockerClient, logStream: mockContext.logStream, values: { ...mockContext.input.values, @@ -166,7 +162,6 @@ describe('fetch:cookiecutter', () => { const newAction = createFetchCookiecutterAction({ integrations, templaters: templatersWithoutCookiecutter, - dockerClient: mockDockerClient as any, reader: mockReader, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts index fca791c7bd..83b373c3de 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts @@ -14,24 +14,22 @@ * limitations under the License. */ -import fs from 'fs-extra'; -import { resolve as resolvePath } from 'path'; -import Docker from 'dockerode'; import { UrlReader } from '@backstage/backend-common'; +import { JsonObject } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; -import { JsonObject } from '@backstage/config'; +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; import { TemplaterBuilder, TemplaterValues } from '../../../stages/templater'; -import { fetchContents } from './helpers'; import { createTemplateAction } from '../../createTemplateAction'; +import { fetchContents } from './helpers'; export function createFetchCookiecutterAction(options: { - dockerClient: Docker; reader: UrlReader; integrations: ScmIntegrations; templaters: TemplaterBuilder; }) { - const { dockerClient, reader, templaters, integrations } = options; + const { reader, templaters, integrations } = options; return createTemplateAction<{ url: string; @@ -134,7 +132,6 @@ export function createFetchCookiecutterAction(options: { // Will execute the template in ./template and put the result in ./result await cookiecutter.run({ workspacePath: workDir, - dockerClient, logStream: ctx.logStream, values, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts index 66dfc1d655..a3befa0dfe 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -19,7 +19,10 @@ import { ScmIntegrationRegistry, } from '@backstage/integration'; import { Octokit } from '@octokit/rest'; -import { initRepoAndPush } from '../../../stages/publish/helpers'; +import { + enableBranchProtectionOnDefaultRepoBranch, + initRepoAndPush, +} from '../../../stages/publish/helpers'; import { getRepoSourceDirectory, parseRepoUrl } from './util'; import { createTemplateAction } from '../../createTemplateAction'; @@ -171,7 +174,7 @@ export function createPublishGithubAction(options: { description: description, }); - const { data } = await repoCreationPromise; + const { data: newRepo } = await repoCreationPromise; if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); await client.teams.addOrUpdateRepoPermissionsInOrg({ @@ -212,8 +215,8 @@ export function createPublishGithubAction(options: { } } - const remoteUrl = data.clone_url; - const repoContentsUrl = `${data.html_url}/blob/master`; + const remoteUrl = newRepo.clone_url; + const repoContentsUrl = `${newRepo.html_url}/blob/master`; await initRepoAndPush({ dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath), @@ -225,6 +228,19 @@ export function createPublishGithubAction(options: { logger: ctx.logger, }); + try { + await enableBranchProtectionOnDefaultRepoBranch({ + owner, + client, + repoName: newRepo.name, + logger: ctx.logger, + }); + } catch (e) { + throw new Error( + `Failed to add branch protection to '${newRepo.name}', ${e}`, + ); + } + ctx.output('remoteUrl', remoteUrl); ctx.output('repoContentsUrl', repoContentsUrl); }, diff --git a/plugins/scaffolder-backend/src/scaffolder/index.ts b/plugins/scaffolder-backend/src/scaffolder/index.ts index 9de10ca1eb..e4bdc73f49 100644 --- a/plugins/scaffolder-backend/src/scaffolder/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/index.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export { createLegacyActions } from './stages/legacy'; export * from './stages'; export * from './jobs'; export * from './actions'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index c782037ec8..c1da51c40e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -60,6 +60,7 @@ describe('JobProcessor', () => { }, }, }, + owner: 'example@email.com', }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index 425fc07946..005df09fec 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -19,20 +19,12 @@ import fs from 'fs-extra'; import { Processor, Job, StageContext, StageInput } from './types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import * as uuid from 'uuid'; -import Docker from 'dockerode'; import path from 'path'; -import { TemplaterValues, TemplaterBase } from '../stages/templater'; -import { PreparerBuilder } from '../stages/prepare'; +import { TemplaterValues } from '../stages/templater'; import { makeLogStream } from './logger'; import { Logger } from 'winston'; import { Config } from '@backstage/config'; -export type JobProcessorArguments = { - preparers: PreparerBuilder; - templater: TemplaterBase; - dockerClient: Docker; -}; - export type JobAndDirectoryTuple = { job: Job; directory: string; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index fc36eb64e5..c314f7c727 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -55,6 +55,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-d@example.com', }, }; @@ -103,6 +104,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -151,6 +153,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-a@example.com', }, }; @@ -198,6 +201,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-b@example.com', }, }; @@ -243,6 +247,7 @@ describe('Helpers', () => { }, }, }, + owner: 'team-c@example.com', }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/index.ts index ef23af1b3e..dfe49a8aa5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/index.ts @@ -17,5 +17,3 @@ export * from './prepare'; export * from './publish'; export * from './templater'; export * from './helpers'; - -export { createLegacyActions } from './legacy'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts index c7c52fa320..bb7ab40d57 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/legacy.ts @@ -14,21 +14,19 @@ * limitations under the License. */ -import { FilePreparer, PreparerBuilder } from './prepare'; -import Docker from 'dockerode'; -import { TemplaterBuilder, TemplaterValues } from './templater'; -import { PublisherBuilder } from './publish'; import { createTemplateAction } from '../actions'; +import { FilePreparer, PreparerBuilder } from './prepare'; +import { PublisherBuilder } from './publish'; +import { TemplaterBuilder, TemplaterValues } from './templater'; type Options = { - dockerClient: Docker; preparers: PreparerBuilder; templaters: TemplaterBuilder; publishers: PublisherBuilder; }; export function createLegacyActions(options: Options) { - const { dockerClient, preparers, templaters, publishers } = options; + const { preparers, templaters, publishers } = options; return [ createTemplateAction({ @@ -55,7 +53,6 @@ export function createLegacyActions(options: Options) { const templater = templaters.get(ctx.input.templater as string); await templater.run({ workspacePath: ctx.workspacePath, - dockerClient, logStream: ctx.logStream, values: ctx.input.values as TemplaterValues, }); 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-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 829856ec0c..d859d97dc3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -15,7 +15,10 @@ */ import { PublisherBase, PublisherOptions, PublisherResult } from './types'; -import { initRepoAndPush } from './helpers'; +import { + enableBranchProtectionOnDefaultRepoBranch, + initRepoAndPush, +} from './helpers'; import { GitHubIntegrationConfig, GithubCredentialsProvider, @@ -26,6 +29,7 @@ import path from 'path'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; +/** @deprecated use createPublishGithubAction instead */ export class GithubPublisher implements PublisherBase { static async fromConfig( config: GitHubIntegrationConfig, @@ -99,6 +103,18 @@ export class GithubPublisher implements PublisherBase { /\.git$/, '/blob/master/catalog-info.yaml', ); + + try { + await enableBranchProtectionOnDefaultRepoBranch({ + owner, + client, + repoName: name, + logger, + }); + } catch (e) { + throw new Error(`Failed to add branch protection to '${name}', ${e}`); + } + return { remoteUrl, catalogInfoUrl }; } @@ -130,7 +146,7 @@ export class GithubPublisher implements PublisherBase { description, }); - const { data } = await repoCreationPromise; + const { data: newRepo } = await repoCreationPromise; try { if (access?.startsWith(`${owner}/`)) { @@ -156,6 +172,7 @@ export class GithubPublisher implements PublisherBase { `Failed to add access to '${access}'. Status ${e.status} ${e.message}`, ); } - return data?.clone_url; + + return newRepo.clone_url; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts index ffefb88cf0..8531dbcdc8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/helpers.ts @@ -17,6 +17,7 @@ import globby from 'globby'; import { Logger } from 'winston'; import { Git } from '@backstage/backend-common'; +import { Octokit } from '@octokit/rest'; export async function initRepoAndPush({ dir, @@ -67,3 +68,65 @@ export async function initRepoAndPush({ remote: 'origin', }); } + +type BranchProtectionOptions = { + client: Octokit; + owner: string; + repoName: string; + logger: Logger; +}; + +export const enableBranchProtectionOnDefaultRepoBranch = async ({ + repoName, + client, + owner, + logger, +}: BranchProtectionOptions): Promise => { + const tryOnce = async () => { + try { + await client.repos.updateBranchProtection({ + mediaType: { + /** + * 👇 we need this preview because allowing a custom + * reviewer count on branch protection is a preview + * feature + * + * More here: https://docs.github.com/en/rest/overview/api-previews#require-multiple-approving-reviews + */ + previews: ['luke-cage-preview'], + }, + owner, + repo: repoName, + branch: 'master', + required_status_checks: { strict: true, contexts: [] }, + restrictions: null, + enforce_admins: true, + required_pull_request_reviews: { required_approving_review_count: 1 }, + }); + } catch (e) { + if ( + e.message.includes( + 'Upgrade to GitHub Pro or make this repository public to enable this feature', + ) + ) { + logger.warn( + 'Branch protection was not enabled as it requires GitHub Pro for private repositories', + ); + } else { + throw e; + } + } + }; + + try { + await tryOnce(); + } catch (e) { + if (!e.message.includes('Branch not found')) { + throw e; + } + + // GitHub has eventual consistency. Fail silently, wait, and try again. + await new Promise(resolve => setTimeout(resolve, 600)); + await tryOnce(); + } +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 7806f089ff..59fb72d515 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -14,16 +14,14 @@ * limitations under the License. */ -const runDockerContainer = jest.fn(); const runCommand = jest.fn(); const commandExists = jest.fn(); jest.mock('./helpers', () => ({ runCommand })); -jest.mock('@backstage/backend-common', () => ({ runDockerContainer })); jest.mock('command-exists-promise', () => commandExists); jest.mock('fs-extra'); -import Docker from 'dockerode'; +import { ContainerRunner } from '@backstage/backend-common'; import fs from 'fs-extra'; import parseGitUrl from 'git-url-parse'; import path from 'path'; @@ -31,7 +29,9 @@ import { PassThrough } from 'stream'; import { CookieCutter } from './cookiecutter'; describe('CookieCutter Templater', () => { - const mockDocker = {} as Docker; + const containerRunner: jest.Mocked = { + runContainer: jest.fn(), + }; beforeEach(() => { jest.clearAllMocks(); @@ -50,11 +50,10 @@ describe('CookieCutter Templater', () => { jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await templater.run({ workspacePath: 'tempdir', values, - dockerClient: mockDocker, }); expect(fs.ensureDir).toBeCalledWith(path.join('tempdir', 'intermediate')); @@ -83,11 +82,10 @@ describe('CookieCutter Templater', () => { }, }; - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await templater.run({ workspacePath: 'tempdir', values, - dockerClient: mockDocker, }); expect(fs.writeJSON).toBeCalledWith( @@ -115,12 +113,11 @@ describe('CookieCutter Templater', () => { }, }; - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await expect( templater.run({ workspacePath: 'tempdir', values, - dockerClient: mockDocker, }), ).rejects.toThrow('BAM'); }); @@ -140,23 +137,16 @@ describe('CookieCutter Templater', () => { .spyOn(fs, 'realpath') .mockImplementation(x => Promise.resolve(x.toString())); - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await templater.run({ workspacePath: 'tempdir', values, - dockerClient: mockDocker, }); - expect(runDockerContainer).toHaveBeenCalledWith({ + expect(containerRunner.runContainer).toHaveBeenCalledWith({ imageName: 'spotify/backstage-cookiecutter', - args: [ - 'cookiecutter', - '--no-input', - '-o', - '/output', - '/input', - '--verbose', - ], + command: 'cookiecutter', + args: ['--no-input', '-o', '/output', '/input', '--verbose'], envVars: { HOME: '/tmp' }, mountDirs: { [path.join('tempdir', 'template')]: '/input', @@ -164,7 +154,6 @@ describe('CookieCutter Templater', () => { }, workingDir: '/input', logStream: undefined, - dockerClient: mockDocker, }); }); @@ -177,14 +166,13 @@ describe('CookieCutter Templater', () => { jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await templater.run({ workspacePath: 'tempdir', values, - dockerClient: mockDocker, }); - expect(runDockerContainer).toHaveBeenCalledWith( + expect(containerRunner.runContainer).toHaveBeenCalledWith( expect.objectContaining({ imageName: 'foo/cookiecutter-image-with-extensions', }), @@ -205,24 +193,17 @@ describe('CookieCutter Templater', () => { jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await templater.run({ workspacePath: 'tempdir', values, logStream: stream, - dockerClient: mockDocker, }); - expect(runDockerContainer).toHaveBeenCalledWith({ + expect(containerRunner.runContainer).toHaveBeenCalledWith({ imageName: 'spotify/backstage-cookiecutter', - args: [ - 'cookiecutter', - '--no-input', - '-o', - '/output', - '/input', - '--verbose', - ], + command: 'cookiecutter', + args: ['--no-input', '-o', '/output', '/input', '--verbose'], envVars: { HOME: '/tmp' }, mountDirs: { [path.join('tempdir', 'template')]: '/input', @@ -230,7 +211,6 @@ describe('CookieCutter Templater', () => { }, workingDir: '/input', logStream: stream, - dockerClient: mockDocker, }); }); @@ -250,12 +230,11 @@ describe('CookieCutter Templater', () => { jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); commandExists.mockImplementationOnce(() => () => true); - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await templater.run({ workspacePath: 'tempdir', values, logStream: stream, - dockerClient: mockDocker, }); expect(runCommand).toHaveBeenCalledWith({ @@ -280,7 +259,7 @@ describe('CookieCutter Templater', () => { .spyOn(fs, 'readdir') .mockImplementationOnce(() => Promise.resolve([])); - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); await expect( templater.run({ workspacePath: 'tempdir', @@ -292,7 +271,6 @@ describe('CookieCutter Templater', () => { }, }, logStream: stream, - dockerClient: mockDocker, }), ).rejects.toThrow(/No data generated by cookiecutter/); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index e60c5efb4d..8819a90d63 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { runDockerContainer } from '@backstage/backend-common'; +import { ContainerRunner } from '@backstage/backend-common'; import { JsonValue } from '@backstage/config'; import fs from 'fs-extra'; import path from 'path'; @@ -24,6 +24,12 @@ import { TemplaterBase, TemplaterRunOptions } from './types'; const commandExists = require('command-exists-promise'); export class CookieCutter implements TemplaterBase { + private readonly containerRunner: ContainerRunner; + + constructor({ containerRunner }: { containerRunner: ContainerRunner }) { + this.containerRunner = containerRunner; + } + private async fetchTemplateCookieCutter( directory: string, ): Promise> { @@ -40,7 +46,6 @@ export class CookieCutter implements TemplaterBase { public async run({ workspacePath, - dockerClient, values, logStream, }: TemplaterRunOptions): Promise { @@ -74,23 +79,16 @@ export class CookieCutter implements TemplaterBase { logStream, }); } else { - await runDockerContainer({ + await this.containerRunner.runContainer({ imageName: imageName || 'spotify/backstage-cookiecutter', - args: [ - 'cookiecutter', - '--no-input', - '-o', - '/output', - '/input', - '--verbose', - ], + command: 'cookiecutter', + args: ['--no-input', '-o', '/output', '/input', '--verbose'], mountDirs, workingDir: '/input', // Set the home directory inside the container as something that applications can // write to, otherwise they will just fail trying to write to / envVars: { HOME: '/tmp' }, logStream, - dockerClient, }); } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts index 3d03b002c0..36716b6c99 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { runDockerContainer } from '@backstage/backend-common'; +import { ContainerRunner } from '@backstage/backend-common'; import fs from 'fs-extra'; import path from 'path'; import * as yaml from 'yaml'; @@ -25,11 +25,16 @@ import { TemplaterBase, TemplaterRunOptions } from '../types'; const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; export class CreateReactAppTemplater implements TemplaterBase { + private readonly containerRunner: ContainerRunner; + + constructor({ containerRunner }: { containerRunner: ContainerRunner }) { + this.containerRunner = containerRunner; + } + public async run({ workspacePath, values, logStream, - dockerClient, }: TemplaterRunOptions): Promise { const { component_id: componentName, @@ -46,23 +51,20 @@ export class CreateReactAppTemplater implements TemplaterBase { [intermediateDir]: '/result', }; - await runDockerContainer({ + await this.containerRunner.runContainer({ imageName: 'node:lts-alpine', + command: ['npx'], args: [ 'create-react-app', componentName as string, withTypescript ? ' --template typescript' : '', ], mountDirs, + workingDir: '/result', logStream: logStream, - dockerClient: dockerClient, // Set the home directory inside the container as something that applications can // write to, otherwise they will just fail trying to write to / envVars: { HOME: '/tmp' }, - createOptions: { - Entrypoint: ['npx'], - WorkingDir: '/result', - }, }); // if cookiecutter was successful, intermediateDir will contain diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts index d390bfbd9d..713ec514c0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts @@ -14,10 +14,15 @@ * limitations under the License. */ +import { ContainerRunner } from '@backstage/backend-common'; import { CookieCutter } from './cookiecutter'; import { Templaters } from './templaters'; describe('Templaters', () => { + const containerRunner: jest.Mocked = { + runContainer: jest.fn(), + }; + it('should throw an error when the templater is not registered', () => { const templaters = new Templaters(); @@ -29,7 +34,7 @@ describe('Templaters', () => { }); it('should return the correct templater when the templater matches', () => { const templaters = new Templaters(); - const templater = new CookieCutter(); + const templater = new CookieCutter({ containerRunner }); templaters.register('cookiecutter', templater); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts index fa14788d22..c5fe454e33 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { Writable } from 'stream'; -import Docker from 'dockerode'; + import gitUrlParse from 'git-url-parse'; +import type { Writable } from 'stream'; /** * Currently the required template values. The owner @@ -48,7 +48,6 @@ export type TemplaterRunOptions = { workspacePath: string; values: TemplaterValues; logStream?: Writable; - dockerClient: Docker; }; export type TemplaterBase = { diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index bb52edaaea..9ca2eef9e6 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -29,20 +29,17 @@ jest.doMock('fs-extra', () => ({ })); import { - SingleConnectionDatabaseManager, - PluginDatabaseManager, getVoidLogger, + PluginDatabaseManager, + SingleConnectionDatabaseManager, UrlReaders, } from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; import { ConfigReader } from '@backstage/config'; import express from 'express'; import request from 'supertest'; +import { Preparers, Publishers, Templaters } from '../scaffolder'; import { createRouter } from './router'; -import { Templaters, Preparers, Publishers } from '../scaffolder'; -import Docker from 'dockerode'; -import { CatalogApi } from '@backstage/catalog-client'; - -jest.mock('dockerode'); const createCatalogClient = (templates: any[] = []) => ({ @@ -67,123 +64,6 @@ const mockUrlReader = UrlReaders.default({ config: new ConfigReader({}), }); -describe('createRouter - working directory', () => { - const mockPrepare = jest.fn(); - const mockPreparers = new Preparers(); - - beforeAll(() => { - const mockPreparer = { - prepare: mockPrepare, - }; - mockPreparers.register('dev.azure.com', mockPreparer); - }); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - const workDirConfig = (path: string) => ({ - backend: { - workingDirectory: path, - }, - }); - - const template = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': 'url:https://dev.azure.com', - }, - }, - spec: { - owner: 'template@backstage.io', - path: '.', - schema: {}, - }, - }; - - it('should throw an error when working directory does not exist or is not writable', async () => { - mockAccess.mockImplementation(() => { - throw new Error('access error'); - }); - - await expect( - createRouter({ - logger: getVoidLogger(), - preparers: new Preparers(), - templaters: new Templaters(), - publishers: new Publishers(), - config: new ConfigReader(workDirConfig('/path')), - dockerClient: new Docker(), - database: createDatabase(), - catalogClient: createCatalogClient([template]), - reader: mockUrlReader, - }), - ).rejects.toThrow('access error'); - }); - - it('should use the working directory when configured', async () => { - const router = await createRouter({ - logger: getVoidLogger(), - preparers: mockPreparers, - templaters: new Templaters(), - publishers: new Publishers(), - config: new ConfigReader(workDirConfig('/path')), - dockerClient: new Docker(), - database: createDatabase(), - catalogClient: createCatalogClient([template]), - reader: mockUrlReader, - }); - - const app = express().use(router); - await request(app) - .post('/v1/jobs') - .send({ - templateName: '', - values: { - storePath: 'https://github.com/backstage/good', - }, - }); - - expect(mockPrepare).toBeCalledWith({ - logger: expect.anything(), - workspacePath: expect.stringContaining('path'), - url: expect.anything(), - }); - }); - - it('should not pass along anything when no working directory is configured', async () => { - const router = await createRouter({ - logger: getVoidLogger(), - preparers: mockPreparers, - templaters: new Templaters(), - publishers: new Publishers(), - config: new ConfigReader({}), - dockerClient: new Docker(), - database: createDatabase(), - catalogClient: createCatalogClient([template]), - reader: mockUrlReader, - }); - - const app = express().use(router); - await request(app) - .post('/v1/jobs') - .send({ - templateName: '', - values: { - storePath: 'https://github.com/backstage/goodrepo', - }, - }); - - expect(mockPrepare).toBeCalledWith({ - logger: expect.anything(), - workspacePath: expect.anything(), - url: expect.anything(), - }); - }); -}); - describe('createRouter', () => { let app: express.Express; const template = { @@ -234,7 +114,6 @@ describe('createRouter', () => { templaters: new Templaters(), publishers: new Publishers(), config: new ConfigReader({}), - dockerClient: new Docker(), database: createDatabase(), catalogClient: createCatalogClient([template]), reader: mockUrlReader, @@ -246,21 +125,6 @@ describe('createRouter', () => { jest.resetAllMocks(); }); - describe('POST /v1/jobs', () => { - it('rejects template values which do not match the template schema definition', async () => { - const response = await request(app) - .post('/v1/jobs') - .send({ - templateName: '', - values: { - storePath: 'https://github.com/backstage/backstage', - }, - }); - - expect(response.status).toEqual(400); - }); - }); - describe('GET /v2/actions', () => { it('lists available actions', async () => { const response = await request(app).get('/v2/actions').send(); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 35a57da6a2..dc8e69eb46 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -15,24 +15,17 @@ */ import { Config } from '@backstage/config'; -import Docker from 'dockerode'; import express from 'express'; -import { resolve as resolvePath, dirname } from 'path'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { - JobProcessor, PreparerBuilder, TemplaterBuilder, TemplaterValues, PublisherBuilder, - parseLocationAnnotation, - joinGitUrlPath, - FilePreparer, } from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; -import { validate, ValidatorResult } from 'jsonschema'; -import parseGitUrl from 'git-url-parse'; +import { validate } from 'jsonschema'; import { DatabaseTaskStore, StorageTaskBroker, @@ -62,7 +55,6 @@ export interface RouterOptions { logger: Logger; config: Config; reader: UrlReader; - dockerClient: Docker; database: PluginDatabaseManager; catalogClient: CatalogApi; actions?: TemplateAction[]; @@ -96,7 +88,6 @@ export async function createRouter( logger: parentLogger, config, reader, - dockerClient, database, catalogClient, actions, @@ -104,7 +95,6 @@ export async function createRouter( const logger = parentLogger.child({ plugin: 'scaffolder' }); const workingDirectory = await getWorkingDirectory(config, logger); - const jobProcessor = await JobProcessor.fromConfig({ config, logger }); const entityClient = new CatalogEntityClient(catalogClient); const integrations = ScmIntegrations.fromConfig(config); @@ -124,13 +114,11 @@ export async function createRouter( ? actions : [ ...createLegacyActions({ - dockerClient, preparers, publishers, templaters, }), ...createBuiltinActions({ - dockerClient, integrations, catalogClient, templaters, @@ -142,135 +130,6 @@ export async function createRouter( worker.start(); - router - .get('/v1/job/:jobId', ({ params }, res) => { - const job = jobProcessor.get(params.jobId); - - if (!job) { - res.status(404).json({ error: 'job not found' }); - return; - } - - res.json({ - id: job.id, - metadata: { - ...job.context, - logger: undefined, - logStream: undefined, - }, - status: job.status, - stages: job.stages.map(stage => ({ - ...stage, - handler: undefined, - })), - error: job.error, - }); - }) - .post('/v1/jobs', async (req, res) => { - const templateName: string = req.body.templateName; - const values: TemplaterValues = { - ...req.body.values, - destination: { - git: parseGitUrl(req.body.values.storePath), - }, - }; - - // Forward authorization from client - const template = await entityClient.findTemplate(templateName, { - token: getBearerToken(req.headers.authorization), - }); - if (!isAlpha1Template(template)) { - throw new InputError( - `This endpoint does not support templates with version ${template.apiVersion}`, - ); - } - - const validationResult: ValidatorResult = validate( - values, - template.spec.schema, - ); - - if (!validationResult.valid) { - res.status(400).json({ errors: validationResult.errors }); - return; - } - - const job = jobProcessor.create({ - entity: template, - values, - stages: [ - { - name: 'Prepare the skeleton', - async handler(ctx) { - const { - protocol, - location: templateEntityLocation, - } = parseLocationAnnotation(ctx.entity); - - if (protocol === 'file') { - const preparer = new FilePreparer(); - - const path = resolvePath( - dirname(templateEntityLocation), - template.spec.path || '.', - ); - - await preparer.prepare({ - url: `file://${path}`, - logger: ctx.logger, - workspacePath: ctx.workspacePath, - }); - return; - } - - const preparer = preparers.get(templateEntityLocation); - - const url = joinGitUrlPath( - templateEntityLocation, - template.spec.path, - ); - - await preparer.prepare({ - url, - logger: ctx.logger, - workspacePath: ctx.workspacePath, - }); - }, - }, - { - name: 'Run the templater', - async handler(ctx) { - const templater = templaters.get(ctx.entity.spec.templater); - await templater.run({ - workspacePath: ctx.workspacePath, - dockerClient, - logStream: ctx.logStream, - values: ctx.values, - }); - }, - }, - { - name: 'Publish template', - handler: async ctx => { - const publisher = publishers.get(ctx.values.storePath); - ctx.logger.info('Will now store the template'); - const result = await publisher.publish({ - values: ctx.values, - workspacePath: ctx.workspacePath, - logger: ctx.logger, - }); - return result; - }, - }, - ], - }); - - jobProcessor.run(job); - - res.status(201).json({ id: job.id }); - }); - - // NOTE: The v2 API is unstable router .get( '/v2/templates/:namespace/:kind/:name/parameter-schema', @@ -365,8 +224,11 @@ export async function createRouter( let taskSpec; if (isAlpha1Template(template)) { - const result = validate(values, template.spec.schema); + logger.warn( + `[DEPRECATION] - Template: ${template.metadata.name} has version ${template.apiVersion} which is going to be deprecated. Please refer to https://backstage.io/docs/features/software-templates/migrating-from-v1alpha1-to-v1beta2 for help on migrating`, + ); + const result = validate(values, template.spec.schema); if (!result.valid) { res.status(400).json({ errors: result.errors }); return; diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 1d0bbcede9..7c1118489a 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,61 @@ # @backstage/plugin-scaffolder +## 0.9.5 + +### Patch Changes + +- f7f7783a3: Add Owner field in template card and new data distribution + Add spec.owner as optional field into TemplateV1Alpha and TemplateV1Beta Schema + Add relations ownedBy and ownerOf into Template entity + Template documentation updated +- 81d7b9c6f: Added deprecation warnings for `v1alpha1` templates +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + +## 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 + +- 9314a8592: Close eventSource upon completion of a scaffolder task +- d8b81fd28: Bump `json-schema` dependency from `0.2.5` to `0.3.0`. +- Updated dependencies [38ca05168] +- Updated dependencies [f65adcde7] +- Updated dependencies [81c54d1f2] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/integration@0.5.2 + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-react@0.1.5 + - @backstage/theme@0.2.7 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + ## 0.9.2 ### Patch Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 32229ccbc6..7203371e35 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.9.2", + "version": "0.9.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,14 +30,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.10", - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", - "@backstage/core": "^0.7.7", - "@backstage/integration": "^0.5.1", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.10", + "@backstage/config": "^0.1.5", + "@backstage/errors": "^0.1.1", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", + "@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", "@material-ui/lab": "4.0.0-alpha.45", @@ -54,17 +55,17 @@ "react-lazylog": "^4.5.2", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "use-immer": "^0.5.1", "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 0c5eb2b268..c7b1594071 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -22,6 +22,7 @@ import { IdentityApi, Observable, } from '@backstage/core'; +import { ResponseError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; import ObservableImpl from 'zen-observable'; import { ListActionsResponse, ScaffolderTask, Status } from './types'; @@ -128,9 +129,7 @@ export class ScaffolderClient implements ScaffolderApi { }); if (!response.ok) { - throw new Error( - `Failed to fetch template parameter schema, ${await response.text()}`, - ); + throw ResponseError.fromResponse(response); } const schema: TemplateParameterSchema = await response.json(); @@ -173,9 +172,15 @@ export class ScaffolderClient implements ScaffolderApi { const token = await this.identityApi.getIdToken(); const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}`; - return fetch(url, { + const response = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {}, - }).then(x => x.json()); + }); + + if (!response.ok) { + throw ResponseError.fromResponse(response); + } + + return await response.json(); } streamLogs({ @@ -234,6 +239,11 @@ export class ScaffolderClient implements ScaffolderApi { async listActions(): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const response = await fetch(`${baseUrl}/v2/actions`); + + if (!response.ok) { + throw ResponseError.fromResponse(response); + } + return await response.json(); } } diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 4a482402f8..bc1bcefe1e 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -175,7 +175,10 @@ export const ScaffolderPageContents = () => { {matchingEntities && matchingEntities?.length > 0 && matchingEntities.map(template => ( - + ))} diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 8c64d981ad..bb56f7fb6e 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -22,33 +22,68 @@ import { CardContent, CardMedia, Chip, + Link, makeStyles, + Tooltip, + Typography, useTheme, } from '@material-ui/core'; import React from 'react'; +import WarningIcon from '@material-ui/icons/Warning'; import { generatePath } from 'react-router'; import { rootRouteRef } from '../../routes'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { + TemplateEntityV1alpha1, + Entity, + RELATION_OWNED_BY, +} from '@backstage/catalog-model'; import { FavouriteTemplate } from '../FavouriteTemplate/FavouriteTemplate'; +import { + getEntityRelations, + EntityRefLinks, +} from '@backstage/plugin-catalog-react'; -const useStyles = makeStyles({ +const useStyles = makeStyles(theme => ({ cardHeader: { position: 'relative', }, title: { backgroundImage: ({ backgroundImage }: any) => backgroundImage, }, - description: { + box: { overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', '-webkit-line-clamp': 10, '-webkit-box-orient': 'vertical', + paddingBottom: '0.8em', }, -}); + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '0.65rem', + fontWeight: 'bold', + letterSpacing: 0.5, + lineHeight: 1, + paddingBottom: '0.2rem', + }, +})); + +const useDeprecationStyles = makeStyles(theme => ({ + deprecationIcon: { + position: 'absolute', + top: theme.spacing(0.5), + right: theme.spacing(3.5), + padding: '0.25rem', + }, + link: { + color: theme.palette.warning.light, + }, +})); export type TemplateCardProps = { template: TemplateEntityV1alpha1; + deprecated?: boolean; }; type TemplateProps = { @@ -72,11 +107,37 @@ const getTemplateCardProps = ( }; }; -export const TemplateCard = ({ template }: TemplateCardProps) => { +const DeprecationWarning = () => { + const styles = useDeprecationStyles(); + + const Title = ( + + This template syntax is deprecated. Click for more info. + + ); + + return ( +
+ + + + + +
+ ); +}; + +export const TemplateCard = ({ template, deprecated }: TemplateCardProps) => { const backstageTheme = useTheme(); const rootLink = useRouteRef(rootRouteRef); const templateProps = getTemplateCardProps(template); - + const ownedByRelations = getEntityRelations( + template as Entity, + RELATION_OWNED_BY, + ); const themeId = pageTheme[templateProps.type] ? templateProps.type : 'other'; const theme = backstageTheme.getPageTheme({ themeId }); const classes = useStyles({ backgroundImage: theme.backgroundImage }); @@ -88,19 +149,34 @@ export const TemplateCard = ({ template }: TemplateCardProps) => { + {deprecated && } - + + + + Description + + {templateProps.description} + + + + Owner + + + + + Tags + {templateProps.tags?.map(tag => ( ))} - {templateProps.description} + } + /> + + + + ); +}; 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..2e94497380 --- /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', + overflowY: '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 c31b99bac8..999d643135 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,10 +32,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.3", - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.9", + "@backstage/plugin-catalog-react": "^0.1.6", + "@backstage/core": "^0.7.10", + "@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", @@ -44,15 +44,15 @@ "rc-progress": "^3.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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..42bde75771 100644 --- a/plugins/splunk-on-call/CHANGELOG.md +++ b/plugins/splunk-on-call/CHANGELOG.md @@ -1,5 +1,54 @@ # @backstage/plugin-splunk-on-call +## 0.3.0 + +### Minor Changes + +- 8aa0b6025: Updates the Splunk On-Call plugin for the [composability system + migration](https://backstage.io/docs/plugins/composability#porting-existing-plugins). + + To upgrade, modify your `EntityPage` to use the updated export names. The + `EntitySplunkOnCallCard` should be wrapped in an `` condition as + shown in the plugin README, which you may already have in place. + + ```diff + import { + - isPluginApplicableToEntity as isSplunkOnCallAvailable, + + isSplunkOnCallAvailable, + - SplunkOnCallCard + + EntitySplunkOnCallCard, + } from '@backstage/plugin-splunk-on-call'; + + ... + - + + + ``` + +### Patch Changes + +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + +## 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/README.md b/plugins/splunk-on-call/README.md index 33cfa091ca..52803473b0 100644 --- a/plugins/splunk-on-call/README.md +++ b/plugins/splunk-on-call/README.md @@ -25,27 +25,24 @@ cd packages/app yarn add @backstage/plugin-splunk-on-call ``` -Add it to the app in `plugins.ts`: - -```ts -export { plugin as SplunkOnCall } from '@backstage/plugin-splunk-on-call'; -``` - -Add it to the `EntityPage.tsx`: +Add it to your `EntityPage`: ```ts +// packages/app/src/components/catalog/EntityPage.tsx import { - isPluginApplicableToEntity as isSplunkOnCallAvailable, - SplunkOnCallCard, + isSplunkOnCallAvailable, + EntitySplunkOnCallCard, } from '@backstage/plugin-splunk-on-call'; // ... -{ - isSplunkOnCallAvailable(entity) && ( - - - - ); -} +const overviewContent = ( + + + + + + + + ``` ## Client configuration diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 9e93004bf5..713cf13109 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.3.0", "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.4", - "@backstage/core": "^0.7.7", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", @@ -42,15 +42,15 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/splunk-on-call/src/components/SplunkOnCallCard.test.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx similarity index 80% rename from plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx rename to plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx index bb0a57f848..119a6fc1f7 100644 --- a/plugins/splunk-on-call/src/components/SplunkOnCallCard.test.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.test.tsx @@ -14,10 +14,8 @@ * limitations under the License. */ import React from 'react'; -import { render, waitFor, fireEvent, act } from '@testing-library/react'; -import { SplunkOnCallCard } from './SplunkOnCallCard'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { Entity } from '@backstage/catalog-model'; -import { wrapInTestApp } from '@backstage/test-utils'; import { alertApiRef, ApiProvider, @@ -27,10 +25,12 @@ import { ConfigReader, createApiRef, } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-react'; +import { wrapInTestApp } from '@backstage/test-utils'; import { splunkOnCallApiRef, - UnauthorizedError, SplunkOnCallClient, + UnauthorizedError, } from '../api'; import { ESCALATION_POLICIES, @@ -39,6 +39,7 @@ import { MOCK_INCIDENT, MOCK_TEAM, } from '../api/mocks'; +import { EntitySplunkOnCallCard } from './EntitySplunkOnCallCard'; const mockSplunkOnCallApi: Partial = { getUsers: async () => [], @@ -65,15 +66,20 @@ const apis = ApiRegistry.from([ }), ], ]); -const entity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'splunkoncall-test', - annotations: { - 'splunk.com/on-call-team': 'Example', + +const mockEntityData = { + loading: false, + error: undefined, + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'splunkoncall-test', + annotations: { + 'splunk.com/on-call-team': 'Example', + }, }, - }, + } as Entity, }; describe('SplunkOnCallCard', () => { @@ -85,7 +91,9 @@ describe('SplunkOnCallCard', () => { const { getByText, queryByTestId } = render( wrapInTestApp( - + + + , ), ); @@ -106,7 +114,9 @@ describe('SplunkOnCallCard', () => { const { getByText, queryByTestId } = render( wrapInTestApp( - + + + , ), ); @@ -123,7 +133,9 @@ describe('SplunkOnCallCard', () => { const { getByText, queryByTestId } = render( wrapInTestApp( - + + + , ), ); @@ -144,7 +156,9 @@ describe('SplunkOnCallCard', () => { const { getByText, queryByTestId, getByRole } = render( wrapInTestApp( - + + + , ), ); diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx similarity index 95% rename from plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx rename to plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx index c4b5aaa11f..43907b5186 100644 --- a/plugins/splunk-on-call/src/components/SplunkOnCallCard.tsx +++ b/plugins/splunk-on-call/src/components/EntitySplunkOnCallCard.tsx @@ -13,33 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; +import { useAsync } from 'react-use'; import { - useApi, - Progress, - HeaderIconLinkRow, - MissingAnnotationEmptyState, configApiRef, EmptyState, + HeaderIconLinkRow, IconLinkVerticalProps, + MissingAnnotationEmptyState, + Progress, + useApi, } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Card, + CardContent, CardHeader, Divider, - CardContent, Typography, } from '@material-ui/core'; -import { Incidents } from './Incident'; -import { EscalationPolicy } from './Escalation'; +import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; import WebIcon from '@material-ui/icons/Web'; -import { useAsync } from 'react-use'; import { Alert } from '@material-ui/lab'; import { splunkOnCallApiRef, UnauthorizedError } from '../api'; -import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; -import { TriggerDialog } from './TriggerDialog'; import { MissingApiKeyOrApiIdError } from './Errors/MissingApiKeyOrApiIdError'; +import { EscalationPolicy } from './Escalation'; +import { Incidents } from './Incident'; +import { TriggerDialog } from './TriggerDialog'; import { User } from './types'; export const SPLUNK_ON_CALL_TEAM = 'splunk.com/on-call-team'; @@ -68,16 +69,13 @@ export const MissingEventsRestEndpoint = () => ( ); -export const isPluginApplicableToEntity = (entity: Entity) => +export const isSplunkOnCallAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[SPLUNK_ON_CALL_TEAM]); -type Props = { - entity: Entity; -}; - -export const SplunkOnCallCard = ({ entity }: Props) => { +export const EntitySplunkOnCallCard = () => { const config = useApi(configApiRef); const api = useApi(splunkOnCallApiRef); + const { entity } = useEntity(); const [showDialog, setShowDialog] = useState(false); const [refreshIncidents, setRefreshIncidents] = useState(false); const team = entity.metadata.annotations![SPLUNK_ON_CALL_TEAM]; diff --git a/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx b/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx index 4222cbfbeb..40695a87e9 100644 --- a/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx +++ b/plugins/splunk-on-call/src/components/SplunkOnCallPage.tsx @@ -23,8 +23,7 @@ import { Header, SupportButton, } from '@backstage/core'; -import { SplunkOnCallCard } from './SplunkOnCallCard'; -import { useEntity } from '@backstage/plugin-catalog-react'; +import { EntitySplunkOnCallCard } from './EntitySplunkOnCallCard'; const useStyles = makeStyles(() => ({ overflowXScroll: { @@ -44,7 +43,6 @@ export const SplunkOnCallPage = ({ pageTitle, }: SplunkOnCallPageProps): JSX.Element => { const classes = useStyles(); - const { entity } = useEntity(); return ( @@ -57,7 +55,7 @@ export const SplunkOnCallPage = ({ - + diff --git a/plugins/splunk-on-call/src/index.ts b/plugins/splunk-on-call/src/index.ts index c8de96f8ea..51476803a1 100644 --- a/plugins/splunk-on-call/src/index.ts +++ b/plugins/splunk-on-call/src/index.ts @@ -14,14 +14,12 @@ * limitations under the License. */ export { + EntitySplunkOnCallCard, splunkOnCallPlugin, splunkOnCallPlugin as plugin, SplunkOnCallPage, } from './plugin'; -export { - isPluginApplicableToEntity, - SplunkOnCallCard, -} from './components/SplunkOnCallCard'; +export { isSplunkOnCallAvailable } from './components/EntitySplunkOnCallCard'; export { SplunkOnCallClient, splunkOnCallApiRef, diff --git a/plugins/splunk-on-call/src/plugin.ts b/plugins/splunk-on-call/src/plugin.ts index 835cda48df..cae413eda9 100644 --- a/plugins/splunk-on-call/src/plugin.ts +++ b/plugins/splunk-on-call/src/plugin.ts @@ -20,6 +20,7 @@ import { discoveryApiRef, configApiRef, createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { splunkOnCallApiRef, SplunkOnCallClient } from './api'; @@ -49,3 +50,14 @@ export const SplunkOnCallPage = splunkOnCallPlugin.provide( mountPoint: rootRouteRef, }), ); + +export const EntitySplunkOnCallCard = splunkOnCallPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntitySplunkOnCallCard').then( + m => m.EntitySplunkOnCallCard, + ), + }, + }), +); diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index 54cefe487f..64f255adb1 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,28 @@ # @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 + +- b2e2ec753: Update README for composability +- Updated dependencies [f65adcde7] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] + - @backstage/core@0.7.8 + - @backstage/theme@0.2.7 + ## 0.3.9 ### Patch Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 37ad7d8211..6b55bd847d 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.9", + "version": "0.3.11", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,8 +30,8 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@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", @@ -40,15 +40,15 @@ "prop-types": "^15.7.2", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 2923e452a7..4b348d49a4 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,92 @@ # @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 + +- e0bfd3d44: Migrate the plugin to use the `ContainerRunner` interface instead of `runDockerContainer(…)`. + It also provides the `ContainerRunner` to the generators instead of to the `createRouter` function. + + To apply this change to an existing backend application, add the following to `src/plugins/techdocs.ts`: + + ```diff + + import { DockerContainerRunner } from '@backstage/backend-common'; + + // ... + + export default async function createPlugin({ + logger, + config, + discovery, + reader, + }: PluginEnvironment): Promise { + // Preparers are responsible for fetching source files for documentation. + const preparers = await Preparers.fromConfig(config, { + logger, + reader, + }); + + + // Docker client (conditionally) used by the generators, based on techdocs.generators config. + + const dockerClient = new Docker(); + + const containerRunner = new DockerContainerRunner({ dockerClient }); + + // Generators are used for generating documentation sites. + const generators = await Generators.fromConfig(config, { + logger, + + containerRunner, + }); + + // Publisher is used for + // 1. Publishing generated files to storage + // 2. Fetching files from storage and passing them to TechDocs frontend. + const publisher = await Publisher.fromConfig(config, { + logger, + discovery, + }); + + // checks if the publisher is working and logs the result + await publisher.getReadiness(); + + - // Docker client (conditionally) used by the generators, based on techdocs.generators config. + - const dockerClient = new Docker(); + + return await createRouter({ + preparers, + generators, + publisher, + - dockerClient, + logger, + config, + discovery, + }); + } + ``` + +### Patch Changes + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [e0bfd3d44] +- Updated dependencies [d8b81fd28] +- Updated dependencies [e9e56b01a] + - @backstage/backend-common@0.7.0 + - @backstage/techdocs-common@0.6.0 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.7.1 ### Patch Changes diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 53e0865430..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.7.1", + "version": "0.8.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.0", - "@backstage/catalog-model": "^0.7.7", - "@backstage/config": "^0.1.4", + "@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.5.0", - "@types/dockerode": "^3.2.1", + "@backstage/techdocs-common": "^0.6.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", @@ -46,7 +45,8 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.8", + "@backstage/cli": "^0.6.11", + "@types/dockerode": "^3.2.1", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index 9dd53f159e..d1aa1e08be 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -28,7 +28,6 @@ import { PublisherBase, UrlPreparer, } from '@backstage/techdocs-common'; -import Docker from 'dockerode'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; @@ -41,7 +40,6 @@ type DocsBuilderArguments = { publisher: PublisherBase; entity: Entity; logger: Logger; - dockerClient: Docker; }; export class DocsBuilder { @@ -50,7 +48,6 @@ export class DocsBuilder { private publisher: PublisherBase; private entity: Entity; private logger: Logger; - private dockerClient: Docker; constructor({ preparers, @@ -58,14 +55,12 @@ export class DocsBuilder { publisher, entity, logger, - dockerClient, }: DocsBuilderArguments) { this.preparer = preparers.get(entity); this.generator = generators.get(entity); this.publisher = publisher; this.entity = entity; this.logger = logger; - this.dockerClient = dockerClient; } public async build(): Promise { @@ -157,7 +152,6 @@ export class DocsBuilder { await this.generator.run({ inputDir: preparedDir, outputDir, - dockerClient: this.dockerClient, parsedLocationAnnotation, etag: newEtag, }); diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 121ddddef5..9f383ea31d 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -24,7 +24,6 @@ import { PublisherBase, } from '@backstage/techdocs-common'; import fetch from 'cross-fetch'; -import Docker from 'dockerode'; import express from 'express'; import Router from 'express-promise-router'; import { Knex } from 'knex'; @@ -40,7 +39,6 @@ type RouterOptions = { discovery: PluginEndpointDiscovery; database?: Knex; // TODO: Make database required when we're implementing database stuff. config: Config; - dockerClient: Docker; }; export async function createRouter({ @@ -48,7 +46,6 @@ export async function createRouter({ generators, publisher, config, - dockerClient, logger, discovery, }: RouterOptions): Promise { @@ -165,7 +162,6 @@ export async function createRouter({ preparers, generators, publisher, - dockerClient, logger, entity, }); diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 0fd23a4f7d..af03987870 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -16,21 +16,22 @@ import { createServiceBuilder, + DockerContainerRunner, SingleHostDiscovery, UrlReader, } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { + DirectoryPreparer, + Generators, + Preparers, + Publisher, + TechdocsGenerator, +} from '@backstage/techdocs-common'; +import Docker from 'dockerode'; import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; -import Docker from 'dockerode'; -import { - Preparers, - DirectoryPreparer, - Generators, - TechdocsGenerator, - Publisher, -} from '@backstage/techdocs-common'; -import { ConfigReader } from '@backstage/config'; export interface ServerOptions { port: number; @@ -65,21 +66,25 @@ export async function startStandaloneServer( ); preparers.register('dir', directoryPreparer); + const dockerClient = new Docker(); + const containerRunner = new DockerContainerRunner({ dockerClient }); + const generators = new Generators(); - const techdocsGenerator = new TechdocsGenerator(logger, config); + const techdocsGenerator = new TechdocsGenerator({ + logger, + containerRunner, + config, + }); generators.register('techdocs', techdocsGenerator); const publisher = await Publisher.fromConfig(config, { logger, discovery }); - const dockerClient = new Docker(); - logger.debug('Starting application server...'); const router = await createRouter({ preparers, generators, logger, publisher, - dockerClient, config, discovery, }); diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 847121c0cf..3df8a9e882 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,56 @@ # @backstage/plugin-techdocs +## 0.9.3 + +### Patch Changes + +- 65e6c4541: Remove circular dependencies +- a62cfe068: Bug fix on sidebar position when Tab-Bar is enabled +- 35e091604: Handle URLs with a `#hash` correctly when rewriting link URLs. +- Updated dependencies [f7f7783a3] +- Updated dependencies [65e6c4541] +- Updated dependencies [68fdbf014] +- Updated dependencies [5da6a561d] + - @backstage/catalog-model@0.7.10 + - @backstage/core@0.7.10 + - @backstage/integration@0.5.3 + +## 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 + +- 2e05277e0: Fix navigation in a page using the table of contents. +- 4075c6367: Make git config optional for techdocs feedback links +- Updated dependencies [38ca05168] +- Updated dependencies [f65adcde7] +- Updated dependencies [81c54d1f2] +- Updated dependencies [80888659b] +- Updated dependencies [7b8272fb7] +- Updated dependencies [d8b81fd28] + - @backstage/integration@0.5.2 + - @backstage/core@0.7.8 + - @backstage/plugin-catalog-react@0.1.5 + - @backstage/theme@0.2.7 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + ## 0.9.0 ### Minor Changes diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 55e5dadb54..3c5cd91ae1 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.9.0", + "version": "0.9.3", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,13 +31,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.4", - "@backstage/catalog-model": "^0.7.7", - "@backstage/core": "^0.7.7", - "@backstage/integration": "^0.5.0", - "@backstage/integration-react": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.4", - "@backstage/theme": "^0.2.6", + "@backstage/config": "^0.1.5", + "@backstage/catalog-model": "^0.7.10", + "@backstage/core": "^0.7.10", + "@backstage/integration": "^0.5.3", + "@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", "@material-ui/icons": "^4.9.1", @@ -47,16 +47,16 @@ "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3", + "react-use": "^17.2.4", "sanitize-html": "^2.3.2" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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/techdocs/src/Router.tsx b/plugins/techdocs/src/Router.tsx index 752ce33eec..f834af14dc 100644 --- a/plugins/techdocs/src/Router.tsx +++ b/plugins/techdocs/src/Router.tsx @@ -23,7 +23,7 @@ import { rootRouteRef, rootDocsRouteRef, rootCatalogDocsRouteRef, -} from './plugin'; +} from './routes'; import { TechDocsHome } from './home/components/TechDocsHome'; import { TechDocsPage } from './reader/components/TechDocsPage'; import { EntityPageDocs } from './EntityPageDocs'; diff --git a/plugins/techdocs/src/home/components/DocsCardGrid.tsx b/plugins/techdocs/src/home/components/DocsCardGrid.tsx index 47f747442c..3b561967e6 100644 --- a/plugins/techdocs/src/home/components/DocsCardGrid.tsx +++ b/plugins/techdocs/src/home/components/DocsCardGrid.tsx @@ -21,7 +21,7 @@ import { Entity } from '@backstage/catalog-model'; import { Button, ItemCardGrid, ItemCardHeader } from '@backstage/core'; import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core'; -import { rootDocsRouteRef } from '../../plugin'; +import { rootDocsRouteRef } from '../../routes'; export const DocsCardGrid = ({ entities, diff --git a/plugins/techdocs/src/home/components/DocsTable.tsx b/plugins/techdocs/src/home/components/DocsTable.tsx index 5b4a337dea..e62435fbe9 100644 --- a/plugins/techdocs/src/home/components/DocsTable.tsx +++ b/plugins/techdocs/src/home/components/DocsTable.tsx @@ -22,7 +22,7 @@ import { IconButton, Tooltip } from '@material-ui/core'; import ShareIcon from '@material-ui/icons/Share'; import { Table, EmptyState, Button, SubvalueCell, Link } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { rootDocsRouteRef } from '../../plugin'; +import { rootDocsRouteRef } from '../../routes'; export const DocsTable = ({ entities, diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index e25f245fb4..f974fbe2de 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -20,27 +20,16 @@ import { createComponentExtension, createPlugin, createRoutableExtension, - createRouteRef, discoveryApiRef, identityApiRef, } from '@backstage/core'; import { techdocsApiRef, techdocsStorageApiRef } from './api'; import { TechDocsClient, TechDocsStorageClient } from './client'; - -export const rootRouteRef = createRouteRef({ - path: '', - title: 'TechDocs Landing Page', -}); - -export const rootDocsRouteRef = createRouteRef({ - path: ':namespace/:kind/:name/*', - title: 'Docs', -}); - -export const rootCatalogDocsRouteRef = createRouteRef({ - path: '*', - title: 'Docs', -}); +import { + rootDocsRouteRef, + rootRouteRef, + rootCatalogDocsRouteRef, +} from './routes'; export const techdocsPlugin = createPlugin({ id: 'techdocs', diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 82b5245cfa..c779985364 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -23,7 +23,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { techdocsStorageApiRef } from '../../api'; -import transformer, { +import { addBaseUrl, addGitFeedbackLink, addLinkClickListener, @@ -33,6 +33,7 @@ import transformer, { rewriteDocLinks, sanitizeDOM, simplifyMkdocsFooter, + transform as transformer, } from '../transformers'; import { TechDocsNotFound } from './TechDocsNotFound'; import TechDocsProgressBar from './TechDocsProgressBar'; @@ -89,12 +90,17 @@ export const Reader = ({ entityId, onReady }: Props) => { useEffect(() => { const updateSidebarPosition = () => { if (!!shadowDomRef.current && !!sidebars) { + const mdTabs = shadowDomRef.current!.querySelector( + '.md-container > .md-tabs', + ); sidebars!.forEach(sidebar => { const newTop = Math.max( shadowDomRef.current!.getBoundingClientRect().top, 0, ); - sidebar.style.top = `${newTop}px`; + sidebar.style.top = mdTabs + ? `${newTop + mdTabs.getBoundingClientRect().height}px` + : `${newTop}px`; }); } }; @@ -177,21 +183,24 @@ export const Reader = ({ entityId, onReady }: Props) => { border-bottom: 1px solid ${theme.palette.text.primary}; } .md-typeset table:not([class]) th { font-weight: bold; } + .md-typeset .admonition, .md-typeset details { + font-size: 1rem; + } @media screen and (max-width: 76.1875em) { - .md-nav { - background-color: ${theme.palette.background.default}; + .md-nav { + background-color: ${theme.palette.background.default}; transition: none !important } .md-sidebar--secondary { display: none; } .md-sidebar--primary { left: 72px; width: 10rem } .md-content { margin-left: 10rem; max-width: calc(100% - 10rem); } .md-content__inner { font-size: 0.9rem } - .md-footer { - position: static; - margin-left: 10rem; - width: calc(100% - 10rem); + .md-footer { + position: static; + margin-left: 10rem; + width: calc(100% - 10rem); } - .md-nav--primary .md-nav__title { + .md-nav--primary .md-nav__title { white-space: normal; height: auto; line-height: 1rem; @@ -264,10 +273,14 @@ export const Reader = ({ entityId, onReady }: Props) => { ); shadowRoot.appendChild(transformedElement); + // Scroll to top after render + window.scroll({ top: 0 }); + // Post-render transformer(shadowRoot.children[0], [ dom => { setTimeout(() => { + // Scoll to the desired anchor on initial navigation if (window.location.hash) { const hash = window.location.hash.slice(1); shadowRoot?.getElementById(hash)?.scrollIntoView(); @@ -278,14 +291,19 @@ export const Reader = ({ entityId, onReady }: Props) => { addLinkClickListener({ baseUrl: window.location.origin, onClick: (_: MouseEvent, url: string) => { - window.scroll({ top: 0 }); const parsedUrl = new URL(url); if (newerDocsExist && isSynced) { // link navigation will load newer docs setNewerDocsExist(false); } + if (parsedUrl.hash) { navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); + + // Scroll to hash if it's on the current page + shadowRoot + ?.getElementById(parsedUrl.hash.slice(1)) + ?.scrollIntoView(); } else { navigate(parsedUrl.pathname); } @@ -309,8 +327,11 @@ export const Reader = ({ entityId, onReady }: Props) => { // set sidebar height so they don't initially render in wrong position const docTopPosition = (dom as HTMLElement).getBoundingClientRect() .top; + const mdTabs = dom.querySelector('.md-container > .md-tabs'); sideDivs!.forEach(sidebar => { - sidebar.style.top = `${docTopPosition}px`; + sidebar.style.top = mdTabs + ? `${docTopPosition + mdTabs.getBoundingClientRect().height}px` + : `${docTopPosition}px`; }); }, }), diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx index 082caeeb09..14c5b64aa0 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx @@ -18,7 +18,7 @@ import { TechDocsPageHeader } from './TechDocsPageHeader'; import { act } from '@testing-library/react'; import { renderInTestApp } from '@backstage/test-utils'; import { entityRouteRef } from '@backstage/plugin-catalog-react'; -import { rootRouteRef } from '../../plugin'; +import { rootRouteRef } from '../../routes'; describe('', () => { it('should render a techdocs page header', async () => { diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index 10df7bff78..11529a24fe 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -24,7 +24,7 @@ import { import CodeIcon from '@material-ui/icons/Code'; import React from 'react'; import { AsyncState } from 'react-use/lib/useAsync'; -import { rootRouteRef } from '../../plugin'; +import { rootRouteRef } from '../../routes'; import { TechDocsMetadata } from '../../types'; type TechDocsPageHeaderProps = { diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 17070dcfe0..9cd11f0903 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { EntityName } from '@backstage/catalog-model'; -import type { Transformer } from './index'; +import type { Transformer } from './transformer'; import { TechDocsStorageApi } from '../../api'; type AddBaseUrlOptions = { diff --git a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts index 7c5fd4d9f4..4f9ba62392 100644 --- a/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts +++ b/plugins/techdocs/src/reader/transformers/addLinkClickListener.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Transformer } from './index'; +import type { Transformer } from './transformer'; type AddLinkClickListenerOptions = { baseUrl: string; diff --git a/plugins/techdocs/src/reader/transformers/index.test.ts b/plugins/techdocs/src/reader/transformers/index.test.ts index 48dfd451cd..93f84d5ffa 100644 --- a/plugins/techdocs/src/reader/transformers/index.test.ts +++ b/plugins/techdocs/src/reader/transformers/index.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import transform, { Transformer } from './index'; +import { Transformer, transform } from './transformer'; describe('transform', () => { it('calls the transformers', () => { diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts index a005ad470b..60a80c04c0 100644 --- a/plugins/techdocs/src/reader/transformers/index.ts +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -23,30 +23,4 @@ export * from './simplifyMkdocsFooter'; export * from './onCssReady'; export * from './sanitizeDOM'; export * from './injectCss'; - -// TODO(freben): move all of this out of index - -export type Transformer = (dom: Element) => Element; - -function transform( - html: string | Element, - transformers: Transformer[], -): Element { - let dom: Element; - - if (typeof html === 'string') { - dom = new DOMParser().parseFromString(html, 'text/html').documentElement; - } else if (html instanceof Element) { - dom = html; - } else { - throw new Error('dom is not a recognized type'); - } - - transformers.forEach(transformer => { - dom = transformer(dom); - }); - - return dom; -} - -export default transform; +export * from './transformer'; diff --git a/plugins/techdocs/src/reader/transformers/injectCss.ts b/plugins/techdocs/src/reader/transformers/injectCss.ts index 2f6aa46bb6..2c0236a29d 100644 --- a/plugins/techdocs/src/reader/transformers/injectCss.ts +++ b/plugins/techdocs/src/reader/transformers/injectCss.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Transformer } from './index'; +import type { Transformer } from './transformer'; type InjectCssOptions = { css: string; diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.ts b/plugins/techdocs/src/reader/transformers/onCssReady.ts index 50dbe52cbd..2f9f7afbe7 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Transformer } from './index'; +import type { Transformer } from './transformer'; type OnCssReadyOptions = { docStorageUrl: Promise; diff --git a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts index 087574b2de..5b7c77b2b5 100644 --- a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts +++ b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Transformer } from './index'; +import type { Transformer } from './transformer'; export const removeMkdocsHeader = (): Transformer => { return dom => { diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts index 06da8a447c..01467f665d 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts @@ -16,6 +16,7 @@ import { createTestShadowDom, getSample } from '../../test-utils'; import { rewriteDocLinks } from '../transformers'; +import { normalizeUrl } from './rewriteDocLinks'; describe('rewriteDocLinks', () => { it('should not do anything', () => { @@ -55,4 +56,33 @@ describe('rewriteDocLinks', () => { 'http://localhost/example-docs/example-page', ]); }); + + it('should rewrite non-parseable URLs as text', () => { + const expectedText = `www.my-internet.[top-level-domain]/pathname/[URLkey]`; + const shadowDom = createTestShadowDom( + `${expectedText}`, + { + preTransformers: [rewriteDocLinks()], + postTransformers: [], + }, + ); + + // There should be no tags, but the link text should remain. + expect(getSample(shadowDom, 'a', 'href')).toEqual([]); + expect(shadowDom.innerHTML).toContain(expectedText); + }); +}); + +describe('normalizeUrl', () => { + it.each([ + ['http://example.org', 'http://example.org/'], + ['http://example.org/', 'http://example.org/'], + ['http://example.org/folder', 'http://example.org/folder/'], + ['http://example.org/folder/', 'http://example.org/folder/'], + ['http://example.org/folder#intro', 'http://example.org/folder/#intro'], + ['http://example.org/folder/#intro', 'http://example.org/folder/#intro'], + ['http://example.org/folder#', 'http://example.org/folder/#'], + ])('should handle %s', (url, expected) => { + expect(normalizeUrl(url)).toEqual(expected); + }); }); diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts index 7b0c23c3e3..8c4bc29201 100644 --- a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Transformer } from './index'; +import type { Transformer } from './transformer'; export const rewriteDocLinks = (): Transformer => { return dom => { @@ -31,14 +31,19 @@ export const rewriteDocLinks = (): Transformer => { if (elemAttribute.match(/^https?:\/\//i)) { elem.setAttribute('target', '_blank'); } - const normalizedWindowLocation = window.location.href.endsWith('/') - ? window.location.href - : `${window.location.href}/`; - elem.setAttribute( - attributeName, - new URL(elemAttribute, normalizedWindowLocation).toString(), - ); + try { + const normalizedWindowLocation = normalizeUrl( + window.location.href, + ); + elem.setAttribute( + attributeName, + new URL(elemAttribute, normalizedWindowLocation).toString(), + ); + } catch (_e) { + // Non-parseable links should be re-written as plain text. + elem.replaceWith(elem.textContent || elemAttribute); + } } }); }; @@ -48,3 +53,14 @@ export const rewriteDocLinks = (): Transformer => { return dom; }; }; + +/** Make sure that the input url always ends with a '/' */ +export function normalizeUrl(input: string): string { + const url = new URL(input); + + if (!url.pathname.endsWith('/')) { + url.pathname += '/'; + } + + return url.toString(); +} diff --git a/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.ts b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.ts index 389e4b625a..4ab8669cc3 100644 --- a/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.ts +++ b/plugins/techdocs/src/reader/transformers/sanitizeDOM/index.ts @@ -16,7 +16,7 @@ // @ts-ignore import sanitizeHtml from 'sanitize-html'; -import type { Transformer } from '../index'; +import type { Transformer } from '../transformer'; import { TECHDOCS_ALLOWED_TAGS } from './tags'; import { TECHDOCS_ALLOWED_ATTRIBUTES } from './attributes'; diff --git a/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.ts b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.ts index 0d0745724d..93e315d42e 100644 --- a/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.ts +++ b/plugins/techdocs/src/reader/transformers/simplifyMkdocsFooter.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Transformer } from './index'; +import type { Transformer } from './transformer'; export const simplifyMkdocsFooter = (): Transformer => { return dom => { diff --git a/plugins/techdocs/src/reader/transformers/transformer.ts b/plugins/techdocs/src/reader/transformers/transformer.ts new file mode 100644 index 0000000000..0a554d76bf --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/transformer.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Transformer = (dom: Element) => Element; + +export const transform = ( + html: string | Element, + transformers: Transformer[], +): Element => { + let dom: Element; + + if (typeof html === 'string') { + dom = new DOMParser().parseFromString(html, 'text/html').documentElement; + } else if (html instanceof Element) { + dom = html; + } else { + throw new Error('dom is not a recognized type'); + } + + transformers.forEach(transformer => { + dom = transformer(dom); + }); + + return dom; +}; diff --git a/plugins/techdocs/src/routes.ts b/plugins/techdocs/src/routes.ts new file mode 100644 index 0000000000..209669d64f --- /dev/null +++ b/plugins/techdocs/src/routes.ts @@ -0,0 +1,32 @@ +/* + * 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 { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + path: '', + title: 'TechDocs Landing Page', +}); + +export const rootDocsRouteRef = createRouteRef({ + path: ':namespace/:kind/:name/*', + title: 'Docs', +}); + +export const rootCatalogDocsRouteRef = createRouteRef({ + path: '*', + title: 'Docs', +}); diff --git a/plugins/techdocs/src/test-utils/shadowDom.ts b/plugins/techdocs/src/test-utils/shadowDom.ts index 556979217e..59e622c165 100644 --- a/plugins/techdocs/src/test-utils/shadowDom.ts +++ b/plugins/techdocs/src/test-utils/shadowDom.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import transformer from '../reader/transformers'; import type { Transformer } from '../reader/transformers'; +import { transform as transformer } from '../reader/transformers'; export type CreateTestShadowDomOptions = { preTransformers: Transformer[]; diff --git a/plugins/todo-backend/CHANGELOG.md b/plugins/todo-backend/CHANGELOG.md index 8468bcd500..ebe3c2003c 100644 --- a/plugins/todo-backend/CHANGELOG.md +++ b/plugins/todo-backend/CHANGELOG.md @@ -1,5 +1,30 @@ # @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 + +- Updated dependencies [e0bfd3d44] +- Updated dependencies [38ca05168] +- Updated dependencies [d8b81fd28] +- Updated dependencies [d1b1306d9] + - @backstage/backend-common@0.7.0 + - @backstage/integration@0.5.2 + - @backstage/catalog-model@0.7.8 + - @backstage/config@0.1.5 + - @backstage/catalog-client@0.3.11 + ## 0.1.3 ### Patch Changes diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json index 2f7348e054..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.3", + "version": "0.1.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -24,12 +24,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.6.1", - "@backstage/catalog-client": "^0.3.9", - "@backstage/catalog-model": "^0.7.5", - "@backstage/config": "^0.1.4", + "@backstage/backend-common": "^0.8.0", + "@backstage/catalog-client": "^0.3.11", + "@backstage/catalog-model": "^0.7.9", + "@backstage/config": "^0.1.5", "@backstage/errors": "^0.1.1", - "@backstage/integration": "^0.5.0", + "@backstage/integration": "^0.5.2", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "express": "^4.17.1", @@ -39,7 +39,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.6", + "@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 92390857ef..5e227b9867 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,25 +26,25 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.7", + "@backstage/catalog-model": "^0.7.9", + "@backstage/core": "^0.7.10", "@backstage/errors": "^0.1.1", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/theme": "^0.2.6", + "@backstage/plugin-catalog-react": "^0.1.6", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 08ae87d3ec..7968f62205 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,23 +30,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 189b6eeb59..eebdbcff50 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,23 +30,23 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.7", - "@backstage/theme": "^0.2.6", + "@backstage/core": "^0.7.10", + "@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", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^17.2.4" }, "devDependencies": { - "@backstage/cli": "^0.6.9", - "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.10", + "@backstage/cli": "^0.6.12", + "@backstage/dev-utils": "^0.1.15", + "@backstage/test-utils": "^0.1.12", "@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 e2ecf9b7c3..f19bff6940 100644 --- a/yarn.lock +++ b/yarn.lock @@ -97,9 +97,9 @@ tiny-merge-patch "^0.1.2" "@asyncapi/react-component@^0.23.0": - version "0.23.0" - resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.23.0.tgz#dce9a95cd0fb0d9f0364278088ad31194d411d52" - integrity sha512-mX70k3j5eSM7ets8BBZWnu6RsNXapLKD/7np+dMeXFk7KFbigVQXXr3hS0k2R+c8tRfkhlklicibq5ar1+fjDA== + version "0.23.1" + resolved "https://registry.npmjs.org/@asyncapi/react-component/-/react-component-0.23.1.tgz#6ae574aac86d835e6845268d49803c4808f37b97" + integrity sha512-xBH8F//sF4Sb7M95eVDnQDG+czklQieFKHkCr8SJmmb2j4NylAOTJBoYSZJ3QTC9r0O+SxdQglwh+u1mhMAU+A== dependencies: "@asyncapi/avro-schema-parser" "^0.2.1" "@asyncapi/openapi-schema-parser" "^2.0.1" @@ -264,7 +264,7 @@ dependencies: "@babel/highlight" "^7.8.3" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.5.5": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3": version "7.12.13" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== @@ -1951,6 +1951,13 @@ dependencies: "@date-io/core" "^1.3.13" +"@date-io/luxon@1.x": + version "1.3.13" + resolved "https://registry.npmjs.org/@date-io/luxon/-/luxon-1.3.13.tgz#68f0134bb38ef486b2ed6df01981f814c633e28a" + integrity sha512-9wUrJCNSMZJeYAiH+dbb45oGpnHeFP7TOH/Lt26If47gjFCkjvyINzWx+K5AGsnlP0Qosxc7hkF1yLi6ecutxw== + dependencies: + "@date-io/core" "^1.3.13" + "@emotion/cache@^10.0.27": version "10.0.29" resolved "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" @@ -2219,10 +2226,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 +2237,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" @@ -2284,12 +2264,12 @@ subscriptions-transport-ws "^0.9.18" "@graphql-codegen/cli@^1.21.3": - version "1.21.3" - resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.21.3.tgz#3506c5d019c6995be1927bd4d9c67a739fafe5e6" - integrity sha512-jwg0mKhseg0QI4/T4IQcttTBCZgnahiTWqnYWIK+E8nrbXCE9o2hxvaYin/Kq9+5oFtxDePED56cjVs/ESRw6g== + version "1.21.4" + resolved "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-1.21.4.tgz#41ce6abc6b33e369a3ee795621373b8ffa1aadeb" + integrity sha512-fCrPn7DeGnCU6d9xjo04VSzIGioVLj1BAjuZpgRJNNjyzTmv+qZJJVTWfD55J17XxlhFsfKJBprtdmRxZ3V2hw== dependencies: "@graphql-codegen/core" "1.17.9" - "@graphql-codegen/plugin-helpers" "^1.18.4" + "@graphql-codegen/plugin-helpers" "^1.18.5" "@graphql-tools/apollo-engine-loader" "^6" "@graphql-tools/code-file-loader" "^6" "@graphql-tools/git-loader" "^6" @@ -2302,7 +2282,7 @@ "@graphql-tools/utils" "^7.0.0" ansi-escapes "^4.3.1" chalk "^4.1.0" - change-case-all "1.0.12" + change-case-all "1.0.14" chokidar "^3.4.3" common-tags "^1.8.0" cosmiconfig "^7.0.0" @@ -2323,7 +2303,7 @@ mkdirp "^1.0.4" string-env-interpolation "^1.0.1" ts-log "^2.2.3" - tslib "~2.1.0" + tslib "~2.2.0" valid-url "^1.0.9" wrap-ansi "^7.0.0" yaml "^1.10.0" @@ -2339,7 +2319,7 @@ "@graphql-tools/utils" "^6" tslib "~2.0.1" -"@graphql-codegen/plugin-helpers@^1.18.2", "@graphql-codegen/plugin-helpers@^1.18.4", "@graphql-codegen/plugin-helpers@^1.18.5": +"@graphql-codegen/plugin-helpers@^1.18.2", "@graphql-codegen/plugin-helpers@^1.18.5": version "1.18.5" resolved "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.5.tgz#e1d875cfb6a2f7bf4b4318135f7fee6e1200f3b0" integrity sha512-xY8dWdU4+mm+253esLYcKQIgWZEgI3spKPmMRQ+oAxlrCn9oIcdhhiMqNxa9rHS20xpZtlAjozxHulrqjFLuyA== @@ -2351,18 +2331,18 @@ tslib "~2.2.0" "@graphql-codegen/typescript-resolvers@^1.17.7": - version "1.18.2" - resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.18.2.tgz#7513b92df7c5a0d3c27342c591ada7340696cf8f" - integrity sha512-aWfRR5y1gXCPUNK7zaUiSbmceqidvZ38mNHIBvXmZArRigyz1QZgN26kKpXjJjtFbPvROPnlGsYcZBReybvZXA== + version "1.19.1" + resolved "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-1.19.1.tgz#56677ec56c1ca7174d22a2f236e3fb7f6503e708" + integrity sha512-KdCVfg2u2RMbHu7eV9SOh5rmfnEQaMsQ0k8741bMbBmCESLnrWltujF2RT1OPN7WCn7xJejBtrFg/3UgT0fNug== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/typescript" "^1.21.0" - "@graphql-codegen/visitor-plugin-common" "^1.18.3" + "@graphql-codegen/plugin-helpers" "^1.18.5" + "@graphql-codegen/typescript" "^1.22.0" + "@graphql-codegen/visitor-plugin-common" "^1.20.0" "@graphql-tools/utils" "^7.0.0" auto-bind "~4.0.0" - tslib "~2.1.0" + tslib "~2.2.0" -"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.21.0": +"@graphql-codegen/typescript@^1.17.7", "@graphql-codegen/typescript@^1.22.0": version "1.22.0" resolved "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-1.22.0.tgz#d05be3a971e5d75a076a43e123b6330f4366a6ab" integrity sha512-YzN/3MBYHrP110m8JgUWQIHt7Ivi3JXiq0RT5XNx/F9mVOSbZz6Ezbaji8YJA3y04Gl2f6ZgtdGazWANUvcOcg== @@ -2372,7 +2352,7 @@ auto-bind "~4.0.0" tslib "~2.2.0" -"@graphql-codegen/visitor-plugin-common@^1.18.3", "@graphql-codegen/visitor-plugin-common@^1.20.0": +"@graphql-codegen/visitor-plugin-common@^1.20.0": version "1.20.0" resolved "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.20.0.tgz#38d829eab7370c79aa5229190788f94adcae8f76" integrity sha512-AYrpy8NA3DpvhDLqYGerQRv44S+YAMPKtwT8x9GNVjzP0gVfmqi3gG1bDWbP5sm6kOZKvDC0kTxGePuBSZerxw== @@ -2664,16 +2644,7 @@ camel-case "4.1.1" tslib "~2.0.1" -"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.0": - version "7.2.6" - resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.2.6.tgz#5d974cbdec5ddf4d7fdc593816335512ee5fe4de" - integrity sha512-/kY7Nb+cCHi/MvU3tjz3KrXzuJNWMlnn7EoWazLmpDvl6b2Qt69hlVoPd5zQtKlGib35zZw9NZ5zs5qTAw8Y9g== - dependencies: - "@ardatan/aggregate-error" "0.0.6" - camel-case "4.1.2" - tslib "~2.1.0" - -"@graphql-tools/utils@^7.1.2", "@graphql-tools/utils@^7.7.0": +"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.0", "@graphql-tools/utils@^7.1.2", "@graphql-tools/utils@^7.7.0": version "7.7.1" resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.7.1.tgz#81f32cb4819b34b3a378d51ab2cd60935977f0b4" integrity sha512-SFT4/dTfrwWer1wSOLU+jqgv3oa/xTR8q+MiNbE9nCH2FXyMsqIOaXKm9wHfKIWFWHozqBdcnwFkQZrdD7H2TQ== @@ -3742,17 +3713,6 @@ prop-types "^15.7.2" react-is "^16.8.0" -"@material-ui/lab@4.0.0-alpha.57": - version "4.0.0-alpha.57" - resolved "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a" - integrity sha512-qo/IuIQOmEKtzmRD2E4Aa6DB4A87kmY6h0uYhjUmrrgmEAgbbw9etXpWPVXuRK6AGIQCjFzV6WO2i21m1R4FCw== - dependencies: - "@babel/runtime" "^7.4.4" - "@material-ui/utils" "^4.11.2" - clsx "^1.0.4" - prop-types "^15.7.2" - react-is "^16.8.0 || ^17.0.0" - "@material-ui/pickers@^3.2.2": version "3.2.10" resolved "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.10.tgz#19df024895876eb0ec7cd239bbaea595f703f0ae" @@ -3765,14 +3725,26 @@ react-transition-group "^4.0.0" rifm "^0.7.0" +"@material-ui/pickers@^3.3.10": + version "3.3.10" + resolved "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.3.10.tgz#f1b0f963348cc191645ef0bdeff7a67c6aa25485" + integrity sha512-hS4pxwn1ZGXVkmgD4tpFpaumUaAg2ZzbTrxltfC5yPw4BJV+mGkfnQOB4VpWEYZw2jv65Z0wLwDE/piQiPPZ3w== + dependencies: + "@babel/runtime" "^7.6.0" + "@date-io/core" "1.x" + "@types/styled-jsx" "^2.2.8" + clsx "^1.0.2" + react-transition-group "^4.0.0" + rifm "^0.7.0" + "@material-ui/styles@^4.10.0", "@material-ui/styles@^4.11.0", "@material-ui/styles@^4.11.3", "@material-ui/styles@^4.9.6": - version "4.11.3" - resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.3.tgz#1b8d97775a4a643b53478c895e3f2a464e8916f2" - integrity sha512-HzVzCG+PpgUGMUYEJ2rTEmQYeonGh41BYfILNFb/1ueqma+p1meSdu4RX6NjxYBMhf7k+jgfHFTTz+L1SXL/Zg== + version "4.11.4" + resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.4.tgz#eb9dfccfcc2d208243d986457dff025497afa00d" + integrity sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew== dependencies: "@babel/runtime" "^7.4.4" "@emotion/hash" "^0.8.0" - "@material-ui/types" "^5.1.0" + "@material-ui/types" "5.1.0" "@material-ui/utils" "^4.11.2" clsx "^1.0.4" csstype "^2.5.2" @@ -3797,7 +3769,7 @@ csstype "^2.5.2" prop-types "^15.7.2" -"@material-ui/types@^5.1.0": +"@material-ui/types@5.1.0", "@material-ui/types@^5.1.0": version "5.1.0" resolved "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2" integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A== @@ -4141,7 +4113,7 @@ dependencies: "@types/node" ">= 8" -"@octokit/types@^6.0.0", "@octokit/types@^6.0.1", "@octokit/types@^6.0.3", "@octokit/types@^6.10.0", "@octokit/types@^6.12.2", "@octokit/types@^6.13.0", "@octokit/types@^6.13.1", "@octokit/types@^6.7.1", "@octokit/types@^6.8.2": +"@octokit/types@^6.0.0", "@octokit/types@^6.0.1", "@octokit/types@^6.0.3", "@octokit/types@^6.10.0", "@octokit/types@^6.12.2", "@octokit/types@^6.13.1", "@octokit/types@^6.14.2", "@octokit/types@^6.7.1", "@octokit/types@^6.8.2": version "6.14.2" resolved "https://registry.npmjs.org/@octokit/types/-/types-6.14.2.tgz#64c9457f38fb8522bdbba3c8cc814590a2d61bf5" integrity sha512-wiQtW9ZSy4OvgQ09iQOdyXYNN60GqjCL/UdMsepDr1Gr0QzpW6irIKbH3REuAHXAhxkEk9/F2a3Gcs1P6kW5jA== @@ -4307,26 +4279,26 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.6" -"@roadiehq/backstage-plugin-github-insights@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-1.0.0.tgz#8b30bd3c4979aab8cd145c4c0b28488badd8e662" - integrity sha512-KynUXVGb+lpMoDoGrolOtsY9roFuPaMre92g1/DZBoLU2BXFpdmnhJXf1YaDHVuBIITP0AhtQfZpu8KNXm/Cmg== +"@roadiehq/backstage-plugin-github-insights@^1.1.10": + version "1.1.10" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-1.1.10.tgz#0af8ae29cab6d92985079a1d8f92ede2e50bf247" + integrity sha512-XeWnPOgLI1BWgRwGqrnGTk79gpjd9bQtzGLzpTAjecFQWMnp6eeCOpIq1xTQcFHnFOSaJhQLp2JOWtiM4ZUAXw== dependencies: - "@backstage/catalog-model" "^0.7.4" - "@backstage/core" "^0.7.3" - "@backstage/plugin-catalog-react" "^0.1.4" - "@backstage/theme" "^0.2.5" + "@backstage/catalog-model" "^0.7.7" + "@backstage/core" "^0.7.8" + "@backstage/plugin-catalog-react" "^0.1.5" + "@backstage/theme" "^0.2.7" "@date-io/core" "2.10.7" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" - "@material-ui/lab" "4.0.0-alpha.57" - "@octokit/rest" "^18.1.1" - "@octokit/types" "^6.13.0" + "@material-ui/lab" "4.0.0-alpha.45" + "@octokit/rest" "^18.5.3" + "@octokit/types" "^6.14.2" history "^5.0.0" react "^16.13.1" react-dom "^16.13.1" react-router "^6.0.0-beta.0" - react-use "^17.2.1" + react-use "^17.2.4" "@roadiehq/backstage-plugin-github-pull-requests@^1.0.0": version "1.0.0" @@ -5642,10 +5614,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" @@ -5762,9 +5734,11 @@ integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== "@types/classnames@^2.2.9": - version "2.2.11" - resolved "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf" - integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== + version "2.3.1" + resolved "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.1.tgz#3c2467aa0f1a93f1f021e3b9bcf938bd5dfdc0dd" + integrity sha512-zeOWb0JGBoVmlQoznvqXbE0tEC/HONsnoUNH19Hc96NFsTAwTXbTqb8FMYkru1F/iqp7a18Ws3nWJvtA1sHD1A== + dependencies: + classnames "*" "@types/clean-css@*": version "4.2.1" @@ -6426,10 +6400,10 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@>= 8", "@types/node@>=12.12.47", "@types/node@^14.14.32": - version "14.14.32" - resolved "https://registry.npmjs.org/@types/node/-/node-14.14.32.tgz#90c5c4a8d72bbbfe53033f122341343249183448" - integrity sha512-/Ctrftx/zp4m8JOujM5ZhwzlWLx22nbQJiVqz8/zE15gOeEW+uly3FSX4fGFpcfEvFzXcMCJwq9lGVWgyARXhg== +"@types/node@*", "@types/node@>= 8", "@types/node@>=12.12.47", "@types/node@^14.14.31", "@types/node@^14.14.32": + version "14.17.0" + resolved "https://registry.npmjs.org/@types/node/-/node-14.17.0.tgz#3ba770047723b3eeb8dc9fca02cce8a7fb6378da" + integrity sha512-w8VZUN/f7SSbvVReb9SWp6cJFevxb4/nkG65yLAya//98WgocKm5PLDAtSs5CtJJJM+kHmJjO/6mmYW4MHShZA== "@types/node@10.17.13": version "10.17.13" @@ -6601,14 +6575,14 @@ "@types/reactcss" "*" "@types/react-dev-utils@^9.0.4": - version "9.0.4" - resolved "https://registry.npmjs.org/@types/react-dev-utils/-/react-dev-utils-9.0.4.tgz#3e4bee79b7536777cef219427ab1d38adc24f3f2" - integrity sha512-8cv9rAeSP1EmyRQAbZ/i6uYtai1VoKHGSBwDyCLM82wCkqoh3WPjJgI1pfi2kiLc0C5hNU7DLo7/c4hylfHLWg== + version "9.0.6" + resolved "https://registry.npmjs.org/@types/react-dev-utils/-/react-dev-utils-9.0.6.tgz#c5d74358786ecd06caea3bd7b3af3195f53c73e4" + integrity sha512-CtrGzH+tSw4MCAZ92QzchrCq8kES/NUUu1kAXeeDXJyb7hRzucry0sRk/bcSwBR8dJJY7N7oaEDranyyOcX3hQ== dependencies: "@types/eslint" "*" "@types/express" "*" "@types/html-webpack-plugin" "*" - "@types/webpack" "*" + "@types/webpack" "^4" "@types/webpack-dev-server" "*" "@types/react-dom@^16.9.8": @@ -6784,10 +6758,10 @@ "@types/express-serve-static-core" "*" "@types/mime" "*" -"@types/sinonjs__fake-timers@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e" - integrity sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA== +"@types/sinonjs__fake-timers@^6.0.2": + version "6.0.2" + resolved "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae" + integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg== "@types/sizzle@*", "@types/sizzle@^2.3.2": version "2.3.2" @@ -7065,7 +7039,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== @@ -7476,7 +7450,7 @@ agentkeepalive@^4.1.3: depd "^1.1.2" humanize-ms "^1.2.1" -aggregate-error@3.0.1, aggregate-error@^3.0.0: +aggregate-error@3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0" integrity sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA== @@ -7484,6 +7458,14 @@ aggregate-error@3.0.1, aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" +aggregate-error@^3.0.0, aggregate-error@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + airbnb-js-shims@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/airbnb-js-shims/-/airbnb-js-shims-2.2.1.tgz#db481102d682b98ed1daa4c5baa697a05ce5c040" @@ -7527,6 +7509,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^6.12.2: + version "6.12.6" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ajv@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" @@ -7885,10 +7877,10 @@ aproba@^2.0.0: resolved "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== -arch@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" - integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== +arch@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" + integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== archiver-utils@^2.1.0: version "2.1.0" @@ -8703,7 +8695,7 @@ base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64url@3.x.x, base64url@^3.0.1: +base64url@3.x.x: version "3.0.1" resolved "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== @@ -8828,6 +8820,11 @@ bl@^4.0.3, bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +blob-util@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" + integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ== + block-stream@*: version "0.0.9" resolved "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" @@ -8883,11 +8880,6 @@ boolbase@^1.0.0, boolbase@~1.0.0: resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= -bowser@^1.7.3: - version "1.9.4" - resolved "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" - integrity sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ== - boxen@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" @@ -9419,20 +9411,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001035, caniuse-lite@^1.0.30001109: - version "1.0.30001113" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001113.tgz#22016ab55b5a8b04fa00ca342d9ee1b98df48065" - integrity sha512-qMvjHiKH21zzM/VDZr6oosO6Ri3U0V2tC015jRXjOecwQCJtsU5zklTNTk31jQbIOP8gha0h1ccM/g0ECP+4BA== - -caniuse-lite@^1.0.30001125: - version "1.0.30001198" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001198.tgz#ed2d9b5f060322ba2efa42afdc56dee3255473f4" - integrity sha512-r5GGgESqOPZzwvdLVER374FpQu2WluCF1Z2DSiFJ89KSmGjT0LVKjgv4NcAqHmGWF9ihNpqRI9KXO9Ex4sKsgA== - -caniuse-lite@^1.0.30001164: - version "1.0.30001165" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001165.tgz#32955490d2f60290bb186bb754f2981917fa744f" - integrity sha512-8cEsSMwXfx7lWSUMA2s08z9dIgsnR5NAqjXP23stdsU3AUWkCr/rr4s4OFtHXn5XXr6+7kam3QFVoYyXNPdJPA== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001035, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001164: + version "1.0.30001228" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz" + integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A== canvas@^2.6.1: version "2.7.0" @@ -9524,22 +9506,6 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -change-case-all@1.0.12: - version "1.0.12" - resolved "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.12.tgz#ae3e0faf5e610e8e25c5d5eaa4a6d5c2f1d68797" - integrity sha512-zdQus7R0lkprF99lrWUC5bFj6Nog4Xt4YCEjQ/vM4vbc6b5JHFBQMxRPAjfx+HJH8WxMzH0E+lQ8yQJLgmPCBg== - dependencies: - change-case "^4.1.1" - is-lower-case "^2.0.1" - is-upper-case "^2.0.1" - lower-case "^2.0.1" - lower-case-first "^2.0.1" - sponge-case "^1.0.0" - swap-case "^2.0.1" - title-case "^3.0.2" - upper-case "^2.0.1" - upper-case-first "^2.0.1" - change-case-all@1.0.14: version "1.0.14" resolved "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.14.tgz#bac04da08ad143278d0ac3dda7eccd39280bfba1" @@ -9556,7 +9522,7 @@ change-case-all@1.0.14: upper-case "^2.0.2" upper-case-first "^2.0.2" -change-case@^4.1.1, change-case@^4.1.2: +change-case@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz#fedfc5f136045e2398c0410ee441f95704641e12" integrity sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A== @@ -9635,22 +9601,7 @@ chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" -chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3.4.2: - version "3.4.3" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" - integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ== - dependencies: - anymatch "~3.1.1" - braces "~3.0.2" - glob-parent "~5.1.0" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.5.0" - optionalDependencies: - fsevents "~2.1.2" - -chokidar@^3.4.3: +chokidar@^3.2.2, chokidar@^3.3.0, chokidar@^3.3.1, chokidar@^3.4.1, chokidar@^3.4.2, chokidar@^3.4.3: version "3.5.1" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== @@ -9687,6 +9638,11 @@ ci-info@^2.0.0: resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== +ci-info@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.1.1.tgz#9a32fcefdf7bcdb6f0a7e1c0f8098ec57897b80a" + integrity sha512-kdRWLBIJwdsYJWYJFtAFFYxybguqeF91qpZaggjG5Nf8QKdizFG2hjqvaTXbxFIcYbSaD74KpAXv6BSm17DHEQ== + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -9717,7 +9673,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: +classnames@*, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== @@ -9775,7 +9731,7 @@ cli-spinners@^2.5.0: resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.5.0.tgz#12763e47251bf951cb75c201dfa58ff1bcb2d047" integrity sha512-PC+AmIuK04E6aeSs/pUccSujsTzBhu4HzC2dL+CfJB/Jcc2qTRbEwZQDfIUpt2Xl8BodYBEq8w4fc0kU2I9DjQ== -cli-table3@0.6.0: +cli-table3@0.6.0, cli-table3@~0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz#b7b1bc65ca8e7b5cef9124e13dc2b21e2ce4faee" integrity sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ== @@ -9785,16 +9741,6 @@ cli-table3@0.6.0: optionalDependencies: colors "^1.1.2" -cli-table3@~0.5.1: - version "0.5.1" - resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" - integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== - dependencies: - object-assign "^4.1.0" - string-width "^2.1.1" - optionalDependencies: - colors "^1.1.2" - cli-truncate@2.1.0, cli-truncate@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" @@ -10028,12 +9974,12 @@ color@^3.0.0, color@^3.1.2: color-convert "^1.9.1" color-string "^1.5.4" -colorette@1.2.1, colorette@^1.2.1: +colorette@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== -colorette@^1.2.2: +colorette@^1.2.1, colorette@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== @@ -10139,11 +10085,6 @@ compare-func@^2.0.0: array-ify "^1.0.0" dot-prop "^5.1.0" -compare-versions@^3.6.0: - version "3.6.0" - resolved "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62" - integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA== - component-emitter@^1.2.0, component-emitter@^1.2.1, component-emitter@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" @@ -10179,21 +10120,21 @@ compression@^1.7.4: safe-buffer "5.1.2" vary "~1.1.2" -compute-gcd@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.0.tgz#fc1ede5b65001e950226502f46543863e4fea10e" - integrity sha1-/B7eW2UAHpUCJlAvRlQ4Y+T+oQ4= +compute-gcd@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/compute-gcd/-/compute-gcd-1.2.1.tgz#34d639f3825625e1357ce81f0e456a6249d8c77f" + integrity sha512-TwMbxBNz0l71+8Sc4czv13h4kEqnchV9igQZBi6QUaz09dnz13juGnnaWWJTRsP3brxOoxeB4SA2WELLw1hCtg== dependencies: validate.io-array "^1.0.3" validate.io-function "^1.0.2" validate.io-integer-array "^1.0.0" -compute-lcm@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.0.tgz#abd96d040b41b0a166f89944b5c8b7c511e21ad5" - integrity sha1-q9ltBAtBsKFm+JlEtci3xRHiGtU= +compute-lcm@^1.1.0, compute-lcm@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/compute-lcm/-/compute-lcm-1.1.2.tgz#9107c66b9dca28cefb22b4ab4545caac4034af23" + integrity sha512-OFNPdQAXnQhDSKioX8/XYT6sdUlXwpeMjfd6ApxMJfyZ4GxmLR1xvMERctlYhlHwIiz6CSpBc2+qYKjHGZw4TQ== dependencies: - compute-gcd "^1.2.0" + compute-gcd "^1.2.1" validate.io-array "^1.0.3" validate.io-function "^1.0.2" validate.io-integer-array "^1.0.0" @@ -10443,7 +10384,7 @@ cookie@0.4.0: resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== -cookie@^0.4.1, cookie@~0.4.1: +cookie@0.4.1, cookie@^0.4.1, cookie@~0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== @@ -10652,13 +10593,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" @@ -10820,14 +10768,6 @@ css-tree@1.0.0-alpha.37: mdn-data "2.0.4" source-map "^0.6.1" -css-tree@^1.0.0-alpha.28: - version "1.0.0-alpha.39" - resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.39.tgz#2bff3ffe1bb3f776cf7eefd91ee5cba77a149eeb" - integrity sha512-7UvkEYgBAHRG9Nt980lYxjsTrCyHFN53ky3wVsDkiMdVqylqRt+Zc+jm5qw7/qyOvN2dHSYtX0e4MbCCExSvnA== - dependencies: - mdn-data "2.0.6" - source-map "^0.6.1" - css-tree@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.2.tgz#9ae393b5dafd7dae8a622475caec78d3d8fbd7b5" @@ -10982,7 +10922,7 @@ cssstyle@^2.2.0: dependencies: cssom "~0.3.6" -csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.5, csstype@^2.5.7, csstype@^2.6.7: +csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.7, csstype@^2.6.7: version "2.6.9" resolved "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q== @@ -11029,45 +10969,47 @@ cyclist@^1.0.1: resolved "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -cypress@^4.2.0: - version "4.12.1" - resolved "https://registry.npmjs.org/cypress/-/cypress-4.12.1.tgz#0ead1b9f4c0917d69d8b57f996b6e01fe693b6ec" - integrity sha512-9SGIPEmqU8vuRA6xst2CMTYd9sCFCxKSzrHt0wr+w2iAQMCIIsXsQ5Gplns1sT6LDbZcmLv6uehabAOl3fhc9Q== +cypress@^7.3.0: + version "7.3.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-7.3.0.tgz#17345b8d18681c120f033e7d8fd0f0271e9d0d51" + integrity sha512-aseRCH1tRVCrM6oEfja6fR/bo5l6e4SkHRRSATh27UeN4f/ANC8U7tGIulmrISJVy9xuOkOdbYKbUb2MNM+nrw== dependencies: "@cypress/listr-verbose-renderer" "^0.4.1" "@cypress/request" "^2.88.5" "@cypress/xvfb" "^1.2.4" - "@types/sinonjs__fake-timers" "^6.0.1" + "@types/node" "^14.14.31" + "@types/sinonjs__fake-timers" "^6.0.2" "@types/sizzle" "^2.3.2" - arch "^2.1.2" + arch "^2.2.0" + blob-util "^2.0.2" bluebird "^3.7.2" cachedir "^2.3.0" - chalk "^2.4.2" + chalk "^4.1.0" check-more-types "^2.24.0" - cli-table3 "~0.5.1" - commander "^4.1.1" + cli-table3 "~0.6.0" + commander "^5.1.0" common-tags "^1.8.0" - debug "^4.1.1" - eventemitter2 "^6.4.2" - execa "^1.0.0" + dayjs "^1.10.4" + debug "4.3.2" + eventemitter2 "^6.4.3" + execa "4.1.0" executable "^4.1.1" extract-zip "^1.7.0" - fs-extra "^8.1.0" + fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^2.0.0" - is-installed-globally "^0.3.2" + is-ci "^3.0.0" + is-installed-globally "~0.4.0" lazy-ass "^1.6.0" listr "^0.14.3" - lodash "^4.17.19" - log-symbols "^3.0.0" + lodash "^4.17.21" + log-symbols "^4.0.0" minimist "^1.2.5" - moment "^2.27.0" ospath "^1.2.2" - pretty-bytes "^5.3.0" - ramda "~0.26.1" + pretty-bytes "^5.6.0" + ramda "~0.27.1" request-progress "^3.0.0" - supports-color "^7.1.0" - tmp "~0.1.0" + supports-color "^8.1.1" + tmp "~0.2.1" untildify "^4.0.0" url "^0.11.0" yauzl "^2.10.0" @@ -11284,16 +11226,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" @@ -11314,7 +11256,7 @@ dateformat@^3.0.0: resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dayjs@^1.9.4: +dayjs@^1.10.4, dayjs@^1.9.4: version "1.10.4" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== @@ -11343,7 +11285,14 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@4.3.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1: +debug@4, debug@4.3.2, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1: + version "4.3.2" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + +debug@4.3.1: version "4.3.1" resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== @@ -11544,6 +11493,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" @@ -12791,10 +12745,10 @@ eventemitter2@^5.0.1: resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz#6197a095d5fb6b57e8942f6fd7eaad63a09c9452" integrity sha1-YZegldX7a1folC9v1+qtY6CclFI= -eventemitter2@^6.4.2: - version "6.4.3" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820" - integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ== +eventemitter2@^6.4.3: + version "6.4.4" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz#aa96e8275c4dbeb017a5d0e03780c65612a1202b" + integrity sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw== eventemitter3@^3.1.0: version "3.1.2" @@ -12870,6 +12824,21 @@ execa@3.4.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" +execa@4.1.0, execa@^4.0.0, execa@^4.0.1: + version "4.1.0" + resolved "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" + integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + human-signals "^1.1.1" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.0" + onetime "^5.1.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + execa@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" @@ -12883,21 +12852,6 @@ execa@^0.7.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -execa@^4.0.0, execa@^4.0.1: - version "4.0.2" - resolved "https://registry.npmjs.org/execa/-/execa-4.0.2.tgz#ad87fb7b2d9d564f70d2b62d511bee41d5cbb240" - integrity sha512-QI2zLa6CjGWdiQsmSkZoGtDx2N+cQIGb3yNolGTdjSQzydzLgYYf8LRuagp7S7fPimjcrzUDSUFd/MgzELMi4Q== - dependencies: - cross-spawn "^7.0.0" - get-stream "^5.0.0" - human-signals "^1.1.1" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.0" - onetime "^5.1.0" - signal-exit "^3.0.2" - strip-final-newline "^2.0.0" - execa@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" @@ -12987,17 +12941,17 @@ express-promise-router@^4.1.0: methods "^1.0.0" express-session@^1.17.1: - version "1.17.1" - resolved "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357" - integrity sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q== + version "1.17.2" + resolved "https://registry.npmjs.org/express-session/-/express-session-1.17.2.tgz#397020374f9bf7997f891b85ea338767b30d0efd" + integrity sha512-mPcYcLA0lvh7D4Oqr5aNJFMtBMKPLl++OKKxkHzZ0U0oDq1rpKBnkR5f5vCHR26VeArlTOEF9td4x5IjICksRQ== dependencies: - cookie "0.4.0" + cookie "0.4.1" cookie-signature "1.0.6" debug "2.6.9" depd "~2.0.0" on-headers "~1.0.2" parseurl "~1.3.3" - safe-buffer "5.2.0" + safe-buffer "5.2.1" uid-safe "~2.1.5" express-xml-bodyparser@^0.3.0: @@ -13204,11 +13158,6 @@ fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: resolved "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== -fastest-stable-stringify@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" - integrity sha1-kSLUBtTJ2YvqZEpraFPVh0uHsCg= - fastest-stable-stringify@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz#3757a6774f6ec8de40c4e86ec28ea02417214c76" @@ -13473,13 +13422,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-versions@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/find-versions/-/find-versions-4.0.0.tgz#3c57e573bf97769b8cb8df16934b627915da4965" - integrity sha512-wgpWy002tA+wgmO27buH/9KzyEOQnKsG/R0yrcjPT9BOFm0zRBVQbZ95nRGXWMywS8YR5knRbpohio0bcJABxQ== - dependencies: - semver-regex "^3.1.2" - find-yarn-workspace-root2@1.2.16: version "1.2.16" resolved "https://registry.npmjs.org/find-yarn-workspace-root2/-/find-yarn-workspace-root2-1.2.16.tgz#60287009dd2f324f59646bdb4b7610a6b301c2a9" @@ -13544,7 +13486,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: @@ -13566,7 +13508,7 @@ fork-ts-checker-webpack-plugin@3.1.1: tapable "^1.0.0" worker-rpc "^0.1.0" -fork-ts-checker-webpack-plugin@4.1.6, fork-ts-checker-webpack-plugin@^4.0.5, fork-ts-checker-webpack-plugin@^4.1.4: +fork-ts-checker-webpack-plugin@4.1.6, fork-ts-checker-webpack-plugin@^4.1.4: version "4.1.6" resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.6.tgz#5055c703febcf37fa06405d400c122b905167fc5" integrity sha512-DUxuQaKoqfNne8iikd14SAkh5uw4+8vNifp6gmA73yYNS6ywLIWSLD/n/mBzHQRpW3J7rbATEakmiA8JvkTyZw== @@ -13579,6 +13521,25 @@ fork-ts-checker-webpack-plugin@4.1.6, fork-ts-checker-webpack-plugin@^4.0.5, for tapable "^1.0.0" worker-rpc "^0.1.0" +fork-ts-checker-webpack-plugin@^6.2.9: + version "6.2.9" + resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.2.9.tgz#08f51b685a48b09ab3ec079a8501762422443120" + integrity sha512-D/KSb/2VeiOy3odDerrC16WiZ1t5TLwiFfZmuDeTXcf3Km79M+f8nTCIdKkokxybybrgMcStbx0QpGaseePxnA== + dependencies: + "@babel/code-frame" "^7.8.3" + "@types/json-schema" "^7.0.5" + chalk "^4.1.0" + chokidar "^3.4.2" + cosmiconfig "^6.0.0" + deepmerge "^4.2.2" + fs-extra "^9.0.0" + glob "^7.1.6" + memfs "^3.1.2" + minimatch "^3.0.4" + schema-utils "2.7.0" + semver "^7.3.2" + tapable "^1.0.0" + form-data@^2.3.1, form-data@^2.3.2, form-data@^2.5.0: version "2.5.1" resolved "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" @@ -13728,6 +13689,11 @@ fs-minipass@^2.0.0, fs-minipass@^2.1.0: dependencies: minipass "^3.0.0" +fs-monkey@1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" + integrity sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q== + fs-write-stream-atomic@^1.0.8: version "1.0.10" resolved "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" @@ -13863,10 +13829,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" @@ -13876,6 +13842,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" @@ -14154,6 +14127,13 @@ global-dirs@^2.0.1: dependencies: ini "^1.3.5" +global-dirs@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" + integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== + dependencies: + ini "2.0.0" + global-modules@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" @@ -14439,23 +14419,7 @@ graphlib@^2.1.8: dependencies: lodash "^4.17.15" -graphql-config@^3.0.2: - version "3.0.3" - resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.0.3.tgz#58907c65ed7d6e04132321450b60e57863ea9a5f" - integrity sha512-MBY0wEjvcgJtZUyoqpPvOE1e5qPI0hJaa1gKTqjonSFiCsNHX2lykNjpOPcodmAgH1V06ELxhGnm9kcVzqvi/g== - dependencies: - "@graphql-tools/graphql-file-loader" "^6.0.0" - "@graphql-tools/json-file-loader" "^6.0.0" - "@graphql-tools/load" "^6.0.0" - "@graphql-tools/merge" "^6.0.0" - "@graphql-tools/url-loader" "^6.0.0" - "@graphql-tools/utils" "^6.0.0" - cosmiconfig "6.0.0" - minimatch "3.0.4" - string-env-interpolation "1.0.1" - tslib "^2.0.0" - -graphql-config@^3.2.0: +graphql-config@^3.0.2, graphql-config@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.2.0.tgz#3ec3a7e319792086b80e54db4b37372ad4a79a32" integrity sha512-ygEKDeQNZKpm4137560n2oY3bGM0D5zyRsQVaJntKkufWdgPg6sb9/4J1zJW2y/yC1ortAbhNho09qmeJeLa9g== @@ -14537,9 +14501,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" @@ -14856,9 +14822,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" @@ -15152,6 +15118,11 @@ humanize-duration@^3.25.1: resolved "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.25.1.tgz#50e12bf4b3f515ec91106107ee981e8cfe955d6f" integrity sha512-P+dRo48gpLgc2R9tMRgiDRNULPKCmqFYgguwqOO2C0fjO35TgdURDQDANSR1Nt92iHlbHGMxOTnsB8H8xnMa2Q== +humanize-duration@^3.26.0: + version "3.26.0" + resolved "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.26.0.tgz#4d77f6b3d2fe0ca1ff14623ccc2b2f8b48ab1aaf" + integrity sha512-SddekX3p5ApvPY6bbAYppGKe874jP6iFZXYtrQToDV4R0j2UpTYPqwTFM2QpXpuw9DhS/eXTUnKYTF9TbXAJ6A== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -15159,21 +15130,10 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -husky@^4.2.3: - version "4.3.8" - resolved "https://registry.npmjs.org/husky/-/husky-4.3.8.tgz#31144060be963fd6850e5cc8f019a1dfe194296d" - integrity sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow== - dependencies: - chalk "^4.0.0" - ci-info "^2.0.0" - compare-versions "^3.6.0" - cosmiconfig "^7.0.0" - find-versions "^4.0.0" - opencollective-postinstall "^2.0.2" - pkg-dir "^5.0.0" - please-upgrade-node "^3.2.0" - slash "^3.0.0" - which-pm-runs "^1.0.0" +husky@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/husky/-/husky-6.0.0.tgz#810f11869adf51604c32ea577edbc377d7f9319e" + integrity sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ== hyphenate-style-name@^1.0.2, hyphenate-style-name@^1.0.3: version "1.0.3" @@ -15417,6 +15377,11 @@ inherits@2.0.3: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= +ini@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + ini@^1.3.2, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" @@ -15436,14 +15401,6 @@ init-package-json@^2.0.2: validate-npm-package-license "^3.0.4" validate-npm-package-name "^3.0.0" -inline-style-prefixer@^4.0.0: - version "4.0.2" - resolved "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-4.0.2.tgz#d390957d26f281255fe101da863158ac6eb60911" - integrity sha512-N8nVhwfYga9MiV9jWlwfdj1UDIaZlBFu4cJSJkIr7tZX7sHpHhGR5su1qdpW+7KPL8ISTvCIkcaFi/JdBknvPg== - dependencies: - bowser "^1.7.3" - css-in-js-utils "^2.0.0" - inline-style-prefixer@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.0.tgz#f73d5dbf2855733d6b153a4d24b7b47a73e9770b" @@ -15661,6 +15618,13 @@ is-ci@^2.0.0: dependencies: ci-info "^2.0.0" +is-ci@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz#c7e7be3c9d8eef7d0fa144390bd1e4b88dc4c994" + integrity sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ== + dependencies: + ci-info "^3.1.1" + is-color-stop@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" @@ -15825,7 +15789,7 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= -is-installed-globally@^0.3.1, is-installed-globally@^0.3.2: +is-installed-globally@^0.3.1: version "0.3.2" resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== @@ -15833,6 +15797,14 @@ is-installed-globally@^0.3.1, is-installed-globally@^0.3.2: global-dirs "^2.0.1" is-path-inside "^3.0.1" +is-installed-globally@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" + integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== + dependencies: + global-dirs "^3.0.0" + is-path-inside "^3.0.2" + is-interactive@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" @@ -15843,7 +15815,7 @@ is-lambda@^1.0.1: resolved "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" integrity sha1-PZh3iZ5qU+/AFgUEzeFfgubwYdU= -is-lower-case@^2.0.1, is-lower-case@^2.0.2: +is-lower-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz#1c0884d3012c841556243483aa5d522f47396d2a" integrity sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ== @@ -15938,6 +15910,11 @@ is-path-inside@^3.0.1: resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017" integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg== +is-path-inside@^3.0.2: + version "3.0.3" + resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" @@ -15982,6 +15959,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" @@ -16103,7 +16085,7 @@ is-unicode-supported@^0.1.0: resolved "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== -is-upper-case@^2.0.1, is-upper-case@^2.0.2: +is-upper-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649" integrity sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ== @@ -16769,10 +16751,10 @@ jose@^1.27.1: dependencies: "@panva/asn1.js" "^1.0.0" -jose@^2.0.2: - version "2.0.3" - resolved "https://registry.npmjs.org/jose/-/jose-2.0.3.tgz#9c931ab3e13e2d16a5b9e6183e60b2fc40a8e1b8" - integrity sha512-L+RlDgjO0Tk+Ki6/5IXCSEnmJCV8iMFZoBuEgu2vPQJJ4zfG/k3CAqZUMKDYNRHIDyy0QidJpOvX0NgpsAqFlw== +jose@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz#29746a18d9fff7dcf9d5d2a6f62cb0c7cd27abd3" + integrity sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA== dependencies: "@panva/asn1.js" "^1.0.0" @@ -16819,6 +16801,14 @@ js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.6.1, js-yaml@^3.8. argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^3.7.0: + version "3.14.1" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + js-yaml@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" @@ -16932,7 +16922,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== @@ -16948,9 +16938,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" @@ -16970,14 +16960,14 @@ json-schema-merge-allof@^0.6.0: json-schema-compare "^0.2.2" lodash "^4.17.4" -json-schema-merge-allof@^0.7.0: - version "0.7.0" - resolved "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.7.0.tgz#84d3e8c3e03d3060014286958eb8834fa9d76304" - integrity sha512-kvsuSVnl1n5xnNEu5ed4o8r8ujSA4/IgRtHmpgfMfa7FOMIRAzN4F9qbuklouTn5J8bi83y6MQ11n+ERMMTXZg== +json-schema-merge-allof@^0.8.1: + version "0.8.1" + resolved "https://registry.npmjs.org/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz#ed2828cdd958616ff74f932830a26291789eaaf2" + integrity sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w== dependencies: - compute-lcm "^1.1.0" + compute-lcm "^1.1.2" json-schema-compare "^0.2.2" - lodash "^4.17.4" + lodash "^4.17.20" json-schema-traverse@^0.4.1: version "0.4.1" @@ -17284,6 +17274,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" @@ -17298,6 +17296,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" @@ -17352,9 +17357,9 @@ klona@^2.0.3: integrity sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA== knex@^0.95.1: - version "0.95.1" - resolved "https://registry.npmjs.org/knex/-/knex-0.95.1.tgz#6a99dfebe992829b417144c68d1532ee2b47e90e" - integrity sha512-8vAmH4M6ks0qXHqaIacUOTtGAVc1PPFuF8W/W9bzuUHcQur4809mtufw1LY6n/tNRTLwMFBSXWkUnfFQFqFvNQ== + version "0.95.6" + resolved "https://registry.npmjs.org/knex/-/knex-0.95.6.tgz#5fc60ffc2935567bf122925526b1b06b8dbca785" + integrity sha512-noRcmkJl1MdicUbezrcr8OtVLcqQ/cfLIwgAx5EaxNxQOIJff88rBeyLywUScGhQNd/b78DIKKXZzLMrm6h/cw== dependencies: colorette "1.2.1" commander "^7.1.0" @@ -17939,13 +17944,6 @@ log-symbols@^1.0.2: dependencies: chalk "^1.0.0" -log-symbols@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" - integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== - dependencies: - chalk "^2.4.2" - log-symbols@^4.0.0, log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" @@ -18014,7 +18012,7 @@ loud-rejection@^1.0.0: currently-unhandled "^0.4.1" signal-exit "^3.0.0" -lower-case-first@^2.0.1, lower-case-first@^2.0.2: +lower-case-first@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz#64c2324a2250bf7c37c5901e76a5b5309301160b" integrity sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg== @@ -18058,7 +18056,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== @@ -18361,11 +18359,6 @@ mdn-data@2.0.4: resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== -mdn-data@2.0.6: - version "2.0.6" - resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.6.tgz#852dc60fcaa5daa2e8cf6c9189c440ed3e042978" - integrity sha512-rQvjv71olwNHgiTbfPZFkJtjNMciWgswYeciZhtvWLO8bmX3TnhyA62I6sTWOyZssWHJJjY6/KiWwqQsWWsqOA== - mdurl@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" @@ -18376,6 +18369,18 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +memfs@^3.1.2: + version "3.2.2" + resolved "https://registry.npmjs.org/memfs/-/memfs-3.2.2.tgz#5de461389d596e3f23d48bb7c2afb6161f4df40e" + integrity sha512-RE0CwmIM3CEvpcdK3rZ19BC4E6hv9kADkMN5rPduRak58cNArWLi/9jFLsa4rhsjfVxMP3v0jO7FHXq7SvFY5Q== + dependencies: + fs-monkey "1.0.3" + +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" @@ -18493,9 +18498,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" @@ -19059,6 +19064,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" @@ -19068,26 +19087,19 @@ 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" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== -nano-css@^5.1.0, nano-css@^5.2.1: - version "5.3.0" - resolved "https://registry.npmjs.org/nano-css/-/nano-css-5.3.0.tgz#9d3cd29788d48b6a07f52aa4aec7cf4da427b6b5" - integrity sha512-uM/9NGK9/E9/sTpbIZ/bQ9xOLOIHZwrrb/CRlbDHBU/GFS7Gshl24v/WJhwsVViWkpOXUmiZ66XO7fSB4Wd92Q== - dependencies: - css-tree "^1.0.0-alpha.28" - csstype "^2.5.5" - fastest-stable-stringify "^1.0.1" - inline-style-prefixer "^4.0.0" - rtl-css-js "^1.9.0" - sourcemap-codec "^1.4.1" - stacktrace-js "^2.0.0" - stylis "3.5.0" - -nano-css@^5.3.1: +nano-css@^5.1.0, nano-css@^5.2.1, nano-css@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/nano-css/-/nano-css-5.3.1.tgz#b709383e07ad3be61f64edffacb9d98250b87a1f" integrity sha512-ENPIyNzANQRyYVvb62ajDd7PAyIgS2LIUnT9ewih4yrXSZX4hKoUwssy8WjUH++kEOA5wUTMgNnV7ko5n34kUA== @@ -19826,10 +19838,10 @@ octokit-plugin-create-pull-request@^3.9.3: dependencies: "@octokit/types" "^6.8.2" -oidc-token-hash@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888" - integrity sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg== +oidc-token-hash@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6" + integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ== on-finished@~2.3.0: version "2.3.0" @@ -19891,24 +19903,18 @@ openapi-sampler@^1.0.0-beta.15: dependencies: json-pointer "^0.6.0" -opencollective-postinstall@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89" - integrity sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw== - openid-client@^4.1.1, openid-client@^4.2.1: - version "4.2.1" - resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.2.1.tgz#8200c0ab6a3b8e954727dfa790847dc5cb8999c2" - integrity sha512-07eOcJeMH3ZHNvx5DVMZQmy3vZSTQqKSSunbtM1pXb+k5LBPi5hMum1vJCFReXlo4wuLEqZ/OgbsZvXPhbGRtA== + version "4.7.3" + resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.7.3.tgz#ea4f6f9ff6203dfbbe3d9bb4415d5dce751b0a70" + integrity sha512-YLwZQLSjo3gdSVxw/G25ddoRp9oCpXkREZXssmenlejZQPsnTq+yQtFUcBmC7u3VVkx+gwqXZF7X0CtAAJrRRg== dependencies: - base64url "^3.0.1" + aggregate-error "^3.1.0" got "^11.8.0" - jose "^2.0.2" + jose "^2.0.5" lru-cache "^6.0.0" make-error "^1.3.6" object-hash "^2.0.1" - oidc-token-hash "^5.0.0" - p-any "^3.0.0" + oidc-token-hash "^5.0.1" opn@^5.5.0: version "5.5.0" @@ -20023,14 +20029,6 @@ p-all@^2.1.0: dependencies: p-map "^2.0.0" -p-any@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/p-any/-/p-any-3.0.0.tgz#79847aeed70b5d3a10ea625296c0c3d2e90a87b9" - integrity sha512-5rqbqfsRWNb0sukt0awwgJMlaep+8jV45S15SKKB34z4UuzjcofIfnriCBhWjZP2jbVtjt9yRl7buB6RlKsu9w== - dependencies: - p-cancelable "^2.0.0" - p-some "^5.0.0" - p-cancelable@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" @@ -20175,14 +20173,6 @@ p-retry@^3.0.1: dependencies: retry "^0.12.0" -p-some@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/p-some/-/p-some-5.0.0.tgz#8b730c74b4fe5169d7264a240ad010b6ebc686a4" - integrity sha512-Js5XZxo6vHjB9NOYAzWDYAIyyiPvva0DWESAIWIK7uhSpGsyg5FwUPxipU/SOQx5x9EqhOh545d1jo6cVkitig== - dependencies: - aggregate-error "^3.0.0" - p-cancelable "^2.0.0" - p-timeout@^3.1.0, p-timeout@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" @@ -20679,11 +20669,16 @@ pg-connection-string@0.1.3, pg-connection-string@^0.1.3: resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= -pg-connection-string@2.4.0, pg-connection-string@^2.3.0, pg-connection-string@^2.4.0: +pg-connection-string@2.4.0: version "2.4.0" resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10" integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ== +pg-connection-string@^2.3.0, pg-connection-string@^2.4.0: + version "2.5.0" + resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34" + integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ== + pg-int8@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" @@ -20838,13 +20833,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-dir@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760" - integrity sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA== - dependencies: - find-up "^5.0.0" - pkg-up@3.1.0, pkg-up@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" @@ -21438,10 +21426,10 @@ prettier@~2.0.5: resolved "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4" integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg== -pretty-bytes@^5.3.0: - version "5.5.0" - resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.5.0.tgz#0cecda50a74a941589498011cf23275aa82b339e" - integrity sha512-p+T744ZyjjiaFlMUZZv6YPC5JrkNj8maRmPaQCWFJFplUAzpIUTRaTcS+7wmZtUoFXHtESJb23ISliaWyz3SHA== +pretty-bytes@^5.6.0: + version "5.6.0" + resolved "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== pretty-error@^2.1.1: version "2.1.1" @@ -21585,9 +21573,9 @@ prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, react-is "^16.8.1" property-expr@^2.0.2: - version "2.0.2" - resolved "https://registry.npmjs.org/property-expr/-/property-expr-2.0.2.tgz#fff2a43919135553a3bc2fdd94bdb841965b2330" - integrity sha512-bc/5ggaYZxNkFKj374aLbEDqVADdYaLcFo8XBkishUWbaAdjlphaBFns9TvRA2pUseVL/wMFmui9X3IdNDU37g== + version "2.0.4" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.4.tgz#37b925478e58965031bb612ec5b3260f8241e910" + integrity sha512-sFPkHQjVKheDNnPvotjQmm3KD3uk1fWKUN7CrpdbwmUx3CrG3QiM8QpTSimvig5vTXmTvjz7+TDvXOI9+4rkcg== property-information@^5.0.0: version "5.4.0" @@ -21743,7 +21731,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== @@ -21825,11 +21813,16 @@ ramda@^0.21.0: resolved "https://registry.npmjs.org/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" integrity sha1-oAGr7bP/YQd9T/HVd9RN536NCjU= -ramda@^0.26, ramda@~0.26.1: +ramda@^0.26: version "0.26.1" resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== +ramda@~0.27.1: + version "0.27.1" + resolved "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9" + integrity sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw== + random-bytes@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" @@ -22108,6 +22101,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" @@ -22435,10 +22433,10 @@ react-use@^15.3.3, react-use@^15.3.6: ts-easing "^0.2.0" tslib "^2.0.0" -react-use@^17.2.1: - version "17.2.1" - resolved "https://registry.npmjs.org/react-use/-/react-use-17.2.1.tgz#c81e12544115ed049c7deba1e3bb3d977dfee9b8" - integrity sha512-9r51/at7/Nr/nEP4CsHz+pl800EAqhIY9R6O68m68kaWc8slDAfx1UrIedQqpsb4ImddFYb+6hF1i5Vj4u4Cnw== +react-use@^17.2.4: + version "17.2.4" + resolved "https://registry.npmjs.org/react-use/-/react-use-17.2.4.tgz#1f89be3db0a8237c79253db0a15e12bbe3cfeff1" + integrity sha512-vQGpsAM0F5UIlshw5UI8ULGPS4yn5rm7/qvn3T1Gnkrz7YRMEEMh+ynKcmRloOyiIeLvKWiQjMiwRGtdbgs5qQ== dependencies: "@types/js-cookie" "^2.2.6" "@xobotyi/scrollbar-width" "^1.9.5" @@ -23346,7 +23344,7 @@ rsvp@^4.8.4: resolved "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== -rtl-css-js@^1.14.0, rtl-css-js@^1.9.0: +rtl-css-js@^1.14.0: version "1.14.0" resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.14.0.tgz#daa4f192a92509e292a0519f4b255e6e3c076b7d" integrity sha512-Dl5xDTeN3e7scU1cWX8c9b6/Nqz3u/HgR4gePc1kWXYiQWVQbKCEyK6+Hxve9LbcJ5EieHy1J9nJCN3grTtGwg== @@ -23396,10 +23394,10 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" - integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== safe-identifier@^0.4.2: version "0.4.2" @@ -23439,9 +23437,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" @@ -23476,6 +23474,15 @@ scheduler@^0.19.0, scheduler@^0.19.1: loose-envify "^1.1.0" object-assign "^4.1.1" +schema-utils@2.7.0: + version "2.7.0" + resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" + integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== + dependencies: + "@types/json-schema" "^7.0.4" + ajv "^6.12.2" + ajv-keywords "^3.4.1" + schema-utils@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" @@ -23503,12 +23510,7 @@ schema-utils@^3.0.0: ajv "^6.12.5" ajv-keywords "^3.5.2" -screenfull@^5.0.0: - version "5.0.2" - resolved "https://registry.npmjs.org/screenfull/-/screenfull-5.0.2.tgz#b9acdcf1ec676a948674df5cd0ff66b902b0bed7" - integrity sha512-cCF2b+L/mnEiORLN5xSAz6H3t18i2oHh9BA8+CQlAh5DRw2+NFAGQJOSYbcGw8B2k04g/lVvFcfZ83b3ysH5UQ== - -screenfull@^5.1.0: +screenfull@^5.0.0, screenfull@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/screenfull/-/screenfull-5.1.0.tgz#85c13c70f4ead4c1b8a935c70010dfdcd2c0e5c8" integrity sha512-dYaNuOdzr+kc6J6CFcBrzkLCfyGcMg+gWkJ8us93IQ7y1cevhQAugFsaCdMHb6lw8KV3xPzSxzH7zM1dQap9mA== @@ -23547,11 +23549,6 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -semver-regex@^3.1.2: - version "3.1.2" - resolved "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.2.tgz#34b4c0d361eef262e07199dbef316d0f2ab11807" - integrity sha512-bXWyL6EAKOJa81XG1OZ/Yyuq+oT0b2YLlxx7c+mrdYPaPbnj6WgVULXhinMIeZGufuUBu/eVRqXEhiv4imfwxA== - "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" @@ -23612,6 +23609,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" @@ -24078,7 +24080,7 @@ source-map@^0.7.3: resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== -sourcemap-codec@^1.4.1, sourcemap-codec@^1.4.4, sourcemap-codec@^1.4.8: +sourcemap-codec@^1.4.4, sourcemap-codec@^1.4.8: version "1.4.8" resolved "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== @@ -24195,7 +24197,7 @@ split@^1.0.0: dependencies: through "2" -sponge-case@^1.0.0, sponge-case@^1.0.1: +sponge-case@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz#260833b86453883d974f84854cdb63aecc5aef4c" integrity sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA== @@ -24217,6 +24219,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" @@ -24311,7 +24318,7 @@ stacktrace-gps@^3.0.4: source-map "0.5.6" stackframe "^1.1.1" -stacktrace-js@^2.0.0, stacktrace-js@^2.0.2: +stacktrace-js@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz#4ca93ea9f494752d55709a081d400fdaebee897b" integrity sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg== @@ -24372,9 +24379,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" @@ -24731,11 +24738,6 @@ stylehacks@^4.0.0: postcss "^7.0.0" postcss-selector-parser "^3.0.0" -stylis@3.5.0: - version "3.5.0" - resolved "https://registry.npmjs.org/stylis/-/stylis-3.5.0.tgz#016fa239663d77f868fef5b67cf201c4b7c701e1" - integrity sha512-pP7yXN6dwMzAR29Q0mBrabPCe0/mNO1MSr93bhay+hcZondvMMTpeGyd8nbhYJdyperNT2DRxONQuUGcJr5iPw== - stylis@^4.0.6: version "4.0.7" resolved "https://registry.npmjs.org/stylis/-/stylis-4.0.7.tgz#412a90c28079417f3d27c028035095e4232d2904" @@ -24844,7 +24846,7 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.1.0: +supports-color@^8.1.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -24944,7 +24946,7 @@ swagger-ui-react@^3.37.2: xml-but-prettier "^1.0.1" zenscroll "^4.0.2" -swap-case@^2.0.1, swap-case@^2.0.2: +swap-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz#671aedb3c9c137e2985ef51c51f9e98445bf70d9" integrity sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw== @@ -25319,7 +25321,7 @@ tinycolor2@^1.4.1: resolved "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8" integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g= -title-case@^3.0.2, title-case@^3.0.3: +title-case@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982" integrity sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA== @@ -25340,20 +25342,13 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" -tmp@^0.2.0: +tmp@^0.2.0, tmp@~0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== dependencies: rimraf "^3.0.0" -tmp@~0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877" - integrity sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw== - dependencies: - rimraf "^2.6.3" - tmpl@1.0.x: version "1.0.4" resolved "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" @@ -25582,17 +25577,6 @@ ts-log@^2.2.3: resolved "https://registry.npmjs.org/ts-log/-/ts-log-2.2.3.tgz#4da5640fe25a9fb52642cd32391c886721318efb" integrity sha512-XvB+OdKSJ708Dmf9ore4Uf/q62AYDTzFcAdxc8KNML1mmAWywRFVt/dn1KYJH8Agt5UJNujfM3znU5PxgAzA2w== -ts-node@^8.10.2, ts-node@^8.6.2: - version "8.10.2" - resolved "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d" - integrity sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA== - dependencies: - arg "^4.1.0" - diff "^4.0.1" - make-error "^1.1.1" - source-map-support "^0.5.17" - yn "3.1.1" - ts-node@^9, ts-node@^9.1.1: version "9.1.1" resolved "https://registry.npmjs.org/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d" @@ -25802,9 +25786,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" @@ -26105,20 +26089,13 @@ update-notifier@^4.1.0: semver-diff "^3.1.1" xdg-basedir "^4.0.0" -upper-case-first@^2.0.1, upper-case-first@^2.0.2: +upper-case-first@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz#992c3273f882abd19d1e02894cc147117f844324" integrity sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg== dependencies: tslib "^2.0.3" -upper-case@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/upper-case/-/upper-case-2.0.1.tgz#6214d05e235dc817822464ccbae85822b3d8665f" - integrity sha512-laAsbea9SY5osxrv7S99vH9xAaJKrw5Qpdh4ENRLcaxipjKsiaBwiAsxfa8X5mObKNTQPsupSq0J/VIxsSJe3A== - dependencies: - tslib "^1.10.0" - upper-case@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz#d89810823faab1df1549b7d97a76f8662bae6f7a" @@ -27142,10 +27119,17 @@ yaml-ast-parser@0.0.43, yaml-ast-parser@^0.0.43: resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== +yaml-jest@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/yaml-jest/-/yaml-jest-1.0.5.tgz#288e541bae1b551d113d4864d6cb8f316e1bd331" + integrity sha1-KI5UG64bVR0RPUhk1suPMW4b0zE= + dependencies: + js-yaml "^3.7.0" + yaml@^1.10.0, yaml@^1.7.2, yaml@^1.9.2: - version "1.10.0" - resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" - integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== + version "1.10.2" + resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== yargs-parser@20.2.4, yargs-parser@20.x, yargs-parser@^20.2.2, yargs-parser@^20.2.3: version "20.2.4"