diff --git a/.changeset/big-plums-remember.md b/.changeset/big-plums-remember.md new file mode 100644 index 0000000000..76578eec0a --- /dev/null +++ b/.changeset/big-plums-remember.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes': patch +--- + +Bump `@kubernetes/client-node` from `^0.13.2` to `^0.14.0`. diff --git a/.changeset/blue-sheep-act.md b/.changeset/blue-sheep-act.md new file mode 100644 index 0000000000..bb13be229f --- /dev/null +++ b/.changeset/blue-sheep-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Fixes bug in the `github:publish` action causing repositories to be set as private even if the visibility is set to internal diff --git a/.changeset/calm-mugs-jog.md b/.changeset/calm-mugs-jog.md new file mode 100644 index 0000000000..6cb5c1de24 --- /dev/null +++ b/.changeset/calm-mugs-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-badges': patch +--- + +Update badge dialog formatting diff --git a/.changeset/chilled-flies-speak.md b/.changeset/chilled-flies-speak.md new file mode 100644 index 0000000000..fc647a4fd5 --- /dev/null +++ b/.changeset/chilled-flies-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-api': patch +--- + +Changed the signature of `createRoutableExtension` to include null diff --git a/.changeset/chilled-kiwis-bathe.md b/.changeset/chilled-kiwis-bathe.md deleted file mode 100644 index ef1517a58c..0000000000 --- a/.changeset/chilled-kiwis-bathe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/catalog-client': patch ---- - -Throw the new `ResponseError` from `@backstage/errors` diff --git a/.changeset/cost-insights-short-garlics-press.md b/.changeset/cost-insights-short-garlics-press.md new file mode 100644 index 0000000000..1c0c8c6229 --- /dev/null +++ b/.changeset/cost-insights-short-garlics-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +fix missing type exports of public components diff --git a/.changeset/curly-fishes-speak.md b/.changeset/curly-fishes-speak.md new file mode 100644 index 0000000000..8cd53daeb4 --- /dev/null +++ b/.changeset/curly-fishes-speak.md @@ -0,0 +1,31 @@ +--- +'@backstage/create-app': patch +--- + +(fix) Adds locationAnalyzer to default-app template + +The locationAnalyzer was missing from the default-app template. +This resulted in 404 errors in newly bootstrapped backstage applications, +when adding components without configuration. + +To fix this in an existing backstage application, the locationAnalyzer needs +to be carried from the builder to the router in the +`packages/backend/src/plugins/catalog.ts` file. + +```diff + const builder = new CatalogBuilder(env); + const { + entitiesCatalog, + locationsCatalog, + higherOrderOperation, ++ locationAnalyzer, + } = await builder.build(); + // ... + return await createRouter({ + entitiesCatalog, + locationsCatalog, + higherOrderOperation, ++ locationAnalyzer, + logger: env.logger, + }); +``` diff --git a/.changeset/curvy-peaches-applaud.md b/.changeset/curvy-peaches-applaud.md new file mode 100644 index 0000000000..f24830ad38 --- /dev/null +++ b/.changeset/curvy-peaches-applaud.md @@ -0,0 +1,8 @@ +--- +'@backstage/core-api': patch +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-jenkins': patch +--- + +Introduce `useRouteRefParams` to `core-api` to retrieve typed route parameters. diff --git a/.changeset/dirty-flowers-fail.md b/.changeset/dirty-flowers-fail.md deleted file mode 100644 index 4a3baabcb4..0000000000 --- a/.changeset/dirty-flowers-fail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-graphiql': patch ---- - -Export `GraphiQLIcon`. diff --git a/.changeset/dull-badgers-pull.md b/.changeset/dull-badgers-pull.md new file mode 100644 index 0000000000..ddbdeb55ca --- /dev/null +++ b/.changeset/dull-badgers-pull.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-search-backend': patch +'@backstage/plugin-search-backend-node': patch +--- + +Bump to use the in-repo latest `backend-common`, and the correct version of `express-promise-router` diff --git a/.changeset/eight-poems-camp.md b/.changeset/eight-poems-camp.md new file mode 100644 index 0000000000..62ee6810c9 --- /dev/null +++ b/.changeset/eight-poems-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-badges': patch +--- + +Get the current entity using `useEntity` from `@backstage/plugin-catalog-react` diff --git a/.changeset/fair-students-buy.md b/.changeset/fair-students-buy.md deleted file mode 100644 index 600c86e6c1..0000000000 --- a/.changeset/fair-students-buy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-user-settings': patch ---- - -Avoid using `ApiRef` descriptions in the UI. diff --git a/.changeset/famous-knives-cough.md b/.changeset/famous-knives-cough.md new file mode 100644 index 0000000000..28869ddf33 --- /dev/null +++ b/.changeset/famous-knives-cough.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Temporarily add `UNSTABLE_extraContextMenuItems` to the entity layout, so that we could detach the catalog plugin from the dependency on the badges plugin diff --git a/.changeset/five-eagles-flow.md b/.changeset/five-eagles-flow.md new file mode 100644 index 0000000000..a546506907 --- /dev/null +++ b/.changeset/five-eagles-flow.md @@ -0,0 +1,41 @@ +--- +'@backstage/create-app': patch +--- + +Adds plugin-org and more capability to the default EntityPage to display Users, Groups and Systems. + +To update an existing application, add the org plugin: + +```shell +cd packages/app +yarn add @backstage/plugin-org +``` + +Then add the example systems locations to your `app-config.yaml`: + +```diff +catalog: + rules: +- - allow: [Component, API, Group, User, Template, Location] ++ - allow: [Component, System, API, Group, User, Template, Location] + locations: + # Backstage example components + - type: url + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml + ++ # Backstage example systems ++ - type: url ++ target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-systems.yaml ++ + # Backstage example APIs +``` + +Additionally, the default app sidebar was updated to parity with the Backstage +repo. You can see these changes in the template +[App.tsx](https://github.com/backstage/backstage/blob/8817a87cdd5c881fbe8a43557ba7f9df0f9e3258/packages/create-app/templates/default-app/packages/app/src/App.tsx#L70) +referencing a new `Root` component. + +Finally, compare your `packages/app/src/components/catalog/EntityPage.tsx` to +[EntityPage](https://github.com/backstage/backstage/blob/8817a87cdd5c881fbe8a43557ba7f9df0f9e3258/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx) +from the `@backstage/create-app` default template to pick up additional +changes there. diff --git a/.changeset/four-hounds-own.md b/.changeset/four-hounds-own.md new file mode 100644 index 0000000000..28eeb89f83 --- /dev/null +++ b/.changeset/four-hounds-own.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-techdocs': minor +'@backstage/plugin-techdocs-backend': minor +--- + +When newer documentation available but not built, show older documentation while async building newer +TechDocs backend: /sync endpoint added to support above, returns immediate success if docs don't need a build, returns delayed success after build if needed +TechDocs backend: /docs endpoint removed as frontend can directly request to techdocs.storageUrl or /static/docs diff --git a/.changeset/giant-flies-roll.md b/.changeset/giant-flies-roll.md new file mode 100644 index 0000000000..c9811c4a9f --- /dev/null +++ b/.changeset/giant-flies-roll.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Support SonarQube project keys containing special characters like colons. diff --git a/.changeset/good-baboons-taste.md b/.changeset/good-baboons-taste.md deleted file mode 100644 index 820c286635..0000000000 --- a/.changeset/good-baboons-taste.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch ---- - -Add tests for kubernetes custom hook diff --git a/.changeset/healthy-cars-do.md b/.changeset/healthy-cars-do.md deleted file mode 100644 index 7f3b874ba3..0000000000 --- a/.changeset/healthy-cars-do.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@backstage/backend-common': minor ---- - -Encode thrown errors in the backend as a JSON payload. This is technically a breaking change, since the response format even of errors are part of the contract. If you relied on the response being text, you will now have some extra JSON "noise" in it. It should still be readable by end users though. - -Before: - -``` -NotFoundError: No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default' - at eval (webpack-internal:///../../plugins/catalog-backend/src/service/router.ts:117:17) -``` - -After: - -```json -{ - "error": { - "name": "NotFoundError", - "message": "No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default'", - "stack": "NotFoundError: No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default'\n at eval (webpack-internal:///../../plugins/catalog-backend/src/service/router.ts:117:17)" - }, - "request": { - "method": "GET", - "url": "/entities/by-name/user/default/tara.macgovern2" - }, - "response": { - "statusCode": 404 - } -} -``` diff --git a/.changeset/khaki-avocados-remain.md b/.changeset/khaki-avocados-remain.md deleted file mode 100644 index b4d8f7a925..0000000000 --- a/.changeset/khaki-avocados-remain.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Lint storybook files, i.e. `*.stories.*`, as if they were tests. diff --git a/.changeset/light-zoos-wonder.md b/.changeset/light-zoos-wonder.md new file mode 100644 index 0000000000..218cf5c6e0 --- /dev/null +++ b/.changeset/light-zoos-wonder.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +The `auth` config types now properly accept any declared auth environment. Previously only `development` was accepted. + +The `audience` configuration is no longer required for GitLab auth; this will default to `https://gitlab.com` diff --git a/.changeset/olive-apples-shop.md b/.changeset/olive-apples-shop.md deleted file mode 100644 index 754c926780..0000000000 --- a/.changeset/olive-apples-shop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core': patch ---- - -Add a `ResponseErrorPanel` to render `ResponseError` from `@backstage/errors` diff --git a/.changeset/orange-flowers-act.md b/.changeset/orange-flowers-act.md new file mode 100644 index 0000000000..ba83f8401e --- /dev/null +++ b/.changeset/orange-flowers-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add inputs for action `fetch:cookiecutter`: copyWithoutRender, extensions, imageName diff --git a/.changeset/orange-shirts-bake.md b/.changeset/orange-shirts-bake.md new file mode 100644 index 0000000000..192559cde5 --- /dev/null +++ b/.changeset/orange-shirts-bake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Introduce workaround for admonition icons of MkDocs. diff --git a/.changeset/quick-falcons-happen.md b/.changeset/quick-falcons-happen.md new file mode 100644 index 0000000000..26540aa38d --- /dev/null +++ b/.changeset/quick-falcons-happen.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-todo-backend': patch +--- + +Forward authorization on todo backend requests diff --git a/.changeset/selfish-apples-fly.md b/.changeset/selfish-apples-fly.md new file mode 100644 index 0000000000..b0be4f0423 --- /dev/null +++ b/.changeset/selfish-apples-fly.md @@ -0,0 +1,7 @@ +--- +'@backstage/create-app': patch +--- + +Update the create-app template to use the correct latest version of `express-promise-router`. + +To apply the same change in your own repository, update all of your repo's dependencies on `express-promise-router` to `"^4.1.0"`. diff --git a/.changeset/shy-owls-grab.md b/.changeset/shy-owls-grab.md new file mode 100644 index 0000000000..ccee56c153 --- /dev/null +++ b/.changeset/shy-owls-grab.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Reuse ResponseErrorList for non ResponseErrors diff --git a/.changeset/silly-crabs-press.md b/.changeset/silly-crabs-press.md new file mode 100644 index 0000000000..64895cb847 --- /dev/null +++ b/.changeset/silly-crabs-press.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Add icon for entity badge menu diff --git a/.changeset/silver-weeks-hide.md b/.changeset/silver-weeks-hide.md deleted file mode 100644 index 15a75016a2..0000000000 --- a/.changeset/silver-weeks-hide.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@backstage/techdocs-common': patch -'@backstage/plugin-auth-backend': patch -'@backstage/plugin-catalog': patch -'@backstage/plugin-catalog-backend': patch -'@backstage/plugin-scaffolder-backend': patch -'@backstage/plugin-techdocs-backend': patch ---- - -Use errors from `@backstage/errors` diff --git a/.changeset/slimy-lies-tap.md b/.changeset/slimy-lies-tap.md new file mode 100644 index 0000000000..8903f2e036 --- /dev/null +++ b/.changeset/slimy-lies-tap.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +TechDocs: links at sidebar and bottom reset scroll position to top diff --git a/.changeset/slow-jeans-cheer.md b/.changeset/slow-jeans-cheer.md new file mode 100644 index 0000000000..11e5029898 --- /dev/null +++ b/.changeset/slow-jeans-cheer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added a `publish:file` action to use for local development. The action is not installed by default. diff --git a/.changeset/small-oranges-enjoy.md b/.changeset/small-oranges-enjoy.md new file mode 100644 index 0000000000..641fe61237 --- /dev/null +++ b/.changeset/small-oranges-enjoy.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Log how many repositories were actually matching in `GithubDiscoveryProcessor` diff --git a/.changeset/spicy-bats-smoke.md b/.changeset/spicy-bats-smoke.md new file mode 100644 index 0000000000..37228166d0 --- /dev/null +++ b/.changeset/spicy-bats-smoke.md @@ -0,0 +1,6 @@ +--- +'@backstage/core': patch +'@backstage/theme': patch +--- + +Changed color for Add Item, Support & Choose buttons with low contrast/readability in dark mode diff --git a/.changeset/techdocs-eleven-planes-teach.md b/.changeset/techdocs-eleven-planes-teach.md deleted file mode 100644 index 2cc4e42175..0000000000 --- a/.changeset/techdocs-eleven-planes-teach.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@backstage/techdocs-common': patch -'@backstage/plugin-techdocs-backend': patch ---- - -Applies only if you use TechDocs local builder instead of building on CI/CD i.e. if `techdocs.builder` in your `app-config.yaml` is set to `'local'` - -Improvements - -1. Do not check for updates in the repository if a check has been made in the last 60 seconds. This is to prevent the annoying check for update on every page switch or load. -2. No need to maintain an in-memory etag storage, and use the one stored in `techdocs_metadata.json` file alongside generated docs. - -New feature - -1. You can now use a mix of basic and recommended setup i.e. `techdocs.builder` is `'local'` but using an external cloud storage instead of local storage. Previously, in this setup, the docs would never get updated. diff --git a/.changeset/techdocs-nervous-plants-carry.md b/.changeset/techdocs-nervous-plants-carry.md new file mode 100644 index 0000000000..59d45b2b88 --- /dev/null +++ b/.changeset/techdocs-nervous-plants-carry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Add sticky sidebars and footer navigation links to TechDocs Reader diff --git a/.changeset/techdocs-tame-spies-kneel.md b/.changeset/techdocs-tame-spies-kneel.md deleted file mode 100644 index 2b69c41213..0000000000 --- a/.changeset/techdocs-tame-spies-kneel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-techdocs': patch ---- - -Extended TechDocs HomePage with owned documents diff --git a/.changeset/tender-keys-study.md b/.changeset/tender-keys-study.md deleted file mode 100644 index a3adcbf783..0000000000 --- a/.changeset/tender-keys-study.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/core-api': patch ---- - -Added new Docs Icon to Core Icons diff --git a/.changeset/twelve-files-invite.md b/.changeset/twelve-files-invite.md new file mode 100644 index 0000000000..8009e92110 --- /dev/null +++ b/.changeset/twelve-files-invite.md @@ -0,0 +1,32 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Introduce pagination in the /entities catalog endpoint. + +Pagination is requested using query parameters. Currently supported parameters, all optional, are: + +- `limit` - an integer number of entities to return, at most +- `offset` - an integer number of entities to skip over at the start +- `after` - an opaque string cursor as returned by a previous paginated request + +Example request: + +`GET /entities?limit=100` + +Example response: + +``` +200 OK +Content-Type: application/json; charset=utf-8 +Link: ; rel="next" + + +[{"metadata":{... +``` + +Note the Link header. It contains the URL (path and query part, relative to the catalog root) to use for requesting the next page. +It uses the `after` cursor to point out the end of the previous page. If the Link header is not present, there is no more data to read. + +The current implementation is naive and encodes offset/limit in the cursor implementation, so it is not robust in the face of overlapping +changes to the catalog. This can be improved separately in the future without having to change the calling patterns. diff --git a/.changeset/twelve-rings-sip.md b/.changeset/twelve-rings-sip.md new file mode 100644 index 0000000000..1fc2f88ec1 --- /dev/null +++ b/.changeset/twelve-rings-sip.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Make the backend plugin template use the correct latest version of `express-promise-router` diff --git a/.changeset/yellow-bananas-live.md b/.changeset/yellow-bananas-live.md new file mode 100644 index 0000000000..82a3ce8b15 --- /dev/null +++ b/.changeset/yellow-bananas-live.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Fix PropTypes error with OverflowTooltip component diff --git a/.changeset/young-suits-sit.md b/.changeset/young-suits-sit.md deleted file mode 100644 index 5728c52ef8..0000000000 --- a/.changeset/young-suits-sit.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@backstage/backend-common': minor ---- - -Removed the custom error types (e.g. `NotFoundError`). Those are now instead in the new `@backstage/errors` package. This is a breaking change, and you will have to update your imports if you were using these types. - -```diff --import { NotFoundError } from '@backstage/backend-common'; -+import { NotFoundError } from '@backstage/errors'; -``` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 371fae6e6b..4e21603c63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,6 +11,7 @@ /plugins/cost-insights @backstage/silver-lining /plugins/cloudbuild @trivago/ebarrios /plugins/search @backstage/techdocs-core +/plugins/search-* @backstage/techdocs-core /plugins/techdocs @backstage/techdocs-core /plugins/techdocs-backend @backstage/techdocs-core /packages/techdocs-common @backstage/techdocs-core diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000000..e556fa9854 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# 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 +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 8997360782..fb69937613 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -33,6 +33,7 @@ Grafana GraphQL Gustavsson Hackathons +Haughwout Henneke Heroku Hostname @@ -42,7 +43,9 @@ JavaScript Kaewkasi Knex Kumar +Leasot Lerna +Lindgren Lundberg Luxon Malus @@ -58,6 +61,7 @@ Oldsberg Olle Onboarding Patrik +Peloton Phoen Platformize Pomaceous @@ -88,6 +92,7 @@ Telenor Templater Templaters Thauer +todo Tolerations Tuite Trendyol @@ -138,6 +143,7 @@ css dariddler dataflow deadnaming +declaratively destructured dev devops @@ -239,6 +245,7 @@ ruleset sam scaffolded scaffolder +seb semlas semver sourcemaps @@ -263,6 +270,7 @@ templated templater templaters theia +thumbsup toc tolerations toolchain diff --git a/.github/workflows/docs-quality-checker.yml b/.github/workflows/docs-quality-checker.yml index 45cf1dafe3..f0bfaefe0a 100644 --- a/.github/workflows/docs-quality-checker.yml +++ b/.github/workflows/docs-quality-checker.yml @@ -1,10 +1,10 @@ name: Check Markdown files quality -on: - pull_request: - branches: [master] - paths: - - '**.md' +# on: +# pull_request: +# branches: [master] +# paths: +# - '**.md' jobs: check-all-files: diff --git a/ADOPTERS.md b/ADOPTERS.md index 9f2dbf3c06..218a4edeba 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,24 +1,26 @@ -| 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) | 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. | +| 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. | diff --git a/OWNERS.md b/OWNERS.md index f1649c50b8..43c1656753 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -14,6 +14,9 @@ This page lists all active sponsors and maintainers. - Patrik Oldsberg ([rugvip](https://github.com/rugvip)) (Discord: @Rugvip) - Fredrik Adelöw ([freben](https://github.com/freben)) (Discord: @freben) - Ben Lambert ([benjdlambert](https://github.com/benjdlambert)) (Discord: @blam) + +# Emeritus maintainers + - Stefan Ålund ([stefanalund](https://github.com/stefanalund)) (Discord: @stalund) # Friends of Backstage diff --git a/contrib/chart/backstage/README.md b/contrib/chart/backstage/README.md index 96bfdb4351..7d04a2cf1e 100644 --- a/contrib/chart/backstage/README.md +++ b/contrib/chart/backstage/README.md @@ -236,6 +236,27 @@ Backend failed to start up Error: unable to verify the first certificate This error happens in the backend when it tries to connect to the configured PostgreSQL database and the specified CA is not correct. The solution is to make sure that the contents of the `configMap` that holds the certificate match the CA for the PostgreSQL instance. A workaround is to set `appConfig.backend.database.connection.ssl.rejectUnauthorized` to `false` in the chart's values. +#### Multi-Platform Kubernetes Services + +If you are running a multi-platform Kubernetes service with Windows and Linux nodes then you will need to apply a `nodeSelector` to the Helm chart to ensure that pods are scheduled onto the correct platform nodes. + +Add the following to your Helm values file: + +```yaml +global: + nodeSelector: + kubernetes.io/os: linux + +# If using Postgres Chart also add +postgresql: + master: + nodeSelector: + kubernetes.io/os: linux + slave: + nodeSelector: + kubernetes.io/os: linux +``` + ## Uninstalling Backstage diff --git a/contrib/chart/backstage/templates/backend-deployment.yaml b/contrib/chart/backstage/templates/backend-deployment.yaml index 99ec955242..144fc93e62 100644 --- a/contrib/chart/backstage/templates/backend-deployment.yaml +++ b/contrib/chart/backstage/templates/backend-deployment.yaml @@ -63,6 +63,10 @@ spec: configMap: name: {{ include "backstage.fullname" . }}-app-config + {{- if .Values.global.nodeSelector }} + nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} + {{- end }} + {{- if .Values.backend.enabled }} --- apiVersion: v1 diff --git a/contrib/chart/backstage/templates/frontend-deployment.yaml b/contrib/chart/backstage/templates/frontend-deployment.yaml index 5430ef041f..9c674cc160 100644 --- a/contrib/chart/backstage/templates/frontend-deployment.yaml +++ b/contrib/chart/backstage/templates/frontend-deployment.yaml @@ -43,6 +43,9 @@ spec: - name: app-config configMap: name: {{ include "backstage.fullname" . }}-app-config + {{- if .Values.global.nodeSelector }} + nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} + {{- end }} {{- if .Values.frontend.enabled }} --- apiVersion: v1 diff --git a/contrib/chart/backstage/templates/lighthouse-deployment.yaml b/contrib/chart/backstage/templates/lighthouse-deployment.yaml index 13bb8eadbb..d6e1ce19dd 100644 --- a/contrib/chart/backstage/templates/lighthouse-deployment.yaml +++ b/contrib/chart/backstage/templates/lighthouse-deployment.yaml @@ -59,6 +59,10 @@ spec: - name: postgres-ca configMap: name: {{ include "backstage.fullname" . }}-postgres-ca + + {{- if .Values.global.nodeSelector }} + nodeSelector: {{- toYaml .Values.global.nodeSelector | nindent 8 }} + {{- end }} --- apiVersion: v1 kind: Service diff --git a/contrib/chart/backstage/values.yaml b/contrib/chart/backstage/values.yaml index 7926ebbc0a..7b26d2bccd 100644 --- a/contrib/chart/backstage/values.yaml +++ b/contrib/chart/backstage/values.yaml @@ -69,6 +69,7 @@ global: postgresql: postgresqlUsername: backend-user caFilename: ca.crt + nodeSelector: {} postgresql: enabled: true diff --git a/contrib/docker/cookiecutter-with-jinja2-extensions/Dockerfile b/contrib/docker/cookiecutter-with-jinja2-extensions/Dockerfile new file mode 100644 index 0000000000..4f5f72bdf8 --- /dev/null +++ b/contrib/docker/cookiecutter-with-jinja2-extensions/Dockerfile @@ -0,0 +1,11 @@ +FROM alpine:3.7 + +RUN apk add --update \ + git \ + python \ + python-dev \ + py-pip \ + g++ && \ + pip install cookiecutter jinja2_custom_filters_extension && \ + apk del g++ py-pip python-dev && \ + rm -rf /var/cache/apk/* diff --git a/contrib/docker/cookiecutter-with-jinja2-extensions/README.md b/contrib/docker/cookiecutter-with-jinja2-extensions/README.md new file mode 100644 index 0000000000..470694513a --- /dev/null +++ b/contrib/docker/cookiecutter-with-jinja2-extensions/README.md @@ -0,0 +1,73 @@ +# Using Cookiecutter with Jinja2 extensions + +Jinja2 extensions can be used with the scaffolder's `fetch:cookiecutter` built-in action to add filters, tests, or to extend the parser. + +Using Cookiecutter extensions is a two-step process: + +- [Installing the extension](#installing-the-extension), and +- [Instructing Cookiecutter to use the extension](#instructing-cookiecutter-to-use-the-extension) + +### Installing the extension + +This step depends on how the scaffolder is setup to use Cookiecutter: + +- Using a local Cookiecutter, or +- Using a Cookiecutter Docker image, e.g. [spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile). + +Say we want to install [`jinja2_custom_filters_extension`](https://pypi.org/project/jinja2-custom-filters-extension/) to use the `upper_case_first_letter` filter in a Cookiecutter template. + +#### Using a local Cookiecutter + +The scaffolder is able to execute a locally installed Cookiecutter, and doesn't pull a Docker image in that case. If that's your setup, just ensure that the Jinja2 extensions, available via `pip` are installed alongside Cookiecutter, e.g. if Cookiecutter is baked into a custom Backstage image using `pip` and a `requirements.txt`: + +In the custom Backstage Dockerfile: + +```Dockerfile +... +RUN pip3 install -r requirements.txt +... +``` + +In requirements.txt: + +```python +... +cookiecutter==1.7.2 +jinja2_custom_filters_extension==0.0.2 +... +``` + +#### Using a Cookiecutter Docker image + +If the scaffolder doesn't find a local Cookiecutter, it pulls down the `spotify/backstage-cookiecutter` image. You can create a custom Cookiecutter image based on that, install extensions into it, and specify that customised image as an input `imageName` to the `fetch:cookiecutter` action: + +```yaml +steps: + - id: fetch-base + name: Fetch Base + action: fetch:cookiecutter + input: + url: https://github.com/spotify/cookiecutter-golang + imageName: 'foo/custom-built-cookiecutter-image-with-extensions' +``` + +See for example, the [`Dockerfile`](./Dockerfile) in this directory. + +### Instructing Cookiecutter to use the extension + +Cookiecutter enables extensions mentioned in `cookiecutter.json`. `fetch:cookiecutter` generates a `cookiecutter.json`, deriving its values from `inputs` to `fetch:cookiecutter` in the scaffolder [Template](https://backstage.io/docs/features/software-templates/writing-templates), as: + +```yaml +steps: + - id: fetch-base + name: Fetch Base + action: fetch:cookiecutter + input: + extensions: + - jinja2_custom_filters_extension.string_filters_extension.StringFilterExtension + url: https://github.com/spotify/cookiecutter-golang + values: + name: '{{ parameters.name }}' +``` + +Cookiecutter enables a few extensions by default. See the official Cookiecutter documentation for [Template Extensions](https://cookiecutter.readthedocs.io/en/1.7.2/advanced/template_extensions.html) for a list of such extensions, and more information. diff --git a/contrib/docs/tutorials/authenticate-api-requests.md b/contrib/docs/tutorials/authenticate-api-requests.md index 4f4547a49e..7422fa03ec 100644 --- a/contrib/docs/tutorials/authenticate-api-requests.md +++ b/contrib/docs/tutorials/authenticate-api-requests.md @@ -6,7 +6,7 @@ API requests from frontend plugins include an authorization header with a Backst Note that this means Backstage will stop working for guests, as no token is issued for them. -Caveat: as of writing this, Backstage does not refresh the identity token so eventually users will get a 401 response on API calls (not on loading the web page as only the API calls are authenticated) and have to logout/login again to get a new token. +As techdocs HTML pages load assets without an Authorization header the code below also sets a token cookie when the user logs in (and when the token is about to expire). ```typescript // packages/backend/src/index.ts from a create-app deployment @@ -82,6 +82,10 @@ async function main() { apiRouter.use(cookieParser()); // The auth route must be publically available as it is used during login apiRouter.use('/auth', await auth(authEnv)); + // Add a simple endpoint to be used when setting a token cookie + apiRouter.use('/cookie', authMiddleware, (_req, res) => { + res.status(200).send(`Coming right up`); + }); // Only authenticated requests are allowed to the routes below apiRouter.use('/catalog', authMiddleware, await catalog(catalogEnv)); apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv)); @@ -91,3 +95,89 @@ async function main() { // ... } ``` + +```typescript +// packages/app/src/App.tsx from a create-app deployment + +import { discoveryApiRef, useApi } from '@backstage/core'; + +// ... + +// Parses supplied JWT token and returns the payload +function parseJwt(token: string): { exp: number } { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(''), + ); + + return JSON.parse(jsonPayload); +} + +// Returns milliseconds until the supplied JWT token expires +function msUntilExpiry(token: string): number { + const payload = parseJwt(token); + const remaining = + new Date(payload.exp * 1000).getTime() - new Date().getTime(); + return remaining; +} + +// Calls the specified url regularly using an auth token to set a token cookie +// to authorize regular HTTP requests when loading techdocs +async function setTokenCookie(url: string, getIdToken: () => Promise) { + const token = await getIdToken(); + await fetch(url, { + mode: 'cors', + credentials: 'include', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + // Call this function again a few minutes before the token expires + const ms = msUntilExpiry(token) - 4 * 60 * 1000; + setTimeout( + () => { + setTokenCookie(url, getIdToken); + }, + ms > 0 ? ms : 10000, + ); +} + +const app = createApp({ + // ... + + components: { + SignInPage: props => { + const discoveryApi = useApi(discoveryApiRef); + return ( + { + // When logged in, set a token cookie + if (typeof result.getIdToken !== 'undefined') { + setTokenCookie( + await discoveryApi.getBaseUrl('cookie'), + result.getIdToken, + ); + } + // Forward results + props.onResult(result); + }} + /> + ); + }, + }, + + // ... +}); + +// ... +``` diff --git a/cypress/src/integration/catalog.ts b/cypress/src/integration/catalog.ts index 31d39e7efd..4016c15470 100644 --- a/cypress/src/integration/catalog.ts +++ b/cypress/src/integration/catalog.ts @@ -23,7 +23,7 @@ describe('Catalog', () => { cy.visit('/catalog'); - cy.contains('Owned (7)').should('be.visible'); + cy.contains('Owned (8)').should('be.visible'); }); }); }); diff --git a/docs/assets/architecture-decisions/catalog-core-entities.png b/docs/assets/architecture-decisions/catalog-core-entities.png index b7f238708f..e39d3217ec 100644 Binary files a/docs/assets/architecture-decisions/catalog-core-entities.png and b/docs/assets/architecture-decisions/catalog-core-entities.png differ diff --git a/docs/assets/search/architecture.drawio.svg b/docs/assets/search/architecture.drawio.svg index a624a375e1..1acad6d74b 100644 --- a/docs/assets/search/architecture.drawio.svg +++ b/docs/assets/search/architecture.drawio.svg @@ -1,4 +1,4 @@ - + @@ -268,7 +268,7 @@ -
+
Pass Search @@ -282,7 +282,7 @@
- + Pass Search... @@ -368,7 +368,7 @@ - + @@ -529,7 +529,7 @@ - + @@ -538,7 +538,7 @@
@backstage/
- plugin-search-indexer + plugin-search-backend-node
diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index d5ad240dde..fb5d664f16 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -6,84 +6,18 @@ description: Documentation on Software Catalog Configuration ## Processors -The catalog makes use of so called processors to perform all kinds of ingestion -tasks, such as reading raw entity data from a remote source, parsing it, -transforming it, and validating it. These processors are configured under the -`catalog.processors` key. +The catalog has a concept of _processors_ to perform catalog ingestion tasks, +such as reading raw entity data from a remote source, parsing it, transforming +it, and validating it. These processors are configured under the +`catalog.processors` configuration key. -### Processor: url +### Static Location Configuration -The `url` processor is responsible for fetching entity data from files in any -external provider like GitHub, GitLab, Bitbucket, etc. The configuration of this -processor lives under the top-level `integrations` key, as it is used by other -parts of Backstage too. +The simplest configuration for the catalog, as shown in the default +`@backstage/create-app` template, is to declaratively add locations pointing to +YAML files with [static configuration](../../conf/index.md). -```yaml -integrations: - github: - - host: github.com - token: - $env: GITHUB_TOKEN - - host: ghe.example.net - apiBaseUrl: https://ghe.example.net/api/v3 - rawBaseUrl: https://ghe.example.net/raw - token: - $env: GHE_TOKEN - gitlab: - - host: gitlab.com - token: - $env: GITLAB_TOKEN - bitbucket: - - host: bitbucket.org - username: - $env: BITBUCKET_USERNAME - appPassword: - $env: BITBUCKET_APP_PASSWORD - azure: - - host: dev.azure.com - token: - $env: AZURE_TOKEN -``` - -Each key under `integrations` is a separate configuration for each external -provider. The providers each have their own configuration, so let's look at the -GitHub section as an example. - -Directly under the `github` key is a list of provider configurations, where you -can list the various GitHub compatible providers you want to be able to fetch -data from. Each entry is a structure with up to four elements: - -- `host` (optional): The host of the location target that you want to match on. - The default host is `github.com`. -- `token` (optional): An authentication token as expected by GitHub. If - supplied, it will be passed along with all calls to this provider, both API - and raw. If it is not supplied, anonymous access will be used. -- `apiBaseUrl` (optional): If you want to communicate using the APIv3 method - with this provider, specify the base URL for its endpoint here, with no - trailing slash. Specifically when the target is GitHub, you can leave it out - to be inferred automatically. For a GitHub Enterprise installation, it is - commonly at `https://api.` or `https:///api/v3`. -- `rawBaseUrl` (optional): If you want to communicate using the raw HTTP method - with this provider, specify the base URL for its endpoint here, with no - trailing slash. Specifically when the target is public GitHub, you can leave - it out to be inferred automatically. For a GitHub Enterprise installation, it - is commonly at `https://api.` or `https:///api/v3`. - -You need to supply either `apiBaseUrl` or `rawBaseUrl` or both (except for -public GitHub, for which we can infer them). The `apiBaseUrl` will always be -preferred over the other if a `token` is given, otherwise `rawBaseUrl` will be -preferred. - -If you do not supply a public GitHub provider, one will be added automatically, -silently at startup for convenience. So you only have to list it if you want to -supply a token for it - and if you do, you can also leave out the `apiBaseUrl` -and `rawBaseUrl` fields. - -## Static Location Configuration - -To enable declarative catalog setups, it is possible to add locations to the -catalog via [static configuration](../../conf/index.md). Locations are added to -the catalog under the `catalog.locations` key, for example: +Locations are added to the catalog under the `catalog.locations` key: ```yaml catalog: @@ -92,10 +26,35 @@ catalog: target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml ``` -The locations added through static configuration can not be removed through the -catalog locations API. To remove the locations, you have to remove them from the +The `url` type locations are handled by a standard processor included with the +catalog (`UrlReaderProcessor`), so no processor configuration is needed. This +processor _does however_ need an [integration](../../integrations/index.md) to +understand how to retrieve a given URL. For the example above, you would need to +configure the [GitHub integration](../../integrations/github/locations.md) to +read files from github.com. + +The locations added through static configuration cannot be removed through the +catalog locations API. To remove these locations, you must remove them from the configuration. +### Integration Processors + +Integrations may simply provide a mechanism to handle `url` location type for an +external provider, or they may also include additional processors out of the +box, such as the GitHub [discovery](../../integrations/github/discovery.md) +processor that scans a GitHub organization for +[entity descriptor files](descriptor-format.md). + +Check the [integrations](../../integrations/index.md) documentation to see what +is offered by each integration. + +### Custom Processors + +To ingest entities from an existing system already tracking software, you can +also write a _custom processor_ to convert between the existing system and +Backstage's descriptor format. This is documented in +[External Integrations](external-integrations.md). + ## Catalog Rules By default the catalog will only allow ingestion of entities with the kind diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 17eaf80832..e4b046d76f 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -35,8 +35,32 @@ we recommend that you name them `catalog-info.yaml`. ## Overall Shape Of An Entity -The following is an example of the shape of an entity as returned from the -software catalog API. +The following is an example of a descriptor file for a Component entity: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: artist-web + description: The place to be, for great artists + labels: + system: public-websites + annotations: + example.com/service-discovery: artistweb + circleci.com/project-slug: github/example-org/artist-website + tags: + - java + links: + - url: https://admin.example-org.com + title: Admin Dashboard + icon: dashboard +spec: + type: website + lifecycle: production + owner: artist-relations-team +``` + +This is the same entity as returned in JSON from the software catalog API: ```js { @@ -71,31 +95,6 @@ software catalog API. } ``` -The corresponding descriptor file that generated it may look as follows: - -```yaml -apiVersion: backstage.io/v1alpha1 -kind: Component -metadata: - name: artist-web - description: The place to be, for great artists - labels: - system: public-websites - annotations: - example.com/service-discovery: artistweb - circleci.com/project-slug: github/example-org/artist-website - tags: - - java - links: - - url: https://admin.example-org.com - title: Admin Dashboard - icon: dashboard -spec: - type: website - lifecycle: production - owner: artist-relations-team -``` - The root fields `apiVersion`, `kind`, `metadata`, and `spec` are part of the _envelope_, defining the overall structure of all kinds of entity. Likewise, some metadata fields like `name`, `labels`, and `annotations` are of special diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md index ae8c671ed1..ff8dfbda68 100644 --- a/docs/features/software-catalog/installation.md +++ b/docs/features/software-catalog/installation.md @@ -10,8 +10,9 @@ outlined below. ## Installing @backstage/plugin-catalog -> **Note that if you used `npx @backstage/create-app`, the plugin may already be -> present** +> **Note that if you used `npx @backstage/create-app`, the plugin is already +> installed and you can skip to +> [adding entries to the catalog](#adding-entries-to-the-catalog)** The catalog frontend plugin should be installed in your `app` package, which is created as a part of `@backstage/create-app`. To install the package, run: @@ -21,10 +22,6 @@ cd packages/app yarn add @backstage/plugin-catalog ``` -Make sure the version of `@backstage/plugin-catalog` matches the version of -other `@backstage` packages. You can update it in `packages/app/package.json` if -it doesn't. - ### Adding the Plugin to your `packages/app` Add the following entry to the head of your `packages/app/src/plugins.ts`: @@ -37,6 +34,7 @@ Next we need to install the two pages that the catalog plugin provides. You can choose any name for these routes, but we recommend the following: ```tsx +// packages/app/src/App.tsx import { catalogPlugin, CatalogIndexPage, @@ -60,6 +58,7 @@ user can create components. In a typical setup the create component route will be linked to the Scaffolder plugin's template index page: ```ts +// packages/app/src/App.tsx import { catalogPlugin } from '@backstage/plugin-catalog'; import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; @@ -76,6 +75,7 @@ const app = createApp({ You may also want to add a link to the catalog index page to your sidebar: ```tsx +// packages/app/src/components/Root.tsx import HomeIcon from '@material-ui/icons/Home'; // Somewhere within the @@ -88,7 +88,7 @@ This is all that is needed for the frontend part of the Catalog plugin to work! Since the catalog plugin currently ships with a sentry plugin `InfoCard` installed by default, you'll need to set `sentry.organization` in your -`app-yaml.yaml`. For example: +`app-config.yaml`. For example: ```yaml sentry: @@ -101,8 +101,9 @@ as that will conflict with the catalog routes. ## Installing @backstage/plugin-catalog-backend -> **Note that if you used `npx @backstage/create-app`, the plugin may already be -> present** +> **Note that if you used `npx @backstage/create-app`, the plugin is already +> installed and you can skip to +> [adding entries to the catalog](#adding-entries-to-the-catalog)** The catalog backend should be installed in your `backend` package, which is created as a part of `@backstage/create-app`. To install the package, run: @@ -112,59 +113,12 @@ cd packages/backend yarn add @backstage/plugin-catalog-backend ``` -Make sure the version of `@backstage/plugin-catalog-backend` matches the version -of other `@backstage` packages. You can update it in -`packages/backend/package.json` if it doesn't. - ### Adding the Plugin to your `packages/backend` You'll need to add the plugin to the `backend`'s router. You can do this by -creating a file called `packages/backend/src/plugins/catalog.ts` with the -following contents to get you up and running quickly. - -```ts -import { - createRouter, - DatabaseEntitiesCatalog, - DatabaseLocationsCatalog, - DatabaseManager, - HigherOrderOperations, - LocationReaders, - runPeriodically, -} from '@backstage/plugin-catalog-backend'; -import { PluginEnvironment } from '../types'; -import { useHotCleanup } from '@backstage/backend-common'; - -export default async function createPlugin({ - logger, - database, -}: PluginEnvironment) { - const locationReader = new LocationReaders(logger); - - const db = await DatabaseManager.createDatabase(database, { logger }); - const entitiesCatalog = new DatabaseEntitiesCatalog(db); - const locationsCatalog = new DatabaseLocationsCatalog(db); - const higherOrderOperation = new HigherOrderOperations( - entitiesCatalog, - locationsCatalog, - locationReader, - db, - logger, - ); - - useHotCleanup( - module, - runPeriodically(() => higherOrderOperation.refreshAllLocations(), 10000), - ); - - return await createRouter({ - entitiesCatalog, - locationsCatalog, - higherOrderOperation, - logger, - }); -} -``` +creating a file called `packages/backend/src/plugins/catalog.ts` with contents +matching +[catalog.ts in the create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts). Once the `catalog.ts` router setup file is in place, add the router to `packages/backend/src/index.ts`: @@ -174,10 +128,9 @@ import catalog from './plugins/catalog'; const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); -const service = createServiceBuilder(module) - .loadConfig(configReader) - /** several different routers */ - .addRouter('/catalog', await catalog(catalogEnv)); +const apiRouter = Router(); +/** several different routers */ +apiRouter.use('/catalog', await catalog(catalogEnv)); ``` ### Adding Entries to the Catalog @@ -192,33 +145,37 @@ our example templates through static configuration. Add the following to the ```yaml catalog: locations: - # Backstage Example Component + # Backstage Example Components - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/artist-lookup-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/playback-order-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/podcast-api-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/queue-proxy-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/searcher-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/playback-lib-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/www-artist-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/shuffle-api-component.yaml + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml ``` ### Running the Backend -Finally, start up the backend with the new configuration: +Finally, start up Backstage with the new configuration: ```bash +# Run from the root to start both backend and frontend +yarn dev + +# Alternatively, run only the backend from its own package cd packages/backend yarn start ``` -If you've also set up the frontend plugin, so you should be ready to go browse -the catalog at [localhost:3000](http://localhost:3000) now! +If you've also set up the frontend plugin, you should be ready to go browse the +catalog at [localhost:3000](http://localhost:3000) now! diff --git a/docs/features/software-catalog/system-model.md b/docs/features/software-catalog/system-model.md index f0ead20664..eedb94d02e 100644 --- a/docs/features/software-catalog/system-model.md +++ b/docs/features/software-catalog/system-model.md @@ -108,8 +108,3 @@ domain would come with some documentation on how to accept payments for a new product or use-case, share the same entity types in their APIs, and integrate well with each other. Other domains could be “Content Ingestion”, “Ads” or “Search”. - -## Links - -- [Original RFC](https://github.com/backstage/backstage/issues/390) -- [YAML file format](../../architecture-decisions/adr002-default-catalog-file-format.md) diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md index fd51bffa5b..baaa45a26d 100644 --- a/docs/features/software-templates/installation.md +++ b/docs/features/software-templates/installation.md @@ -24,10 +24,6 @@ cd packages/app yarn add @backstage/plugin-scaffolder ``` -Make sure the version of `@backstage/plugin-scaffolder` matches the version of -other `@backstage` packages. You can update it in `packages/app/package.json` if -it doesn't. - ### Adding the Plugin to your `packages/app` Add the following entry to the head of your `packages/app/src/plugins.ts`: @@ -71,10 +67,6 @@ cd packages/backend yarn add @backstage/plugin-scaffolder-backend ``` -Make sure the version of `@backstage/plugin-scaffolder-backend` matches the -version of other `@backstage` packages. You can update it in -`packages/backend/package.json` if it doesn't. - ### Adding the Plugin to your `packages/backend` You'll need to add the plugin to the `backend`'s router. You can do this by diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 2a48a014d6..ff145ee5af 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -16,7 +16,8 @@ alongside your `backend` package in `packages/backend`. Let's create a simple action that adds a new file and some contents that are passed as `input` to the function. -In `packages/backend/src/actions/custom.ts` we can create a new action. +In `packages/backend/src/plugins/scaffolder/actions/custom.ts` we can create a +new action. ```ts import { createTemplateAction } from '@backstage/plugin-scaffolder-backend'; @@ -56,7 +57,7 @@ export const createNewFileAction = () => { So let's break this down. The `createNewFileAction` is a function that returns a `createTemplateAction`, and it's a good place to pass in dependencies which close over the `TemplateAction`. Take a look at our -[built-in actions](https://github.com/backstage/backstage/blob/7f5716081f45a41dc8a4246134b50c893e15c5e1/../plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts) +[built-in actions](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/src/scaffolder/actions/builtin) for reference. We set the type generic to `{ contents: string, filename: string}` which is diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index d9dbb67ab2..4b88e78db3 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -265,7 +265,7 @@ The main two that are used are the following: ```yaml output: remoteUrl: '{{ steps.publish.output.remoteUrl }}' # link to the remote repository - entityRef: '{{ steps.register.output.entityRef }}' # link to the entitiy that has been ingested to the catalog + entityRef: '{{ steps.register.output.entityRef }}' # link to the entity that has been ingested to the catalog ``` ### The templating syntax diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index efed896fd0..a96e881849 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -35,20 +35,19 @@ Add the following to `packages/app/src/plugins.ts`: export { plugin as TechDocs } from '@backstage/plugin-techdocs'; ``` -Now let us embed the TechDocs router in our main Backstage frontend router. In -`packages/app/src/App.tsx`, import the TechDocs router and add the following to -`AppRoutes`: +Now we can add a route for the TechDocs page. In `packages/app/src/App.tsx`, +import TechDocsPage and add the following to `FlatRoutes`: ```tsx -import { Router as DocsRouter } from '@backstage/plugin-techdocs'; +import { TechDocsPage } from '@backstage/plugin-techdocs'; // ... const AppRoutes = () => { - + // ... other plugin routes - } /> - ; + } /> + ; }; ``` @@ -72,12 +71,12 @@ add the following ```typescript import { createRouter, - Preparers, Generators, + Preparers, Publisher, } from '@backstage/plugin-techdocs-backend'; -import { PluginEnvironment } from '../types'; import Docker from 'dockerode'; +import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, @@ -126,8 +125,8 @@ it. See [Concepts](concepts.md) and [TechDocs Architecture](architecture.md) to learn more about how preparers, generators and publishers work. -Final step is to import the techdocs backend plugin in Backstage app backend. -Add the following to your `packages/backend/src/index.ts`: +The final step is to import the techdocs backend plugin in Backstage app +backend. Add the following to your `packages/backend/src/index.ts`: ```typescript import techdocs from './plugins/techdocs'; diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md new file mode 100644 index 0000000000..f8aba8537d --- /dev/null +++ b/docs/getting-started/concepts.md @@ -0,0 +1,21 @@ +--- +id: concepts +title: Key Concepts +# prettier-ignore +description: High level key concepts used in the Backstage project +--- + +For users of Backstage, there are certain concepts which are central to its +design and functionality. Being an expert in each of these concepts is not +necessary, however having a base understanding of each will make administering, +configuring, and operating Backstage easier. + +- CHANGELOG - https://keepachangelog.com +- Docker - https://www.docker.com/ +- Monorepo - https://semaphoreci.com/blog/what-is-monorepo +- Node.js - https://nodejs.org +- React - https://reactjs.org +- Semantic Versioning - https://semver.org +- TypeScript - https://www.typescriptlang.org +- YAML - https://yaml.org +- Yarn - https://www.pluralsight.com/guides/yarn-a-package-manager-for-node-js diff --git a/docs/getting-started/configure-app-with-plugins.md b/docs/getting-started/configure-app-with-plugins.md index cf7806a5d2..964987c4a7 100644 --- a/docs/getting-started/configure-app-with-plugins.md +++ b/docs/getting-started/configure-app-with-plugins.md @@ -68,8 +68,8 @@ proxy: In a standard Backstage app created with [@backstage/create-app](./create-an-app.md), the sidebar is managed inside -`packages/app/src/sidebar.tsx`. The file exports the entire `Sidebar` element of -your app, which you can extend with additional entries by adding new +`packages/app/src/components/Root.tsx`. The file exports the entire `Sidebar` +element of your app, which you can extend with additional entries by adding new `SidebarItem` elements. For example, if you install the `api-docs` plugin, a matching `SidebarItem` diff --git a/docs/getting-started/deployment-docker.md b/docs/getting-started/deployment-docker.md index 37a48f5457..70963df3d3 100644 --- a/docs/getting-started/deployment-docker.md +++ b/docs/getting-started/deployment-docker.md @@ -15,6 +15,14 @@ are stateless, so for a production deployment you will want to set up and connect to an external PostgreSQL instance where the backend plugins can store their state, rather than using SQLite. +By default, in an app created with `@backstage/create-app`, the frontend is +bundled and served from the backend. This is done using the +`@backstage/plugin-app-backend` plugin, which also injects the frontend +configuration into the app. This means you that you only need to build and +deploy a single container in a minimal setup of Backstage. If you wish to +separate the serving of the frontend out from the backend, see +[the section on that topic below](#separate-frontend). + ### Host Build This section describes how to build a Docker image from a Backstage repo with @@ -224,3 +232,7 @@ Once the `app-backend` is removed from the backend, you can use your favorite static file serving method for serving the frontend. An example of how to set up an NGINX image is available in the [contrib folder in the main repo](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx/Dockerfile) + +Note that if you're building a separate docker build of the frontend you +probably need to adjust `.dockerignore` appropriately. Most likely by making +sure `packages/app/dist` is not ignored. diff --git a/docs/getting-started/deployment-helm.md b/docs/getting-started/deployment-helm.md index e1f3ea7629..d1a52a5b30 100644 --- a/docs/getting-started/deployment-helm.md +++ b/docs/getting-started/deployment-helm.md @@ -33,7 +33,7 @@ Then use it to run: ```bash git clone https://github.com/backstage/backstage.git -cd contrib/chart/backstage +cd backstage/contrib/chart/backstage helm dependency update helm install -f backstage-mydomain.yaml backstage . ``` diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 2536944a7c..13b52181df 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -42,6 +42,13 @@ this process in [Create an app](./create-an-app.md). ### Contributing to Backstage +If you intend to make changes to the core project's packages, certain plugins, +or project documentation, then you can fork and clone +[https://github.com/backstage/backstage](https://github.com/backstage/backstage). + +This will let you run the latest code off of the `master` branch, fix bugs or +contribute new features, run test suites, etc. + You can read more in our [CONTRIBUTING](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md) guide, which can help you get setup with a Backstage development environment. diff --git a/docs/integrations/azure/locations.md b/docs/integrations/azure/locations.md new file mode 100644 index 0000000000..4ce19bad4e --- /dev/null +++ b/docs/integrations/azure/locations.md @@ -0,0 +1,30 @@ +--- +id: locations +title: Azure DevOps Locations +sidebar_label: Locations +description: + Integrating source code stored in Azure DevOps into the Backstage catalog +--- + +The Azure integration supports loading catalog entities from Azure DevOps. +Entities can be added to +[static catalog configuration](../../features/software-catalog/configuration.md), +or registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin. + +```yaml +integrations: + azure: + - host: dev.azure.com + token: ${AZURE_TOKEN} +``` + +> Note: An Azure DevOps provider is added automatically at startup for +> convenience, so you only need to list it if you want to supply a +> [token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate). + +The configuration is a structure with two elements: + +- `host`: The DevOps host; only `dev.azure.com` is supported. +- `token` (optional): A personal access token as expected by Azure DevOps. diff --git a/docs/integrations/bitbucket/locations.md b/docs/integrations/bitbucket/locations.md new file mode 100644 index 0000000000..bf7a6af57c --- /dev/null +++ b/docs/integrations/bitbucket/locations.md @@ -0,0 +1,41 @@ +--- +id: locations +title: BitBucket Locations +sidebar_label: Locations +description: + Integrating source code stored in BitBucket into the Backstage catalog +--- + +The BitBucket integration supports loading catalog entities from bitbucket.com +or a self-hosted BitBucket. Entities can be added to +[static catalog configuration](../../features/software-catalog/configuration.md), +or registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin. + +```yaml +integrations: + bitbucket: + - host: bitbucket.org + username: ${BITBUCKET_USERNAME} + token: ${BITBUCKET_TOKEN} +``` + +> Note: A public BitBucket provider is added automatically at startup for +> convenience, so you only need to list it if you want to supply a +> [token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html). + +Directly under the `bitbucket` key is a list of provider configurations, where +you can list the BitBucket providers you want to fetch data from. Each entry is +a structure with up to four elements: + +- `host`: The host of the BitBucket instance, e.g. `bitbucket.company.com`. +- `token` (optional): An personal access token as expected by BitBucket. Either + an access token **or** a username + appPassword may be supplied. +- `username`: The BitBucket username to use in API requests. If neither a + username nor token are supplied, anonymous access will be used. +- `appPassword` (optional): The password for the BitBucket user. Only needed + when using `username` instead of `token`. +- `apiBaseUrl` (optional): The URL of the GitLab API. For self-hosted + installations, it is commonly at `https:///api/v4`. For gitlab.com, this + configuration is not needed as it can be inferred. diff --git a/docs/integrations/github/discovery.md b/docs/integrations/github/discovery.md new file mode 100644 index 0000000000..fe52feab6e --- /dev/null +++ b/docs/integrations/github/discovery.md @@ -0,0 +1,55 @@ +--- +id: discovery +title: GitHub Discovery +sidebar_label: Discovery +description: + Automatically discovering catalog entities from repositories in a GitHub + organization +--- + +The GitHub integration has a special discovery processor for discovering catalog +entities within a GitHub organization. The processor will crawl the GitHub +organization and register entities matching the configured path. This can be +useful as an alternative to static locations or manually adding things to the +catalog. + +To use the discovery processor, you'll need a GitHub integration +[set up](locations.md) with a `GITHUB_TOKEN`. Then you can add a location target +to the catalog configuration: + +```yaml +catalog: + locations: + - type: github-discovery + target: https://github.com/myorg/service-*/blob/main/catalog-info.yaml +``` + +Note the `github-discovery` type, as this is not a regular `url` processor. + +The target is composed of three parts: + +- The base organization URL, `https://github.com/myorg` in this case +- The repository blob to scan, which accepts \* wildcard tokens. This can simply + be `*` to scan all repositories in the organization. This example only looks + for repositories prefixed with `service-`. +- The path within each repository to find the catalog YAML file. This will + usually be `/blob/main/catalog-info.yaml`, `/blob/master/catalog-info.yaml` or + a similar variation for catalog files stored in the root directory of each + repository. + +## GitHub API Rate Limits + +GitHub +[rate limits](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting) +API requests to 5,000 per hour (or more for Enterprise accounts). The default +Backstage catalog backend refreshes data every 100 seconds, which issues an API +request for each discovered location. + +This means if you have more than ~140 catalog entities, you may get throttled by +rate limiting. This will soon be resolved once catalog refreshes make use of +ETags; to work around this in the meantime, you can change the refresh rate of +the catalog in your `packages/backend/src/plugins/catalog.ts` file, or configure +Backstage to use the [github-apps plugin](../../plugins/github-apps.md). + +This is true for any method of adding GitHub entities to the catalog, but +especially easy to hit with automatic discovery. diff --git a/docs/integrations/github/locations.md b/docs/integrations/github/locations.md new file mode 100644 index 0000000000..5b972f332c --- /dev/null +++ b/docs/integrations/github/locations.md @@ -0,0 +1,64 @@ +--- +id: locations +title: GitHub Locations +sidebar_label: Locations +description: Integrating source code stored in GitHub into the Backstage catalog +--- + +The GitHub integration supports loading catalog entities from github.com or +GitHub Enterprise. Entities can be added to +[static catalog configuration](../../features/software-catalog/configuration.md), +registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin, or [discovered](discovery.md) from a GitHub organization. Users and +Groups can also be [loaded from an organization](org.md). + +## Configuration + +To use this integration, add configuration to your root `app-config.yaml`: + +```yaml +integrations: + github: + - host: github.com + token: ${GITHUB_TOKEN} + - host: ghe.example.net + apiBaseUrl: https://ghe.example.net/api/v3 + rawBaseUrl: https://ghe.example.net/raw + token: ${GHE_TOKEN} +``` + +> Note: A public GitHub provider is added automatically at startup for +> convenience, so you only need to list it if you want to supply a +> [token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). + +Directly under the `github` key is a list of provider configurations, where you +can list the various GitHub-compatible providers you want to be able to fetch +data from. Each entry is a structure with up to four elements: + +- `host` (optional): The host of the location target that you want to match on. + The default host is `github.com`. +- `token` (optional): An authentication token as expected by GitHub. If + supplied, it will be passed along with all calls to this provider, both API + and raw. If it is not supplied, anonymous access will be used. +- `apiBaseUrl` (optional): If you want to communicate using the APIv3 method + with this provider, specify the base URL for its endpoint here, with no + trailing slash. Specifically when the target is GitHub, you can leave it out + to be inferred automatically. For a GitHub Enterprise installation, it is + commonly at `https://api.` or `https:///api/v3`. +- `rawBaseUrl` (optional): If you want to communicate using the raw HTTP method + with this provider, specify the base URL for its endpoint here, with no + trailing slash. Specifically when the target is public GitHub, you can leave + it out to be inferred automatically. For a GitHub Enterprise installation, it + is commonly at `https:///raw`. + +You need to supply either `apiBaseUrl` or `rawBaseUrl` or both (except for +public GitHub, for which we can infer them). The `apiBaseUrl` will always be +preferred over the other if a `token` is given, otherwise `rawBaseUrl` will be +preferred. + +## Authentication with GitHub Apps + +Alternatively, Backstage can use GitHub Apps for backend authentication. This +has higher rate limits, and a clearer authorization model. See the +[github-apps plugin](../../plugins/github-apps.md) for how to set this up. diff --git a/docs/integrations/github/org.md b/docs/integrations/github/org.md index 848a306381..b52e811002 100644 --- a/docs/integrations/github/org.md +++ b/docs/integrations/github/org.md @@ -3,11 +3,11 @@ id: org title: GitHub Organizational Data sidebar_label: Org Data # prettier-ignore -description: Setting up ingestion of organizational data from GitHub +description: Importing users and groups from a GitHub organization into Backstage --- -The Backstage catalog can be set up to ingest organizational data - teams and -users - directly from an organization in GitHub or GitHub Enterprise. The result +The Backstage catalog can be set up to ingest organizational data - users and +teams - directly from an organization in GitHub or GitHub Enterprise. The result is a hierarchy of [`User`](../../features/software-catalog/descriptor-format.md#kind-user) and [`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind @@ -46,8 +46,7 @@ catalog: providers: - target: https://github.com apiBaseUrl: https://api.github.com - token: - $env: GITHUB_TOKEN + token: ${GITHUB_TOKEN} ``` Locations point out the specific org(s) you want to import. The `type` of these diff --git a/docs/integrations/gitlab/locations.md b/docs/integrations/gitlab/locations.md new file mode 100644 index 0000000000..965d183a9d --- /dev/null +++ b/docs/integrations/gitlab/locations.md @@ -0,0 +1,38 @@ +--- +id: locations +title: GitLab Locations +sidebar_label: Locations +description: Integrating source code stored in GitLab into the Backstage catalog +--- + +The GitLab integration supports loading catalog entities from gitlab.com or a +self-hosted GitLab. Entities can be added to +[static catalog configuration](../../features/software-catalog/configuration.md), +or registered with the +[catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) +plugin. + +```yaml +integrations: + gitlab: + - host: gitlab.com + token: ${GITLAB_TOKEN} +``` + +> Note: A public GitLab provider is added automatically at startup for +> convenience, so you only need to list it if you want to supply a +> [token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html). + +Directly under the `gitlab` key is a list of provider configurations, where you +can list the GitLab providers you want to fetch data from. Each entry is a +structure with up to four elements: + +- `host`: The host of the GitLab instance, e.g. `gitlab.company.com`. +- `token` (optional): An authentication token as expected by GitLab. If this is + not supplied, anonymous access will be used. +- `apiBaseUrl` (optional): The URL of the GitLab API. For self-hosted + installations, it is commonly at `https:///api/v4`. For gitlab.com, this + configuration is not needed as it can be inferred. +- `baseUrl` (optional): The base URL for this provider, e.g. + `https://gitlab.com`. If this is not provided, it is assumed to be + `https://{host}`. diff --git a/docs/integrations/google-analytics/installation.md b/docs/integrations/google-analytics/installation.md index a0e6f69d13..d6c23cc4b0 100644 --- a/docs/integrations/google-analytics/installation.md +++ b/docs/integrations/google-analytics/installation.md @@ -6,19 +6,18 @@ sidebar_label: Installation description: Adding Google Analytics to Your App --- -There is a basic Google Analytics integration built into Backstage. You can -enable it by adding the following to your app configuration: +There is a basic +[Google Analytics](https://marketingplatform.google.com/about/analytics/) +integration built into Backstage. You can enable it by adding the following to +your app configuration: ```yaml app: googleAnalyticsTrackingId: UA-000000-0 ``` -Replace the tracking ID with your own. +Replace the tracking ID with the one generated for you after signing up for the +Google Analytics service. -For more information, learn about Google Analytics -[here](https://marketingplatform.google.com/about/analytics/). - -The default behavior is only to send a pageview hit to Google Analytics. To -record more, look at the developer documentation -[here](https://developers.google.com/analytics/devguides/collection/gtagjs). +The default behavior is only to send a pageview hit. To record more, review the +[Google Analytics developer documentation](https://developers.google.com/analytics/devguides/collection/gtagjs). diff --git a/docs/integrations/index.md b/docs/integrations/index.md new file mode 100644 index 0000000000..544ce08ac4 --- /dev/null +++ b/docs/integrations/index.md @@ -0,0 +1,34 @@ +--- +id: index +title: Integrations +sidebar_label: Overview +description: + Configuring Backstage to read or publish data with external providers using + integrations +--- + +Integrations allow Backstage to read or publish data using external providers +such as GitHub, GitLab, BitBucket, LDAP, or cloud providers. + +## Configuration + +Integrations are configured at the root level of `app-config.yaml` since +integrations are used by many Backstage core features and other plugins. + +Each key under `integrations` is a separate configuration for a single external +provider. Providers each have different configuration; here's an example of +configuration to use both GitHub and BitBucket: + +```yaml +integrations: + github: + - host: github.com + token: ${GITHUB_TOKEN} + bitbucket: + - host: bitbucket.org + username: ${BITBUCKET_USERNAME} + appPassword: ${BITBUCKET_APP_PASSWORD} +``` + +See documentation for each type of integration for full details on +configuration. diff --git a/docs/integrations/ldap/org.md b/docs/integrations/ldap/org.md index dea33e4024..24a4335b8b 100644 --- a/docs/integrations/ldap/org.md +++ b/docs/integrations/ldap/org.md @@ -6,8 +6,8 @@ sidebar_label: Org Data description: Setting up ingestion of organizational data from LDAP --- -The Backstage catalog can be set up to ingest organizational data - groups and -users - directly from an LDAP compatible service. The result is a hierarchy of +The Backstage catalog can be set up to ingest organizational data - users and +groups - directly from an LDAP compatible service. The result is a hierarchy of [`User`](../../features/software-catalog/descriptor-format.md#kind-user) and [`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind entities that mirror your org setup. @@ -46,8 +46,7 @@ catalog: - target: ldaps://ds.example.net bind: dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net - secret: - $env: LDAP_SECRET + secret: ${LDAP_SECRET} users: dn: ou=people,ou=example,dc=example,dc=net options: @@ -93,11 +92,10 @@ authenticate) towards the server. It has the following fields. ```yaml dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net -secret: - $env: LDAP_SECRET +secret: ${LDAP_SECRET} ``` -The `dn` is the full LDAP DN (distinguished name) for the user that the plugin +The `dn` is the full LDAP Distinguished Name for the user that the plugin authenticates itself as. At this point, only regular user based authentication is supported. @@ -110,12 +108,12 @@ backend starts. The `users` block defines the settings that govern the reading and interpretation of users. Its fields are explained in separate sections below. -### users.dn +#### users.dn The DN under which users are stored, e.g. `ou=people,ou=example,dc=example,dc=net`. -### users.options +#### users.options The search options to use when sending the query to the server, when reading all users. All of the options are shown below, with their default values, but they @@ -138,7 +136,7 @@ options: paged: false ``` -### users.set +#### users.set This optional piece lets you specify a number of JSON paths (on a.b.c form) and hard coded values to set on those paths. This can be useful for example if you @@ -150,7 +148,7 @@ set: metadata.namespace: 'ldap' ``` -### users.map +#### users.map Mappings from well known entity fields, to LDAP attribute names. This is where you are able to define how to interpret the attributes of each LDAP result item, @@ -192,12 +190,12 @@ map: The `groups` block defines the settings that govern the reading and interpretation of groups. Its fields are explained in separate sections below. -### groups.dn +#### groups.dn The DN under which groups are stored, e.g. `ou=people,ou=example,dc=example,dc=net`. -### groups.options +#### groups.options The search options to use when sending the query to the server, when reading all groups. All of the options are shown below, with their default values, but they @@ -220,7 +218,7 @@ options: paged: false ``` -### groups.set +#### groups.set This optional piece lets you specify a number of JSON paths (on a.b.c form) and hard coded values to set on those paths. This can be useful for example if you @@ -232,7 +230,7 @@ set: metadata.namespace: 'ldap' ``` -### groups.map +#### groups.map Mappings from well known entity fields, to LDAP attribute names. This is where you are able to define how to interpret the attributes of each LDAP result item, diff --git a/docs/plugins/composability.md b/docs/plugins/composability.md index e95a67417b..52d8b69f7b 100644 --- a/docs/plugins/composability.md +++ b/docs/plugins/composability.md @@ -393,24 +393,26 @@ export const myPlugin = createPlugin({ routes: { root: rootRouteRef, details: detailsRouteRef, - } -}) + }, +}); -export const MyPage = plugin.provide(createRoutableExtension({ - component: () => import('./components/MyPage').then(m => m.MyPage), - mountPoint: rootRouteRef, -})) +export const MyPage = myPlugin.provide( + createRoutableExtension({ + component: () => import('./components/MyPage').then(m => m.MyPage), + mountPoint: rootRouteRef, + }), +); // components/MyPage.tsx const MyPage = () => ( {/* myPlugin.routes.root will take the user to this page */} - }> + } /> {/* myPlugin.routes.details will take the user to this page */} - }> + } /> -) +); ``` ### New Catalog Components diff --git a/docs/tutorials/quickstart-app-plugin.md b/docs/tutorials/quickstart-app-plugin.md index 6208fbe30d..f1efcf0745 100644 --- a/docs/tutorials/quickstart-app-plugin.md +++ b/docs/tutorials/quickstart-app-plugin.md @@ -49,7 +49,7 @@ title: Adding Custom Plugin to Existing Monorepo App Let's add a shortcut. -1. Open and modify `root: packages > app > src > sidebar.tsx` with the +1. Open and modify `root: packages > app > src > components > Root.tsx` with the following: ```tsx diff --git a/docs/tutorials/switching-sqlite-postgres.md b/docs/tutorials/switching-sqlite-postgres.md new file mode 100644 index 0000000000..dfb82ddc51 --- /dev/null +++ b/docs/tutorials/switching-sqlite-postgres.md @@ -0,0 +1,61 @@ +--- +id: switching-sqlite-postgres +title: Switching Backstage from SQLite to PostgreSQL +description: + How to get ready for deploying Backstage to production with PostgreSQL +--- + +The default `@backstage/create-app` database is SQLite, an in-memory database +that's perfect for initial experimentation as it requires no environment setup. + +Once you're ready to deploy Backstage in production, or to have a more +persistent development setup, you can switch the Backstage database to +PostgreSQL. + +Backstage uses the [Knex](http://knexjs.org/) library, making it fairly easy to +switch between database backends. + +## Install PostgreSQL + +First, swap out SQLite for PostgreSQL in your `backend` package: + +```shell +cd packages/backend +yarn remove sqlite3 +yarn add pg +``` + +## Add PostgreSQL configuration + +Next, modify `app-config.yaml` in the root folder to add PostgreSQL +configuration for the backend: + +```diff +backend: + database: +- client: sqlite3 +- connection: ':memory:' ++ # config options: https://node-postgres.com/api/client ++ client: pg ++ connection: ++ host: ++ $env: POSTGRES_HOST ++ port: ++ $env: POSTGRES_PORT ++ user: ++ $env: POSTGRES_USER ++ password: ++ $env: POSTGRES_PASSWORD ++ # https://node-postgres.com/features/ssl ++ #ssl: require # see https://www.postgresql.org/docs/current/libpq-ssl.html Table 33.1. SSL Mode Descriptions (e.g. require) ++ #ca: # if you have a CA file and want to verify it you can uncomment this section ++ #$file: /ca/server.crt + +``` + +If you have a `app-config.local.yaml` for local development, a similar update +should be made there. You can set the `POSTGRES_` environment variables prior to +launching Backstage, or remove the $env keys and simply set values directly for +development. + +The Backstage App is now ready to start up with a PostgreSQL backing database. diff --git a/microsite/data/plugins/todo.yaml b/microsite/data/plugins/todo.yaml new file mode 100644 index 0000000000..44128c40b2 --- /dev/null +++ b/microsite/data/plugins/todo.yaml @@ -0,0 +1,9 @@ +--- +title: TODO +author: Spotify +authorUrl: https://github.com/spotify +category: Discovery +description: Browse TODO comments in your project's source code. +documentation: https://github.com/backstage/backstage/tree/master/plugins/todo +iconUrl: https://backstage.io/img/todo-logo.png +npmPackageName: '@backstage/plugin-todo' diff --git a/microsite/sidebars.json b/microsite/sidebars.json index d7ee3ebd80..322ffc2e03 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -33,6 +33,7 @@ ] }, "getting-started/keeping-backstage-updated", + "getting-started/concepts", "getting-started/contributors" ], "CLI": ["cli/index", "cli/commands"], @@ -104,20 +105,40 @@ } ], "Integrations": [ + "integrations/index", { "type": "subcategory", - "label": "GitHub", - "ids": ["integrations/github/org"] + "label": "Azure DevOps", + "ids": ["integrations/azure/locations"] }, { "type": "subcategory", - "label": "LDAP", - "ids": ["integrations/ldap/org"] + "label": "BitBucket", + "ids": ["integrations/bitbucket/locations"] + }, + { + "type": "subcategory", + "label": "GitHub", + "ids": [ + "integrations/github/locations", + "integrations/github/discovery", + "integrations/github/org" + ] + }, + { + "type": "subcategory", + "label": "GitLab", + "ids": ["integrations/gitlab/locations"] }, { "type": "subcategory", "label": "Google Analytics", "ids": ["integrations/google-analytics/installation"] + }, + { + "type": "subcategory", + "label": "LDAP", + "ids": ["integrations/ldap/org"] } ], "Plugins": [ @@ -193,7 +214,8 @@ "Tutorials": [ "tutorials/journey", "tutorials/quickstart-app-auth", - "tutorials/quickstart-app-plugin" + "tutorials/quickstart-app-plugin", + "tutorials/switching-sqlite-postgres" ], "Architecture Decision Records (ADRs)": [ "architecture-decisions/adrs-overview", diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 51354343d8..5d61e55bc1 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -105,7 +105,7 @@ const siteConfig = { // enableUpdateBy: true, // Show documentation's last update time. - // enableUpdateTime: true, + enableUpdateTime: true, // You may provide arbitrary config keys to be used as needed by your // template. For example, if you need your repo's URL... diff --git a/microsite/static/img/todo-logo.png b/microsite/static/img/todo-logo.png new file mode 100644 index 0000000000..a8513e04f7 Binary files /dev/null and b/microsite/static/img/todo-logo.png differ diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index e9e7f46bc6..5e7dedd5dc 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,49 @@ # example-app +## 0.2.20 + +### Patch Changes + +- Updated dependencies [633a31fec] +- Updated dependencies [34e6bb409] +- Updated dependencies [b56815b40] +- Updated dependencies [09eb54e01] +- Updated dependencies [147b4c5b1] +- Updated dependencies [83bfc98a3] +- Updated dependencies [7d8c4c97c] +- Updated dependencies [e7baa0d2e] +- Updated dependencies [1df417bd3] +- Updated dependencies [84972540b] +- Updated dependencies [3385b374b] +- Updated dependencies [a0dacc184] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] +- Updated dependencies [8686eb38c] +- Updated dependencies [c8b54c370] +- Updated dependencies [1f98a6ff8] +- Updated dependencies [e8b2ed9cc] + - @backstage/plugin-catalog@0.5.0 + - @backstage/plugin-tech-radar@0.3.8 + - @backstage/plugin-graphiql@0.2.9 + - @backstage/plugin-user-settings@0.2.8 + - @backstage/plugin-techdocs@0.6.2 + - @backstage/plugin-catalog-import@0.5.0 + - @backstage/plugin-kubernetes@0.4.1 + - @backstage/cli@0.6.5 + - @backstage/plugin-scaffolder@0.8.0 + - @backstage/core@0.7.2 + - @backstage/plugin-api-docs@0.4.9 + - @backstage/plugin-catalog-react@0.1.2 + - @backstage/plugin-circleci@0.2.12 + - @backstage/plugin-cloudbuild@0.2.13 + - @backstage/plugin-explore@0.3.2 + - @backstage/plugin-github-actions@0.4.1 + - @backstage/plugin-jenkins@0.4.0 + - @backstage/plugin-lighthouse@0.2.14 + - @backstage/plugin-org@0.3.10 + - @backstage/plugin-search@0.3.4 + ## 0.2.19 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 21f0d0c3d0..3cb3f2e2cf 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,45 +1,48 @@ { "name": "example-app", - "version": "0.2.19", + "version": "0.2.20", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/cli": "^0.6.4", - "@backstage/core": "^0.7.1", - "@backstage/plugin-api-docs": "^0.4.8", - "@backstage/plugin-catalog": "^0.4.2", - "@backstage/plugin-catalog-import": "^0.4.3", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/plugin-circleci": "^0.2.11", - "@backstage/plugin-cloudbuild": "^0.2.12", + "@backstage/cli": "^0.6.5", + "@backstage/core": "^0.7.2", + "@backstage/integration-react": "^0.1.1", + "@backstage/plugin-api-docs": "^0.4.9", + "@backstage/plugin-badges": "^0.1.1", + "@backstage/plugin-catalog": "^0.5.0", + "@backstage/plugin-catalog-import": "^0.5.0", + "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/plugin-circleci": "^0.2.12", + "@backstage/plugin-cloudbuild": "^0.2.13", "@backstage/plugin-cost-insights": "^0.8.3", - "@backstage/plugin-explore": "^0.3.1", + "@backstage/plugin-explore": "^0.3.2", "@backstage/plugin-gcp-projects": "^0.2.5", - "@backstage/plugin-github-actions": "^0.4.0", + "@backstage/plugin-github-actions": "^0.4.1", "@backstage/plugin-gitops-profiles": "^0.2.6", - "@backstage/plugin-graphiql": "^0.2.8", - "@backstage/plugin-jenkins": "^0.3.12", + "@backstage/plugin-graphiql": "^0.2.9", + "@backstage/plugin-jenkins": "^0.4.0", "@backstage/plugin-kafka": "^0.2.6", - "@backstage/plugin-kubernetes": "^0.4.0", - "@backstage/plugin-lighthouse": "^0.2.13", + "@backstage/plugin-kubernetes": "^0.4.1", + "@backstage/plugin-lighthouse": "^0.2.14", "@backstage/plugin-newrelic": "^0.2.6", - "@backstage/plugin-org": "^0.3.9", + "@backstage/plugin-org": "^0.3.10", "@backstage/plugin-pagerduty": "0.3.2", "@backstage/plugin-register-component": "^0.2.12", "@backstage/plugin-rollbar": "^0.3.3", - "@backstage/plugin-scaffolder": "^0.7.1", + "@backstage/plugin-scaffolder": "^0.8.0", + "@backstage/plugin-search": "^0.3.4", "@backstage/plugin-sentry": "^0.3.8", - "@backstage/plugin-search": "^0.3.3", - "@backstage/plugin-tech-radar": "^0.3.7", - "@backstage/plugin-techdocs": "^0.6.1", - "@backstage/plugin-user-settings": "^0.2.7", + "@backstage/plugin-tech-radar": "^0.3.8", + "@backstage/plugin-techdocs": "^0.6.2", + "@backstage/plugin-todo": "^0.1.0", + "@backstage/plugin-user-settings": "^0.2.8", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@octokit/rest": "^18.0.12", "@roadiehq/backstage-plugin-buildkite": "^0.1.3", - "@roadiehq/backstage-plugin-github-insights": "^0.2.16", + "@roadiehq/backstage-plugin-github-insights": "^0.3.2", "@roadiehq/backstage-plugin-github-pull-requests": "^0.7.6", "@roadiehq/backstage-plugin-travis-ci": "^0.4.5", "history": "^5.0.0", @@ -53,7 +56,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 7acde03953..defa70dbb7 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -39,7 +39,7 @@ import { GraphiQLPage } from '@backstage/plugin-graphiql'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; -import { SearchPage } from '@backstage/plugin-search'; +import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs'; import { UserSettingsPage } from '@backstage/plugin-user-settings'; @@ -49,7 +49,7 @@ import { hot } from 'react-hot-loader/root'; import { Navigate, Route } from 'react-router'; import { apis } from './apis'; import { EntityPage } from './components/catalog/EntityPage'; -import Root from './components/Root'; +import { Root } from './components/Root'; import { providers } from './identityProviders'; import * as plugins from './plugins'; @@ -112,6 +112,10 @@ const routes = ( } /> } /> } /> + } + /> } /> ScmIntegrationsApi.fromConfig(configApi), + }), -export const apis = [ createApiFactory({ api: graphQlBrowseApiRef, deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef }, diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 7c33113e83..454c0d2e84 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -73,7 +73,7 @@ const SidebarLogo = () => { ); }; -const Root = ({ children }: PropsWithChildren<{}>) => ( +export const Root = ({ children }: PropsWithChildren<{}>) => ( @@ -98,5 +98,3 @@ const Root = ({ children }: PropsWithChildren<{}>) => ( {children} ); - -export default Root; diff --git a/packages/app/src/components/Root/index.ts b/packages/app/src/components/Root/index.ts index e997e09c18..ab65cb2451 100644 --- a/packages/app/src/components/Root/index.ts +++ b/packages/app/src/components/Root/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './Root'; +export { Root } from './Root'; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index e46186bbe0..47bb9ef09f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -31,6 +31,7 @@ import { ProvidedApisCard, ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; +import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { AboutCard, EntityHasComponentsCard, @@ -82,6 +83,7 @@ import { } from '@backstage/plugin-rollbar'; import { Router as SentryRouter } from '@backstage/plugin-sentry'; import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; +import { EntityTodoContent } from '@backstage/plugin-todo'; import { Button, Grid } from '@material-ui/core'; import { isPluginApplicableToEntity as isBuildkiteAvailable, @@ -104,7 +106,8 @@ import { RecentTravisCIBuildsWidget, Router as TravisCIRouter, } from '@roadiehq/backstage-plugin-travis-ci'; -import React, { ReactNode } from 'react'; +import React, { ReactNode, useMemo, useState } from 'react'; +import BadgeIcon from '@material-ui/icons/CallToAction'; export const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. @@ -178,6 +181,32 @@ export const ErrorsSwitcher = ({ entity }: { entity: Entity }) => { } }; +const EntityPageLayoutWrapper = (props: { children?: React.ReactNode }) => { + const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); + + const extraMenuItems = useMemo(() => { + return [ + { + title: 'Badges', + Icon: BadgeIcon, + onClick: () => setBadgesDialogOpen(true), + }, + ]; + }, []); + + return ( + <> + + {props.children} + + setBadgesDialogOpen(false)} + /> + + ); +}; + const ComponentOverviewContent = ({ entity }: { entity: Entity }) => ( @@ -233,7 +262,7 @@ const ComponentApisContent = ({ entity }: { entity: Entity }) => ( ); const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( - + ( title="Kafka" element={} /> - + } + /> + ); const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( - + ( title="Code Insights" element={} /> - + } + /> + ); const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( - + ( title="Docs" element={} /> - + } + /> + ); export const ComponentEntityPage = ({ entity }: { entity: Entity }) => { @@ -378,7 +422,7 @@ const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => ( ); const ApiEntityPage = ({ entity }: { entity: Entity }) => ( - + ( title="Definition" element={} /> - + ); const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( @@ -404,13 +448,13 @@ const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( ); const UserEntityPage = ({ entity }: { entity: Entity }) => ( - + } /> - + ); const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => ( @@ -428,13 +472,13 @@ const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => ( ); const GroupEntityPage = ({ entity }: { entity: Entity }) => ( - + } /> - + ); const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( @@ -452,7 +496,7 @@ const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( ); const SystemEntityPage = ({ entity }: { entity: Entity }) => ( - + ( title="Diagram" element={} /> - + ); const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( @@ -478,19 +522,19 @@ const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( ); const DomainEntityPage = ({ entity }: { entity: Entity }) => ( - + } /> - + ); export const EntityPage = () => { const { entity } = useEntity(); - switch (entity?.kind?.toLowerCase()) { + switch (entity?.kind?.toLocaleLowerCase('en-US')) { case 'component': return ; case 'api': @@ -503,6 +547,9 @@ export const EntityPage = () => { return ; case 'domain': return ; + case 'location': + case 'resource': + case 'template': default: return ; } diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 862d821ed6..13b0daf7e3 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -43,3 +43,5 @@ export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite'; export { plugin as Search } from '@backstage/plugin-search'; export { plugin as Org } from '@backstage/plugin-org'; export { plugin as Kafka } from '@backstage/plugin-kafka'; +export { todoPlugin } from '@backstage/plugin-todo'; +export { badgesPlugin } from '@backstage/plugin-badges'; diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 74c9c74d15..b1009c8732 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,49 @@ # @backstage/backend-common +## 0.6.0 + +### Minor Changes + +- 8686eb38c: Encode thrown errors in the backend as a JSON payload. This is technically a breaking change, since the response format even of errors are part of the contract. If you relied on the response being text, you will now have some extra JSON "noise" in it. It should still be readable by end users though. + + Before: + + ``` + NotFoundError: No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default' + at eval (webpack-internal:///../../plugins/catalog-backend/src/service/router.ts:117:17) + ``` + + After: + + ```json + { + "error": { + "name": "NotFoundError", + "message": "No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default'", + "stack": "NotFoundError: No entity named 'tara.macgovern2' found, with kind 'user' in namespace 'default'\n at eval (webpack-internal:///../../plugins/catalog-backend/src/service/router.ts:117:17)" + }, + "request": { + "method": "GET", + "url": "/entities/by-name/user/default/tara.macgovern2" + }, + "response": { + "statusCode": 404 + } + } + ``` + +- 8686eb38c: Removed the custom error types (e.g. `NotFoundError`). Those are now instead in the new `@backstage/errors` package. This is a breaking change, and you will have to update your imports if you were using these types. + + ```diff + -import { NotFoundError } from '@backstage/backend-common'; + +import { NotFoundError } from '@backstage/errors'; + ``` + +### Patch Changes + +- Updated dependencies [0434853a5] + - @backstage/config@0.1.4 + ## 0.5.6 ### Patch Changes diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 933d2ccb3a..6b85b278e2 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.5.6", + "version": "0.6.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.2", + "@backstage/config": "^0.1.4", "@backstage/config-loader": "^0.5.1", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", @@ -45,7 +45,7 @@ "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "fs-extra": "^9.0.1", "git-url-parse": "^11.4.4", "helmet": "^4.0.0", @@ -71,8 +71,8 @@ } }, "devDependencies": { - "@backstage/cli": "^0.6.4", - "@backstage/test-utils": "^0.1.7", + "@backstage/cli": "^0.6.5", + "@backstage/test-utils": "^0.1.9", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", "@types/concat-stream": "^1.6.0", diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 618a4bf34c..0a96217976 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,38 @@ # example-backend +## 0.2.20 + +### Patch Changes + +- Updated dependencies [010aed784] +- Updated dependencies [8686eb38c] +- Updated dependencies [e7baa0d2e] +- Updated dependencies [8b4f7e42a] +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [4bc98a5b9] +- Updated dependencies [d2f4efc5d] +- Updated dependencies [8686eb38c] +- Updated dependencies [424742dc1] +- Updated dependencies [1f98a6ff8] +- Updated dependencies [8b5e59750] +- Updated dependencies [8686eb38c] + - @backstage/plugin-catalog-backend@0.6.6 + - @backstage/catalog-client@0.3.8 + - @backstage/plugin-techdocs-backend@0.6.5 + - @backstage/plugin-scaffolder-backend@0.9.2 + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + - @backstage/plugin-auth-backend@0.3.5 + - @backstage/plugin-kubernetes-backend@0.3.1 + - example-app@0.2.20 + - @backstage/plugin-app-backend@0.3.10 + - @backstage/plugin-graphql-backend@0.1.6 + - @backstage/plugin-kafka-backend@0.2.2 + - @backstage/plugin-proxy-backend@0.2.6 + - @backstage/plugin-rollbar-backend@0.1.8 + - @backstage/plugin-todo-backend@0.1.1 + ## 0.2.19 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index d3f967b3b3..c039b8ce25 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.19", + "version": "0.2.20", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,27 +27,31 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.5.6", - "@backstage/catalog-client": "^0.3.7", + "@backstage/backend-common": "^0.6.0", + "@backstage/catalog-client": "^0.3.8", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", - "@backstage/plugin-app-backend": "^0.3.9", - "@backstage/plugin-auth-backend": "^0.3.4", - "@backstage/plugin-catalog-backend": "^0.6.5", - "@backstage/plugin-graphql-backend": "^0.1.5", - "@backstage/plugin-kubernetes-backend": "^0.3.0", - "@backstage/plugin-kafka-backend": "^0.2.1", - "@backstage/plugin-proxy-backend": "^0.2.5", - "@backstage/plugin-rollbar-backend": "^0.1.7", - "@backstage/plugin-scaffolder-backend": "^0.9.1", - "@backstage/plugin-techdocs-backend": "^0.6.4", + "@backstage/config": "^0.1.4", + "@backstage/plugin-app-backend": "^0.3.10", + "@backstage/plugin-auth-backend": "^0.3.5", + "@backstage/plugin-badges-backend": "^0.1.1", + "@backstage/plugin-catalog-backend": "^0.6.6", + "@backstage/plugin-graphql-backend": "^0.1.6", + "@backstage/plugin-kubernetes-backend": "^0.3.1", + "@backstage/plugin-kafka-backend": "^0.2.2", + "@backstage/plugin-proxy-backend": "^0.2.6", + "@backstage/plugin-rollbar-backend": "^0.1.8", + "@backstage/plugin-scaffolder-backend": "^0.9.2", + "@backstage/plugin-search-backend": "^0.1.1", + "@backstage/plugin-search-backend-node": "^0.1.1", + "@backstage/plugin-techdocs-backend": "^0.6.5", + "@backstage/plugin-todo-backend": "^0.1.1", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.19", + "example-app": "^0.2.20", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "knex": "^0.95.1", "pg": "^8.3.0", "pg-connection-string": "^2.3.0", @@ -55,7 +59,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@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 81d0bb96d2..cbbdaecf5e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -42,9 +42,12 @@ import kafka from './plugins/kafka'; import rollbar from './plugins/rollbar'; import scaffolder from './plugins/scaffolder'; import proxy from './plugins/proxy'; +import search from './plugins/search'; import techdocs from './plugins/techdocs'; +import todo from './plugins/todo'; import graphql from './plugins/graphql'; import app from './plugins/app'; +import badges from './plugins/badges'; import { PluginEnvironment } from './types'; function makeCreateEnv(config: Config) { @@ -76,22 +79,28 @@ async function main() { const authEnv = useHotMemoize(module, () => createEnv('auth')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); + const searchEnv = useHotMemoize(module, () => createEnv('search')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); + const todoEnv = useHotMemoize(module, () => createEnv('todo')); const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); const kafkaEnv = useHotMemoize(module, () => createEnv('kafka')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); const appEnv = useHotMemoize(module, () => createEnv('app')); + const badgesEnv = useHotMemoize(module, () => createEnv('badges')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); apiRouter.use('/auth', await auth(authEnv)); + apiRouter.use('/search', await search(searchEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); + apiRouter.use('/todo', await todo(todoEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); apiRouter.use('/kafka', await kafka(kafkaEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use('/graphql', await graphql(graphqlEnv)); + apiRouter.use('/badges', await badges(badgesEnv)); apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) diff --git a/packages/backend/src/plugins/badges.ts b/packages/backend/src/plugins/badges.ts new file mode 100644 index 0000000000..befca76542 --- /dev/null +++ b/packages/backend/src/plugins/badges.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 { + createRouter, + createDefaultBadgeFactories, +} from '@backstage/plugin-badges-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ + config, + discovery, + badgeFactories: createDefaultBadgeFactories(), + }); +} diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts new file mode 100644 index 0000000000..7bd473756d --- /dev/null +++ b/packages/backend/src/plugins/search.ts @@ -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 { useHotCleanup } from '@backstage/backend-common'; +import { createRouter } from '@backstage/plugin-search-backend'; +import { IndexBuilder } from '@backstage/plugin-search-backend-node'; +import { PluginEnvironment } from '../types'; +import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend'; + +export default async function createPlugin({ + logger, + discovery, +}: PluginEnvironment) { + const indexBuilder = new IndexBuilder({ logger }); + + indexBuilder.addCollator({ + type: 'software-catalog', + defaultRefreshIntervalSeconds: 600, + collator: new DefaultCatalogCollator(discovery), + }); + + // TODO: Move refresh loop logic into the builder. + const timerId = setInterval(() => { + indexBuilder.build(); + }, 60000); + useHotCleanup(module, () => clearInterval(timerId)); + + return await createRouter({ + logger, + }); +} diff --git a/packages/backend/src/plugins/todo.ts b/packages/backend/src/plugins/todo.ts new file mode 100644 index 0000000000..84f2feac92 --- /dev/null +++ b/packages/backend/src/plugins/todo.ts @@ -0,0 +1,42 @@ +/* + * 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 { CatalogClient } from '@backstage/catalog-client'; +import { + createRouter, + TodoReaderService, + TodoScmReader, +} from '@backstage/plugin-todo-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + reader, + config, + discovery, +}: PluginEnvironment): Promise { + const todoReader = TodoScmReader.fromConfig(config, { + logger, + reader, + }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const todoService = new TodoReaderService({ + todoReader, + catalogClient, + }); + + return await createRouter({ todoService }); +} diff --git a/packages/catalog-client/CHANGELOG.md b/packages/catalog-client/CHANGELOG.md index c4e1f0bd1c..746f107ec6 100644 --- a/packages/catalog-client/CHANGELOG.md +++ b/packages/catalog-client/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/catalog-client +## 0.3.8 + +### Patch Changes + +- 8686eb38c: Throw the new `ResponseError` from `@backstage/errors` +- Updated dependencies [0434853a5] + - @backstage/config@0.1.4 + ## 0.3.7 ### Patch Changes diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 36d63a4230..2d664fc612 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-client", - "version": "0.3.7", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,12 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.2", + "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index b0b9ed33e0..0971d2c0ff 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/cli +## 0.6.5 + +### Patch Changes + +- 84972540b: Lint storybook files, i.e. `*.stories.*`, as if they were tests. +- Updated dependencies [0434853a5] + - @backstage/config@0.1.4 + ## 0.6.4 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index ab03115da9..a61698ba3b 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.4", + "version": "0.6.5", "private": false, "publishConfig": { "access": "public" @@ -31,7 +31,7 @@ "@babel/core": "^7.4.4", "@babel/plugin-transform-modules-commonjs": "^7.4.4", "@backstage/cli-common": "^0.1.1", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/config-loader": "^0.5.1", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^4.0.0", @@ -96,7 +96,7 @@ "rollup-plugin-dts": "^2.0.1", "rollup-plugin-esbuild": "2.6.x", "rollup-plugin-peer-deps-external": "^2.2.2", - "rollup-plugin-postcss": "^3.1.1", + "rollup-plugin-postcss": "^4.0.0", "rollup-pluginutils": "^2.8.2", "semver": "^7.3.2", "start-server-webpack-plugin": "^2.2.5", @@ -116,11 +116,11 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.5.6", - "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.1", + "@backstage/backend-common": "^0.6.0", + "@backstage/config": "^0.1.4", + "@backstage/core": "^0.7.2", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@backstage/theme": "^0.2.4", "@types/diff": "^5.0.0", "@types/express": "^4.17.6", diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index b762bb9645..d041773d3c 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -27,7 +27,7 @@ "@backstage/config": "^{{version '@backstage/config'}}", "@types/express": "^4.17.6", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "winston": "^3.2.1", "cross-fetch": "^3.0.6", "yn": "^4.0.0" diff --git a/packages/config/CHANGELOG.md b/packages/config/CHANGELOG.md index cfc48fe21a..c190207547 100644 --- a/packages/config/CHANGELOG.md +++ b/packages/config/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/config +## 0.1.4 + +### Patch Changes + +- 0434853a5: Reformulate the json types to break type recursion + ## 0.1.3 ### Patch Changes diff --git a/packages/config/package.json b/packages/config/package.json index fa0ebe1a19..8f08a5dcfc 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.3", + "version": "0.1.4", "private": false, "publishConfig": { "access": "public", diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index d873bc93a9..70a6a7a29b 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -14,11 +14,12 @@ * limitations under the License. */ +export { ConfigReader } from './reader'; export type { AppConfig, Config, JsonArray, JsonObject, + JsonPrimitive, JsonValue, } from './types'; -export { ConfigReader } from './reader'; diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 92293a3f26..76bbc2f9bf 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -14,15 +14,10 @@ * limitations under the License. */ +export type JsonPrimitive = number | string | boolean | null; export type JsonObject = { [key in string]?: JsonValue }; -export type JsonArray = JsonValue[]; -export type JsonValue = - | JsonObject - | JsonArray - | number - | string - | boolean - | null; +export interface JsonArray extends Array {} +export type JsonValue = JsonObject | JsonArray | JsonPrimitive; export type AppConfig = { context: string; diff --git a/packages/core-api/CHANGELOG.md b/packages/core-api/CHANGELOG.md index 700f6827c9..b8f97a06bf 100644 --- a/packages/core-api/CHANGELOG.md +++ b/packages/core-api/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/core-api +## 0.2.14 + +### Patch Changes + +- a51dc0006: Export `SubRouteRef` type, and allow `SubRouteRef`s to be assigned to `plugin.routes`. +- e7f9b9435: Allow elements to be used multiple times in the app element tree. +- 34ff49b0f: Allow extension components to also return `null` in addition to a `JSX.Element`. +- d88dd219e: Internal refactor to allow for future package splits. As part of this `ApiRef`s are now identified by their ID rather than their reference. +- c8b54c370: Added new Docs Icon to Core Icons +- Updated dependencies [0434853a5] + - @backstage/config@0.1.4 + ## 0.2.13 ### Patch Changes diff --git a/packages/core-api/package.json b/packages/core-api/package.json index cdc21264d3..e6064a318b 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.13", + "version": "0.2.14", "private": false, "publishConfig": { "access": "public", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,11 +42,12 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.4", - "@backstage/test-utils": "^0.1.8", + "@backstage/cli": "^0.6.5", + "@backstage/test-utils": "^0.1.9", "@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", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", diff --git a/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts index 9f44adaef3..a51ed03fea 100644 --- a/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts +++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.test.ts @@ -67,4 +67,25 @@ describe('ApiFactoryRegistry', () => { expect(registry.get(cRef)).toBe(cFactory); expect(registry.getAllApis()).toEqual(new Set([aRef, bRef, cRef])); }); + + it('should identify ApiRefs by id but still return the correct factory ref when listing all apis', () => { + const ref1 = createApiRef({ id: 'a', description: 'ref1' }); + const ref2 = createApiRef({ id: 'a', description: 'ref2' }); + + const factory1 = { api: ref1, deps: {}, factory: () => 3 }; + const factory2 = { api: ref2, deps: {}, factory: () => 3 }; + + const registry = new ApiFactoryRegistry(); + expect(registry.register('default', factory1)).toBe(true); + expect(registry.register('default', factory2)).toBe(false); + expect(registry.get(ref1)).toEqual(factory1); + expect(registry.get(ref2)).toEqual(factory1); + expect(registry.getAllApis()).toEqual(new Set([ref1])); + + expect(registry.register('app', factory2)).toBe(true); + expect(registry.get(ref1)).toEqual(factory2); + expect(registry.get(ref2)).toEqual(factory2); + expect(registry.getAllApis()).toEqual(new Set([ref2])); + expect(registry.getAllApis()).not.toEqual(new Set([ref1])); + }); }); diff --git a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts index 9d9b058eac..c5a76ee1d4 100644 --- a/packages/core-api/src/apis/system/ApiFactoryRegistry.ts +++ b/packages/core-api/src/apis/system/ApiFactoryRegistry.ts @@ -46,7 +46,7 @@ type FactoryTuple = { * higher priority scopes override ones with lower priority. */ export class ApiFactoryRegistry implements ApiFactoryHolder { - private readonly factories = new Map(); + private readonly factories = new Map(); /** * Register a new API factory. Returns true if the factory was added @@ -60,19 +60,19 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { factory: ApiFactory, ) { const priority = ScopePriority[scope]; - const existing = this.factories.get(factory.api); + const existing = this.factories.get(factory.api.id); if (existing && existing.priority >= priority) { return false; } - this.factories.set(factory.api, { priority, factory }); + this.factories.set(factory.api.id, { priority, factory }); return true; } get( api: ApiRef, ): ApiFactory | undefined { - const tuple = this.factories.get(api); + const tuple = this.factories.get(api.id); if (!tuple) { return undefined; } @@ -80,6 +80,10 @@ export class ApiFactoryRegistry implements ApiFactoryHolder { } getAllApis(): Set { - return new Set(this.factories.keys()); + const refs = new Set(); + for (const { factory } of this.factories.values()) { + refs.add(factory.api); + } + return refs; } } diff --git a/packages/core-api/src/apis/system/ApiProvider.test.tsx b/packages/core-api/src/apis/system/ApiProvider.test.tsx index 71269697c2..e95842aebc 100644 --- a/packages/core-api/src/apis/system/ApiProvider.test.tsx +++ b/packages/core-api/src/apis/system/ApiProvider.test.tsx @@ -14,12 +14,15 @@ * limitations under the License. */ -import React from 'react'; +import React, { Context, useContext } from 'react'; import { ApiProvider, useApi, withApis } from './ApiProvider'; import { createApiRef } from './ApiRef'; import { ApiRegistry } from './ApiRegistry'; import { render } from '@testing-library/react'; import { withLogCollector } from '@backstage/test-utils-core'; +import { getGlobalSingleton } from '../../lib/globalObject'; +import { ApiHolder, ApiRef } from './types'; +import { VersionedValue } from '../../lib/versionedValues'; describe('ApiProvider', () => { type Api = () => string; @@ -175,3 +178,35 @@ describe('ApiProvider', () => { ]); }); }); + +describe('v1 consumer', () => { + const ApiContext = getGlobalSingleton< + Context> + >('api-context'); + + function useMockApiV1(apiRef: ApiRef): T { + const impl = useContext(ApiContext)?.atVersion(1)?.get(apiRef); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + type Api = () => string; + const apiRef = createApiRef({ id: 'x', description: '' }); + const registry = ApiRegistry.with(apiRef, () => 'hello'); + + const MyHookConsumerV1 = () => { + const api = useMockApiV1(apiRef); + return

hook message: {api()}

; + }; + + it('should provide apis', () => { + const renderedHook = render( + + + , + ); + renderedHook.getByText('hook message: hello'); + }); +}); diff --git a/packages/core-api/src/apis/system/ApiProvider.tsx b/packages/core-api/src/apis/system/ApiProvider.tsx index d41730cb59..807b2ff7e0 100644 --- a/packages/core-api/src/apis/system/ApiProvider.tsx +++ b/packages/core-api/src/apis/system/ApiProvider.tsx @@ -19,10 +19,20 @@ import React, { useContext, ReactNode, PropsWithChildren, + Context, + useMemo, } from 'react'; import PropTypes from 'prop-types'; import { ApiRef, ApiHolder, TypesToApiRefs } from './types'; import { ApiAggregator } from './ApiAggregator'; +import { + getGlobalSingleton, + getOrCreateGlobalSingleton, +} from '../../lib/globalObject'; +import { + VersionedValue, + createVersionedValueMap, +} from '../../lib/versionedValues'; const missingHolderMessage = 'No ApiProvider available in react context. ' + @@ -35,16 +45,25 @@ type ApiProviderProps = { children: ReactNode; }; -const Context = createContext(undefined); +type ApiContextType = VersionedValue<{ 1: ApiHolder }> | undefined; +const ApiContext = getOrCreateGlobalSingleton('api-context', () => + createContext(undefined), +); export const ApiProvider = ({ apis, children, }: PropsWithChildren) => { - const parentHolder = useContext(Context); - const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; + const parentHolder = useContext(ApiContext)?.atVersion(1); + const versionedValue = useMemo( + () => + createVersionedValueMap({ + 1: parentHolder ? new ApiAggregator(apis, parentHolder) : apis, + }), + [parentHolder, apis], + ); - return ; + return ; }; ApiProvider.propTypes = { @@ -53,12 +72,19 @@ ApiProvider.propTypes = { }; export function useApiHolder(): ApiHolder { - const apiHolder = useContext(Context); + const versionedHolder = useContext( + getGlobalSingleton>('api-context'), + ); - if (!apiHolder) { + if (!versionedHolder) { throw new Error(missingHolderMessage); } + const apiHolder = versionedHolder.atVersion(1); + if (!apiHolder) { + throw new Error('ApiContext v1 not available'); + } + return apiHolder; } @@ -77,11 +103,7 @@ export function withApis(apis: TypesToApiRefs) { WrappedComponent: React.ComponentType

, ) { const Hoc = (props: PropsWithChildren>) => { - const apiHolder = useContext(Context); - - if (!apiHolder) { - throw new Error(missingHolderMessage); - } + const apiHolder = useApiHolder(); const impls = {} as T; diff --git a/packages/core-api/src/apis/system/ApiRef.ts b/packages/core-api/src/apis/system/ApiRef.ts index 0e1eb177c8..e61036c61c 100644 --- a/packages/core-api/src/apis/system/ApiRef.ts +++ b/packages/core-api/src/apis/system/ApiRef.ts @@ -18,7 +18,7 @@ import type { ApiRef } from './types'; export type ApiRefConfig = { id: string; - description: string; + description?: string; }; class ApiRefImpl implements ApiRef { @@ -38,7 +38,7 @@ class ApiRefImpl implements ApiRef { return this.config.id; } - get description(): string { + get description() { return this.config.description; } diff --git a/packages/core-api/src/apis/system/ApiRegistry.test.ts b/packages/core-api/src/apis/system/ApiRegistry.test.ts index a859d51004..93dd6dc085 100644 --- a/packages/core-api/src/apis/system/ApiRegistry.test.ts +++ b/packages/core-api/src/apis/system/ApiRegistry.test.ts @@ -18,8 +18,9 @@ import { ApiRegistry } from './ApiRegistry'; import { createApiRef } from './ApiRef'; describe('ApiRegistry', () => { - const x1Ref = createApiRef({ id: 'x', description: '' }); - const x2Ref = createApiRef({ id: 'x', description: '' }); + const x1Ref = createApiRef({ id: 'x1', description: '' }); + const x1DuplicateRef = createApiRef({ id: 'x1', description: '' }); + const x2Ref = createApiRef({ id: 'x2', description: '' }); it('should be created', () => { const registry = ApiRegistry.from([]); @@ -32,12 +33,14 @@ describe('ApiRegistry', () => { [x2Ref, 'y'], ]); expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); expect(registry.get(x2Ref)).toBe('y'); }); it('should be built', () => { const registry = ApiRegistry.builder().build(); expect(registry.get(x1Ref)).toBe(undefined); + expect(registry.get(x1DuplicateRef)).toBe(undefined); }); it('should be built with APIs', () => { @@ -47,6 +50,7 @@ describe('ApiRegistry', () => { const registry = builder.build(); expect(registry.get(x1Ref)).toBe(3); + expect(registry.get(x1DuplicateRef)).toBe(3); expect(registry.get(x2Ref)).toBe('y'); }); @@ -55,6 +59,7 @@ describe('ApiRegistry', () => { const reg2 = reg1.with(x2Ref, 'y'); const reg3 = reg2.with(x2Ref, 'z'); const reg4 = reg3.with(x1Ref, 2); + const reg5 = reg3.with(x1DuplicateRef, 4); expect(reg1.get(x1Ref)).toBe(3); expect(reg1.get(x2Ref)).toBe(undefined); @@ -64,5 +69,7 @@ describe('ApiRegistry', () => { expect(reg3.get(x2Ref)).toBe('z'); expect(reg4.get(x1Ref)).toBe(2); expect(reg4.get(x2Ref)).toBe('z'); + expect(reg5.get(x1Ref)).toBe(4); + expect(reg5.get(x2Ref)).toBe('z'); }); }); diff --git a/packages/core-api/src/apis/system/ApiRegistry.ts b/packages/core-api/src/apis/system/ApiRegistry.ts index b105633c51..01101b2b62 100644 --- a/packages/core-api/src/apis/system/ApiRegistry.ts +++ b/packages/core-api/src/apis/system/ApiRegistry.ts @@ -19,10 +19,10 @@ import { ApiRef, ApiHolder } from './types'; type ApiImpl = readonly [ApiRef, T]; class ApiRegistryBuilder { - private apis: ApiImpl[] = []; + private apis: [string, unknown][] = []; add(api: ApiRef, impl: I): I { - this.apis.push([api, impl]); + this.apis.push([api.id, impl]); return impl; } @@ -38,7 +38,7 @@ export class ApiRegistry implements ApiHolder { } static from(apis: ApiImpl[]) { - return new ApiRegistry(new Map(apis)); + return new ApiRegistry(new Map(apis.map(([api, impl]) => [api.id, impl]))); } /** @@ -48,10 +48,10 @@ export class ApiRegistry implements ApiHolder { * @param impl Implementation of the API to add */ static with(api: ApiRef, impl: T): ApiRegistry { - return new ApiRegistry(new Map([[api, impl]])); + return new ApiRegistry(new Map([[api.id, impl]])); } - constructor(private readonly apis: Map, unknown>) {} + constructor(private readonly apis: Map) {} /** * Returns a new ApiRegistry with the provided API added to the existing ones. @@ -60,10 +60,10 @@ export class ApiRegistry implements ApiHolder { * @param impl Implementation of the API to add */ with(api: ApiRef, impl: T): ApiRegistry { - return new ApiRegistry(new Map([...this.apis, [api, impl]])); + return new ApiRegistry(new Map([...this.apis, [api.id, impl]])); } get(api: ApiRef): T | undefined { - return this.apis.get(api) as T | undefined; + return this.apis.get(api.id) as T | undefined; } } diff --git a/packages/core-api/src/apis/system/ApiResolver.test.ts b/packages/core-api/src/apis/system/ApiResolver.test.ts index 3cd40f289e..7a46d2db3b 100644 --- a/packages/core-api/src/apis/system/ApiResolver.test.ts +++ b/packages/core-api/src/apis/system/ApiResolver.test.ts @@ -19,8 +19,14 @@ import { createApiRef } from './ApiRef'; import { ApiFactoryRegistry } from './ApiFactoryRegistry'; const aRef = createApiRef({ id: 'a', description: '' }); +const otherARef = createApiRef({ id: 'a', description: 'other' }); const bRef = createApiRef({ id: 'b', description: '' }); +const otherBRef = createApiRef({ id: 'b', description: 'other' }); const cRef = createApiRef<{ x: string }>({ id: 'c', description: '' }); +const otherCRef = createApiRef<{ x: string }>({ + id: 'c', + description: 'other', +}); function createRegistry() { const registry = new ApiFactoryRegistry(); @@ -36,28 +42,18 @@ function createRegistry() { }); registry.register('default', { api: cRef, - deps: { b: bRef }, + deps: { b: otherBRef }, factory: ({ b }) => ({ x: 'x', b }), }); return registry; } -function createLongCyclicRegistry() { +function createSelfCyclicRegistry() { const registry = new ApiFactoryRegistry(); registry.register('default', { api: aRef, - deps: { b: bRef }, - factory: () => 1, - }); - registry.register('default', { - api: bRef, - deps: { c: cRef }, - factory: () => 'b', - }); - registry.register('default', { - api: cRef, deps: { a: aRef }, - factory: () => ({ x: 'x' }), + factory: () => 1, }); return registry; } @@ -66,7 +62,37 @@ function createShortCyclicRegistry() { const registry = new ApiFactoryRegistry(); registry.register('default', { api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: bRef, deps: { a: aRef }, + factory: () => 'x', + }); + return registry; +} + +function createShortCyclicRegistryWithOther() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: bRef }, + factory: () => 1, + }); + registry.register('default', { + api: otherBRef, + deps: { a: otherARef }, + factory: () => 'x', + }); + return registry; +} + +function createLongCyclicRegistry() { + const registry = new ApiFactoryRegistry(); + registry.register('default', { + api: aRef, + deps: { b: otherBRef }, factory: () => 1, }); registry.register('default', { @@ -76,7 +102,7 @@ function createShortCyclicRegistry() { }); registry.register('default', { api: cRef, - deps: { b: bRef }, + deps: { a: aRef }, factory: () => ({ x: 'x' }), }); return registry; @@ -87,15 +113,51 @@ describe('ApiResolver', () => { const resolver = new ApiResolver(new ApiFactoryRegistry()); expect(resolver.get(aRef)).toBe(undefined); expect(resolver.get(bRef)).toBe(undefined); + expect(resolver.get(otherBRef)).toBe(undefined); expect(resolver.get(cRef)).toBe(undefined); }); it('should instantiate APIs', () => { const resolver = new ApiResolver(createRegistry()); expect(resolver.get(aRef)).toBe(1); + expect(resolver.get(otherARef)).toBe(1); expect(resolver.get(bRef)).toBe('b'); + expect(resolver.get(otherBRef)).toBe('b'); expect(resolver.get(cRef)).toEqual({ x: 'x', b: 'b' }); - expect(resolver.get(cRef)).toBe(resolver.get(cRef)); + expect(resolver.get(cRef)).toBe(resolver.get(otherCRef)); + }); + + it('should detect self dependency cycles', () => { + const resolver = new ApiResolver(createSelfCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + + it('should detect short dependency cycles', () => { + const resolver = new ApiResolver(createShortCyclicRegistry()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + }); + + it('should detect short dependency cycles with other refs', () => { + const resolver = new ApiResolver(createShortCyclicRegistryWithOther()); + expect(() => resolver.get(aRef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(bRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => resolver.get(otherARef)).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => resolver.get(otherBRef)).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); }); it('should detect long dependency cycles', () => { @@ -110,17 +172,7 @@ describe('ApiResolver', () => { expect(() => resolver.get(bRef)).toThrow( 'Circular dependency of api factory for apiRef{b}', ); - expect(() => resolver.get(cRef)).toThrow( - 'Circular dependency of api factory for apiRef{c}', - ); - }); - - it('should detect short dependency cycles', () => { - const resolver = new ApiResolver(createShortCyclicRegistry()); - expect(() => resolver.get(aRef)).toThrow( - 'Circular dependency of api factory for apiRef{a}', - ); - expect(() => resolver.get(bRef)).toThrow( + expect(() => resolver.get(otherBRef)).toThrow( 'Circular dependency of api factory for apiRef{b}', ); expect(() => resolver.get(cRef)).toThrow( @@ -130,22 +182,54 @@ describe('ApiResolver', () => { it('should validate a factory holder', () => { expect(() => { - ApiResolver.validateFactories(createRegistry(), [aRef, bRef, cRef]); + ApiResolver.validateFactories(createRegistry(), [ + aRef, + bRef, + otherBRef, + cRef, + ]); }).not.toThrow(); }); + it('should find self cycles with validation', () => { + const self = createSelfCyclicRegistry(); + expect(() => ApiResolver.validateFactories(self, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(self, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + }); + it('should find dependency cycles with validation', () => { const short = createShortCyclicRegistry(); - expect(() => - ApiResolver.validateFactories(short, short.getAllApis()), - ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(short, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => ApiResolver.validateFactories(short, [otherARef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); expect(() => ApiResolver.validateFactories(short, [bRef])).toThrow( 'Circular dependency of api factory for apiRef{b}', ); - expect(() => ApiResolver.validateFactories(short, [cRef])).toThrow( - 'Circular dependency of api factory for apiRef{c}', + expect(() => ApiResolver.validateFactories(short, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', ); + const shortOther = createShortCyclicRegistryWithOther(); + expect(() => ApiResolver.validateFactories(shortOther, [aRef])).toThrow( + 'Circular dependency of api factory for apiRef{a}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherARef]), + ).toThrow('Circular dependency of api factory for apiRef{a}'); + expect(() => ApiResolver.validateFactories(shortOther, [bRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); + expect(() => + ApiResolver.validateFactories(shortOther, [otherBRef]), + ).toThrow('Circular dependency of api factory for apiRef{b}'); + const long = createLongCyclicRegistry(); expect(() => ApiResolver.validateFactories(long, long.getAllApis()), @@ -153,6 +237,9 @@ describe('ApiResolver', () => { expect(() => ApiResolver.validateFactories(long, [bRef])).toThrow( 'Circular dependency of api factory for apiRef{b}', ); + expect(() => ApiResolver.validateFactories(long, [otherBRef])).toThrow( + 'Circular dependency of api factory for apiRef{b}', + ); expect(() => ApiResolver.validateFactories(long, [cRef])).toThrow( 'Circular dependency of api factory for apiRef{c}', ); @@ -173,5 +260,7 @@ describe('ApiResolver', () => { expect(factory).toHaveBeenCalledTimes(1); expect(resolver.get(aRef)).toBe(2); expect(factory).toHaveBeenCalledTimes(1); + expect(resolver.get(otherARef)).toBe(2); + expect(factory).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core-api/src/apis/system/ApiResolver.ts b/packages/core-api/src/apis/system/ApiResolver.ts index 0dfeaeedd0..9738e09622 100644 --- a/packages/core-api/src/apis/system/ApiResolver.ts +++ b/packages/core-api/src/apis/system/ApiResolver.ts @@ -23,8 +23,6 @@ import { } from './types'; export class ApiResolver implements ApiHolder { - private readonly apis = new Map(); - /** * Validate factories by making sure that each of the apis can be created * without hitting any circular dependencies. @@ -45,7 +43,7 @@ export class ApiResolver implements ApiHolder { } for (const dep of Object.values(factory.deps)) { - if (dep === api) { + if (dep.id === api.id) { throw new Error(`Circular dependency of api factory for ${api}`); } if (!allDeps.has(dep)) { @@ -57,6 +55,8 @@ export class ApiResolver implements ApiHolder { } } + private readonly apis = new Map(); + constructor(private readonly factories: ApiFactoryHolder) {} get(ref: ApiRef): T | undefined { @@ -64,7 +64,7 @@ export class ApiResolver implements ApiHolder { } private load(ref: ApiRef, loading: AnyApiRef[] = []): T | undefined { - const impl = this.apis.get(ref); + const impl = this.apis.get(ref.id); if (impl) { return impl as T; } @@ -80,7 +80,7 @@ export class ApiResolver implements ApiHolder { const deps = this.loadDeps(ref, factory.deps, [...loading, factory.api]); const api = factory.factory(deps); - this.apis.set(ref, api); + this.apis.set(ref.id, api); return api as T; } diff --git a/packages/core-api/src/apis/system/types.ts b/packages/core-api/src/apis/system/types.ts index 6234a55c71..a4bc95a3c3 100644 --- a/packages/core-api/src/apis/system/types.ts +++ b/packages/core-api/src/apis/system/types.ts @@ -16,7 +16,7 @@ export type ApiRef = { id: string; - description: string; + description?: string; T: T; }; diff --git a/packages/core-api/src/app/App.test.tsx b/packages/core-api/src/app/App.test.tsx index 4e7bf80624..c91eef1fb0 100644 --- a/packages/core-api/src/app/App.test.tsx +++ b/packages/core-api/src/app/App.test.tsx @@ -98,6 +98,12 @@ describe('Integration Test', () => { const plugin1 = createPlugin({ id: 'blob', + // Both absolute and sub route refs should be assignable to the plugin routes + routes: { + ref1: plugin1RouteRef, + ref2: plugin2RouteRef, + ref3: subRouteRef1, + }, externalRoutes: { extRouteRef1, extRouteRef2, diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index c20eb0824e..d2346e478c 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -53,7 +53,7 @@ import { import { IconComponent, IconComponentMap, IconKey } from '../icons'; import { BackstagePlugin } from '../plugin'; import { AnyRoutes } from '../plugin/types'; -import { RouteRef, ExternalRouteRef } from '../routing'; +import { RouteRef, ExternalRouteRef, SubRouteRef } from '../routing'; import { routeObjectCollector, routeParentCollector, @@ -75,10 +75,8 @@ import { SignInResult, } from './types'; -export function generateBoundRoutes( - bindRoutes: AppOptions['bindRoutes'], -): Map { - const result = new Map(); +export function generateBoundRoutes(bindRoutes: AppOptions['bindRoutes']) { + const result = new Map(); if (bindRoutes) { const bind: AppRouteBinder = (externalRoutes, targetRoutes: AnyRoutes) => { diff --git a/packages/core-api/src/app/AppContext.test.tsx b/packages/core-api/src/app/AppContext.test.tsx new file mode 100644 index 0000000000..55d98c18c0 --- /dev/null +++ b/packages/core-api/src/app/AppContext.test.tsx @@ -0,0 +1,79 @@ +/* + * 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, { useContext, Context } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { VersionedValue } from '../lib/versionedValues'; +import { getGlobalSingleton } from '../lib/globalObject'; +import { AppContext as AppContextV1 } from './types'; +import { AppContextProvider } from './AppContext'; + +describe('v1 consumer', () => { + const AppContext = getGlobalSingleton< + Context> + >('app-context'); + + function useMockAppV1(): AppContextV1 { + const impl = useContext(AppContext)?.atVersion(1); + if (!impl) { + throw new Error('no impl'); + } + return impl; + } + + it('should provide an app context', () => { + const mockContext: AppContextV1 = { + getComponents: jest.fn(), + getSystemIcon: jest.fn(), + getPlugins: jest.fn(), + getProvider: jest.fn(), + getRouter: jest.fn(), + getRoutes: jest.fn(), + }; + + const renderedHook = renderHook(() => useMockAppV1(), { + wrapper: ({ children }) => ( + + ), + }); + const result = renderedHook.result.current; + + expect(mockContext.getComponents).toHaveBeenCalledTimes(0); + result.getComponents(); + expect(mockContext.getComponents).toHaveBeenCalledTimes(1); + + expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(0); + result.getSystemIcon('icon'); + expect(mockContext.getSystemIcon).toHaveBeenCalledTimes(1); + expect(mockContext.getSystemIcon).toHaveBeenCalledWith('icon'); + + expect(mockContext.getPlugins).toHaveBeenCalledTimes(0); + result.getPlugins(); + expect(mockContext.getPlugins).toHaveBeenCalledTimes(1); + + expect(mockContext.getProvider).toHaveBeenCalledTimes(0); + result.getProvider(); + expect(mockContext.getProvider).toHaveBeenCalledTimes(1); + + expect(mockContext.getRouter).toHaveBeenCalledTimes(0); + result.getRouter(); + expect(mockContext.getRouter).toHaveBeenCalledTimes(1); + + expect(mockContext.getRoutes).toHaveBeenCalledTimes(0); + result.getRoutes(); + expect(mockContext.getRoutes).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core-api/src/app/AppContext.tsx b/packages/core-api/src/app/AppContext.tsx index c7c405a959..c143aa86bc 100644 --- a/packages/core-api/src/app/AppContext.tsx +++ b/packages/core-api/src/app/AppContext.tsx @@ -14,26 +14,54 @@ * limitations under the License. */ -import React, { createContext, PropsWithChildren, useContext } from 'react'; -import { AppContext } from './types'; +import React, { + createContext, + PropsWithChildren, + useContext, + Context, + useMemo, +} from 'react'; +import { + VersionedValue, + createVersionedValueMap, +} from '../lib/versionedValues'; +import { + getGlobalSingleton, + getOrCreateGlobalSingleton, +} from '../lib/globalObject'; +import { AppContext as AppContextV1 } from './types'; -const Context = createContext(undefined); +type AppContextType = VersionedValue<{ 1: AppContextV1 }> | undefined; +const AppContext = getOrCreateGlobalSingleton('app-context', () => + createContext(undefined), +); type Props = { - appContext: AppContext; + appContext: AppContextV1; }; export const AppContextProvider = ({ appContext, children, -}: PropsWithChildren) => ( - -); +}: PropsWithChildren) => { + const versionedValue = useMemo( + () => createVersionedValueMap({ 1: appContext }), + [appContext], + ); -export const useApp = (): AppContext => { - const appContext = useContext(Context); - if (!appContext) { + return ; +}; + +export const useApp = (): AppContextV1 => { + const versionedContext = useContext( + getGlobalSingleton>('app-context'), + ); + if (!versionedContext) { throw new Error('No app context available'); } + const appContext = versionedContext.atVersion(1); + if (!appContext) { + throw new Error('AppContext v1 not available'); + } return appContext; }; diff --git a/packages/core-api/src/extensions/componentData.tsx b/packages/core-api/src/extensions/componentData.tsx index 8b46d34705..fc8594039c 100644 --- a/packages/core-api/src/extensions/componentData.tsx +++ b/packages/core-api/src/extensions/componentData.tsx @@ -15,7 +15,7 @@ */ import { ComponentType, ReactNode } from 'react'; -import { getGlobalSingleton } from '../lib/globalObject'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; // TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x const DATA_KEY = Symbol('backstage-component-data'); @@ -33,7 +33,7 @@ type MaybeComponentNode = ReactNode & { }; // The store is bridged across versions using the global object -const store = getGlobalSingleton( +const store = getOrCreateGlobalSingleton( 'component-data-store', () => new WeakMap, DataContainer>(), ); diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index e56b6901dd..191e4c4fb7 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -27,8 +27,11 @@ type ComponentLoader = sync: T; }; +// We do not use ComponentType as the return type, since it doesn't let us convey the children prop. +// ComponentType inserts children as an optional prop whether the inner component accepts it or not, +// making it impossible to make the usage of children type safe. export function createRoutableExtension< - T extends (props: any) => JSX.Element + T extends (props: any) => JSX.Element | null >(options: { component: () => Promise; mountPoint: RouteRef; @@ -60,15 +63,21 @@ export function createRoutableExtension< }); } +// We do not use ComponentType as the return type, since it doesn't let us convey the children prop. +// ComponentType inserts children as an optional prop whether the inner component accepts it or not, +// making it impossible to make the usage of children type safe. export function createComponentExtension< - T extends (props: any) => JSX.Element + T extends (props: any) => JSX.Element | null >(options: { component: ComponentLoader }): Extension { const { component } = options; return createReactExtension({ component }); } +// We do not use ComponentType as the return type, since it doesn't let us convey the children prop. +// ComponentType inserts children as an optional prop whether the inner component accepts it or not, +// making it impossible to make the usage of children type safe. export function createReactExtension< - T extends (props: any) => JSX.Element + T extends (props: any) => JSX.Element | null >(options: { component: ComponentLoader; data?: Record; diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts index bdf02d16c8..4431bbd24c 100644 --- a/packages/core-api/src/extensions/traversal.ts +++ b/packages/core-api/src/extensions/traversal.ts @@ -37,7 +37,6 @@ export function traverseElementTree(options: { discoverers: Discoverer[]; collectors: { [name in keyof Results]: Collector }; }): Results { - const visited = new Set(); const collectors: { [name in string]: ReturnType>; } = {}; @@ -74,14 +73,6 @@ export function traverseElementTree(options: { if (!isValidElement(element)) { return; } - if (visited.has(element)) { - const anyType = element?.type as - | { displayName?: string; name?: string } - | undefined; - const name = anyType?.displayName || anyType?.name || String(anyType); - throw new Error(`Visited element ${name} twice`); - } - visited.add(element); const nextContexts: QueueItem['contexts'] = {}; diff --git a/packages/core-api/src/lib/globalObject.test.ts b/packages/core-api/src/lib/globalObject.test.ts index f539483a83..e72f027b46 100644 --- a/packages/core-api/src/lib/globalObject.test.ts +++ b/packages/core-api/src/lib/globalObject.test.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { getGlobalSingleton } from './globalObject'; +import { + getGlobalSingleton, + getOrCreateGlobalSingleton, + setGlobalSingleton, +} from './globalObject'; const anyGlobal = global as any; @@ -23,20 +27,64 @@ describe('getGlobalSingleton', () => { delete anyGlobal['__@backstage/my-thing__']; }); + it('should return an existing value', () => { + const myThing = {}; + const myOtherThing = {}; + + anyGlobal['__@backstage/my-thing__'] = myThing; + expect(getGlobalSingleton('my-thing')).toBe(myThing); + expect(getGlobalSingleton('my-thing')).toBe(myThing); + anyGlobal['__@backstage/my-thing__'] = myOtherThing; + expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); + }); + + it('should throw if the value is not set', () => { + expect(() => getGlobalSingleton('my-thing')).toThrow( + 'Global my-thing is not set', + ); + }); +}); + +describe('getOrCreateGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + it('should return an existing value', () => { const myThing = {}; anyGlobal['__@backstage/my-thing__'] = myThing; - expect(getGlobalSingleton('my-thing', () => ({}))).toBe(myThing); - expect(getGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); }); it('should should create a new value', () => { const myNewThing = {}; expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); - expect(getGlobalSingleton('my-thing', () => myNewThing)).toBe(myNewThing); + expect(getOrCreateGlobalSingleton('my-thing', () => myNewThing)).toBe( + myNewThing, + ); expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); - expect(getGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); + }); +}); + +describe('setGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should set a global value', () => { + setGlobalSingleton('my-thing', 'global value'); + + expect(anyGlobal['__@backstage/my-thing__']).toBe('global value'); + }); + + it('should throw if global value is set', () => { + anyGlobal['__@backstage/my-thing__'] = 'already defined'; + expect(() => setGlobalSingleton('my-thing', () => 'global value')).toThrow( + 'Global my-thing is already se', + ); }); }); diff --git a/packages/core-api/src/lib/globalObject.ts b/packages/core-api/src/lib/globalObject.ts index c7f9f98882..87be58499d 100644 --- a/packages/core-api/src/lib/globalObject.ts +++ b/packages/core-api/src/lib/globalObject.ts @@ -26,10 +26,41 @@ function getGlobalObject() { return Function('return this')(); } -export const globalObject = getGlobalObject(); +const globalObject = getGlobalObject(); -export function getGlobalSingleton(id: string, supplier: () => T): T { - const key = `__@backstage/${id}__`; +const makeKey = (id: string) => `__@backstage/${id}__`; + +/** + * Used to provide a global singleton value, failing if it is already set. + */ +export function setGlobalSingleton(id: string, value: unknown): void { + const key = makeKey(id); + if (key in globalObject) { + throw new Error(`Global ${id} is already set`); // TODO some sort of special build err + } + globalObject[key] = value; +} + +/** + * Used to access a global singleton value, failing if it is not already set. + */ +export function getGlobalSingleton(id: string): T { + const key = makeKey(id); + if (!(key in globalObject)) { + throw new Error(`Global ${id} is not set`); // TODO some sort of special build err + } + + return globalObject[key]; +} + +/** + * Serializes access to a global singleton value, with the first caller creating the value. + */ +export function getOrCreateGlobalSingleton( + id: string, + supplier: () => T, +): T { + const key = makeKey(id); let value = globalObject[key]; if (value) { diff --git a/packages/core-api/src/lib/versionedValues.test.ts b/packages/core-api/src/lib/versionedValues.test.ts new file mode 100644 index 0000000000..6ba7db4970 --- /dev/null +++ b/packages/core-api/src/lib/versionedValues.test.ts @@ -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 { createVersionedValueMap } from './versionedValues'; + +describe('createVersionedValueMap', () => { + it('should be empty', () => { + const map = createVersionedValueMap({}); + + // @ts-expect-error + expect(map.atVersion(1 as any)).toBe(undefined); + }); + + it('should access values by version', () => { + const map = createVersionedValueMap({ 1: 'v1', 2: 'v2' }); + + expect(map.atVersion(1)).toBe('v1'); + expect(map.atVersion(2)).toBe('v2'); + + // @ts-expect-error + expect(map.atVersion(0)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(NaN)).toBe(undefined); + // @ts-expect-error + expect(map.atVersion(Infinity)).toBe(undefined); + }); +}); diff --git a/packages/core-api/src/lib/versionedValues.ts b/packages/core-api/src/lib/versionedValues.ts new file mode 100644 index 0000000000..88e4e90084 --- /dev/null +++ b/packages/core-api/src/lib/versionedValues.ts @@ -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. + */ + +/** + * The versioned value interface is a container for a set of values that + * can be looked up by version. It is intended to be used as a container + * for values that can be versioned independently of package versions. + */ +export type VersionedValue = { + atVersion( + version: Version, + ): Versions[Version] | undefined; +}; + +/** + * Creates a container for a map of versioned values that implements VersionedValue. + */ +export function createVersionedValueMap< + Versions extends { [version: number]: any } +>(versions: Versions): VersionedValue { + Object.freeze(versions); + return { + atVersion(version) { + return versions[version]; + }, + }; +} diff --git a/packages/core-api/src/plugin/types.ts b/packages/core-api/src/plugin/types.ts index c3b64d8cb6..2ad02b243d 100644 --- a/packages/core-api/src/plugin/types.ts +++ b/packages/core-api/src/plugin/types.ts @@ -15,7 +15,7 @@ */ import { ComponentType } from 'react'; -import { RouteRef, ExternalRouteRef } from '../routing'; +import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing'; import { AnyApiFactory } from '../apis/system'; export type RouteOptions = { @@ -70,7 +70,7 @@ export type Extension = { expose(plugin: BackstagePlugin): T; }; -export type AnyRoutes = { [name: string]: RouteRef }; +export type AnyRoutes = { [name: string]: RouteRef | SubRouteRef }; export type AnyExternalRoutes = { [name: string]: ExternalRouteRef }; diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index 44d53856b4..cd3e378fff 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -353,24 +353,4 @@ describe('discovery', () => { }); }).toThrow('Mounted routable extension must have a path'); }); - - it('should not visit the same element twice', () => { - const element = ; - - expect(() => - traverseElementTree({ - root: ( - - {element} - {element} - - ), - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routes: routePathCollector, - routeParents: routeParentCollector, - }, - }), - ).toThrow(`Visited element Extension(Component) twice`); - }); }); diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 9a65286fd6..8a9b70ed7d 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -15,25 +15,34 @@ */ import { render } from '@testing-library/react'; -import React, { PropsWithChildren, ReactElement } from 'react'; -import { MemoryRouter, Routes } from 'react-router-dom'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { + Context, + PropsWithChildren, + ReactElement, + useContext, +} from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { createRoutableExtension } from '../extensions'; import { childDiscoverer, routeElementDiscoverer, traverseElementTree, } from '../extensions/traversal'; +import { getGlobalSingleton } from '../lib/globalObject'; +import { VersionedValue } from '../lib/versionedValues'; import { createPlugin } from '../plugin'; import { - routePathCollector, - routeParentCollector, routeObjectCollector, + routeParentCollector, + routePathCollector, } from './collectors'; -import { validateRoutes } from './validation'; -import { useRouteRef, RoutingProvider } from './hooks'; -import { createRouteRef, RouteRefConfig } from './RouteRef'; import { createExternalRouteRef } from './ExternalRouteRef'; -import { AnyRouteRef, RouteFunc, RouteRef, ExternalRouteRef } from './types'; +import { RoutingProvider, useRouteRef, useRouteRefParams } from './hooks'; +import { createRouteRef, RouteRefConfig } from './RouteRef'; +import { RouteResolver } from './RouteResolver'; +import { AnyRouteRef, ExternalRouteRef, RouteFunc, RouteRef } from './types'; +import { validateRoutes } from './validation'; const mockConfig = (extra?: Partial>) => ({ path: '/unused', @@ -309,3 +318,88 @@ describe('discovery', () => { ); }); }); + +describe('v1 consumer', () => { + const RoutingContext = getGlobalSingleton< + Context> + >('routing-context'); + + function useMockRouteRefV1( + routeRef: AnyRouteRef, + location: string, + ): RouteFunc | undefined { + const resolver = useContext(RoutingContext)?.atVersion(1); + if (!resolver) { + throw new Error('no impl'); + } + return resolver.resolve(routeRef, location); + } + + it('should resolve routes', () => { + const routeRef1 = createRouteRef({ id: 'ref1' }); + const routeRef2 = createRouteRef({ id: 'ref2' }); + const routeRef3 = createRouteRef({ id: 'ref3', params: ['x'] }); + + const renderedHook = renderHook( + ({ routeRef }) => useMockRouteRefV1(routeRef, '/'), + { + initialProps: { + routeRef: routeRef1 as AnyRouteRef, + }, + wrapper: ({ children }) => ( + , string>([ + [routeRef2, '/foo'], + [routeRef3, '/bar/:x'], + ]) + } + routeParents={new Map()} + routeObjects={[]} + routeBindings={new Map()} + children={children} + /> + ), + }, + ); + + expect(renderedHook.result.current).toBe(undefined); + renderedHook.rerender({ routeRef: routeRef2 }); + expect(renderedHook.result.current?.()).toBe('/foo'); + renderedHook.rerender({ routeRef: routeRef3 }); + expect(renderedHook.result.current?.({ x: 'my-x' })).toBe('/bar/my-x'); + }); +}); + +describe('useRouteRefParams', () => { + it('should provide types params', () => { + const routeRef = createRouteRef({ + id: 'ref1', + params: ['a', 'b'], + }); + + const Page = () => { + const params: { a: string; b: string } = useRouteRefParams(routeRef); + + return ( +

+ {params.a} + {params.b} +
+ ); + }; + + const { getByText } = render( + + + + + + + , + ); + + expect(getByText('foo')).toBeInTheDocument(); + expect(getByText('bar')).toBeInTheDocument(); + }); +}); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 2e65efaba6..1867ac5c67 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -14,8 +14,14 @@ * limitations under the License. */ -import React, { createContext, ReactNode, useContext, useMemo } from 'react'; -import { useLocation } from 'react-router-dom'; +import React, { + createContext, + ReactNode, + useContext, + useMemo, + Context, +} from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { BackstageRouteObject, RouteRef, @@ -25,8 +31,19 @@ import { RouteFunc, } from './types'; import { RouteResolver } from './RouteResolver'; +import { + VersionedValue, + createVersionedValueMap, +} from '../lib/versionedValues'; +import { + getGlobalSingleton, + getOrCreateGlobalSingleton, +} from '../lib/globalObject'; -const RoutingContext = createContext(undefined); +type RoutingContextType = VersionedValue<{ 1: RouteResolver }> | undefined; +const RoutingContext = getOrCreateGlobalSingleton('routing-context', () => + createContext(undefined), +); export function useRouteRef( routeRef: ExternalRouteRef, @@ -41,14 +58,20 @@ export function useRouteRef( | ExternalRouteRef, ): RouteFunc | undefined { const sourceLocation = useLocation(); - const resolver = useContext(RoutingContext); + const versionedContext = useContext( + getGlobalSingleton>('routing-context'), + ); + const resolver = versionedContext?.atVersion(1); const routeFunc = useMemo( () => resolver && resolver.resolve(routeRef, sourceLocation), [resolver, routeRef, sourceLocation], ); - if (!routeFunc && !resolver) { - throw new Error('No route resolver found in context'); + if (!versionedContext) { + throw new Error('useRouteRef used outside of routing context'); + } + if (!resolver) { + throw new Error('RoutingContext v1 not available'); } const isOptional = 'optional' in routeRef && routeRef.optional; @@ -80,9 +103,17 @@ export const RoutingProvider = ({ routeObjects, routeBindings, ); + + const versionedValue = createVersionedValueMap({ 1: resolver }); return ( - + {children} ); }; + +export function useRouteRefParams( + _routeRef: RouteRef | SubRouteRef, +): Params { + return useParams() as Params; +} diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index c6134cfe4f..0c01e74f3f 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,16 +14,17 @@ * limitations under the License. */ +export { createExternalRouteRef } from './ExternalRouteRef'; +export { FlatRoutes } from './FlatRoutes'; +export { useRouteRef, useRouteRefParams } from './hooks'; +export { createRouteRef } from './RouteRef'; +export type { RouteRefConfig } from './RouteRef'; +export { createSubRouteRef } from './SubRouteRef'; export type { - RouteRef, AbsoluteRouteRef, ConcreteRoute, - MutableRouteRef, ExternalRouteRef, + MutableRouteRef, + RouteRef, + SubRouteRef, } from './types'; -export { FlatRoutes } from './FlatRoutes'; -export { createRouteRef } from './RouteRef'; -export { createSubRouteRef } from './SubRouteRef'; -export { createExternalRouteRef } from './ExternalRouteRef'; -export type { RouteRefConfig } from './RouteRef'; -export { useRouteRef } from './hooks'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 56b991f3af..1fabd39cc4 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -15,7 +15,7 @@ */ import { IconComponent } from '../icons'; -import { getGlobalSingleton } from '../lib/globalObject'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; export type AnyParams = { [param in string]: string } | undefined; export type ParamKeys = keyof Params extends never @@ -34,7 +34,7 @@ export type RouteFunc = ( ...[params]: Params extends undefined ? readonly [] : readonly [Params] ) => string; -export const routeRefType: unique symbol = getGlobalSingleton( +export const routeRefType: unique symbol = getOrCreateGlobalSingleton( 'route-ref-type', () => Symbol('route-ref-type'), ); diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7c658aa049..0f96f7279b 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/core +## 0.7.2 + +### Patch Changes + +- 8686eb38c: Add a `ResponseErrorPanel` to render `ResponseError` from `@backstage/errors` +- 9ca0e4009: use local version of lowerCase and upperCase methods +- 34ff49b0f: Allow extension components to also return `null` in addition to a `JSX.Element`. +- Updated dependencies [a51dc0006] +- Updated dependencies [e7f9b9435] +- Updated dependencies [0434853a5] +- Updated dependencies [34ff49b0f] +- Updated dependencies [d88dd219e] +- Updated dependencies [c8b54c370] + - @backstage/core-api@0.2.14 + - @backstage/config@0.1.4 + ## 0.7.1 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 4a95fe63e2..139238a538 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.1", + "version": "0.7.2", "private": false, "publishConfig": { "access": "public", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.3", - "@backstage/core-api": "^0.2.13", + "@backstage/config": "^0.1.4", + "@backstage/core-api": "^0.2.14", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -69,8 +69,8 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.4", - "@backstage/test-utils": "^0.1.8", + "@backstage/cli": "^0.6.5", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 9a8f7a36df..db8d0d0636 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -58,7 +58,10 @@ export const defaultConfigLoader: AppConfigLoader = async ( const configs = (appConfig.slice() as unknown) as AppConfig[]; // Avoiding this string also being replaced at runtime - if (runtimeConfigJson !== '__app_injected_runtime_config__'.toUpperCase()) { + if ( + runtimeConfigJson !== + '__app_injected_runtime_config__'.toLocaleUpperCase('en-US') + ) { try { const data = JSON.parse(runtimeConfigJson) as JsonObject; if (Array.isArray(data)) { diff --git a/plugins/techdocs/src/reader/hooks/shadowDom.ts b/packages/core/src/components/OverflowTooltip/OverflowTooltip.test.tsx similarity index 58% rename from plugins/techdocs/src/reader/hooks/shadowDom.ts rename to packages/core/src/components/OverflowTooltip/OverflowTooltip.test.tsx index 93e4c1d375..52f44bb4d0 100644 --- a/plugins/techdocs/src/reader/hooks/shadowDom.ts +++ b/packages/core/src/components/OverflowTooltip/OverflowTooltip.test.tsx @@ -14,18 +14,17 @@ * limitations under the License. */ -import { useEffect, useRef } from 'react'; -import type { RefObject } from 'react'; +import React from 'react'; +import { render } from '@testing-library/react'; -type IUseShadowDOM = () => [RefObject, ShadowRoot?]; +import { OverflowTooltip } from '.'; -export const useShadowDom: IUseShadowDOM = () => { - const ref = useRef(null); +describe('', () => { + it('renders without exploding', async () => { + render(); + }); - useEffect(() => { - const divElement = ref.current; - divElement?.attachShadow({ mode: 'open' }); - }, []); - - return [ref, ref.current?.shadowRoot || undefined]; -}; + it('renders without exploding when the text prop is missing', async () => { + render(); + }); +}); diff --git a/packages/core/src/components/OverflowTooltip/OverflowTooltip.tsx b/packages/core/src/components/OverflowTooltip/OverflowTooltip.tsx index faf064f9f4..b7bad6f377 100644 --- a/packages/core/src/components/OverflowTooltip/OverflowTooltip.tsx +++ b/packages/core/src/components/OverflowTooltip/OverflowTooltip.tsx @@ -42,7 +42,7 @@ export const OverflowTooltip = (props: Props) => { return ( diff --git a/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx b/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx index c36be78f08..52f7ab03f9 100644 --- a/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx +++ b/packages/core/src/components/ResponseErrorPanel/ResponseErrorPanel.tsx @@ -39,6 +39,78 @@ const useStyles = makeStyles(theme => ({ }, })); +type ResponseErrorListProps = { + error: string; + message: string; + request?: string; + stack?: string; + json?: string; +}; + +const ResponseErrorList = ({ + error, + request, + message, + stack, + json, +}: ResponseErrorListProps) => { + const classes = useStyles(); + + return ( + + + + + + + + + + {request && ( + + + + + )} + {stack && ( + + + + + )} + {json && ( + <> + + + } + /> + + + + )} + + ); +}; + type Props = { error: Error; }; @@ -50,13 +122,13 @@ type Props = { * server-provided information about what happened. */ export const ResponseErrorDetails = ({ error }: Props) => { - const classes = useStyles(); - if (error.name !== 'ResponseError') { return ( - - - + ); } @@ -70,53 +142,13 @@ export const ResponseErrorDetails = ({ error }: Props) => { const jsonString = JSON.stringify(data, undefined, 2); return ( - - - - - - - - - - {requestString && ( - - - - - )} - {stackString && ( - - - - - )} - - - } - /> - - - + ); }; diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index 64ceb761f8..3e2f73b7cb 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -213,8 +213,8 @@ export const SubvalueTable = () => { row: any, // Only needed if you want subvalue searchable ) => `${row.col1} ${row.subvalue}` - .toUpperCase() - .includes(query.toUpperCase()), + .toLocaleUpperCase('en-US') + .includes(query.toLocaleUpperCase('en-US')), field: 'col1', highlight: true, render: (row: any): React.ReactNode => ( diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index e82c49c49a..1faf3bb142 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -81,7 +81,7 @@ type Props = { }; const capitalize = (s: string) => { - return s.charAt(0).toUpperCase() + s.slice(1); + return s.charAt(0).toLocaleUpperCase('en-US') + s.slice(1); }; /** diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 6bbb5fa803..6794ba72b1 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,107 @@ # @backstage/create-app +## 0.3.14 + +### Patch Changes + +- 3385b374b: Supply a `scmIntegrationsApiRef` from the new `@backstage/integration-react`. + + This is a new facility that plugins will start to use. You will have to add it to your local `packages/app` as described below. If this is not done, runtime errors will be seen in the frontend, on the form `No API factory available for dependency apiRef{integration.scmintegrations}`. + + In `packages/app/package.json`: + + ```diff + "dependencies": { + + "@backstage/integration-react": "^0.1.1", + ``` + + In `packages/app/src/apis.ts`: + + ```diff + +import { + + scmIntegrationsApiRef, + + ScmIntegrationsApi, + +} from '@backstage/integration-react'; + + export const apis: AnyApiFactory[] = [ + + createApiFactory({ + + api: scmIntegrationsApiRef, + + deps: { configApi: configApiRef }, + + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + + }), + ``` + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- 028339210: Adds example groups and users to the default app template. + + To apply this change in an existing application, change the following in `app-config.yaml`: + + ```diff + - type: url + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.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 + target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml + ``` + +- Updated dependencies [010aed784] +- Updated dependencies [633a31fec] +- Updated dependencies [8686eb38c] +- Updated dependencies [34e6bb409] +- Updated dependencies [b56815b40] +- Updated dependencies [147b4c5b1] +- Updated dependencies [83bfc98a3] +- Updated dependencies [7d8c4c97c] +- Updated dependencies [e7baa0d2e] +- Updated dependencies [8b4f7e42a] +- Updated dependencies [8686eb38c] +- Updated dependencies [84972540b] +- Updated dependencies [3385b374b] +- Updated dependencies [0434853a5] +- Updated dependencies [a0dacc184] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [4bc98a5b9] +- Updated dependencies [34ff49b0f] +- Updated dependencies [d2f4efc5d] +- Updated dependencies [8686eb38c] +- Updated dependencies [424742dc1] +- Updated dependencies [c8b54c370] +- Updated dependencies [4e0b5055a] +- Updated dependencies [8b5e59750] +- Updated dependencies [8686eb38c] + - @backstage/plugin-catalog-backend@0.6.6 + - @backstage/plugin-catalog@0.5.0 + - @backstage/catalog-client@0.3.8 + - @backstage/plugin-tech-radar@0.3.8 + - @backstage/plugin-user-settings@0.2.8 + - @backstage/plugin-techdocs@0.6.2 + - @backstage/plugin-catalog-import@0.5.0 + - @backstage/plugin-techdocs-backend@0.6.5 + - @backstage/plugin-scaffolder-backend@0.9.2 + - @backstage/backend-common@0.6.0 + - @backstage/cli@0.6.5 + - @backstage/plugin-scaffolder@0.8.0 + - @backstage/config@0.1.4 + - @backstage/core@0.7.2 + - @backstage/plugin-api-docs@0.4.9 + - @backstage/plugin-explore@0.3.2 + - @backstage/plugin-github-actions@0.4.1 + - @backstage/plugin-lighthouse@0.2.14 + - @backstage/plugin-search@0.3.4 + - @backstage/plugin-auth-backend@0.3.5 + - @backstage/test-utils@0.1.9 + - @backstage/plugin-app-backend@0.3.10 + - @backstage/plugin-proxy-backend@0.2.6 + - @backstage/plugin-rollbar-backend@0.1.8 + ## 0.3.13 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 4a78ce8715..8a947ef9a1 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.13", + "version": "0.3.14", "private": false, "publishConfig": { "access": "public" @@ -69,6 +69,7 @@ "@backstage/plugin-techdocs": "*", "@backstage/plugin-techdocs-backend": "*", "@backstage/plugin-user-settings": "*", + "@backstage/integration-react": "*", "@backstage/test-utils": "*", "@backstage/theme": "*" }, diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index 775d53dfdc..8a269416a3 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -110,7 +110,7 @@ export default async (cmd: Command): Promise => { name: 'dbType', message: chalk.blue('Select database for the backend [required]'), // @ts-ignore - choices: ['PostgreSQL', 'SQLite'], + choices: ['SQLite', 'PostgreSQL'], }, ]; const answers: Answers = await inquirer.prompt(questions); diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 32a87bb323..2a133b4bf8 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -37,6 +37,7 @@ import { version as cli } from '../../../cli/package.json'; import { version as config } from '../../../config/package.json'; import { version as core } from '../../../core/package.json'; import { version as errors } from '../../../errors/package.json'; +import { version as integrationReact } from '../../../integration-react/package.json'; import { version as testUtils } from '../../../test-utils/package.json'; import { version as theme } from '../../../theme/package.json'; @@ -51,6 +52,7 @@ import { version as pluginCircleci } from '../../../../plugins/circleci/package. import { version as pluginExplore } from '../../../../plugins/explore/package.json'; import { version as pluginGithubActions } from '../../../../plugins/github-actions/package.json'; import { version as pluginLighthouse } from '../../../../plugins/lighthouse/package.json'; +import { version as pluginOrg } from '../../../../plugins/org/package.json'; import { version as pluginProxyBackend } from '../../../../plugins/proxy-backend/package.json'; import { version as pluginRollbarBackend } from '../../../../plugins/rollbar-backend/package.json'; import { version as pluginScaffolder } from '../../../../plugins/scaffolder/package.json'; @@ -69,6 +71,7 @@ export const packageVersions = { '@backstage/config': config, '@backstage/core': core, '@backstage/errors': errors, + '@backstage/integration-react': integrationReact, '@backstage/plugin-api-docs': pluginApiDocs, '@backstage/plugin-app-backend': pluginAppBackend, '@backstage/plugin-auth-backend': pluginAuthBackend, @@ -80,6 +83,7 @@ export const packageVersions = { '@backstage/plugin-explore': pluginExplore, '@backstage/plugin-github-actions': pluginGithubActions, '@backstage/plugin-lighthouse': pluginLighthouse, + '@backstage/plugin-org': pluginOrg, '@backstage/plugin-proxy-backend': pluginProxyBackend, '@backstage/plugin-rollbar-backend': pluginRollbarBackend, '@backstage/plugin-scaffolder': pluginScaffolder, 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 f8a345cd1e..e7e44c9961 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -79,16 +79,26 @@ scaffolder: catalog: rules: - - allow: [Component, API, Group, User, Template, Location] + - allow: [Component, System, API, Group, User, Template, Location] locations: # Backstage example components - type: url target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml + # Backstage example systems + - type: url + target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-systems.yaml + # Backstage example APIs - type: url target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-apis.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 target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml 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 10701d65eb..97ff521a89 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 @@ -4,23 +4,25 @@ "private": true, "bundled": true, "dependencies": { - "@material-ui/core": "^4.11.0", - "@material-ui/icons": "^4.9.1", + "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", "@backstage/cli": "^{{version '@backstage/cli'}}", "@backstage/core": "^{{version '@backstage/core'}}", + "@backstage/integration-react": "^{{version '@backstage/integration-react'}}", "@backstage/plugin-api-docs": "^{{version '@backstage/plugin-api-docs'}}", "@backstage/plugin-catalog": "^{{version '@backstage/plugin-catalog'}}", - "@backstage/plugin-catalog-react": "^{{version '@backstage/plugin-catalog-react'}}", "@backstage/plugin-catalog-import": "^{{version '@backstage/plugin-catalog-import'}}", - "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", - "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", - "@backstage/catalog-model": "^{{version '@backstage/catalog-model'}}", - "@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}", + "@backstage/plugin-catalog-react": "^{{version '@backstage/plugin-catalog-react'}}", "@backstage/plugin-github-actions": "^{{version '@backstage/plugin-github-actions'}}", - "@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}", + "@backstage/plugin-org": "^{{version '@backstage/plugin-org'}}", + "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", "@backstage/plugin-search": "^{{version '@backstage/plugin-search'}}", + "@backstage/plugin-tech-radar": "^{{version '@backstage/plugin-tech-radar'}}", + "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", + "@backstage/plugin-user-settings": "^{{version '@backstage/plugin-user-settings'}}", "@backstage/test-utils": "^{{version '@backstage/test-utils'}}", "@backstage/theme": "^{{version '@backstage/theme'}}", + "@material-ui/core": "^4.11.0", + "@material-ui/icons": "^4.9.1", "history": "^5.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index b2c9d10eb0..1cd7b77965 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -1,9 +1,10 @@ +import React from 'react'; +import { Navigate, Route } from 'react-router'; import { AlertDisplay, createApp, FlatRoutes, OAuthRequestDialog, - SidebarPage, } from '@backstage/core'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; import { @@ -17,12 +18,10 @@ import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs'; import { UserSettingsPage } from '@backstage/plugin-user-settings'; -import React from 'react'; -import { Navigate, Route } from 'react-router'; import { apis } from './apis'; import { EntityPage } from './components/catalog/EntityPage'; +import { Root } from './components/Root'; import * as plugins from './plugins'; -import { AppSidebar } from './sidebar'; const app = createApp({ apis, @@ -40,34 +39,35 @@ const app = createApp({ const AppProvider = app.getProvider(); const AppRouter = app.getRouter(); +const routes = ( + + + } /> + } + > + + + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + +); + const App = () => ( - - - - - } /> - } - > - - - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - - + {routes} ); diff --git a/packages/create-app/templates/default-app/packages/app/src/apis.ts b/packages/create-app/templates/default-app/packages/app/src/apis.ts index 24b22a83bc..d803416c3f 100644 --- a/packages/create-app/templates/default-app/packages/app/src/apis.ts +++ b/packages/create-app/templates/default-app/packages/app/src/apis.ts @@ -1 +1,14 @@ -export const apis = []; +import { + AnyApiFactory, configApiRef, createApiFactory +} from '@backstage/core'; +import { + ScmIntegrationsApi, scmIntegrationsApiRef +} from '@backstage/integration-react'; + +export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + }), +]; diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx b/packages/create-app/templates/default-app/packages/app/src/components/Root/LogoFull.tsx similarity index 100% rename from packages/create-app/templates/default-app/packages/app/src/LogoFull.tsx rename to packages/create-app/templates/default-app/packages/app/src/components/Root/LogoFull.tsx diff --git a/packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx b/packages/create-app/templates/default-app/packages/app/src/components/Root/LogoIcon.tsx similarity index 100% rename from packages/create-app/templates/default-app/packages/app/src/LogoIcon.tsx rename to packages/create-app/templates/default-app/packages/app/src/components/Root/LogoIcon.tsx diff --git a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx similarity index 50% rename from packages/create-app/templates/default-app/packages/app/src/sidebar.tsx rename to packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx index 9f7973c387..9c07e0c5e2 100644 --- a/packages/create-app/templates/default-app/packages/app/src/sidebar.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/Root/Root.tsx @@ -1,26 +1,41 @@ -import React, { useContext } from 'react'; -import HomeIcon from '@material-ui/icons/Home'; -import LibraryBooks from '@material-ui/icons/LibraryBooks'; -import ExtensionIcon from '@material-ui/icons/Extension'; -import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import MapIcon from '@material-ui/icons/MyLocation'; +/* + * 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, { useContext, PropsWithChildren } from 'react'; import { Link, makeStyles } from '@material-ui/core'; -import { NavLink } from 'react-router-dom'; +import HomeIcon from '@material-ui/icons/Home'; +import ExtensionIcon from '@material-ui/icons/Extension'; +import MapIcon from '@material-ui/icons/MyLocation'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; -import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; - import { Sidebar, - SidebarItem, - SidebarDivider, + SidebarPage, sidebarConfig, SidebarContext, + SidebarItem, + SidebarDivider, SidebarSpace, } from '@backstage/core'; +import { NavLink } from 'react-router-dom'; +import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; import { SidebarSearch } from '@backstage/plugin-search'; - const useSidebarLogoStyles = makeStyles({ root: { width: sidebarConfig.drawerWidthClosed, @@ -54,22 +69,24 @@ const SidebarLogo = () => { ); }; -export const AppSidebar = () => ( - - - - - {/* Global nav, not org-specific */} - - - - - - - {/* End global nav */} - - - - - +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + + + + {/* Global nav, not org-specific */} + + + + + {/* End global nav */} + + + + + + + {children} + ); diff --git a/plugins/techdocs/src/reader/hooks/index.ts b/packages/create-app/templates/default-app/packages/app/src/components/Root/index.ts similarity index 93% rename from plugins/techdocs/src/reader/hooks/index.ts rename to packages/create-app/templates/default-app/packages/app/src/components/Root/index.ts index f66c5303ed..ab65cb2451 100644 --- a/plugins/techdocs/src/reader/hooks/index.ts +++ b/packages/create-app/templates/default-app/packages/app/src/components/Root/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { useShadowDom } from './shadowDom'; +export { Root } from './Root'; 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 de6ab2b6b5..d05f6bfa8d 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 @@ -13,27 +13,43 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ApiEntity, Entity } from '@backstage/catalog-model'; -import { WarningPanel } from '@backstage/core'; +import React from 'react'; +import { Button, Grid } from '@material-ui/core'; +import { + ApiEntity, + DomainEntity, + Entity, + GroupEntity, + SystemEntity, + UserEntity, +} from '@backstage/catalog-model'; +import { EmptyState } from '@backstage/core'; import { ApiDefinitionCard, ConsumedApisCard, ConsumingComponentsCard, + EntityHasApisCard, ProvidedApisCard, - ProvidingComponentsCard + ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { - AboutCard, EntityPageLayout, + AboutCard, + EntityHasComponentsCard, + EntityHasSystemsCard, + EntityPageLayout, } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { - useEntity -} from '@backstage/plugin-catalog-react'; -import { - isPluginApplicableToEntity as isGitHubActionsAvailable, Router as GitHubActionsRouter + isPluginApplicableToEntity as isGitHubActionsAvailable, + Router as GitHubActionsRouter, } from '@backstage/plugin-github-actions'; +import { + GroupProfileCard, + MembersListCard, + OwnershipCard, + UserProfileCard, +} from '@backstage/plugin-org'; import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; -import { Grid } from '@material-ui/core'; -import React from 'react'; const CICDSwitcher = ({ entity }: { entity: Entity }) => { @@ -44,17 +60,27 @@ const CICDSwitcher = ({ entity }: { entity: Entity }) => { return ; default: return ( - - No CI/CD is available for this entity. Check corresponding - annotations! - + + Read more + + } + /> ); } }; const OverviewContent = ({ entity }: { entity: Entity }) => ( - + @@ -181,14 +207,115 @@ const ApiEntityPage = ({ entity }: { entity: Entity }) => ( ); +const UserOverviewContent = ({ entity }: { entity: UserEntity }) => ( + + + + + + + + +); + +const UserEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const GroupOverviewContent = ({ entity }: { entity: GroupEntity }) => ( + + + + + + + + + + + +); + +const GroupEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => ( + + + + + + + + + + + +); + +const SystemEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + +const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => ( + + + + + + + + +); + +const DomainEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> + +); + export const EntityPage = () => { const { entity } = useEntity(); - switch (entity?.kind?.toLowerCase()) { + switch (entity?.kind?.toLocaleLowerCase('en-US')) { case 'component': return ; case 'api': return ; + case 'group': + return ; + case 'user': + return ; + case 'system': + return ; + case 'domain': + return ; + case 'location': + case 'resource': + case 'template': default: return ; } diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index 8c8cfae0b3..138bcaec8d 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -32,7 +32,7 @@ "@octokit/rest": "^18.0.12", "dockerode": "^3.2.1", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "knex": "^0.21.6", {{#if dbTypePG}} "pg": "^8.3.0", diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts index 883b70c587..556b385af3 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts @@ -13,6 +13,7 @@ export default async function createPlugin(env: PluginEnvironment): Promise Page belonging to another plugin.; attachComponentData(DummyPage, 'core.mountPoint', dummyRouteRef); + const apis = [...this.apis]; + if (!apis.some(api => api.api.id === scmIntegrationsApiRef.id)) { + apis.push( + createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), + }), + ); + } + const app = createApp({ apis: this.apis, plugins: this.plugins, diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index 851dccae16..c055bcd21e 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -203,7 +203,7 @@ async function createApp( await waitFor(() => stdout.includes('Select database for the backend')); - if (!isPostgres) { + if (isPostgres) { // Simulate down arrow press child.stdin?.write(`\u001B\u005B\u0042`); } diff --git a/packages/integration-react/.eslintrc.js b/packages/integration-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/packages/integration-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/packages/integration-react/README.md b/packages/integration-react/README.md new file mode 100644 index 0000000000..99ecf30b74 --- /dev/null +++ b/packages/integration-react/README.md @@ -0,0 +1,5 @@ +# Integrations React-specific functionality + +Exposes a frontend API for interacting with configured integrations (as added +under the `integrations` root config key). Most of the actual code is in the +`@backstage/integration` package. diff --git a/packages/integration-react/dev/DevPage.tsx b/packages/integration-react/dev/DevPage.tsx new file mode 100644 index 0000000000..f86cc35fc5 --- /dev/null +++ b/packages/integration-react/dev/DevPage.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 { Content, useApi } from '@backstage/core'; +import { ScmIntegration, ScmIntegrationsGroup } from '@backstage/integration'; +import { Typography } from '@material-ui/core'; +import React from 'react'; +import { scmIntegrationsApiRef } from '../src/ScmIntegrationsApi'; + +const Integrations = (props: { + group: ScmIntegrationsGroup; +}) => { + const integrations = props.group.list(); + if (!integrations) { + return ( + + No integrations of this type + + ); + } + + return ( + +
{JSON.stringify(integrations, undefined, 2)}
+
+ ); +}; + +export const DevPage = () => { + const integrations = useApi(scmIntegrationsApiRef); + return ( + + + Azure + + + + Bitbucket + + + + GitHub + + + + GitLab + + + + ); +}; diff --git a/packages/integration-react/dev/index.tsx b/packages/integration-react/dev/index.tsx new file mode 100644 index 0000000000..34cfee3fd8 --- /dev/null +++ b/packages/integration-react/dev/index.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 { configApiRef, createApiFactory } from '@backstage/core'; +import { createDevApp } from '@backstage/dev-utils'; +import { ScmIntegrations } from '@backstage/integration'; +import React from 'react'; +import { scmIntegrationsApiRef } from '../src/ScmIntegrationsApi'; +import { DevPage } from './DevPage'; + +createDevApp() + .registerApi( + createApiFactory({ + api: scmIntegrationsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => ScmIntegrations.fromConfig(configApi), + }), + ) + .addPage({ + element: , + title: 'Root Page', + }) + .render(); diff --git a/packages/integration-react/package.json b/packages/integration-react/package.json new file mode 100644 index 0000000000..9f33a25ac8 --- /dev/null +++ b/packages/integration-react/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/integration-react", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.2", + "@backstage/core": "^0.7.0", + "@backstage/integration": "^0.5.0", + "@backstage/theme": "^0.2.3", + "@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" + }, + "devDependencies": { + "@backstage/cli": "^0.6.3", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.8", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" + }, + "files": [ + "dist" + ] +} diff --git a/packages/integration-react/src/ScmIntegrationsApi.test.ts b/packages/integration-react/src/ScmIntegrationsApi.test.ts new file mode 100644 index 0000000000..3788574950 --- /dev/null +++ b/packages/integration-react/src/ScmIntegrationsApi.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 { ConfigReader } from '@backstage/config'; +import { + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from './ScmIntegrationsApi'; + +describe('scmIntegrationsApiRef', () => { + it('should export api', () => { + expect(scmIntegrationsApiRef).toBeDefined(); + }); + + it('should be instantiated', () => { + const i = ScmIntegrationsApi.fromConfig(new ConfigReader({})); + expect(i.list().length).toBe(4); // The default ones + }); +}); diff --git a/packages/integration-react/src/ScmIntegrationsApi.ts b/packages/integration-react/src/ScmIntegrationsApi.ts new file mode 100644 index 0000000000..507ecd8913 --- /dev/null +++ b/packages/integration-react/src/ScmIntegrationsApi.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 { Config } from '@backstage/config'; +import { ApiRef, createApiRef } from '@backstage/core'; +import { + ScmIntegrationRegistry, + ScmIntegrations, +} from '@backstage/integration'; + +export class ScmIntegrationsApi { + static fromConfig(config: Config): ScmIntegrationRegistry { + return ScmIntegrations.fromConfig(config); + } +} + +export const scmIntegrationsApiRef: ApiRef = createApiRef( + { + id: 'integration.scmintegrations', + description: 'All of the registered SCM integrations of your config', + }, +); diff --git a/packages/integration-react/src/index.ts b/packages/integration-react/src/index.ts new file mode 100644 index 0000000000..5483765b2e --- /dev/null +++ b/packages/integration-react/src/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from './ScmIntegrationsApi'; diff --git a/packages/integration-react/src/setupTests.ts b/packages/integration-react/src/setupTests.ts new file mode 100644 index 0000000000..3ffe1424cc --- /dev/null +++ b/packages/integration-react/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/packages/integration/src/index.ts b/packages/integration/src/index.ts index c39608fe8e..e89cf6ef92 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -20,4 +20,8 @@ export * from './github'; export * from './gitlab'; export { defaultScmResolveUrl } from './helpers'; export { ScmIntegrations } from './ScmIntegrations'; -export type { ScmIntegration, ScmIntegrationRegistry } from './types'; +export type { + ScmIntegration, + ScmIntegrationRegistry, + ScmIntegrationsGroup, +} from './types'; diff --git a/packages/search-common/.eslintrc.js b/packages/search-common/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/search-common/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/search-common/README.md b/packages/search-common/README.md new file mode 100644 index 0000000000..66828cc7af --- /dev/null +++ b/packages/search-common/README.md @@ -0,0 +1,3 @@ +# @backstage/search-common + +Common functionalities for Search, to be shared between various search-enabled plugins. diff --git a/packages/search-common/package.json b/packages/search-common/package.json new file mode 100644 index 0000000000..18e3557a49 --- /dev/null +++ b/packages/search-common/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/search-common", + "description": "Common functionalities for Search, to be shared between various search-enabled plugins", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/search-common" + }, + "keywords": [ + "backstage", + "search" + ], + "license": "Apache-2.0", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli build --outputs cjs,types", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "bugs": { + "url": "https://github.com/backstage/backstage/issues" + }, + "dependencies": { + "@backstage/config": "^0.1.3" + }, + "devDependencies": { + "@backstage/cli": "^0.6.2" + }, + "jest": { + "roots": [ + ".." + ] + } +} diff --git a/packages/search-common/src/index.test.ts b/packages/search-common/src/index.test.ts new file mode 100644 index 0000000000..9e32ffa412 --- /dev/null +++ b/packages/search-common/src/index.test.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 * as anything from './'; + +describe('search-common', () => { + // TODO: Test real things once they exist. + it('should exist', () => { + expect(anything).toBeTruthy(); + }); +}); diff --git a/packages/search-common/src/index.ts b/packages/search-common/src/index.ts new file mode 100644 index 0000000000..e6432477f2 --- /dev/null +++ b/packages/search-common/src/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 './types'; diff --git a/packages/search-common/src/types.ts b/packages/search-common/src/types.ts new file mode 100644 index 0000000000..ad784c3b36 --- /dev/null +++ b/packages/search-common/src/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. + */ +import { JsonObject } from '@backstage/config'; + +export interface SearchQuery { + term: string; + filters?: JsonObject; + pageCursor: string; +} + +export interface SearchResult { + document: IndexableDocument; +} + +export interface SearchResultSet { + results: SearchResult[]; +} + +/** + * Base properties that all indexed documents must include, as well as some + * common properties that documents are encouraged to use where appropriate. + */ +export interface IndexableDocument { + /** + * The primary name of the document (e.g. name, title, identifier, etc). + */ + title: string; + + /** + * Free-form text of the document (e.g. description, content, etc). + */ + text: string; + + /** + * The relative or absolute URL of the document (target when a search result + * is clicked). + */ + location: string; +} + +/** + * Interface that must be implemented in order to expose new documents to + * search. + */ +export interface DocumentCollator { + execute(): Promise; +} + +/** + * Interface that must be implemented in order to decorate existing documents with + * additional metadata. + */ +export interface DocumentDecorator { + execute(documents: IndexableDocument[]): Promise; +} diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index c9d87fe096..84b1be1fd5 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,27 @@ # @backstage/techdocs-common +## 0.4.5 + +### Patch Changes + +- 8686eb38c: Use errors from `@backstage/errors` +- 424742dc1: Applies only if you use TechDocs local builder instead of building on CI/CD i.e. if `techdocs.builder` in your `app-config.yaml` is set to `'local'` + + Improvements + + 1. Do not check for updates in the repository if a check has been made in the last 60 seconds. This is to prevent the annoying check for update on every page switch or load. + 2. No need to maintain an in-memory etag storage, and use the one stored in `techdocs_metadata.json` file alongside generated docs. + + New feature + + 1. You can now use a mix of basic and recommended setup i.e. `techdocs.builder` is `'local'` but using an external cloud storage instead of local storage. Previously, in this setup, the docs would never get updated. + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.4.4 ### Patch Changes diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index bf17cc3d9d..fe13a0fc88 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.4.4", + "version": "0.4.5", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -38,9 +38,9 @@ "dependencies": { "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", - "@backstage/backend-common": "^0.5.6", + "@backstage/backend-common": "^0.6.0", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@google-cloud/storage": "^5.6.0", @@ -62,10 +62,10 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", - "@types/js-yaml": "^3.12.5", + "@types/js-yaml": "^4.0.0", "@types/mime-types": "^2.1.0", "@types/mock-fs": "^4.13.0", "@types/pkgcloud": "^1.7.4", diff --git a/packages/test-utils/CHANGELOG.md b/packages/test-utils/CHANGELOG.md index 50f656ec67..bcdc94abbb 100644 --- a/packages/test-utils/CHANGELOG.md +++ b/packages/test-utils/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/test-utils +## 0.1.9 + +### Patch Changes + +- 4e0b5055a: Allow `ExternalRouteRef` bindings in `mountedRoutes` to work with the latest version of core-api. +- Updated dependencies [a51dc0006] +- Updated dependencies [e7f9b9435] +- Updated dependencies [34ff49b0f] +- Updated dependencies [d88dd219e] +- Updated dependencies [c8b54c370] + - @backstage/core-api@0.2.14 + ## 0.1.8 ### Patch Changes diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 52c1a620c6..f3a979fc72 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.8", + "version": "0.1.9", "private": false, "publishConfig": { "access": "public", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core-api": "^0.2.11", + "@backstage/core-api": "^0.2.14", "@backstage/test-utils-core": "^0.1.1", "@backstage/theme": "^0.2.3", "@material-ui/core": "^4.11.0", @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.2", + "@backstage/cli": "^0.6.5", "@types/jest": "^26.0.7", "@types/node": "^14.14.32" }, diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index 4899f07061..1283128df2 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -14,20 +14,22 @@ * limitations under the License. */ -import React, { useEffect } from 'react'; -import { render } from '@testing-library/react'; -import { wrapInTestApp, renderInTestApp } from './appWrappers'; -import { Route, Routes } from 'react-router'; -import { withLogCollector } from '@backstage/test-utils-core'; import { - useApi, - useRouteRef, - errorApiRef, ApiProvider, ApiRegistry, + createExternalRouteRef, createRouteRef, + createSubRouteRef, + errorApiRef, + useApi, + useRouteRef, } from '@backstage/core-api'; +import { withLogCollector } from '@backstage/test-utils-core'; +import { render } from '@testing-library/react'; +import React, { useEffect } from 'react'; +import { Route, Routes } from 'react-router'; import { MockErrorApi } from './apis'; +import { renderInTestApp, wrapInTestApp } from './appWrappers'; describe('wrapInTestApp', () => { it('should provide routing and warn about missing act()', async () => { @@ -117,16 +119,29 @@ describe('wrapInTestApp', () => { }); it('should allow route refs to be mounted on specific paths', async () => { - const aRouteRef = createRouteRef({ title: 'A' }); - const bRouteRef = createRouteRef({ title: 'B', params: ['name'] }); + const aRouteRef = createRouteRef({ id: 'A' }); + const bRouteRef = createRouteRef({ id: 'B', params: ['name'] }); + const subRouteRef = createSubRouteRef({ + id: 'S', + parent: bRouteRef, + path: '/:page', + }); + const externalRouteRef = createExternalRouteRef({ + id: 'E', + params: ['name'], + }); const MyComponent = () => { const a = useRouteRef(aRouteRef); const b = useRouteRef(bRouteRef); + const s = useRouteRef(subRouteRef); + const e = useRouteRef(externalRouteRef); return (
Link A: {a()}
Link B: {b({ name: 'x' })}
+
Link S: {s({ name: 'y', page: 'p' })}
+
Link E: {e({ name: 'z' })}
); }; @@ -135,9 +150,12 @@ describe('wrapInTestApp', () => { mountedRoutes: { '/my-a-path': aRouteRef, '/my-b-path/:name': bRouteRef, + '/my-e-path/:name': externalRouteRef, }, }); expect(rendered.getByText('Link A: /my-a-path')).toBeInTheDocument(); expect(rendered.getByText('Link B: /my-b-path/x')).toBeInTheDocument(); + expect(rendered.getByText('Link S: /my-b-path/y/p')).toBeInTheDocument(); + expect(rendered.getByText('Link E: /my-e-path/z')).toBeInTheDocument(); }); }); diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index fd13343ca8..0cdd41508c 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -102,7 +102,10 @@ export const darkTheme = createTheme({ }, }, primary: { - main: '#2E77D0', + main: '#8CB4E5', + }, + secondary: { + main: '#FF88B2', }, banner: { info: '#2E77D0', diff --git a/plugins/api-docs/CHANGELOG.md b/plugins/api-docs/CHANGELOG.md index cd4cd82831..c70db48b7e 100644 --- a/plugins/api-docs/CHANGELOG.md +++ b/plugins/api-docs/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-api-docs +## 0.4.9 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.4.8 ### Patch Changes diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 04150ce03c..635b6bf3b8 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.8", + "version": "0.4.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ "dependencies": { "@asyncapi/react-component": "^0.19.2", "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/core": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", @@ -49,9 +49,9 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx index 9c3203d532..7cc0257e85 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -74,7 +74,8 @@ export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { emptyContent={
- This {entity.kind.toLowerCase()} does not consume any APIs. + This {entity.kind.toLocaleLowerCase('en-US')} does not consume any + APIs. diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx index beeb4d051e..e6da61fcd9 100644 --- a/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.tsx @@ -78,7 +78,8 @@ export const HasApisCard = ({ variant = 'gridItem' }: Props) => { emptyContent={
- This {entity.kind.toLowerCase()} does not contain any APIs. + This {entity.kind.toLocaleLowerCase('en-US')} does not contain any + APIs. diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx index 2447054b89..6659d7d85e 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -74,7 +74,8 @@ export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { emptyContent={
- This {entity.kind.toLowerCase()} does not provide any APIs. + This {entity.kind.toLocaleLowerCase('en-US')} does not provide any + APIs. diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 23d6f0037e..e8c4510d54 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-app-backend +## 0.3.10 + +### Patch Changes + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.3.9 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index 85d733abba..132437597b 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.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,18 +29,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.6", + "@backstage/backend-common": "^0.6.0", "@backstage/config-loader": "^0.5.1", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@types/express": "^4.17.6", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "fs-extra": "^9.0.0", "winston": "^3.2.1", "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^4.0.2" diff --git a/plugins/app-backend/src/lib/config.test.ts b/plugins/app-backend/src/lib/config.test.ts index 95b34c8422..4018766ac9 100644 --- a/plugins/app-backend/src/lib/config.test.ts +++ b/plugins/app-backend/src/lib/config.test.ts @@ -36,7 +36,7 @@ const baseOptions = { describe('injectConfig', () => { beforeEach(() => { - fsMock.readdir.mockResolvedValue(['main.js']); + fsMock.readdir.mockResolvedValue(['main.js'] as any); }); afterEach(() => { @@ -44,7 +44,7 @@ describe('injectConfig', () => { }); it('should inject without config', async () => { - fsMock.readdir.mockResolvedValue(['main.js']); + fsMock.readdir.mockResolvedValue(['main.js'] as any); readFileMock.mockImplementation( async () => '"__APP_INJECTED_RUNTIME_CONFIG__"', ); @@ -68,7 +68,7 @@ describe('injectConfig', () => { 'not-js.txt', 'main.js', 'after.js', - ]); + ] as any); readFileMock.mockImplementation(async (file: string) => { if (file.endsWith('main.js')) { return '"__APP_INJECTED_RUNTIME_CONFIG__"'; @@ -111,7 +111,7 @@ describe('injectConfig', () => { }); it('should re-inject config', async () => { - fsMock.readdir.mockResolvedValue(['main.js']); + fsMock.readdir.mockResolvedValue(['main.js'] as any); readFileMock.mockResolvedValue( 'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")', ); diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 1f2fd4de47..cf0a574e9c 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-auth-backend +## 0.3.5 + +### Patch Changes + +- 8686eb38c: Use errors from `@backstage/errors` +- 8b5e59750: expose verifyNonce and readState publicly from auth-backend +- Updated dependencies [8686eb38c] +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [4e0b5055a] +- Updated dependencies [8686eb38c] + - @backstage/catalog-client@0.3.8 + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + - @backstage/test-utils@0.1.9 + ## 0.3.4 ### Patch Changes diff --git a/plugins/auth-backend/config.d.ts b/plugins/auth-backend/config.d.ts index 9850e05235..64de6cdd50 100644 --- a/plugins/auth-backend/config.d.ts +++ b/plugins/auth-backend/config.d.ts @@ -36,13 +36,13 @@ export interface Config { */ providers?: { google?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; github?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; gitlab?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; saml?: { entryPoint: string; @@ -55,10 +55,10 @@ export interface Config { digestAlgorithm?: string; }; okta?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; oauth2?: { - development: { + [authEnv: string]: { clientId: string; clientSecret: string; authorizationUrl: string; @@ -67,16 +67,16 @@ export interface Config { }; }; oidc?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; auth0?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; microsoft?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; onelogin?: { - development: { [key: string]: string }; + [authEnv: string]: { [key: string]: string }; }; awsalb?: { issuer?: string; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index f1c334cf75..2e6b1af3db 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.4", + "version": "0.3.5", "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.5.6", - "@backstage/catalog-client": "^0.3.7", + "@backstage/backend-common": "^0.6.0", + "@backstage/catalog-client": "^0.3.8", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@types/express": "^4.17.6", "@types/passport": "^1.0.3", "compression": "^1.7.4", @@ -42,7 +42,7 @@ "cors": "^2.8.5", "cross-fetch": "^3.0.6", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "express-session": "^1.17.1", "fs-extra": "^9.0.0", "got": "^11.5.2", @@ -68,7 +68,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@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/index.ts b/plugins/auth-backend/src/lib/oauth/index.ts index bf3e0658e4..6bbe017eb3 100644 --- a/plugins/auth-backend/src/lib/oauth/index.ts +++ b/plugins/auth-backend/src/lib/oauth/index.ts @@ -16,7 +16,7 @@ export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; export { OAuthAdapter } from './OAuthAdapter'; -export { encodeState } from './helpers'; +export { encodeState, verifyNonce, readState } from './helpers'; export type { OAuthHandlers, OAuthProviderInfo, diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 630cad0554..b8de398af5 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -122,7 +122,7 @@ export const createGitlabProvider = ( OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); + const audience = envConfig.getOptionalString('audience'); const baseUrl = audience || 'https://gitlab.com'; const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; diff --git a/plugins/badges-backend/.eslintrc.js b/plugins/badges-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/badges-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/badges-backend/README.md b/plugins/badges-backend/README.md new file mode 100644 index 0000000000..e64360656d --- /dev/null +++ b/plugins/badges-backend/README.md @@ -0,0 +1,98 @@ +# Badges Backend + +Backend plugin for serving badges to the `@backstage/plugin-badges` plugin. +Default implementation uses +[badge-maker](https://www.npmjs.com/package/badge-maker) for creating the +badges, in SVG. + +Currently, only entity badges are implemented. i.e. badges that may have entity +specific information in them, and as such, are served from an entity specific +endpoint. + +## Installation + +Install the `@backstage/plugin-badges-backend` package in your backend package, +and then integrate the plugin using the following default setup for +`src/plugins/badges.ts`: + +```ts +import { + createRouter, + createDefaultBadgeFactories, +} from '@backstage/plugin-badges-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + config, + discovery, +}: PluginEnvironment) { + return await createRouter({ + config, + discovery, + badgeFactories: createDefaultBadgeFactories(), + }); +} +``` + +The `createDefaultBadgeFactories()` returns an object with badge factories to +the badges-backend `createRouter()` to forward to the default badge builder. To +customize the available badges, provide a custom set of badge factories. See +further down for an example of a custom badge factories function. + +## Badge builder + +Badges are created by classes implementing the `BadgeBuilder` type. The default +badge builder uses badge factories to turn a `BadgeContext` into a `Badge` spec +for the `badge-maker` to create the SVG image. + +### Default badges + +A set of default badge factories are defined in +[badges.ts](https://github.com/backstage/backstage/tree/master/plugins/badges-backend/src/badges.ts) +as examples. + +Additional badges may be provided in your application by defining custom badge +factories, and provide them to the badge builder. + +### Custom badges + +To provide custom badges, create a badge factories function, and use that when +creating the badges backend router. + +```ts +import type { Badge, BadgeContext, BadgeFactories } from '@backstage/plugin-badges-backend'; +export const createMyCustomBadgeFactories = (): BadgeFactories => ({ + : { + createBadge: (context: BadgeContext): Badge | null => { + // ... + return { + label: 'my-badge', + message: 'custom stuff', + // ... + }; + }, + }, + + // optional: include the default badges + // ...createDefaultBadgeFactories(), +}); +``` + +## API + +The badges backend api exposes two main endpoints for entity badges. The +`/badges` prefix is arbitrary, and the default for the example backend. + +- `/badges/entity/:namespace/:kind/:name/badge-specs` List all defined badges + for a particular entity, in json format. See + [BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts) + from the frontend plugin for a type declaration. + +- `/badges/entity/:namespace/:kind/:name/:badgeId` Get the entity badge as an + SVG image. If the `accept` request header prefers `application/json` the badge + spec as JSON will be returned instead of the image. + +## Links + +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/badges-backend/package.json b/plugins/badges-backend/package.json new file mode 100644 index 0000000000..5694adef70 --- /dev/null +++ b/plugins/badges-backend/package.json @@ -0,0 +1,55 @@ +{ + "name": "@backstage/plugin-badges-backend", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/badges-backend" + }, + "keywords": [ + "backstage", + "badges" + ], + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.6.0", + "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-model": "^0.7.3", + "@backstage/config": "^0.1.3", + "@backstage/errors": "^0.1.1", + "@types/express": "^4.17.6", + "badge-maker": "^3.3.0", + "cors": "^2.8.5", + "cross-fetch": "^3.0.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.6.3", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/badges-backend/src/badges.ts b/plugins/badges-backend/src/badges.ts new file mode 100644 index 0000000000..2e2848a8af --- /dev/null +++ b/plugins/badges-backend/src/badges.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 { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; +import { Badge, BadgeContext, BadgeFactories } from './types'; + +function appTitle(context: BadgeContext): string { + return context.config.getString('app.title') || 'Backstage'; +} + +function entityUrl(context: BadgeContext): string { + const e = context.entity!; + const entityUri = `${e.kind}/${ + e.metadata.namespace || ENTITY_DEFAULT_NAMESPACE + }/${e.metadata.name}`; + const catalogUrl = `${context.config.getString('app.baseUrl')}/catalog`; + return `${catalogUrl}/${entityUri}`.toLowerCase(); +} + +export const createDefaultBadgeFactories = (): BadgeFactories => ({ + pingback: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"pingback" badge only defined for entities'); + } + return { + description: `Link to ${context.entity.metadata.name} in ${appTitle( + context, + )}`, + kind: 'entity', + label: context.entity.kind, + link: entityUrl(context), + message: context.entity.metadata.name, + style: 'flat-square', + }; + }, + }, + + lifecycle: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"lifecycle" badge only defined for entities'); + } + return { + description: 'Entity lifecycle badge', + kind: 'entity', + label: 'lifecycle', + link: entityUrl(context), + message: `${context.entity.spec?.lifecycle || 'unknown'}`, + style: 'flat-square', + }; + }, + }, + + owner: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"owner" badge only defined for entities'); + } + return { + description: 'Entity owner badge', + kind: 'entity', + label: 'owner', + link: entityUrl(context), + message: `${context.entity.spec?.owner || 'unknown'}`, + style: 'flat-square', + }; + }, + }, + + docs: { + createBadge: (context: BadgeContext): Badge => { + if (!context.entity) { + throw new InputError('"docs" badge only defined for entities'); + } + return { + description: 'Entity docs badge', + kind: 'entity', + label: 'docs', + link: `${entityUrl(context)}/docs`, + message: context.entity.metadata.name, + style: 'flat-square', + }; + }, + }, +}); diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts new file mode 100644 index 0000000000..240bce56af --- /dev/null +++ b/plugins/badges-backend/src/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 './badges'; +export * from './lib'; +export * from './service/router'; +export * from './types'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.test.ts new file mode 100644 index 0000000000..a36587f241 --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.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 { Config, ConfigReader } from '@backstage/config'; +import { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; +import { BadgeBuilder, BadgeOptions } from './types'; +import { BadgeContext, BadgeFactories } from '../../types'; + +describe('DefaultBadgeBuilder', () => { + let builder: BadgeBuilder; + let config: Config; + let factories: BadgeFactories; + + const badge = { + description: 'a test badge', + label: 'test', + message: 'ok', + link: 'http://example.com/badgelink', + }; + + beforeAll(() => { + config = new ConfigReader({ + backend: { baseUrl: 'http://127.0.0.1' }, + }); + + factories = { + testbadge: { + createBadge: () => badge, + }, + }; + }); + + beforeEach(() => { + jest.resetAllMocks(); + builder = new DefaultBadgeBuilder(factories); + }); + + it('getBadges() returns all badge factory ids', async () => { + expect(await builder.getBadges()).toEqual([{ id: 'testbadge' }]); + }); + + describe('createBadge[Json|Svg]', () => { + const context: BadgeContext = { + badgeUrl: 'http://127.0.0.1/badge/url', + config, + }; + + it('badge spec', async () => { + const options: BadgeOptions = { + badgeInfo: { id: 'testbadge' }, + context, + }; + + const spec = await builder.createBadgeJson(options); + expect(spec).toEqual({ + badge, + id: 'testbadge', + url: context.badgeUrl, + markdown: `[![a test badge, test: ok](${context.badgeUrl} "a test badge")](${badge.link})`, + }); + }); + + it('badge image', async () => { + const options: BadgeOptions = { + badgeInfo: { id: 'testbadge' }, + context, + }; + + const img = await builder.createBadgeSvg(options); + expect(img).toEqual(expect.stringMatching(/^]*>.*<\/svg>$/)); + }); + + it('returns "unknown" badge for missing factory', async () => { + const options: BadgeOptions = { + badgeInfo: { id: 'other-id' }, + context, + }; + + const spec = await builder.createBadgeJson(options); + expect(spec).toEqual({ + badge: { + label: 'unknown badge', + message: 'other-id', + color: 'red', + }, + id: 'other-id', + url: context.badgeUrl, + markdown: `![unknown badge: other-id](${context.badgeUrl})`, + }); + }); + }); +}); diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts new file mode 100644 index 0000000000..42823ebd09 --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.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 { InputError } from '@backstage/errors'; +import { makeBadge, Format } from 'badge-maker'; +import { BadgeBuilder, BadgeInfo, BadgeOptions, BadgeSpec } from './types'; +import { Badge, BadgeFactories } from '../../types'; + +export class DefaultBadgeBuilder implements BadgeBuilder { + constructor(private readonly factories: BadgeFactories) {} + + public async getBadges(): Promise { + return Object.keys(this.factories).map(id => ({ id })); + } + + public async createBadgeJson(options: BadgeOptions): Promise { + const factory = this.factories[options.badgeInfo.id]; + const badge = factory + ? factory.createBadge(options.context) + : ({ + label: 'unknown badge', + message: options.badgeInfo.id, + color: 'red', + } as Badge); + + if (!badge) { + throw new InputError( + `The badge factory failed to produce a "${options.badgeInfo.id}" badge with the provided context`, + ); + } + + return { + badge, + id: options.badgeInfo.id, + url: options.context.badgeUrl, + markdown: this.getMarkdownCode(badge, options.context.badgeUrl), + }; + } + + public async createBadgeSvg(options: BadgeOptions): Promise { + const { badge } = await this.createBadgeJson(options); + try { + const format = { + message: badge.message, + color: badge.color || '#36BAA2', + label: badge.label || '', + labelColor: badge.labelColor || '', + style: badge.style || 'flat-square', + } as Format; + return makeBadge(format); + } catch (err) { + return makeBadge({ + label: 'invalid badge', + message: `${err}`, + color: 'red', + }); + } + } + + private getMarkdownCode(params: Badge, badgeUrl: string): string { + let altText = `${params.label}: ${params.message}`; + if (params.description && params.description !== params.label) { + altText = `${params.description}, ${altText}`; + } + const tooltip = params.description ? ` "${params.description}"` : ''; + const img = `![${altText}](${badgeUrl}${tooltip})`; + return params.link ? `[${img}](${params.link})` : img; + } +} diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/index.ts b/plugins/badges-backend/src/lib/BadgeBuilder/index.ts new file mode 100644 index 0000000000..4947db96e2 --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/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 { DefaultBadgeBuilder } from './DefaultBadgeBuilder'; +export type { BadgeBuilder, BadgeInfo, BadgeOptions, BadgeSpec } from './types'; diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts new file mode 100644 index 0000000000..fcd1cd4c5b --- /dev/null +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -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 { Badge, BadgeContext } from '../../types'; + +export type BadgeInfo = { + id: string; +}; + +export type BadgeOptions = { + badgeInfo: BadgeInfo; + context: BadgeContext; +}; + +export type BadgeSpec = { + /** Badge id */ + id: string; + + /** Badge data */ + badge: Badge; + + /** The URL to the badge image */ + url: string; + + /** The markdown code to use the badge */ + markdown: string; +}; + +export type BadgeBuilder = { + getBadges(): Promise; + createBadgeJson(options: BadgeOptions): Promise; + createBadgeSvg(options: BadgeOptions): Promise; +}; diff --git a/plugins/badges-backend/src/lib/index.ts b/plugins/badges-backend/src/lib/index.ts new file mode 100644 index 0000000000..47cccec8f8 --- /dev/null +++ b/plugins/badges-backend/src/lib/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 './BadgeBuilder'; diff --git a/plugins/badges-backend/src/run.ts b/plugins/badges-backend/src/run.ts new file mode 100644 index 0000000000..a59d90d09a --- /dev/null +++ b/plugins/badges-backend/src/run.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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts new file mode 100644 index 0000000000..e8de5dfd42 --- /dev/null +++ b/plugins/badges-backend/src/service/router.test.ts @@ -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 express from 'express'; +import request from 'supertest'; +import { + PluginEndpointDiscovery, + SingleHostDiscovery, +} from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import type { Entity } from '@backstage/catalog-model'; +import { Config, ConfigReader } from '@backstage/config'; +import { createRouter } from './router'; +import { BadgeBuilder } from '../lib'; + +describe('createRouter', () => { + let app: express.Express; + let badgeBuilder: jest.Mocked; + let catalog: jest.Mocked; + let config: Config; + let discovery: PluginEndpointDiscovery; + + const entity: Entity = { + apiVersion: 'v1', + kind: 'service', + metadata: { + name: 'test', + }, + }; + + const badge = { + id: 'test-badge', + badge: { + label: 'test', + message: 'badge', + }, + url: '/...', + markdown: '[![...](...)]', + }; + + beforeAll(async () => { + badgeBuilder = { + getBadges: jest.fn(), + createBadgeJson: jest.fn(), + createBadgeSvg: jest.fn(), + }; + catalog = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + + config = new ConfigReader({ + backend: { + baseUrl: 'http://127.0.0.1', + listen: { port: 7000 }, + }, + }); + discovery = SingleHostDiscovery.fromConfig(config); + + const router = await createRouter({ + badgeBuilder, + catalog, + config, + discovery, + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('works', async () => { + const router = await createRouter({ + badgeBuilder, + catalog, + config, + discovery, + }); + expect(router).toBeDefined(); + }); + + describe('GET /entity/:namespace/:kind/:name/badge-specs', () => { + it('returns all badge specs for entity', async () => { + catalog.getEntityByName.mockResolvedValueOnce(entity); + + badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]); + badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge); + + const response = await request(app).get( + '/entity/default/service/test/badge-specs', + ); + + expect(response.status).toEqual(200); + expect(response.text).toEqual(JSON.stringify([badge], null, 2)); + + expect(catalog.getEntityByName).toHaveBeenCalledTimes(1); + expect(catalog.getEntityByName).toHaveBeenCalledWith({ + namespace: 'default', + kind: 'service', + name: 'test', + }); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeJson).toHaveBeenCalledWith({ + badgeInfo: { id: badge.id }, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, + ), + config, + entity, + }, + }); + }); + }); + + describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => { + it('returns badge for entity', async () => { + catalog.getEntityByName.mockResolvedValueOnce(entity); + + const image = '...'; + badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image); + + const response = await request(app).get( + '/entity/default/service/test/badge/test-badge', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(Buffer.from(image)); + + expect(catalog.getEntityByName).toHaveBeenCalledTimes(1); + expect(catalog.getEntityByName).toHaveBeenCalledWith({ + namespace: 'default', + kind: 'service', + name: 'test', + }); + + expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1); + expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({ + badgeInfo: { id: badge.id }, + context: { + badgeUrl: expect.stringMatching( + /http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/, + ), + config, + entity, + }, + }); + }); + }); +}); diff --git a/plugins/badges-backend/src/service/router.ts b/plugins/badges-backend/src/service/router.ts new file mode 100644 index 0000000000..bfbb10fc94 --- /dev/null +++ b/plugins/badges-backend/src/service/router.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import Router from 'express-promise-router'; +import { + errorHandler, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { NotFoundError } from '@backstage/errors'; +import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder'; +import { BadgeContext, BadgeFactories } from '../types'; + +export interface RouterOptions { + badgeBuilder?: BadgeBuilder; + badgeFactories?: BadgeFactories; + catalog?: CatalogApi; + config: Config; + discovery: PluginEndpointDiscovery; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const catalog = + options.catalog || new CatalogClient({ discoveryApi: options.discovery }); + const badgeBuilder = + options.badgeBuilder || + new DefaultBadgeBuilder(options.badgeFactories || {}); + const router = Router(); + + router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => { + const { namespace, kind, name } = req.params; + const entity = await catalog.getEntityByName({ namespace, kind, name }); + if (!entity) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); + } + + const specs = []; + for (const badgeInfo of await badgeBuilder.getBadges()) { + const context: BadgeContext = { + badgeUrl: await getBadgeUrl( + namespace, + kind, + name, + badgeInfo.id, + options, + ), + config: options.config, + entity, + }; + + const badge = await badgeBuilder.createBadgeJson({ badgeInfo, context }); + if (badge) { + specs.push(badge); + } + } + + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(JSON.stringify(specs, null, 2)); + }); + + router.get( + '/entity/:namespace/:kind/:name/badge/:badgeId', + async (req, res) => { + const { namespace, kind, name, badgeId } = req.params; + const entity = await catalog.getEntityByName({ namespace, kind, name }); + if (!entity) { + throw new NotFoundError( + `No ${kind} entity in ${namespace} named "${name}"`, + ); + } + + let format = + req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml'; + if (req.query.format === 'json') { + format = 'application/json'; + } + + const badgeOptions = { + badgeInfo: { id: badgeId }, + context: { + badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options), + config: options.config, + entity, + }, + }; + + let data: string; + if (format === 'application/json') { + data = JSON.stringify( + await badgeBuilder.createBadgeJson(badgeOptions), + null, + 2, + ); + } else { + data = await badgeBuilder.createBadgeSvg(badgeOptions); + } + + res.setHeader('Content-Type', format); + res.status(200).send(data); + }, + ); + + router.use(errorHandler()); + + return router; +} + +async function getBadgeUrl( + namespace: string, + kind: string, + name: string, + badgeId: string, + options: RouterOptions, +): Promise { + const baseUrl = await options.discovery.getExternalBaseUrl('badges'); + return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`; +} diff --git a/plugins/badges-backend/src/service/standaloneServer.ts b/plugins/badges-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..c210efa249 --- /dev/null +++ b/plugins/badges-backend/src/service/standaloneServer.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Server } from 'http'; +import { Logger } from 'winston'; +import { + createServiceBuilder, + loadBackendConfig, + SingleHostDiscovery, +} from '@backstage/backend-common'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'badges-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); + + logger.debug('Creating application...'); + + const router = await createRouter({ config, discovery }); + + const service = createServiceBuilder(module) + .enableCors({ origin: 'http://localhost:3000' }) + .addRouter('/badges', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} diff --git a/plugins/badges-backend/src/setupTests.ts b/plugins/badges-backend/src/setupTests.ts new file mode 100644 index 0000000000..4e230aca20 --- /dev/null +++ b/plugins/badges-backend/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. + */ + +export {}; diff --git a/plugins/badges-backend/src/types.ts b/plugins/badges-backend/src/types.ts new file mode 100644 index 0000000000..69fc275f3f --- /dev/null +++ b/plugins/badges-backend/src/types.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 { Config } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; + +export const BADGE_STYLES = [ + 'plastic', + 'flat', + 'flat-square', + 'for-the-badge', + 'social', +] as const; +export type BadgeStyle = typeof BADGE_STYLES[number]; + +export interface Badge { + /** Badge message background color. */ + color?: string; + /** Badge description (tooltip text) */ + description?: string; + /** Kind of badge */ + kind?: 'entity'; + /** + * Badge label (should be a rather static value) + * ref. shields spec https://github.com/badges/shields/blob/master/spec/SPECIFICATION.md + */ + label: string; + /** Badge label background color */ + labelColor?: string; + /** Custom badge link */ + link?: string; + /** Badge message */ + message: string; + /** Badge style (apperance). One of "plastic", "flat", "flat-square", "for-the-badge" and "social" */ + style?: BadgeStyle; +} + +export interface BadgeContext { + badgeUrl: string; + config: Config; + entity?: Entity; // for entity badges +} + +export interface BadgeFactory { + createBadge(context: BadgeContext): Badge; +} + +export interface BadgeFactories { + [id: string]: BadgeFactory; +} diff --git a/plugins/badges/.eslintrc.js b/plugins/badges/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/badges/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/badges/README.md b/plugins/badges/README.md new file mode 100644 index 0000000000..bfc01ec63c --- /dev/null +++ b/plugins/badges/README.md @@ -0,0 +1,19 @@ +# @backstage/plugin-badges + +The badges plugin offers a set of badges that can be used outside of +your backstage deployment, showing information related to data from +the catalog, such as entity owner and lifecycle data for instance. + +The available badges are setup in the `badges-backend` plugin, see +link below for more details. + +## Entity badges + +To get markdown code for the entity badges, access the `Badges` context menu +(three dots in the upper right corner) of an entity page, which will popup a +badges dialog showing all available badges for that entity. + +## Links + +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/badges/dev/index.tsx b/plugins/badges/dev/index.tsx new file mode 100644 index 0000000000..1616edacb4 --- /dev/null +++ b/plugins/badges/dev/index.tsx @@ -0,0 +1,19 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createDevApp } from '@backstage/dev-utils'; +import { badgesPlugin } from '../src/plugin'; + +createDevApp().registerPlugin(badgesPlugin).render(); diff --git a/plugins/badges/package.json b/plugins/badges/package.json new file mode 100644 index 0000000000..cc55a0fed8 --- /dev/null +++ b/plugins/badges/package.json @@ -0,0 +1,51 @@ +{ + "name": "@backstage/plugin-badges", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-model": "^0.7.3", + "@backstage/core": "^0.7.2", + "@backstage/errors": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/theme": "^0.2.4", + "@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" + }, + "devDependencies": { + "@backstage/cli": "^0.6.5", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.9", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts new file mode 100644 index 0000000000..927a16a7eb --- /dev/null +++ b/plugins/badges/src/api/BadgesClient.ts @@ -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 { generatePath } from 'react-router'; +import { DiscoveryApi } from '@backstage/core'; +import { ResponseError } from '@backstage/errors'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { entityRoute } from '@backstage/plugin-catalog-react'; +import { BadgesApi, BadgeSpec } from './types'; + +export class BadgesClient implements BadgesApi { + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; + } + + public async getEntityBadgeSpecs(entity: Entity): Promise { + const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity); + const response = await fetch(entityBadgeSpecsUrl); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + + return await response.json(); + } + + private async getEntityBadgeSpecsUrl(entity: Entity): Promise { + const routeParams = this.getEntityRouteParams(entity); + const path = generatePath(entityRoute.path, routeParams); + return `${await this.discoveryApi.getBaseUrl( + 'badges', + )}/entity/${path}/badge-specs`; + } + + private getEntityRouteParams(entity: Entity) { + return { + kind: entity.kind.toLowerCase(), + namespace: + entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + }; + } +} diff --git a/plugins/badges/src/api/index.ts b/plugins/badges/src/api/index.ts new file mode 100644 index 0000000000..1c6a381916 --- /dev/null +++ b/plugins/badges/src/api/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 './BadgesClient'; +export * from './types'; diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts new file mode 100644 index 0000000000..9765af2c93 --- /dev/null +++ b/plugins/badges/src/api/types.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 { Entity } from '@backstage/catalog-model'; +import { createApiRef } from '@backstage/core'; + +export const badgesApiRef = createApiRef({ + id: 'plugin.badges.client', + description: 'Used to make requests to the badges backend', +}); + +export type BadgeStyle = + | 'plastic' + | 'flat' + | 'flat-square' + | 'for-the-badge' + | 'social'; + +interface Badge { + color?: string; + description?: string; + kind?: 'entity'; + label: string; + labelColor?: string; + link?: string; + message: string; + style?: BadgeStyle; +} + +export interface BadgeSpec { + /** Badge id */ + id: string; + + /** Badge data */ + badge: Badge; + + /** The URL to the badge image */ + url: string; + + /** The markdown code to use the badge */ + markdown: string; +} + +export interface BadgesApi { + getEntityBadgeSpecs(entity: Entity): Promise; +} diff --git a/plugins/badges/src/components/EntityBadgesDialog.test.tsx b/plugins/badges/src/components/EntityBadgesDialog.test.tsx new file mode 100644 index 0000000000..62f2b55bc5 --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesDialog.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 { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + ErrorApi, + errorApiRef, +} from '@backstage/core'; +import { renderWithEffects } from '@backstage/test-utils'; +import { BadgesApi, badgesApiRef } from '../api'; +import { EntityBadgesDialog } from './EntityBadgesDialog'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; + +describe('EntityBadgesDialog', () => { + it('should render', async () => { + const mockApi: jest.Mocked = { + getEntityBadgeSpecs: jest.fn().mockResolvedValue([ + { + id: 'testbadge', + badge: { + label: 'test', + message: 'badge', + }, + url: 'http://127.0.0.1/badges/entity/.../testbadge', + markdown: '![test: badge](http://127.0.0.1/catalog/...)', + }, + ]), + }; + const mockEntity = { metadata: { name: 'mock' } } as Entity; + + const rendered = await renderWithEffects( + + + + + , + ); + + await expect( + rendered.findByText('![test: badge](http://127.0.0.1/catalog/...)'), + ).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx new file mode 100644 index 0000000000..c0cbd6b15c --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesDialog.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 { + CodeSnippet, + Progress, + ResponseErrorPanel, + useApi, +} from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + useMediaQuery, + useTheme, +} from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { badgesApiRef } from '../api'; + +type Props = { + open: boolean; + onClose?: () => any; +}; + +export const EntityBadgesDialog = ({ open, onClose }: Props) => { + const theme = useTheme(); + const { entity } = useEntity(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const badgesApi = useApi(badgesApiRef); + + const { value: badges, loading, error } = useAsync(async () => { + if (open && entity) { + return await badgesApi.getEntityBadgeSpecs(entity); + } + return []; + }, [badgesApi, entity, open]); + + const content = (badges || []).map( + ({ badge: { description }, id, url, markdown }) => ( + + + {description + + + + ), + ); + + return ( + + Entity Badges + + + Embed badges in other web sites that link back to this entity. Copy + the relevant snippet of Markdown code to use the badge. + + + {loading && } + {error && } + + {content} + + + + + + + ); +}; diff --git a/plugins/badges/src/index.ts b/plugins/badges/src/index.ts new file mode 100644 index 0000000000..4de5957426 --- /dev/null +++ b/plugins/badges/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './plugin'; diff --git a/plugins/badges/src/plugin.test.ts b/plugins/badges/src/plugin.test.ts new file mode 100644 index 0000000000..eb5a1fc0ba --- /dev/null +++ b/plugins/badges/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 { badgesPlugin } from './plugin'; + +describe('badges', () => { + it('should export plugin', () => { + expect(badgesPlugin).toBeDefined(); + }); +}); diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts new file mode 100644 index 0000000000..92932aa356 --- /dev/null +++ b/plugins/badges/src/plugin.ts @@ -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 { + createApiFactory, + createComponentExtension, + createPlugin, + discoveryApiRef, +} from '@backstage/core'; +import { badgesApiRef, BadgesClient } from './api'; + +export const badgesPlugin = createPlugin({ + id: 'badges', + apis: [ + createApiFactory({ + api: badgesApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new BadgesClient({ discoveryApi }), + }), + ], +}); + +export const EntityBadgesDialog = badgesPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntityBadgesDialog').then( + m => m.EntityBadgesDialog, + ), + }, + }), +); diff --git a/plugins/badges/src/setupTests.ts b/plugins/badges/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/badges/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/bitrise/CHANGELOG.md b/plugins/bitrise/CHANGELOG.md new file mode 100644 index 0000000000..66314933bb --- /dev/null +++ b/plugins/bitrise/CHANGELOG.md @@ -0,0 +1,13 @@ +# @backstage/plugin-bitrise + +## 0.1.1 + +### Patch Changes + +- 68f024fa8: The apps lookup is now getting all apps by fetching every page of the app list +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 diff --git a/plugins/bitrise/package.json b/plugins/bitrise/package.json index 4f7d7356f6..d555381e2c 100644 --- a/plugins/bitrise/package.json +++ b/plugins/bitrise/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-bitrise", - "version": "0.1.0", + "version": "0.1.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,8 +21,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.2", - "@backstage/core": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.1.0", + "@backstage/core": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -37,9 +37,9 @@ "recharts": "^1.8.5" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/bitrise/src/api/bitriseApi.client.test.ts b/plugins/bitrise/src/api/bitriseApi.client.test.ts index 9e125d8d06..bc62491c34 100644 --- a/plugins/bitrise/src/api/bitriseApi.client.test.ts +++ b/plugins/bitrise/src/api/bitriseApi.client.test.ts @@ -80,6 +80,42 @@ describe('BitriseClientApi', () => { }); describe('getApp()', () => { + it('should get all apps', async () => { + server.use( + rest.get( + `${mockBaseUrl}/bitrise/apps?next=slug-2-1`, + (_req, res, ctx) => { + const next = _req.url.searchParams.get('next'); + if (next === 'slug-2-1') { + return res( + ctx.json({ + data: [{ title: 'app21', slug: 'slug-2-1' }], + }), + ); + } + return res( + ctx.json({ + data: [ + { title: 'app11', slug: 'slug-1-1' }, + { title: 'app12', slug: 'slug-1-2' }, + ], + paging: { + next: 'slug-2-1', + page_item_limit: 0, + total_item_count: 0, + }, + }), + ); + }, + ), + ); + + const apps = await client.getApps(); + + expect(apps).toBeDefined(); + expect(apps.length).toBe(3); + }); + it('should get the app', async () => { server.use( rest.get(`${mockBaseUrl}/*`, (_req, res, ctx) => { diff --git a/plugins/bitrise/src/api/bitriseApi.client.ts b/plugins/bitrise/src/api/bitriseApi.client.ts index 4fc66c55a1..e7af5259ab 100644 --- a/plugins/bitrise/src/api/bitriseApi.client.ts +++ b/plugins/bitrise/src/api/bitriseApi.client.ts @@ -61,15 +61,25 @@ export class BitriseClientApi implements BitriseApi { return data.data; } - async getApps(): Promise { + async getAppsPaginated(from: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('proxy'); - const appsResponse = await fetch(`${baseUrl}/bitrise/apps`); + const appsResponse = await fetch(`${baseUrl}/bitrise/apps?next=${from}`); const appsData = await appsResponse.json(); + if (appsData.paging?.next) { + return appsData.data.concat( + await this.getAppsPaginated(appsData.paging.next), + ); + } + return appsData.data; } + async getApps(): Promise { + return await this.getAppsPaginated(''); + } + async getApp(appName: string): Promise { const apps = await this.getApps(); diff --git a/plugins/bitrise/src/components/BitriseBuildsComponent/BitriseBuildsComponent.tsx b/plugins/bitrise/src/components/BitriseBuildsComponent/BitriseBuildsComponent.tsx index 0e4bd3e5db..eeabaf0b08 100644 --- a/plugins/bitrise/src/components/BitriseBuildsComponent/BitriseBuildsComponent.tsx +++ b/plugins/bitrise/src/components/BitriseBuildsComponent/BitriseBuildsComponent.tsx @@ -34,6 +34,9 @@ export type Props = { export const BITRISE_APP_ANNOTATION = 'bitrise.io/app'; +export const isBitriseAvailable = (entity: Entity) => + Boolean(entity.metadata.annotations?.[BITRISE_APP_ANNOTATION]); + export const BitriseBuildsComponent = () => { const { entity } = useEntity(); const appName = entity.metadata.annotations?.[ diff --git a/plugins/bitrise/src/components/BitriseBuildsComponent/index.ts b/plugins/bitrise/src/components/BitriseBuildsComponent/index.ts index 8bb1b7e13f..aaf235251d 100644 --- a/plugins/bitrise/src/components/BitriseBuildsComponent/index.ts +++ b/plugins/bitrise/src/components/BitriseBuildsComponent/index.ts @@ -16,5 +16,6 @@ export { BitriseBuildsComponent, + isBitriseAvailable, BITRISE_APP_ANNOTATION, } from './BitriseBuildsComponent'; diff --git a/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.tsx b/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.tsx index 86722436ed..06a9ddd9f8 100644 --- a/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.tsx +++ b/plugins/bitrise/src/components/BitriseBuildsTableComponent/BitriseBuildsTableComponent.tsx @@ -162,8 +162,8 @@ export const BitriseBuildsTable = ({ title: 'Branch', customFilterAndSearch: (query, row: any) => `${row.message} ${row.workflow}` - .toUpperCase() - .includes(query.toUpperCase()), + .toLocaleUpperCase('en-US') + .includes(query.toLocaleUpperCase('en-US')), field: 'message', width: 'auto', highlight: true, diff --git a/plugins/bitrise/src/index.ts b/plugins/bitrise/src/index.ts index 83dad176c7..acec26825a 100644 --- a/plugins/bitrise/src/index.ts +++ b/plugins/bitrise/src/index.ts @@ -16,3 +16,4 @@ export { bitrisePlugin } from './plugin'; export { EntityBitriseContent } from './extensions'; +export { isBitriseAvailable } from './components/BitriseBuildsComponent'; diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index c7e078cfe1..df738d4a84 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,30 @@ # @backstage/plugin-catalog-backend +## 0.6.6 + +### Patch Changes + +- 010aed784: Add `AnnotateScmSlugEntityProcessor` that automatically adds the + `github.com/project-slug` annotation for components coming from GitHub. + + The processor is optional and not automatically registered in the catalog + builder. To add it to your instance, add it to your `CatalogBuilder` using + `addProcessor()`: + + ```typescript + const builder = new CatalogBuilder(env); + builder.addProcessor(AnnotateScmSlugEntityProcessor.fromConfig(env.config)); + ``` + +- 4bc98a5b9: Refactor CodeOwnersProcessor to use ScmIntegrations +- d2f4efc5d: Add location to thrown exception when parsing YAML +- 8686eb38c: Use errors from `@backstage/errors` +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.6.5 ### Patch Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 204c6f4002..479fc7ba4d 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.6.5", + "version": "0.6.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,11 +30,13 @@ }, "dependencies": { "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.5.6", + "@backstage/backend-common": "^0.6.0", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", + "@backstage/plugin-search-backend-node": "^0.1.1", + "@backstage/search-common": "^0.1.1", "@octokit/graphql": "^4.5.8", "@types/express": "^4.17.6", "@types/ldapjs": "^1.0.9", @@ -43,7 +45,7 @@ "core-js": "^3.6.5", "cross-fetch": "^3.0.6", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "fs-extra": "^9.0.0", "git-url-parse": "^11.4.4", "glob": "^7.1.6", @@ -60,8 +62,8 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", - "@backstage/test-utils": "^0.1.8", + "@backstage/cli": "^0.6.5", + "@backstage/test-utils": "^0.1.9", "@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/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index 5059d741f4..a81655c5ea 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -17,7 +17,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; import { Database, DatabaseManager, Transaction } from '../database'; -import { EntityFilters } from '../service/EntityFilters'; +import { basicEntityFilter } from '../service/request'; import { DatabaseEntitiesCatalog } from './DatabaseEntitiesCatalog'; import { EntityUpsertRequest } from './types'; @@ -63,7 +63,10 @@ describe('DatabaseEntitiesCatalog', () => { }, }; - db.entities.mockResolvedValue([]); + db.entities.mockResolvedValue({ + entities: [], + pageInfo: { hasNextPage: false }, + }); db.addEntities.mockResolvedValue([ { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, ]); @@ -74,12 +77,13 @@ describe('DatabaseEntitiesCatalog', () => { ]); expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith( - expect.anything(), - EntityFilters.ofFilterString( - 'kind=b,metadata.namespace=d,metadata.name=c', - ), - ); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + filter: basicEntityFilter({ + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': 'c', + }), + }); expect(db.addEntities).toHaveBeenCalledTimes(1); expect(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ { entity: expect.anything(), relations: [] }, @@ -96,7 +100,10 @@ describe('DatabaseEntitiesCatalog', () => { namespace: 'd', }, }; - db.entities.mockResolvedValue([]); + db.entities.mockResolvedValue({ + entities: [], + pageInfo: { hasNextPage: false }, + }); db.addEntities.mockResolvedValue([ { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, ]); @@ -108,12 +115,13 @@ describe('DatabaseEntitiesCatalog', () => { ); expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith( - expect.anything(), - EntityFilters.ofFilterString( - 'kind=b,metadata.namespace=d,metadata.name=c', - ), - ); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + filter: basicEntityFilter({ + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': 'c', + }), + }); expect(db.addEntities).toHaveBeenCalledTimes(1); expect(db.addEntities).toHaveBeenCalledWith(expect.anything(), [ { entity: expect.anything(), relations: [] }, @@ -147,7 +155,10 @@ describe('DatabaseEntitiesCatalog', () => { }, }, }; - db.entities.mockResolvedValue([{ entity: dbEntity }]); + db.entities.mockResolvedValue({ + entities: [{ entity: dbEntity }], + pageInfo: { hasNextPage: false }, + }); db.addEntities.mockResolvedValue([ { entity: { ...entity, metadata: { ...entity.metadata, uid: 'u' } } }, ]); @@ -198,7 +209,10 @@ describe('DatabaseEntitiesCatalog', () => { }, }; - db.entities.mockResolvedValue([existing]); + db.entities.mockResolvedValue({ + entities: [existing], + pageInfo: { hasNextPage: false }, + }); db.entityByUid.mockResolvedValue(existing); db.updateEntity.mockResolvedValue({ entity }); @@ -208,12 +222,13 @@ describe('DatabaseEntitiesCatalog', () => { ]); expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith( - expect.anything(), - EntityFilters.ofFilterString( - 'kind=b,metadata.namespace=d,metadata.name=c', - ), - ); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + filter: basicEntityFilter({ + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': 'c', + }), + }); expect(db.entityByName).not.toHaveBeenCalled(); expect(db.entityByUid).toHaveBeenCalledTimes(1); expect(db.entityByUid).toHaveBeenCalledWith(transaction, 'u'); @@ -272,7 +287,10 @@ describe('DatabaseEntitiesCatalog', () => { }, }; - db.entities.mockResolvedValue([existing]); + db.entities.mockResolvedValue({ + entities: [existing], + pageInfo: { hasNextPage: false }, + }); db.entityByName.mockResolvedValue(existing); db.updateEntity.mockResolvedValue(existing); @@ -282,12 +300,13 @@ describe('DatabaseEntitiesCatalog', () => { ]); expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith( - expect.anything(), - EntityFilters.ofFilterString( - 'kind=b,metadata.namespace=d,metadata.name=c', - ), - ); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + filter: basicEntityFilter({ + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': 'c', + }), + }); expect(db.entityByName).toHaveBeenCalledTimes(1); expect(db.entityByName).toHaveBeenCalledWith(transaction, { kind: 'b', @@ -334,7 +353,10 @@ describe('DatabaseEntitiesCatalog', () => { }, }; - db.entities.mockResolvedValue([{ entity }]); + db.entities.mockResolvedValue({ + entities: [{ entity }], + pageInfo: { hasNextPage: false }, + }); db.entityByUid.mockResolvedValue({ entity }); db.updateEntity.mockResolvedValue({ entity }); @@ -344,12 +366,13 @@ describe('DatabaseEntitiesCatalog', () => { ]); expect(db.entities).toHaveBeenCalledTimes(1); - expect(db.entities).toHaveBeenCalledWith( - expect.anything(), - EntityFilters.ofFilterString( - 'kind=b,metadata.namespace=d,metadata.name=c', - ), - ); + expect(db.entities).toHaveBeenCalledWith(expect.anything(), { + filter: basicEntityFilter({ + kind: 'b', + 'metadata.namespace': 'd', + 'metadata.name': 'c', + }), + }); expect(db.entityByName).not.toHaveBeenCalled(); expect(db.entityByUid).not.toHaveBeenCalled(); expect(db.updateEntity).not.toHaveBeenCalled(); @@ -377,7 +400,7 @@ describe('DatabaseEntitiesCatalog', () => { await catalog.batchAddOrUpdateEntities(entities); const afterFirst = await catalog.entities(); - expect(afterFirst.length).toBe(300); + expect(afterFirst.entities.length).toBe(300); entities[40].entity.metadata.op = 'changed'; entities.push({ @@ -391,9 +414,13 @@ describe('DatabaseEntitiesCatalog', () => { await catalog.batchAddOrUpdateEntities(entities); const afterSecond = await catalog.entities(); - expect(afterSecond.length).toBe(301); - expect(afterSecond.find(e => e.metadata.op === 'changed')).toBeDefined(); - expect(afterSecond.find(e => e.metadata.op === 'added')).toBeDefined(); + expect(afterSecond.entities.length).toBe(301); + expect( + afterSecond.entities.find(e => e.metadata.op === 'changed'), + ).toBeDefined(); + expect( + afterSecond.entities.find(e => e.metadata.op === 'added'), + ).toBeDefined(); }, 10000); }); }); diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 7cc88b9f18..c53bf751a0 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { ConflictError, NotFoundError } from '@backstage/errors'; import { Entity, entityHasChanges, @@ -23,19 +22,18 @@ import { LOCATION_ANNOTATION, serializeEntityRef, } from '@backstage/catalog-model'; +import { ConflictError, NotFoundError } from '@backstage/errors'; import { chunk, groupBy } from 'lodash'; import limiterFactory from 'p-limit'; import { Logger } from 'winston'; -import type { - Database, - DbEntityResponse, - EntityFilter, - Transaction, -} from '../database'; -import { EntityFilters } from '../service/EntityFilters'; +import type { Database, DbEntityResponse, Transaction } from '../database'; +import { DbEntitiesRequest } from '../database/types'; +import { basicEntityFilter } from '../service/request'; import { durationText } from '../util/timing'; import type { EntitiesCatalog, + EntitiesRequest, + EntitiesResponse, EntityUpsertRequest, EntityUpsertResponse, } from './types'; @@ -65,11 +63,24 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { private readonly logger: Logger, ) {} - async entities(filter?: EntityFilter): Promise { - const items = await this.database.transaction(tx => - this.database.entities(tx, filter), + async entities(request?: EntitiesRequest): Promise { + const dbRequest: DbEntitiesRequest = { + filter: request?.filter, + pagination: request?.pagination, + }; + + const dbResponse = await this.database.transaction(tx => + this.database.entities(tx, dbRequest), ); - return items.map(i => i.entity); + + const entities = dbResponse.entities.map(e => + request?.fields ? request.fields(e.entity) : e.entity, + ); + + return { + entities, + pageInfo: dbResponse.pageInfo, + }; } async removeEntityByUid(uid: string): Promise { @@ -78,16 +89,20 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { if (!entityResponse) { throw new NotFoundError(`Entity with ID ${uid} was not found`); } + const location = entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION]; + const colocatedEntities = location - ? await this.database.entities( - tx, - EntityFilters.ofMatchers({ - [`metadata.annotations.${LOCATION_ANNOTATION}`]: location, - }), - ) + ? ( + await this.database.entities(tx, { + filter: basicEntityFilter({ + [`metadata.annotations.${LOCATION_ANNOTATION}`]: location, + }), + }) + ).entities : [entityResponse]; + for (const dbResponse of colocatedEntities) { await this.database.removeEntityByUid( tx, @@ -98,6 +113,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { if (entityResponse.locationId) { await this.database.removeLocation(tx, entityResponse?.locationId!); } + return undefined; }); } @@ -206,13 +222,12 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { } if (options?.outputEntities && responses.length > 0) { - const writtenEntities = await this.database.entities( - tx, - EntityFilters.ofMatchers({ + const writtenEntities = await this.database.entities(tx, { + filter: basicEntityFilter({ 'metadata.uid': responses.map(e => e.entityId), }), - ); - responses = writtenEntities.map(e => ({ + }); + responses = writtenEntities.entities.map(e => ({ entityId: e.entity.metadata.uid!, entity: e.entity, })); @@ -247,17 +262,16 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // Here we make use of the fact that all of the entities share kind and // namespace within a batch const names = requests.map(({ entity }) => entity.metadata.name); - const oldEntities = await this.database.entities( - tx, - EntityFilters.ofMatchers({ + const oldEntitiesResponse = await this.database.entities(tx, { + filter: basicEntityFilter({ kind: kind, 'metadata.namespace': namespace, 'metadata.name': names, }), - ); + }); const oldEntitiesByName = new Map( - oldEntities.map(e => [e.entity.metadata.name, e.entity]), + oldEntitiesResponse.entities.map(e => [e.entity.metadata.name, e.entity]), ); const toAdd: EntityUpsertRequest[] = []; diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index b012ee3606..51f66b3a13 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -15,12 +15,32 @@ */ import { Entity, EntityRelationSpec, Location } from '@backstage/catalog-model'; -import type { EntityFilter } from '../database'; +import { EntityFilter, EntityPagination } from '../database/types'; // // Entities // +export type PageInfo = + | { + hasNextPage: false; + } + | { + hasNextPage: true; + endCursor: string; + }; + +export type EntitiesRequest = { + filter?: EntityFilter; + fields?: (entity: Entity) => Entity; + pagination?: EntityPagination; +}; + +export type EntitiesResponse = { + entities: Entity[]; + pageInfo: PageInfo; +}; + export type EntityUpsertRequest = { entity: Entity; relations: EntityRelationSpec[]; @@ -35,9 +55,9 @@ export type EntitiesCatalog = { /** * Fetch entities. * - * @param filter A filter to apply when reading + * @param request Request options */ - entities(filter?: EntityFilter): Promise; + entities(request?: EntitiesRequest): Promise; /** * Removes a single entity. diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index 0e26384358..27da003791 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { ConflictError } from '@backstage/errors'; import { Entity, Location, parseEntityRef } from '@backstage/catalog-model'; -import { EntityFilters } from '../service/EntityFilters'; +import { ConflictError } from '@backstage/errors'; +import { basicEntityFilter } from '../service/request'; import { DatabaseManager } from './DatabaseManager'; import type { DbEntityRequest, @@ -385,8 +385,8 @@ describe('CommonDatabase', () => { ]); }); const result = await db.transaction(async tx => db.entities(tx)); - expect(result.length).toEqual(2); - expect(result).toEqual( + expect(result.entities.length).toEqual(2); + expect(result.entities).toEqual( expect.arrayContaining([ { locationId: undefined, @@ -424,11 +424,13 @@ describe('CommonDatabase', () => { ); }); - await expect( - db.transaction(async tx => - db.entities(tx, EntityFilters.ofFilterString('kind=k2,spec.c=some')), - ), - ).resolves.toEqual([ + const response = await db.transaction(async tx => + db.entities(tx, { + filter: basicEntityFilter({ kind: 'k2', 'spec.c': 'some' }), + }), + ); + + expect(response.entities).toEqual([ { locationId: undefined, entity: expect.objectContaining({ kind: 'k2' }), @@ -466,14 +468,13 @@ describe('CommonDatabase', () => { }); const rows = await db.transaction(async tx => - db.entities( - tx, - EntityFilters.ofFilterString('ApiVersioN=A,spEc.C=some'), - ), + db.entities(tx, { + filter: basicEntityFilter({ ApiVersioN: 'A', 'spEc.C': 'some' }), + }), ); - expect(rows.length).toEqual(3); - expect(rows).toEqual( + expect(rows.entities.length).toEqual(3); + expect(rows.entities).toEqual( expect.arrayContaining([ { locationId: undefined, @@ -531,7 +532,8 @@ describe('CommonDatabase', () => { { target: mockRelations[0].target, type: 'child' }, ]); - const [returnedEntity3] = await db.transaction(tx => db.entities(tx)); + const { entities } = await db.transaction(tx => db.entities(tx)); + const [returnedEntity3] = entities; expect(returnedEntity3?.entity.relations).toEqual([ { target: mockRelations[0].target, type: 'child' }, ]); @@ -615,7 +617,7 @@ describe('CommonDatabase', () => { const res = await db.transaction(tx => db.entities(tx)); expect( - res.map(r => ({ + res.entities.map(r => ({ name: r.entity.metadata.name, relations: r.entity.relations, })), @@ -660,7 +662,7 @@ describe('CommonDatabase', () => { const res2 = await db.transaction(tx => db.entities(tx)); expect( - res2.map(r => ({ + res2.entities.map(r => ({ name: r.entity.metadata.name, relations: r.entity.relations, })), diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 890ce48e0c..e4ae1e7ee1 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -35,13 +35,16 @@ import { DatabaseLocationUpdateLogEvent, DatabaseLocationUpdateLogStatus, DbEntitiesRelationsRow, + DbEntitiesRequest, + DbEntitiesResponse, DbEntitiesRow, DbEntitiesSearchRow, DbEntityRequest, DbEntityResponse, DbLocationsRow, DbLocationsRowWithStatus, - EntityFilter, + DbPageInfo, + EntityPagination, Transaction, } from './types'; @@ -207,13 +210,13 @@ export class CommonDatabase implements Database { async entities( txOpaque: Transaction, - filter?: EntityFilter, - ): Promise { + request?: DbEntitiesRequest, + ): Promise { const tx = txOpaque as Knex.Transaction; let entitiesQuery = tx('entities'); - for (const singleFilter of filter?.anyOf ?? []) { + 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 @@ -235,17 +238,43 @@ export class CommonDatabase implements Database { } } }); - this.andWhere('id', 'in', matchQuery); } }); } - const rows = await entitiesQuery + entitiesQuery = entitiesQuery .select('entities.*') .orderBy('full_name', 'asc'); - return this.toEntityResponses(tx, rows); + 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: await this.toEntityResponses(tx, rows), + pageInfo, + }; } async entityByName( @@ -519,6 +548,46 @@ export class CommonDatabase implements Database { } } +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; +} + function deduplicateRelations( rows: DbEntitiesRelationsRow[], ): DbEntitiesRelationsRow[] { diff --git a/plugins/catalog-backend/src/database/index.ts b/plugins/catalog-backend/src/database/index.ts index c2cc07788a..edc8c56ac2 100644 --- a/plugins/catalog-backend/src/database/index.ts +++ b/plugins/catalog-backend/src/database/index.ts @@ -22,5 +22,6 @@ export type { DbEntityResponse, EntitiesSearchFilter, EntityFilter, + EntityPagination, Transaction, } from './types'; diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index ee2d4222c3..797402f652 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -36,6 +36,25 @@ export type DbEntityRequest = { relations: EntityRelationSpec[]; }; +export type DbEntitiesRequest = { + filter?: EntityFilter; + pagination?: EntityPagination; +}; + +export type DbEntitiesResponse = { + entities: DbEntityResponse[]; + pageInfo: DbPageInfo; +}; + +export type DbPageInfo = + | { + hasNextPage: false; + } + | { + hasNextPage: true; + endCursor: string; + }; + export type DbEntityResponse = { locationId?: string; entity: Entity; @@ -111,6 +130,15 @@ export type EntityFilter = { anyOf: { allOf: EntitiesSearchFilter[] }[]; }; +/** + * A pagination rule for entities. + */ +export type EntityPagination = { + limit?: number; + offset?: number; + after?: string; +}; + /** * An abstraction for transactions of the underlying database technology. */ @@ -170,7 +198,10 @@ export type Database = { matchingGeneration?: number, ): Promise; - entities(tx: Transaction, filter?: EntityFilter): Promise; + entities( + tx: Transaction, + request?: DbEntitiesRequest, + ): Promise; entityByName( tx: Transaction, diff --git a/plugins/catalog-backend/src/index.ts b/plugins/catalog-backend/src/index.ts index f621930d10..51df909d05 100644 --- a/plugins/catalog-backend/src/index.ts +++ b/plugins/catalog-backend/src/index.ts @@ -17,5 +17,6 @@ export * from './catalog'; export * from './database'; export * from './ingestion'; +export * from './search'; export * from './service'; export * from './util'; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts index b9078df93f..9e99cc282c 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.test.ts @@ -360,7 +360,10 @@ describe('HigherOrderOperations', () => { entities: [{ entity: desc, location, relations: [] }], errors: [], }); - entitiesCatalog.entities.mockResolvedValue([]); + entitiesCatalog.entities.mockResolvedValue({ + entities: [], + pageInfo: { hasNextPage: false }, + }); entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([]); await expect( diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateScmSlugEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateScmSlugEntityProcessor.test.ts new file mode 100644 index 0000000000..24a5687e0d --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateScmSlugEntityProcessor.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, LocationSpec } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { AnnotateScmSlugEntityProcessor } from './AnnotateScmSlugEntityProcessor'; + +describe('AnnotateScmSlugEntityProcessor', () => { + describe('github', () => { + it('adds annotation', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + }; + const location: LocationSpec = { + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }; + + const processor = AnnotateScmSlugEntityProcessor.fromConfig( + new ConfigReader({}), + ); + + expect(await processor.preProcessEntity(entity, location)).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'github.com/project-slug': 'backstage/backstage', + }, + }, + }); + }); + + it('does not override existing annotation', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'github.com/project-slug': 'backstage/community', + }, + }, + }; + const location: LocationSpec = { + type: 'url', + target: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }; + + const processor = AnnotateScmSlugEntityProcessor.fromConfig( + new ConfigReader({}), + ); + + expect(await processor.preProcessEntity(entity, location)).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'github.com/project-slug': 'backstage/community', + }, + }, + }); + }); + + it('should not add annotation for other providers', async () => { + const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + }; + const location: LocationSpec = { + type: 'url', + target: + 'https://gitlab.com/backstage/backstage/-/blob/master/catalog-info.yaml', + }; + + const processor = AnnotateScmSlugEntityProcessor.fromConfig( + new ConfigReader({}), + ); + + expect(await processor.preProcessEntity(entity, location)).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + }, + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateScmSlugEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateScmSlugEntityProcessor.ts new file mode 100644 index 0000000000..312693cc07 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateScmSlugEntityProcessor.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 { Entity, LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { + ScmIntegrationRegistry, + ScmIntegrations, +} from '@backstage/integration'; +import parseGitUrl from 'git-url-parse'; +import { identity, merge, pickBy } from 'lodash'; +import { CatalogProcessor } from './types'; + +const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; + +export class AnnotateScmSlugEntityProcessor implements CatalogProcessor { + constructor( + private readonly opts: { scmIntegrationRegistry: ScmIntegrationRegistry }, + ) {} + + static fromConfig(config: Config): AnnotateScmSlugEntityProcessor { + return new AnnotateScmSlugEntityProcessor({ + scmIntegrationRegistry: ScmIntegrations.fromConfig(config), + }); + } + + async preProcessEntity( + entity: Entity, + location: LocationSpec, + ): Promise { + if (entity.kind !== 'Component' || location.type !== 'url') { + return entity; + } + + const scmIntegration = this.opts.scmIntegrationRegistry.byUrl( + location.target, + ); + + if (!scmIntegration || scmIntegration.type !== 'github') { + return entity; + } + + const gitUrl = parseGitUrl(location.target); + let githubProjectSlug = + entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]; + + if (!githubProjectSlug) { + githubProjectSlug = `${gitUrl.owner}/${gitUrl.name}`; + } + + return merge( + { + metadata: { + annotations: pickBy( + { + [GITHUB_ACTIONS_ANNOTATION]: githubProjectSlug, + }, + identity, + ), + }, + }, + entity, + ); + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts index eb1187eda5..d268606205 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.test.ts @@ -16,59 +16,23 @@ import { getVoidLogger } from '@backstage/backend-common'; import { LocationSpec } from '@backstage/catalog-model'; -import { CodeOwnersEntry } from 'codeowners-utils'; -import { - buildCodeOwnerUrl, - buildUrl, - CodeOwnersProcessor, - findPrimaryCodeOwner, - findRawCodeOwners, - normalizeCodeOwner, - parseCodeOwners, - resolveCodeOwner, -} from './CodeOwnersProcessor'; +import { ConfigReader } from '@backstage/config'; +import { CodeOwnersProcessor } from './CodeOwnersProcessor'; -const logger = getVoidLogger(); +const mockCodeOwnersText = () => ` +* @acme/team-foo @acme/team-bar +/docs @acme/team-bar +`; describe('CodeOwnersProcessor', () => { - const mockUrl = ({ basePath = '' } = {}): string => - `https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`; const mockLocation = ({ basePath = '', type = 'github', } = {}): LocationSpec => ({ type, - target: mockUrl({ basePath }), + target: `https://github.com/backstage/backstage/blob/master/${basePath}catalog-info.yaml`, }); - const mockReadUrl = (basePath = '') => - `https://github.com/backstage/backstage/blob/master/${basePath}CODEOWNERS`; - - const mockGitUri = (codeOwnersPath: string = '') => { - return { - source: 'github.com', - owner: 'backstage', - name: 'backstage', - codeOwnersPath, - }; - }; - - const mockCodeOwnersText = () => ` -# https://help.github.com/articles/about-codeowners/ -* @spotify/backstage-core @acme/team-foo -/plugins/techdocs @spotify/techdocs-core - `; - - const mockCodeOwners = (): CodeOwnersEntry[] => { - return [ - { - pattern: '/plugins/techdocs', - owners: ['@spotify/techdocs-core'], - }, - { pattern: '*', owners: ['@spotify/backstage-core', '@acme/team-foo'] }, - ]; - }; - const mockReadResult = ({ error = undefined, data = undefined, @@ -82,156 +46,19 @@ describe('CodeOwnersProcessor', () => { return data; }; - describe('buildUrl', () => { - it.each([['azure.com'], ['dev.azure.com']])( - 'should throw not implemented error', - source => { - expect(() => buildUrl({ ...mockGitUri(), source })).toThrow(); - }, - ); - - it('should build github.com url', () => { - expect( - buildUrl({ - ...mockGitUri(), - codeOwnersPath: '/.github/CODEOWNERS', - }), - ).toBe( - 'https://github.com/backstage/backstage/blob/master/.github/CODEOWNERS', - ); - }); - }); - - describe('buildCodeOwnerUrl', () => { - it('should build a location spec to the codeowners', () => { - expect(buildCodeOwnerUrl(mockUrl(), '/docs/CODEOWNERS')).toEqual( - 'https://github.com/backstage/backstage/blob/master/docs/CODEOWNERS', - ); - }); - - it('should handle nested paths from original location spec', () => { - expect( - buildCodeOwnerUrl( - mockUrl({ basePath: 'packages/foo/' }), - '/CODEOWNERS', - ), - ).toEqual( - 'https://github.com/backstage/backstage/blob/master/CODEOWNERS', - ); - }); - }); - - describe('parseCodeOwners', () => { - it('should parse the codeowners file', () => { - expect(parseCodeOwners(mockCodeOwnersText())).toEqual(mockCodeOwners()); - }); - }); - - describe('normalizeCodeOwner', () => { - it('should remove the @ symbol', () => { - expect(normalizeCodeOwner('@yoda')).toBe('yoda'); - }); - - it('should remove org from org/team format', () => { - expect(normalizeCodeOwner('@acme/foo')).toBe('foo'); - }); - - it('should return username from email format', () => { - expect(normalizeCodeOwner('foo@acme.com')).toBe('foo'); - }); - - it.each([['acme/foo'], ['dacme/foo']])( - 'should return string everything else', - owner => { - expect(normalizeCodeOwner(owner)).toBe(owner); - }, - ); - }); - - describe('findPrimaryCodeOwner', () => { - it('should return the primary owner', () => { - expect(findPrimaryCodeOwner(mockCodeOwners())).toBe('backstage-core'); - }); - }); - - describe('findRawCodeOwners', () => { - it('should return found codeowner', async () => { - const ownersText = mockCodeOwnersText(); - const read = jest - .fn() - .mockResolvedValue(mockReadResult({ data: ownersText })); - const reader = { read, readTree: jest.fn(), search: jest.fn() }; - const result = await findRawCodeOwners(mockLocation(), { - reader, - logger, - }); - expect(result).toEqual(ownersText); - }); - - it('should return undefined when no codeowner', async () => { - const read = jest.fn().mockRejectedValue(mockReadResult()); - const reader = { read, readTree: jest.fn(), search: jest.fn() }; - - await expect( - findRawCodeOwners(mockLocation(), { reader, logger }), - ).resolves.toBeUndefined(); - }); - - it('should look at known codeowner locations', async () => { - const ownersText = mockCodeOwnersText(); - const read = jest - .fn() - .mockImplementationOnce(() => mockReadResult({ error: 'foo' })) - .mockImplementationOnce(() => mockReadResult({ error: 'bar' })) - .mockResolvedValue(mockReadResult({ data: ownersText })); - const reader = { read, readTree: jest.fn(), search: jest.fn() }; - - const result = await findRawCodeOwners(mockLocation(), { - reader, - logger, - }); - - expect(read.mock.calls.length).toBe(5); - expect(read.mock.calls[0]).toEqual([mockReadUrl('')]); - expect(read.mock.calls[1]).toEqual([mockReadUrl('docs/')]); - expect(read.mock.calls[2]).toEqual([mockReadUrl('.bitbucket/')]); - expect(read.mock.calls[3]).toEqual([mockReadUrl('.github/')]); - expect(read.mock.calls[4]).toEqual([mockReadUrl('.gitlab/')]); - expect(result).toEqual(ownersText); - }); - }); - - describe('resolveCodeOwner', () => { - it('should return found codeowner', async () => { - const read = jest - .fn() - .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); - const reader = { read, readTree: jest.fn(), search: jest.fn() }; - - const owner = await resolveCodeOwner(mockLocation(), { reader, logger }); - expect(owner).toBe('backstage-core'); - }); - - it('should return undefined when no codeowner', async () => { - const read = jest - .fn() - .mockImplementation(() => mockReadResult({ error: 'error: foo' })); - const reader = { read, readTree: jest.fn(), search: jest.fn() }; - - await expect( - resolveCodeOwner(mockLocation(), { reader, logger }), - ).resolves.toBeUndefined(); - }); - }); - - describe('CodeOwnersProcessor', () => { + describe('preProcessEntity', () => { const setupTest = ({ kind = 'Component', spec = {} } = {}) => { const entity = { kind, spec }; const read = jest .fn() .mockResolvedValue(mockReadResult({ data: mockCodeOwnersText() })); + + const config = new ConfigReader({}); const reader = { read, readTree: jest.fn(), search: jest.fn() }; - const processor = new CodeOwnersProcessor({ reader, logger }); + const processor = CodeOwnersProcessor.fromConfig(config, { + logger: getVoidLogger(), + reader, + }); return { entity, processor, read }; }; @@ -249,18 +76,15 @@ describe('CodeOwnersProcessor', () => { expect(result).toEqual(entity); }); - it('should handle url locations', async () => { + it('should ingore invalid locations type', async () => { const { entity, processor } = setupTest(); const result = await processor.preProcessEntity( entity as any, - mockLocation({ type: 'url' }), + mockLocation({ type: 'github-org' }), ); - expect(result).toEqual({ - ...entity, - spec: { owner: 'backstage-core' }, - }); + expect(result).toEqual(entity); }); it('should ignore invalid kinds', async () => { @@ -284,7 +108,7 @@ describe('CodeOwnersProcessor', () => { expect(result).toEqual({ ...entity, - spec: { owner: 'backstage-core' }, + spec: { owner: 'team-foo' }, }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts index 3855335eb9..e8ce75e7d9 100644 --- a/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/CodeOwnersProcessor.ts @@ -15,19 +15,11 @@ */ import { UrlReader } from '@backstage/backend-common'; -import { NotFoundError } from '@backstage/errors'; -import { - Entity, - LocationSpec, - stringifyLocationReference, -} from '@backstage/catalog-model'; -import * as codeowners from 'codeowners-utils'; -import { CodeOwnersEntry } from 'codeowners-utils'; -// NOTE: This can be removed when ES2021 is implemented -import 'core-js/features/promise'; -import parseGitUrl from 'git-url-parse'; -import { filter, get, head, pipe, reverse } from 'lodash/fp'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; import { Logger } from 'winston'; +import { findCodeOwnerByTarget } from './codeowners'; import { CatalogProcessor } from './types'; const ALLOWED_KINDS = ['API', 'Component', 'Domain', 'Resource', 'System']; @@ -42,18 +34,32 @@ const ALLOWED_LOCATION_TYPES = [ 'gitlab/api', ]; -// TODO(Rugvip): We want to properly detect out repo provider, but for now it's -// best to wait for GitHub Apps to be properly introduced and see -// what kind of APIs that integrations will expose. -const KNOWN_LOCATIONS = ['', '/docs', '/.bitbucket', '/.github', '/.gitlab']; - -type Options = { - reader: UrlReader; - logger: Logger; -}; - export class CodeOwnersProcessor implements CatalogProcessor { - constructor(private readonly options: Options) {} + private readonly integrations: ScmIntegrations; + private readonly logger: Logger; + private readonly reader: UrlReader; + + static fromConfig( + config: Config, + options: { logger: Logger; reader: UrlReader }, + ) { + const integrations = ScmIntegrations.fromConfig(config); + + return new CodeOwnersProcessor({ + ...options, + integrations, + }); + } + + constructor(options: { + integrations: ScmIntegrations; + logger: Logger; + reader: UrlReader; + }) { + this.integrations = options.integrations; + this.logger = options.logger; + this.reader = options.reader; + } async preProcessEntity( entity: Entity, @@ -69,8 +75,21 @@ export class CodeOwnersProcessor implements CatalogProcessor { return entity; } - const owner = await resolveCodeOwner(location, this.options); + const scmIntegration = this.integrations.byUrl(location.target); + if (!scmIntegration) { + return entity; + } + + const owner = await findCodeOwnerByTarget( + this.reader, + location.target, + scmIntegration, + ); + if (!owner) { + this.logger.debug( + `CodeOwnerProcessor could not resolve owner for ${location.target}`, + ); return entity; } @@ -80,112 +99,3 @@ export class CodeOwnersProcessor implements CatalogProcessor { }; } } - -export async function resolveCodeOwner( - location: LocationSpec, - options: Options, -): Promise { - const ownersText = await findRawCodeOwners(location, options); - if (!ownersText) { - return undefined; - } - - const owners = parseCodeOwners(ownersText); - - return findPrimaryCodeOwner(owners); -} - -export async function findRawCodeOwners( - location: LocationSpec, - options: Options, -): Promise { - const readOwnerLocation = async (basePath: string): Promise => { - const ownerUrl = buildCodeOwnerUrl( - location.target, - `${basePath}/CODEOWNERS`, - ); - const data = await options.reader.read(ownerUrl); - return data.toString(); - }; - - const candidates = KNOWN_LOCATIONS.map(readOwnerLocation); - return Promise.any(candidates).catch((aggregateError: AggregateError) => { - const hardError = aggregateError.errors.find( - error => !(error instanceof NotFoundError), - ); - if (hardError) { - options.logger.warn( - `Failed to read codeowners for location ${stringifyLocationReference( - location, - )}, ${hardError}`, - ); - } else { - options.logger.debug( - `Failed to find codeowners for location ${stringifyLocationReference( - location, - )}`, - ); - } - return undefined; - }); -} - -export function buildCodeOwnerUrl( - basePath: string, - codeOwnersPath: string, -): string { - return buildUrl({ ...parseGitUrl(basePath), codeOwnersPath }); -} - -export function parseCodeOwners(ownersText: string) { - return codeowners.parse(ownersText); -} - -export function findPrimaryCodeOwner( - owners: CodeOwnersEntry[], -): string | undefined { - return pipe( - filter((e: CodeOwnersEntry) => e.pattern === '*'), - reverse, - head, - get('owners'), - head, - normalizeCodeOwner, - )(owners); -} - -export function normalizeCodeOwner(owner: string) { - if (owner.match(/^@.*\/.*/)) { - return owner.split('/')[1]; - } else if (owner.match(/^@.*/)) { - return owner.substring(1); - } else if (owner.match(/^.*@.*\..*$/)) { - return owner.split('@')[0]; - } - - return owner; -} - -export function buildUrl({ - protocol = 'https', - source = 'github.com', - owner, - name, - ref = 'master', - codeOwnersPath = '/CODEOWNERS', -}: { - protocol?: string; - source?: string; - owner: string; - name: string; - ref?: string; - codeOwnersPath?: string; -}) { - switch (source) { - case 'dev.azure.com': - case 'azure.com': - throw Error('Azure codeowner url builder not implemented'); - default: - return `${protocol}://${source}/${owner}/${name}/blob/${ref}${codeOwnersPath}`; - } -} diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts index 34f7fa2782..f1818a3ab8 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubDiscoveryProcessor.ts @@ -78,16 +78,14 @@ export class GithubDiscoveryProcessor implements CatalogProcessor { this.logger.info(`Reading GitHub repositories from ${location.target}`); const { repositories } = await getOrganizationRepositories(client, org); + const matching = repositories.filter(r => repoSearchPath.test(r.name)); const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1); this.logger.debug( - `Read ${repositories.length} GitHub repositories in ${duration} seconds`, + `Read ${repositories.length} GitHub repositories (${matching.length} matching the pattern) in ${duration} seconds`, ); - for (const repository of repositories) { - if (!repoSearchPath.test(repository.name)) { - continue; - } + for (const repository of matching) { emit( results.location( { diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/index.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/index.ts new file mode 100644 index 0000000000..ccbb437874 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { findCodeOwnerByTarget, readCodeOwners } from './read'; +export { resolveCodeOwner } from './resolve'; +export { scmCodeOwnersPaths } from './scm'; diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/read.test.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.test.ts new file mode 100644 index 0000000000..7427fde3d0 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.test.ts @@ -0,0 +1,131 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { findCodeOwnerByTarget, readCodeOwners } from './read'; + +const sourceUrl = 'https://github.com/acme/foobar/tree/master/'; + +const mockCodeowners = ` +* @acme/team-foo @acme/team-bar +/docs @acme/team-bar +`; + +const mockReadResult = ({ + error = undefined, + data = undefined, +}: { + error?: string; + data?: string; +} = {}) => { + if (error) { + throw Error(error); + } + return data; +}; + +describe('readCodeOwners', () => { + it('should return found codeowners file', async () => { + const ownersText = mockCodeowners; + const read = jest + .fn() + .mockResolvedValue(mockReadResult({ data: ownersText })); + const reader = { read, readTree: jest.fn(), search: jest.fn() }; + const result = await readCodeOwners(reader, sourceUrl, [ + '.github/CODEOWNERS', + ]); + expect(result).toEqual(ownersText); + }); + + it('should return undefined when no codeowner', async () => { + const read = jest.fn().mockRejectedValue(mockReadResult()); + const reader = { read, readTree: jest.fn(), search: jest.fn() }; + + await expect( + readCodeOwners(reader, sourceUrl, ['.github/CODEOWNERS']), + ).resolves.toBeUndefined(); + }); + + it('should look at multiple locations', async () => { + const ownersText = mockCodeowners; + const read = jest + .fn() + .mockImplementationOnce(() => mockReadResult({ error: 'not found' })) + .mockResolvedValue(mockReadResult({ data: ownersText })); + const reader = { read, readTree: jest.fn(), search: jest.fn() }; + + const result = await readCodeOwners(reader, sourceUrl, [ + '.github/CODEOWNERS', + 'docs/CODEOWNERS', + ]); + + expect(read.mock.calls.length).toBe(2); + expect(read.mock.calls[0]).toEqual([`${sourceUrl}.github/CODEOWNERS`]); + expect(read.mock.calls[1]).toEqual([`${sourceUrl}docs/CODEOWNERS`]); + expect(result).toEqual(ownersText); + }); +}); + +describe('findCodeOwnerByLocation', () => { + const setupTest = ({ + target = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + codeownersContents: codeOwnersContents = mockCodeowners, + }: { target?: string; codeownersContents?: string } = {}) => { + const read = jest + .fn() + .mockResolvedValue(mockReadResult({ data: codeOwnersContents })); + + const scmIntegration = ScmIntegrations.fromConfig( + new ConfigReader({}), + ).byUrl(target); + + const reader = { read, readTree: jest.fn(), search: jest.fn() }; + + return { target, reader, scmIntegration, codeOwnersContents }; + }; + + it('should return an owner', async () => { + const { target, reader, scmIntegration } = setupTest({ + target: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }); + + const result = await findCodeOwnerByTarget( + reader, + target, + scmIntegration as any, + ); + + expect(result).toBe('team-foo'); + }); + + it('should return undefined for invalid scm', async () => { + const { target, reader, scmIntegration } = setupTest({ + target: + 'https://unknown-git-host/backstage/backstage/blob/master/catalog-info.yaml', + codeownersContents: undefined, + }); + + const result = await findCodeOwnerByTarget( + reader, + target, + scmIntegration as any, + ); + + expect(result).toBeUndefined(); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts new file mode 100644 index 0000000000..62899aeaf4 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/read.ts @@ -0,0 +1,75 @@ +/* + * 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 { UrlReader } from '@backstage/backend-common'; +import { NotFoundError } from '@backstage/errors'; +import { ScmIntegration } from '@backstage/integration'; +import 'core-js/features/promise'; // NOTE: This can be removed when ES2021 is implemented +import { resolveCodeOwner } from './resolve'; +import { scmCodeOwnersPaths } from './scm'; + +export async function readCodeOwners( + reader: UrlReader, + sourceUrl: string, + codeownersPaths: string[], +): Promise { + const readOwnerLocation = async (path: string): Promise => { + const url = `${sourceUrl}${path}`; + const data = await reader.read(url); + return data.toString(); + }; + + const candidates = codeownersPaths.map(readOwnerLocation); + + return Promise.any(candidates).catch((aggregateError: AggregateError) => { + const hardError = aggregateError.errors.find( + error => !(error instanceof NotFoundError), + ); + + if (hardError) { + throw hardError; + } + + return undefined; + }); +} + +export async function findCodeOwnerByTarget( + reader: UrlReader, + targetUrl: string, + scmIntegration: ScmIntegration, +): Promise { + const codeownersPaths = scmCodeOwnersPaths[scmIntegration?.type ?? '']; + + const sourceUrl = scmIntegration?.resolveUrl({ + url: '/', + base: targetUrl, + }); + + if (!sourceUrl || !codeownersPaths) { + return undefined; + } + + const contents = await readCodeOwners(reader, sourceUrl, codeownersPaths); + + if (!contents) { + return undefined; + } + + const owner = resolveCodeOwner(contents); + + return owner; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.test.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.test.ts new file mode 100644 index 0000000000..8023e1af27 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { normalizeCodeOwner, resolveCodeOwner } from './resolve'; + +const mockCodeOwnersText = () => ` +* @acme/team-foo @acme/team-bar +/docs @acme/team-bar +`; + +describe('resolveCodeOwner', () => { + it('should parse the codeowners file', () => { + expect(resolveCodeOwner(mockCodeOwnersText())).toBe('team-foo'); + }); +}); + +describe('normalizeCodeOwner', () => { + it('should remove the @ symbol', () => { + expect(normalizeCodeOwner('@yoda')).toBe('yoda'); + }); + + it('should remove org from org/team format', () => { + expect(normalizeCodeOwner('@acme/foo')).toBe('foo'); + }); + + it('should return username from email format', () => { + expect(normalizeCodeOwner('foo@acme.com')).toBe('foo'); + }); + + it.each([['acme/foo'], ['dacme/foo']])( + 'should return string everything else', + owner => { + expect(normalizeCodeOwner(owner)).toBe(owner); + }, + ); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.ts new file mode 100644 index 0000000000..1c059ea48e --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/resolve.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as codeowners from 'codeowners-utils'; +import { CodeOwnersEntry } from 'codeowners-utils'; +import { filter, get, head, pipe, reverse } from 'lodash/fp'; + +export function resolveCodeOwner( + contents: string, + pattern = '*', +): string | undefined { + const owners = codeowners.parse(contents); + + return pipe( + filter((e: CodeOwnersEntry) => e.pattern === pattern), + reverse, + head, + get('owners'), + head, + normalizeCodeOwner, + )(owners); +} + +export function normalizeCodeOwner(owner: string) { + if (owner.match(/^@.*\/.*/)) { + return owner.split('/')[1]; + } else if (owner.match(/^@.*/)) { + return owner.substring(1); + } else if (owner.match(/^.*@.*\..*$/)) { + return owner.split('@')[0]; + } + + return owner; +} diff --git a/plugins/catalog-backend/src/ingestion/processors/codeowners/scm.ts b/plugins/catalog-backend/src/ingestion/processors/codeowners/scm.ts new file mode 100644 index 0000000000..50559709b1 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/codeowners/scm.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +const CODEOWNERS = 'CODEOWNERS'; + +export const scmCodeOwnersPaths: Record = { + // https://mibexsoftware.atlassian.net/wiki/spaces/CODEOWNERS/pages/222822413/Usage + bitbucket: [CODEOWNERS, `.bitbucket/${CODEOWNERS}`], + + // https://docs.gitlab.com/ee/user/project/code_owners.html#how-to-set-up-code-owners + gitlab: [CODEOWNERS, `.gitlab/${CODEOWNERS}`, `docs/${CODEOWNERS}`], + + // https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-file-location + github: [CODEOWNERS, `.github/${CODEOWNERS}`, `docs/${CODEOWNERS}`], +}; diff --git a/plugins/catalog-backend/src/ingestion/processors/index.ts b/plugins/catalog-backend/src/ingestion/processors/index.ts index 736c64ea39..fe4fe1c1dd 100644 --- a/plugins/catalog-backend/src/ingestion/processors/index.ts +++ b/plugins/catalog-backend/src/ingestion/processors/index.ts @@ -17,6 +17,7 @@ import * as results from './results'; export { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor'; +export { AnnotateScmSlugEntityProcessor } from './AnnotateScmSlugEntityProcessor'; export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; export { BuiltinKindsEntityProcessor } from './BuiltinKindsEntityProcessor'; export { CodeOwnersProcessor } from './CodeOwnersProcessor'; diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts index ab6e287698..4d6f67de22 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.test.ts @@ -159,7 +159,7 @@ describe('parseEntityYaml', () => { expect(results).toEqual([ result.generalError( testLoc, - 'YAML error, YAMLSemanticError: Plain value cannot start with reserved character `', + 'YAML error at my-loc-type:my-loc-target, YAMLSemanticError: Plain value cannot start with reserved character `', ), ]); }); @@ -198,7 +198,7 @@ describe('parseEntityYaml', () => { }), result.generalError( testLoc, - 'YAML error, YAMLSemanticError: Nested mappings are not allowed in compact mappings', + 'YAML error at my-loc-type:my-loc-target, YAMLSemanticError: Nested mappings are not allowed in compact mappings', ), ]); }); diff --git a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts index aa24968d6d..0d70d1b35c 100644 --- a/plugins/catalog-backend/src/ingestion/processors/util/parse.ts +++ b/plugins/catalog-backend/src/ingestion/processors/util/parse.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + LocationSpec, + stringifyLocationReference, +} from '@backstage/catalog-model'; import lodash from 'lodash'; import yaml from 'yaml'; import * as result from '../results'; @@ -28,13 +32,16 @@ export function* parseEntityYaml( try { documents = yaml.parseAllDocuments(data.toString('utf8')).filter(d => d); } catch (e) { - yield result.generalError(location, `Failed to parse YAML, ${e}`); + const loc = stringifyLocationReference(location); + const message = `Failed to parse YAML at ${loc}, ${e}`; + yield result.generalError(location, message); return; } for (const document of documents) { if (document.errors?.length) { - const message = `YAML error, ${document.errors[0]}`; + const loc = stringifyLocationReference(location); + const message = `YAML error at ${loc}, ${document.errors[0]}`; yield result.generalError(location, message); } else { const json = document.toJSON(); diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts new file mode 100644 index 0000000000..72bf43f8f9 --- /dev/null +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Entity } from '@backstage/catalog-model'; +import { IndexableDocument, DocumentCollator } from '@backstage/search-common'; +import fetch from 'cross-fetch'; + +export interface CatalogEntityDocument extends IndexableDocument { + componentType: string; + namespace: string; + kind: string; +} + +export class DefaultCatalogCollator implements DocumentCollator { + protected discovery: PluginEndpointDiscovery; + + constructor(discovery: PluginEndpointDiscovery) { + this.discovery = discovery; + } + + async execute() { + const baseUrl = await this.discovery.getBaseUrl('catalog'); + const res = await fetch(`${baseUrl}/entities`); + const entities: Entity[] = await res.json(); + return entities.map( + (entity): CatalogEntityDocument => { + return { + title: entity.metadata.name, + // TODO: Use a config-based template approach for entity location. + location: `/catalog/${ + entity.metadata.namespace || 'default' + }/component/${entity.metadata.name}`, + text: entity.metadata.description || '', + componentType: entity.spec?.type?.toString() || 'other', + namespace: entity.metadata.namespace || 'default', + kind: entity.kind, + }; + }, + ); + } +} diff --git a/plugins/catalog-backend/src/search/index.ts b/plugins/catalog-backend/src/search/index.ts new file mode 100644 index 0000000000..aed16aad76 --- /dev/null +++ b/plugins/catalog-backend/src/search/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 './DefaultCatalogCollator'; diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts index cdc01ba60e..bddc255b02 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.test.ts @@ -61,7 +61,10 @@ describe('CatalogBuilder', () => { it('works with no changes', async () => { const builder = new CatalogBuilder(env); const built = await builder.build(); - await expect(built.entitiesCatalog.entities()).resolves.toEqual([]); + await expect(built.entitiesCatalog.entities()).resolves.toEqual({ + entities: [], + pageInfo: { hasNextPage: false }, + }); await expect(built.locationsCatalog.locations()).resolves.toEqual([ expect.objectContaining({ data: expect.objectContaining({ type: 'bootstrap' }), @@ -166,7 +169,7 @@ describe('CatalogBuilder', () => { type: 'github', target: 'https://github.com/a/b/x.yaml', }); - const entities = await entitiesCatalog.entities(); + const { entities } = await entitiesCatalog.entities(); expect(entities).toEqual([ expect.objectContaining({ @@ -200,7 +203,7 @@ describe('CatalogBuilder', () => { type: 'x', target: 'y', }); - const entities = await entitiesCatalog.entities(); + const { entities } = await entitiesCatalog.entities(); expect.assertions(3); expect(entities).toEqual([ diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 126a138de4..e0b447c686 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -309,7 +309,7 @@ export class CatalogBuilder { LdapOrgReaderProcessor.fromConfig(config, { logger }), MicrosoftGraphOrgReaderProcessor.fromConfig(config, { logger }), new UrlReaderProcessor({ reader, logger }), - new CodeOwnersProcessor({ reader, logger }), + CodeOwnersProcessor.fromConfig(config, { logger, reader }), new LocationEntityProcessor({ integrations }), new AnnotateLocationEntityProcessor({ integrations }), ); diff --git a/plugins/catalog-backend/src/service/EntityFilters.test.ts b/plugins/catalog-backend/src/service/EntityFilters.test.ts deleted file mode 100644 index bfc20c625a..0000000000 --- a/plugins/catalog-backend/src/service/EntityFilters.test.ts +++ /dev/null @@ -1,113 +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 { EntityFilters } from './EntityFilters'; - -describe('EntityFilters', () => { - describe('ofQuery', () => { - it('translates empty query to empty list', () => { - const result = EntityFilters.ofQuery({}); - expect(result).toEqual(undefined); - }); - - it('supports single-string format', () => { - const result = EntityFilters.ofQuery({ filter: 'a=1' })!; - expect(result).toEqual({ - anyOf: [{ allOf: [{ key: 'a', matchValueIn: ['1'] }] }], - }); - }); - - it('supports array-of-strings format', () => { - const result = EntityFilters.ofQuery({ filter: ['a=1', 'b=2'] }); - expect(result).toEqual({ - anyOf: [ - { allOf: [{ key: 'a', matchValueIn: ['1'] }] }, - { allOf: [{ key: 'b', matchValueIn: ['2'] }] }, - ], - }); - }); - - it('throws for non-strings', () => { - expect(() => EntityFilters.ofQuery({ filter: [3] })).toThrow(/string/); - }); - }); - - describe('ofFilterString', () => { - it('runs the happy path', () => { - const result = EntityFilters.ofFilterString('a=1,b=2'); - expect(result).toEqual({ - anyOf: [ - { - allOf: [ - { key: 'a', matchValueIn: ['1'] }, - { key: 'b', matchValueIn: ['2'] }, - ], - }, - ], - }); - }); - - it('ignores empty', () => { - const result = EntityFilters.ofFilterString('a=1,,b=2,'); - expect(result).toEqual({ - anyOf: [ - { - allOf: [ - { key: 'a', matchValueIn: ['1'] }, - { key: 'b', matchValueIn: ['2'] }, - ], - }, - ], - }); - }); - - it('trims', () => { - const result = EntityFilters.ofFilterString(' a = 1 ,, b=2 ,'); - expect(result).toEqual({ - anyOf: [ - { - allOf: [ - { key: 'a', matchValueIn: ['1'] }, - { key: 'b', matchValueIn: ['2'] }, - ], - }, - ], - }); - }); - - it('merges multiple of the same key', () => { - const result = EntityFilters.ofFilterString('a=1,a=2,b=3'); - expect(result).toEqual({ - anyOf: [ - { - allOf: [ - { key: 'a', matchValueIn: ['1', '2'] }, - { key: 'b', matchValueIn: ['3'] }, - ], - }, - ], - }); - }); - - it('throws on missing equal sign', () => { - expect(() => EntityFilters.ofFilterString('a,b=2')).toThrow(); - }); - - it('throws on misplaced equal sign', () => { - expect(() => EntityFilters.ofFilterString('a=,b=2')).toThrow(); - }); - }); -}); diff --git a/plugins/catalog-backend/src/service/EntityFilters.ts b/plugins/catalog-backend/src/service/EntityFilters.ts deleted file mode 100644 index 83c0bfea6c..0000000000 --- a/plugins/catalog-backend/src/service/EntityFilters.ts +++ /dev/null @@ -1,120 +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 { InputError } from '@backstage/errors'; -import { EntitiesSearchFilter, EntityFilter } from '../database'; - -/** - * A builder that assists in creating entity filter instances. - */ -export class EntityFilters { - // Builds a filter from the value of a filter=a=1,b=2 type filter string - static ofFilterString(filterString: string): EntityFilter | undefined { - const builder = new EntityFilters(); - builder.addFilterString(filterString); - return builder.build(); - } - - // Builds a filter from an entire query params structure - static ofQuery(query: Record): EntityFilter | undefined { - const filterStrings = [query.filter || []].flat(); - - if (filterStrings.some(f => typeof f !== 'string')) { - throw new InputError( - 'Only string type filter query parameters are supported', - ); - } - - const builder = new EntityFilters(); - for (const filterString of filterStrings) { - builder.addFilterString(filterString); - } - - return builder.build(); - } - - // Builds a filter from a { key: value } matcher object - static ofMatchers( - matchers: Record, - ): EntityFilter | undefined { - const builder = new EntityFilters(); - builder.addMatchers(matchers); - return builder.build(); - } - - private filters: EntitiesSearchFilter[][]; - - constructor() { - this.filters = []; - } - - addFilterString(filterString: string): EntityFilters { - const filtersByKey: Record = {}; - - const addFilter = (key: string, value: string) => { - const f = - key in filtersByKey - ? filtersByKey[key] - : (filtersByKey[key] = { key, matchValueIn: [] }); - f.matchValueIn!.push(value); - }; - - const statements = filterString - .split(',') - .map(s => s.trim()) - .filter(Boolean); - - for (const statement of statements) { - const equalsIndex = statement.indexOf('='); - if (equalsIndex < 1) { - throw new InputError('Malformed filter query'); - } else { - const key = statement.substr(0, equalsIndex).trim(); - const value = statement.substr(equalsIndex + 1).trim(); - if (!key || !value) { - throw new InputError('Malformed filter query'); - } - addFilter(key, value); - } - } - - this.filters.push(Object.values(filtersByKey)); - - return this; - } - - addMatchers(matchers: Record): EntityFilters { - const filters: EntitiesSearchFilter[] = []; - - for (const [key, value] of Object.entries(matchers)) { - filters.push({ key, matchValueIn: [value].flat() }); - } - - if (filters.length) { - this.filters.push(filters); - } - - return this; - } - - build(): EntityFilter | undefined { - if (!this.filters.length) { - return undefined; - } - - return { anyOf: this.filters.map(f => ({ allOf: f })) }; - } -} diff --git a/plugins/catalog-backend/src/service/request/basicEntityFilter.ts b/plugins/catalog-backend/src/service/request/basicEntityFilter.ts new file mode 100644 index 0000000000..06f2c013b3 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/basicEntityFilter.ts @@ -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 { EntitiesSearchFilter, EntityFilter } from '../../database'; + +/** + * Forms a full EntityFilter based on a single key-value(s) object. + */ +export function basicEntityFilter( + items: Record, +): EntityFilter { + const filtersByKey: Record = {}; + + for (const [key, value] of Object.entries(items)) { + const values = [value].flat(); + + const f = + key in filtersByKey + ? filtersByKey[key] + : (filtersByKey[key] = { key, matchValueIn: [] }); + + f.matchValueIn!.push(...values); + } + + return { anyOf: [{ allOf: Object.values(filtersByKey) }] }; +} diff --git a/plugins/catalog-backend/src/service/request/common.ts b/plugins/catalog-backend/src/service/request/common.ts new file mode 100644 index 0000000000..81369d032b --- /dev/null +++ b/plugins/catalog-backend/src/service/request/common.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 { InputError } from '@backstage/errors'; + +/** + * Takes a single unknown parameter and makes sure that it's a string that can + * be parsed as an integer. + */ +export function parseIntegerParam( + param: unknown, + ctx: string, +): number | undefined { + if (param === undefined) { + return undefined; + } + + if (typeof param !== 'string') { + throw new InputError(`Invalid ${ctx}, not an integer on string form`); + } + + const parsed = parseInt(param, 10); + if (!Number.isInteger(parsed) || String(parsed) !== param) { + throw new InputError(`Invalid ${ctx}, not an integer`); + } + + return parsed; +} + +/** + * Takes a single unknown parameter and makes sure that it's a string. + */ +export function parseStringParam( + param: unknown, + ctx: string, +): string | undefined { + if (param === undefined) { + return undefined; + } + + if (typeof param !== 'string') { + throw new InputError(`Invalid ${ctx}, not a string`); + } + + return param; +} + +/** + * Takes a single unknown parameter and makes sure that it's a single string or + * an array of strings, and returns as an array. + */ +export function parseStringsParam( + param: unknown, + ctx: string, +): string[] | undefined { + if (param === undefined) { + return undefined; + } + + const array = [param].flat(); + if (array.some(p => typeof p !== 'string')) { + throw new InputError(`Invalid ${ctx}, not a string`); + } + + return array as string[]; +} diff --git a/plugins/catalog-backend/src/service/request/index.ts b/plugins/catalog-backend/src/service/request/index.ts new file mode 100644 index 0000000000..c2c51f9ab7 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 { basicEntityFilter } from './basicEntityFilter'; +export { parseEntityFilterParams } from './parseEntityFilterParams'; +export { parseEntityPaginationParams } from './parseEntityPaginationParams'; +export { parseEntityTransformParams } from './parseEntityTransformParams'; diff --git a/plugins/catalog-backend/src/service/request/parseEntityFilterParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.test.ts new file mode 100644 index 0000000000..c44edd7e9b --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.test.ts @@ -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 { + parseEntityFilterParams, + parseEntityFilterString, +} from './parseEntityFilterParams'; + +describe('parseEntityFilterParams', () => { + it('translates empty query to empty list', () => { + const result = parseEntityFilterParams({}); + expect(result).toEqual(undefined); + }); + + it('supports single-string format', () => { + const result = parseEntityFilterParams({ filter: 'a=1' })!; + expect(result).toEqual({ + anyOf: [{ allOf: [{ key: 'a', matchValueIn: ['1'] }] }], + }); + }); + + it('supports array-of-strings format', () => { + const result = parseEntityFilterParams({ + filter: ['a=1', 'b=2'], + }); + expect(result).toEqual({ + anyOf: [ + { allOf: [{ key: 'a', matchValueIn: ['1'] }] }, + { allOf: [{ key: 'b', matchValueIn: ['2'] }] }, + ], + }); + }); + + it('merges values within each filter', () => { + const result = parseEntityFilterParams({ + filter: ['a=1', 'b=2,b=3,c=4'], + }); + expect(result).toEqual({ + anyOf: [ + { allOf: [{ key: 'a', matchValueIn: ['1'] }] }, + { + allOf: [ + { key: 'b', matchValueIn: ['2', '3'] }, + { key: 'c', matchValueIn: ['4'] }, + ], + }, + ], + }); + }); + + it('throws for non-strings', () => { + expect(() => parseEntityFilterParams({ filter: [3] })).toThrow(/string/); + }); +}); + +describe('parseEntityFilterString', () => { + it('works for the happy path', () => { + expect(parseEntityFilterString('')).toBeUndefined(); + expect(parseEntityFilterString('a=1,b=2,a=3')).toEqual([ + { key: 'a', matchValueIn: ['1', '3'] }, + { key: 'b', matchValueIn: ['2'] }, + ]); + }); + + it('trims values', () => { + expect(parseEntityFilterString(' a = 1 , b = 2 , a = 3 ')).toEqual([ + { key: 'a', matchValueIn: ['1', '3'] }, + { key: 'b', matchValueIn: ['2'] }, + ]); + }); + + it('rejects malformed strings', () => { + expect(() => parseEntityFilterString('x=2,a=')).toThrow( + "Invalid filter, 'a=' is not a valid statement (expected a string on the form a=b)", + ); + expect(() => parseEntityFilterString('x=2,=a')).toThrow( + "Invalid filter, '=a' is not a valid statement (expected a string on the form a=b)", + ); + expect(() => parseEntityFilterString('x=2,=')).toThrow( + "Invalid filter, '=' is not a valid statement (expected a string on the form a=b)", + ); + expect(() => parseEntityFilterString('x=2,a')).toThrow( + "Invalid filter, 'a' is not a valid statement (expected a string on the form a=b)", + ); + }); +}); diff --git a/plugins/catalog-backend/src/service/request/parseEntityFilterParams.ts b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.ts new file mode 100644 index 0000000000..726eab9382 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityFilterParams.ts @@ -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 { InputError } from '@backstage/errors'; +import { EntitiesSearchFilter, EntityFilter } from '../../database'; +import { parseStringsParam } from './common'; + +/** + * Parses the filtering part of a query, like + * /entities?filter=metadata.namespace=default,kind=Component + */ +export function parseEntityFilterParams( + params: Record, +): EntityFilter | undefined { + // Each filter string is on the form a=b,c=d + const filterStrings = parseStringsParam(params.filter, 'filter'); + if (!filterStrings) { + return undefined; + } + + // Outer array: "any of the inner ones" + // Inner arrays: "all of these must match" + const filters = filterStrings.map(parseEntityFilterString).filter(Boolean); + if (!filters.length) { + return undefined; + } + + return { anyOf: filters.map(f => ({ allOf: f! })) }; +} + +/** + * Parses a single filter string as seen in a filter query, for example + * metadata.namespace=default,kind=Component + */ +export function parseEntityFilterString( + filterString: string, +): EntitiesSearchFilter[] | undefined { + const statements = filterString + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + if (!statements.length) { + return undefined; + } + + const filtersByKey: Record = {}; + + for (const statement of statements) { + const equalsIndex = statement.indexOf('='); + if (equalsIndex < 1) { + throw new InputError( + `Invalid filter, '${statement}' is not a valid statement (expected a string on the form a=b)`, + ); + } + + const key = statement.substr(0, equalsIndex).trim(); + const value = statement.substr(equalsIndex + 1).trim(); + if (!key || !value) { + throw new InputError( + `Invalid filter, '${statement}' is not a valid statement (expected a string on the form a=b)`, + ); + } + + const f = + key in filtersByKey + ? filtersByKey[key] + : (filtersByKey[key] = { key, matchValueIn: [] }); + f.matchValueIn!.push(value); + } + + return Object.values(filtersByKey); +} diff --git a/plugins/catalog-backend/src/service/request/parseEntityPaginationParams.test.ts b/plugins/catalog-backend/src/service/request/parseEntityPaginationParams.test.ts new file mode 100644 index 0000000000..d537c431c0 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityPaginationParams.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityPaginationParams } from './parseEntityPaginationParams'; + +describe('parseEntityPaginationParams', () => { + it('works for the happy path', () => { + expect(parseEntityPaginationParams({})).toBeUndefined(); + expect(parseEntityPaginationParams({ limit: '1' })).toEqual({ limit: 1 }); + expect(parseEntityPaginationParams({ offset: '0' })).toEqual({ offset: 0 }); + expect(parseEntityPaginationParams({ offset: '2' })).toEqual({ offset: 2 }); + expect(parseEntityPaginationParams({ after: 'x' })).toEqual({ after: 'x' }); + expect( + parseEntityPaginationParams({ limit: '1', offset: '2', after: 'x' }), + ).toEqual({ limit: 1, offset: 2, after: 'x' }); + }); + + it('rejects bad values', () => { + expect(() => parseEntityPaginationParams({ limit: '' })).toThrow( + 'Invalid limit, not an integer', + ); + expect(() => parseEntityPaginationParams({ limit: '0' })).toThrow( + 'Invalid limit, must be greater than zero', + ); + expect(() => parseEntityPaginationParams({ limit: '-1' })).toThrow( + 'Invalid limit, must be greater than zero', + ); + expect(() => parseEntityPaginationParams({ offset: '' })).toThrow( + 'Invalid offset, not an integer', + ); + expect(() => parseEntityPaginationParams({ offset: '-1' })).toThrow( + 'Invalid offset, must be zero or greater', + ); + expect(() => parseEntityPaginationParams({ after: '' })).toThrow( + 'Invalid after, must not be empty', + ); + }); +}); diff --git a/plugins/catalog-backend/src/service/request/parseEntityPaginationParams.ts b/plugins/catalog-backend/src/service/request/parseEntityPaginationParams.ts new file mode 100644 index 0000000000..6ebd8c6d99 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityPaginationParams.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { EntityPagination } from '../../database'; +import { parseIntegerParam, parseStringParam } from './common'; + +/** + * Parses the pagination related parameters out of a query, e.g. + * /entities?offset=100&limit=10 + */ +export function parseEntityPaginationParams( + params: Record, +): EntityPagination | undefined { + const offset = parseIntegerParam(params.offset, 'offset'); + const limit = parseIntegerParam(params.limit, 'limit'); + const after = parseStringParam(params.after, 'after'); + + if (offset === undefined && limit === undefined && after === undefined) { + return undefined; + } + + if (offset !== undefined && offset < 0) { + throw new InputError(`Invalid offset, must be zero or greater`); + } + if (limit !== undefined && limit <= 0) { + throw new InputError(`Invalid limit, must be greater than zero`); + } + if (after !== undefined && !after) { + throw new InputError(`Invalid after, must not be empty`); + } + + return { + ...(offset !== undefined ? { offset } : {}), + ...(limit !== undefined ? { limit } : {}), + ...(after !== undefined ? { after } : {}), + }; +} diff --git a/plugins/catalog-backend/src/service/filterQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts similarity index 55% rename from plugins/catalog-backend/src/service/filterQuery.test.ts rename to plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.ts index a4b3d9c671..b8b43a5098 100644 --- a/plugins/catalog-backend/src/service/filterQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.test.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. @@ -15,9 +15,9 @@ */ import { Entity } from '@backstage/catalog-model'; -import { translateQueryToFieldMapper } from './filterQuery'; +import { parseEntityTransformParams } from './parseEntityTransformParams'; -describe('translateQueryToFieldMapper', () => { +describe('parseEntityTransformParams', () => { const entity: Entity = { apiVersion: 'av', kind: 'k', @@ -30,37 +30,38 @@ describe('translateQueryToFieldMapper', () => { }, }; - it('passes through when no fields given', () => { - expect(translateQueryToFieldMapper({})(entity)).toBe(entity); - expect(translateQueryToFieldMapper({ fields: [] })(entity)).toBe(entity); - expect(translateQueryToFieldMapper({ fields: [''] })(entity)).toBe(entity); - expect(translateQueryToFieldMapper({ fields: [','] })(entity)).toBe(entity); + it('returns undefined when no fields given', () => { + expect(parseEntityTransformParams({})).toBeUndefined(); + expect(parseEntityTransformParams({ fields: '' })).toBeUndefined(); + expect(parseEntityTransformParams({ fields: [] })).toBeUndefined(); + expect(parseEntityTransformParams({ fields: [''] })).toBeUndefined(); + expect(parseEntityTransformParams({ fields: [','] })).toBeUndefined(); }); it('rejects attempts at array filtering', () => { expect(() => - translateQueryToFieldMapper({ fields: 'metadata.tags[0]' })(entity), - ).toThrow(/array/i); + parseEntityTransformParams({ fields: 'metadata.tags[0]' })!(entity), + ).toThrow(/invalid fields, array type fields are not supported/i); }); it('accepts both strings and arrays of strings as input', () => { - expect(translateQueryToFieldMapper({ fields: 'kind' })(entity)).toEqual({ + expect(parseEntityTransformParams({ fields: 'kind' })!(entity)).toEqual({ kind: 'k', }); - expect(translateQueryToFieldMapper({ fields: ['kind'] })(entity)).toEqual({ + expect(parseEntityTransformParams({ fields: ['kind'] })!(entity)).toEqual({ kind: 'k', }); expect( - translateQueryToFieldMapper({ fields: ['kind', 'apiVersion'] })(entity), + parseEntityTransformParams({ fields: ['kind', 'apiVersion'] })!(entity), ).toEqual({ apiVersion: 'av', kind: 'k' }); }); it('supports sub-selection properly', () => { expect( - translateQueryToFieldMapper({ fields: 'kind,metadata.name' })(entity), + parseEntityTransformParams({ fields: 'kind,metadata.name' })!(entity), ).toEqual({ kind: 'k', metadata: { name: 'n' } }); expect( - translateQueryToFieldMapper({ fields: 'metadata' })(entity), + parseEntityTransformParams({ fields: 'metadata' })!(entity), ).toEqual({ metadata: { name: 'n', tags: ['t1', 't2'] } }); }); }); diff --git a/plugins/catalog-backend/src/service/filterQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts similarity index 66% rename from plugins/catalog-backend/src/service/filterQuery.ts rename to plugins/catalog-backend/src/service/request/parseEntityTransformParams.ts index 5e6b18ddf1..0a7c97e8dc 100644 --- a/plugins/catalog-backend/src/service/filterQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityTransformParams.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. @@ -14,26 +14,18 @@ * limitations under the License. */ -import { InputError } from '@backstage/errors'; import { Entity } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; import lodash from 'lodash'; -import { RecursivePartial } from '../util'; +import { RecursivePartial } from '../../util'; +import { parseStringsParam } from './common'; -type FieldMapper = (entity: Entity) => Entity; - -export function translateQueryToFieldMapper( - query: Record, -): FieldMapper { - if (!query.fields) { - return x => x; - } - - const fieldsStrings = [query.fields].flat() as string[]; - - if (fieldsStrings.some(s => typeof s !== 'string')) { - throw new InputError( - 'Only string type fields query parameters are supported', - ); +export function parseEntityTransformParams( + params: Record, +): ((entity: Entity) => Entity) | undefined { + const fieldsStrings = parseStringsParam(params.fields, 'fields'); + if (!fieldsStrings) { + return undefined; } const fields = fieldsStrings @@ -43,13 +35,11 @@ export function translateQueryToFieldMapper( .filter(Boolean); if (!fields.length) { - return x => x; + return undefined; } if (fields.some(f => f.includes('['))) { - throw new InputError( - 'Array type fields query parameters are not supported', - ); + throw new InputError('invalid fields, array type fields are not supported'); } return input => { diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts index f7655b859e..74cf187548 100644 --- a/plugins/catalog-backend/src/service/router.test.ts +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -22,8 +22,8 @@ import request from 'supertest'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { LocationResponse } from '../catalog/types'; import { HigherOrderOperation } from '../ingestion/types'; -import { EntityFilters } from './EntityFilters'; import { createRouter } from './router'; +import { basicEntityFilter } from './request'; describe('createRouter', () => { let entitiesCatalog: jest.Mocked; @@ -69,7 +69,10 @@ describe('createRouter', () => { { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, ]; - entitiesCatalog.entities.mockResolvedValueOnce(entities); + entitiesCatalog.entities.mockResolvedValueOnce({ + entities: [entities[0]], + pageInfo: { hasNextPage: false }, + }); const response = await request(app).get('/entities'); @@ -78,7 +81,10 @@ describe('createRouter', () => { }); it('parses single and multiple request parameters and passes them down', async () => { - entitiesCatalog.entities.mockResolvedValueOnce([]); + entitiesCatalog.entities.mockResolvedValueOnce({ + entities: [], + pageInfo: { hasNextPage: false }, + }); const response = await request(app).get( '/entities?filter=a=1,a=2,b=3&filter=c=4', ); @@ -86,15 +92,17 @@ describe('createRouter', () => { expect(response.status).toEqual(200); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); expect(entitiesCatalog.entities).toHaveBeenCalledWith({ - anyOf: [ - { - allOf: [ - { key: 'a', matchValueIn: ['1', '2'] }, - { key: 'b', matchValueIn: ['3'] }, - ], - }, - { allOf: [{ key: 'c', matchValueIn: ['4'] }] }, - ], + filter: { + anyOf: [ + { + allOf: [ + { key: 'a', matchValueIn: ['1', '2'] }, + { key: 'b', matchValueIn: ['3'] }, + ], + }, + { allOf: [{ key: 'c', matchValueIn: ['4'] }] }, + ], + }, }); }); }); @@ -108,27 +116,33 @@ describe('createRouter', () => { name: 'c', }, }; - entitiesCatalog.entities.mockResolvedValue([entity]); + entitiesCatalog.entities.mockResolvedValue({ + entities: [entity], + pageInfo: { hasNextPage: false }, + }); const response = await request(app).get('/entities/by-uid/zzz'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith( - EntityFilters.ofMatchers({ 'metadata.uid': 'zzz' }), - ); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ 'metadata.uid': 'zzz' }), + }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); it('responds with a 404 for missing entities', async () => { - entitiesCatalog.entities.mockResolvedValue([]); + entitiesCatalog.entities.mockResolvedValue({ + entities: [], + pageInfo: { hasNextPage: false }, + }); const response = await request(app).get('/entities/by-uid/zzz'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith( - EntityFilters.ofMatchers({ 'metadata.uid': 'zzz' }), - ); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ 'metadata.uid': 'zzz' }), + }); expect(response.status).toEqual(404); expect(response.text).toMatch(/uid/); }); @@ -144,35 +158,41 @@ describe('createRouter', () => { namespace: 'ns', }, }; - entitiesCatalog.entities.mockResolvedValue([entity]); + entitiesCatalog.entities.mockResolvedValue({ + entities: [entity], + pageInfo: { hasNextPage: false }, + }); const response = await request(app).get('/entities/by-name/k/ns/n'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith( - EntityFilters.ofMatchers({ + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ kind: 'k', 'metadata.namespace': 'ns', 'metadata.name': 'n', }), - ); + }); expect(response.status).toEqual(200); expect(response.body).toEqual(expect.objectContaining(entity)); }); it('responds with a 404 for missing entities', async () => { - entitiesCatalog.entities.mockResolvedValue([]); + entitiesCatalog.entities.mockResolvedValue({ + entities: [], + pageInfo: { hasNextPage: false }, + }); const response = await request(app).get('/entities/by-name/b/d/c'); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith( - EntityFilters.ofMatchers({ + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ kind: 'b', 'metadata.namespace': 'd', 'metadata.name': 'c', }), - ); + }); expect(response.status).toEqual(404); expect(response.text).toMatch(/name/); }); @@ -203,7 +223,10 @@ describe('createRouter', () => { entitiesCatalog.batchAddOrUpdateEntities.mockResolvedValue([ { entityId: 'u' }, ]); - entitiesCatalog.entities.mockResolvedValue([entity]); + entitiesCatalog.entities.mockResolvedValue({ + entities: [entity], + pageInfo: { hasNextPage: false }, + }); const response = await request(app) .post('/entities') @@ -215,9 +238,9 @@ describe('createRouter', () => { { entity, relations: [] }, ]); expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1); - expect(entitiesCatalog.entities).toHaveBeenCalledWith( - EntityFilters.ofMatchers({ 'metadata.uid': 'u' }), - ); + expect(entitiesCatalog.entities).toHaveBeenCalledWith({ + filter: basicEntityFilter({ 'metadata.uid': 'u' }), + }); expect(response.status).toEqual(200); expect(response.body).toEqual(entity); }); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index 96e424c70b..048126aace 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -27,8 +27,12 @@ import { Logger } from 'winston'; import yn from 'yn'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; import { HigherOrderOperation, LocationAnalyzer } from '../ingestion/types'; -import { EntityFilters } from './EntityFilters'; -import { translateQueryToFieldMapper } from './filterQuery'; +import { + basicEntityFilter, + parseEntityFilterParams, + parseEntityPaginationParams, + parseEntityTransformParams, +} from './request'; import { requireRequestBody, validateRequestBody } from './util'; export interface RouterOptions { @@ -55,10 +59,22 @@ export async function createRouter( if (entitiesCatalog) { router .get('/entities', async (req, res) => { - const filter = EntityFilters.ofQuery(req.query); - const fieldMapper = translateQueryToFieldMapper(req.query); - const entities = await entitiesCatalog.entities(filter); - res.status(200).json(entities.map(fieldMapper)); + 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); }) .post('/entities', async (req, res) => { /* @@ -75,16 +91,16 @@ export async function createRouter( const [result] = await entitiesCatalog.batchAddOrUpdateEntities([ { entity: body as Entity, relations: [] }, ]); - const [entity] = await entitiesCatalog.entities( - EntityFilters.ofMatchers({ 'metadata.uid': result.entityId }), - ); - res.status(200).json(entity); + const response = await entitiesCatalog.entities({ + filter: basicEntityFilter({ 'metadata.uid': result.entityId }), + }); + res.status(200).json(response.entities[0]); }) .get('/entities/by-uid/:uid', async (req, res) => { const { uid } = req.params; - const entities = await entitiesCatalog.entities( - EntityFilters.ofMatchers({ 'metadata.uid': uid }), - ); + const { entities } = await entitiesCatalog.entities({ + filter: basicEntityFilter({ 'metadata.uid': uid }), + }); if (!entities.length) { throw new NotFoundError(`No entity with uid ${uid}`); } @@ -97,13 +113,13 @@ export async function createRouter( }) .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { const { kind, namespace, name } = req.params; - const entities = await entitiesCatalog.entities( - EntityFilters.ofMatchers({ + 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}'`, diff --git a/plugins/catalog-graphql/CHANGELOG.md b/plugins/catalog-graphql/CHANGELOG.md index ad536e45dc..899bf8a406 100644 --- a/plugins/catalog-graphql/CHANGELOG.md +++ b/plugins/catalog-graphql/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-catalog-graphql +## 0.2.7 + +### Patch Changes + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.2.6 ### Patch Changes diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 9d76545909..00640b9ad5 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.6", + "version": "0.2.7", "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.5.2", + "@backstage/backend-common": "^0.6.0", "@backstage/catalog-model": "^0.7.1", - "@backstage/config": "^0.1.2", + "@backstage/config": "^0.1.4", "@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.0", - "@backstage/test-utils": "^0.1.5", + "@backstage/cli": "^0.6.5", + "@backstage/test-utils": "^0.1.9", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", "@graphql-codegen/typescript-resolvers": "^1.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index ed48368ee9..ea158b03dc 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,40 @@ # @backstage/plugin-catalog-import +## 0.5.0 + +### Minor Changes + +- 3385b374b: Use `scmIntegrationsApiRef` from the new `@backstage/integration-react`. + +### Patch Changes + +- 7d8c4c97c: Update the wording for unknown URLs in the import flow +- a0dacc184: Use title form field value for the commit message on catalog import PRs. Also allow customization of the pull requests title or body only. For example: + + ```tsx + ({ + title: 'chore: add backstage catalog file [skip ci]', + }), + }} + /> + } + /> + ``` + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/catalog-client@0.3.8 + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.4.3 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 935d65891e..09cd47747f 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.4.3", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,10 +31,11 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/catalog-client": "^0.3.6", - "@backstage/core": "^0.7.1", + "@backstage/catalog-client": "^0.3.8", + "@backstage/core": "^0.7.2", "@backstage/integration": "^0.5.0", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/integration-react": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,9 +52,9 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 95082829c4..9d3ca8d22a 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -52,7 +52,10 @@ jest.mock('./GitHub', () => ({ })); import { ConfigReader, OAuthApi, UrlPatternDiscovery } from '@backstage/core'; -import { GitHubIntegrationConfig } from '@backstage/integration'; +import { + GitHubIntegrationConfig, + ScmIntegrations, +} from '@backstage/integration'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { msw } from '@backstage/test-utils'; import { Octokit } from '@octokit/rest'; @@ -87,7 +90,7 @@ describe('CatalogImportClient', () => { }, }; - const configApi = new ConfigReader({}); + const scmIntegrationsApi = ScmIntegrations.fromConfig(new ConfigReader({})); const catalogApi: jest.Mocked = { getEntities: jest.fn(), @@ -105,7 +108,7 @@ describe('CatalogImportClient', () => { catalogImportClient = new CatalogImportClient({ discoveryApi, githubAuthApi, - configApi, + scmIntegrationsApi, identityApi, catalogApi, }); @@ -167,7 +170,11 @@ describe('CatalogImportClient', () => { catalogImportClient.analyzeUrl( 'https://github.com/backstage/backstage', ), - ).rejects.toThrow(new Error('Invalid url')); + ).rejects.toThrow( + new Error( + 'This URL was not recognized as a valid GitHub URL because there was no configured integration that matched the given host name. You could try to paste the full URL to a catalog-info.yaml file instead.', + ), + ); }); it('should find locations from github', async () => { @@ -301,7 +308,7 @@ describe('CatalogImportClient', () => { catalogImportClient.submitPullRequest({ repositoryUrl: 'https://github.com/backstage/backstage', fileContent: 'some content', - title: 'A title', + title: 'A title/message', body: 'A body', }), ).resolves.toEqual({ @@ -325,7 +332,7 @@ describe('CatalogImportClient', () => { owner: 'backstage', repo: 'backstage', path: 'catalog-info.yaml', - message: 'Add catalog-info.yaml config file', + message: 'A title/message', content: 'c29tZSBjb250ZW50', branch: 'backstage-integration', }); @@ -334,7 +341,7 @@ describe('CatalogImportClient', () => { ).toEqual({ owner: 'backstage', repo: 'backstage', - title: 'A title', + title: 'A title/message', head: 'backstage-integration', body: 'A body', base: 'main', diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index b4b1ebe845..f6e8cb69d4 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -16,13 +16,11 @@ import { CatalogApi } from '@backstage/catalog-client'; import { EntityName } from '@backstage/catalog-model'; +import { DiscoveryApi, IdentityApi, OAuthApi } from '@backstage/core'; import { - ConfigApi, - DiscoveryApi, - IdentityApi, - OAuthApi, -} from '@backstage/core'; -import { GitHubIntegrationConfig } from '@backstage/integration'; + GitHubIntegrationConfig, + ScmIntegrationRegistry, +} from '@backstage/integration'; import { Octokit } from '@octokit/rest'; import { PartialEntity } from '../types'; import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi'; @@ -32,20 +30,20 @@ export class CatalogImportClient implements CatalogImportApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; private readonly githubAuthApi: OAuthApi; - private readonly configApi: ConfigApi; + private readonly scmIntegrationsApi: ScmIntegrationRegistry; private readonly catalogApi: CatalogApi; constructor(options: { discoveryApi: DiscoveryApi; githubAuthApi: OAuthApi; identityApi: IdentityApi; - configApi: ConfigApi; + scmIntegrationsApi: ScmIntegrationRegistry; catalogApi: CatalogApi; }) { this.discoveryApi = options.discoveryApi; this.githubAuthApi = options.githubAuthApi; this.identityApi = options.identityApi; - this.configApi = options.configApi; + this.scmIntegrationsApi = options.scmIntegrationsApi; this.catalogApi = options.catalogApi; } @@ -72,33 +70,34 @@ export class CatalogImportClient implements CatalogImportApi { }; } - const ghConfig = getGithubIntegrationConfig(this.configApi, url); + const ghConfig = getGithubIntegrationConfig(this.scmIntegrationsApi, url); + if (!ghConfig) { + throw new Error( + 'This URL was not recognized as a valid GitHub URL because there was no configured integration that matched the given host name. You could try to paste the full URL to a catalog-info.yaml file instead.', + ); + } - if (ghConfig) { - // TODO: this could be part of the analyze-location endpoint - const locations = await this.checkGitHubForExistingCatalogInfo({ - ...ghConfig, - url, - }); - - if (locations.length > 0) { - return { - type: 'locations', - locations, - }; - } + // TODO: this could be part of the analyze-location endpoint + const locations = await this.checkGitHubForExistingCatalogInfo({ + ...ghConfig, + url, + }); + if (locations.length > 0) { return { - type: 'repository', - integrationType: 'github', - url: url, - generatedEntities: await this.generateEntityDefinitions({ - repo: url, - }), + type: 'locations', + locations, }; } - throw new Error('Invalid url'); + return { + type: 'repository', + integrationType: 'github', + url: url, + generatedEntities: await this.generateEntityDefinitions({ + repo: url, + }), + }; } async submitPullRequest({ @@ -112,7 +111,10 @@ export class CatalogImportClient implements CatalogImportApi { title: string; body: string; }): Promise<{ link: string; location: string }> { - const ghConfig = getGithubIntegrationConfig(this.configApi, repositoryUrl); + const ghConfig = getGithubIntegrationConfig( + this.scmIntegrationsApi, + repositoryUrl, + ); if (ghConfig) { return await this.submitGitHubPrToRepo({ @@ -297,7 +299,7 @@ export class CatalogImportClient implements CatalogImportApi { owner, repo, path: fileName, - message: `Add ${fileName} config file`, + message: title, content: btoa(fileContent), branch: branchName, }) diff --git a/plugins/catalog-import/src/api/GitHub.ts b/plugins/catalog-import/src/api/GitHub.ts index 6a798ec201..1e80715982 100644 --- a/plugins/catalog-import/src/api/GitHub.ts +++ b/plugins/catalog-import/src/api/GitHub.ts @@ -14,26 +14,22 @@ * limitations under the License. */ -import { ConfigApi } from '@backstage/core'; -import { ScmIntegrations } from '@backstage/integration'; +import { ScmIntegrationRegistry } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; export const getGithubIntegrationConfig = ( - config: ConfigApi, + scmIntegrationsApi: ScmIntegrationRegistry, location: string, ) => { - const { name: repo, owner } = parseGitUrl(location); - - const scmIntegrations = ScmIntegrations.fromConfig(config); - const githubIntegrationConfig = scmIntegrations.github.byUrl(location); - - if (!githubIntegrationConfig) { + const integration = scmIntegrationsApi.github.byUrl(location); + if (!integration) { return undefined; } + const { name: repo, owner } = parseGitUrl(location); return { repo, owner, - githubIntegrationConfig: githubIntegrationConfig.config, + githubIntegrationConfig: integration.config, }; }; diff --git a/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx index d94a1cad19..bdeb483df4 100644 --- a/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx +++ b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx @@ -54,7 +54,7 @@ function sortEntities(entities: Array) { } function getEntityIcon(entity: { kind: string }): React.ReactElement { - switch (entity.kind.toLowerCase()) { + switch (entity.kind.toLocaleLowerCase('en-US')) { case 'api': return ; diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx index 4cadea85c0..08903e9933 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -60,7 +60,7 @@ describe('', () => { getAccessToken: async () => 'token', }, identityApi, - configApi: {} as any, + scmIntegrationsApi: {} as any, catalogApi: {} as any, }), ); diff --git a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx index dff2136aaa..354ff6974f 100644 --- a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx +++ b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx @@ -39,7 +39,9 @@ import { ImportFlows, ImportState } from '../useImportState'; export type StepperProviderOpts = { pullRequest?: { disable?: boolean; - preparePullRequest?: (apis: StepperApis) => { title: string; body: string }; + preparePullRequest?: ( + apis: StepperApis, + ) => { title?: string; body?: string }; }; }; @@ -71,13 +73,18 @@ export type StepperProvider = { ) => StepConfiguration; }; -function defaultPreparePullRequest(apis: StepperApis) { +function defaultPreparePullRequest( + apis: StepperApis, + { title, body }: { title?: string; body?: string } = {}, +) { const appTitle = apis.configApi.getOptionalString('app.title') ?? 'Backstage'; const appBaseUrl = apis.configApi.getString('app.baseUrl'); return { - title: 'Add catalog-info.yaml config file', - body: `This pull request adds a **Backstage entity metadata file** \ + title: title ?? 'Add catalog-info.yaml config file', + body: + body ?? + `This pull request adds a **Backstage entity metadata file** \ to this repository so that the component can be added to the \ [${appTitle} software catalog](${appBaseUrl}).\n\nAfter this pull request is merged, \ the component will become available.\n\nFor more information, read an \ @@ -160,10 +167,12 @@ export function defaultGenerateStepper( return defaults.prepare(state, opts); } - const { title, body } = ( - opts?.opts?.pullRequest?.preparePullRequest ?? - defaultPreparePullRequest - )(opts.apis); + const preparePullRequest = + opts?.opts?.pullRequest?.preparePullRequest; + const { title, body } = defaultPreparePullRequest( + opts.apis, + preparePullRequest ? preparePullRequest(opts.apis) : {}, + ); return { stepLabel: Create Pull Request, diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index 18b3124b3b..b0beaf5a50 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -15,15 +15,15 @@ */ import { - identityApiRef, - configApiRef, createApiFactory, createPlugin, createRoutableExtension, createRouteRef, discoveryApiRef, githubAuthApiRef, + identityApiRef, } from '@backstage/core'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogImportApiRef, CatalogImportClient } from './api'; @@ -41,20 +41,20 @@ export const catalogImportPlugin = createPlugin({ discoveryApi: discoveryApiRef, githubAuthApi: githubAuthApiRef, identityApi: identityApiRef, - configApi: configApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, catalogApi: catalogApiRef, }, factory: ({ discoveryApi, githubAuthApi, identityApi, - configApi, + scmIntegrationsApi, catalogApi, }) => new CatalogImportClient({ discoveryApi, githubAuthApi, - configApi, + scmIntegrationsApi, identityApi, catalogApi, }), diff --git a/plugins/catalog-react/CHANGELOG.md b/plugins/catalog-react/CHANGELOG.md index 65ebd5a39a..eb1b87349f 100644 --- a/plugins/catalog-react/CHANGELOG.md +++ b/plugins/catalog-react/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-catalog-react +## 0.1.2 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/catalog-client@0.3.8 + - @backstage/core@0.7.2 + ## 0.1.1 ### Patch Changes diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 474fa6d46d..567c89db80 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.1", + "version": "0.1.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,9 +28,9 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.6", + "@backstage/catalog-client": "^0.3.8", "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.0", + "@backstage/core": "^0.7.2", "@material-ui/core": "^4.11.0", "@types/react": "^16.9", "react": "^16.13.1", @@ -39,9 +39,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.3", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx index 73e3824d98..c19c9c1703 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx @@ -48,11 +48,12 @@ export const EntityRefLink = forwardRef( name = entityRef.name; } - kind = kind.toLowerCase(); + kind = kind.toLocaleLowerCase('en-US'); const routeParams = { kind, - namespace: namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + namespace: + namespace?.toLocaleLowerCase('en-US') ?? ENTITY_DEFAULT_NAMESPACE, name, }; diff --git a/plugins/catalog-react/src/hooks/useEntityCompoundName.ts b/plugins/catalog-react/src/hooks/useEntityCompoundName.ts index dd9c0d26ff..f76097a6dc 100644 --- a/plugins/catalog-react/src/hooks/useEntityCompoundName.ts +++ b/plugins/catalog-react/src/hooks/useEntityCompoundName.ts @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useParams } from 'react-router'; +import { useRouteRefParams } from '@backstage/core'; +import { entityRouteRef } from '../routes'; /** * Grabs entity kind, namespace, and name from the location */ export const useEntityCompoundName = () => { - const { kind, namespace, name } = useParams(); + const { kind, namespace, name } = useRouteRefParams(entityRouteRef); return { kind, namespace, name }; }; diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index 8fa0ba08f9..4dd511f42c 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/plugin-catalog +## 0.5.0 + +### Minor Changes + +- 3385b374b: Use `scmIntegrationsApiRef` from the new `@backstage/integration-react`. + +### Patch Changes + +- 633a31fec: Add the ability to change the initially selected filter, if not set it still defaults to `owned`. + + ```js + } + /> + ``` + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- 8686eb38c: Use errors from `@backstage/errors` +- Updated dependencies [8686eb38c] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/catalog-client@0.3.8 + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.4.2 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index e5b76d50dc..8c05c376f9 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.4.2", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,11 +30,12 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.7", + "@backstage/catalog-client": "^0.3.8", "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/integration": "^0.5.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/integration-react": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,9 +52,9 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 926deec325..435a8d14d5 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -15,12 +15,11 @@ */ import { RELATION_OWNED_BY } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry, ConfigReader } from '@backstage/core'; import { - ApiProvider, - ApiRegistry, - configApiRef, - ConfigReader, -} from '@backstage/core'; + ScmIntegrationsApi, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { act, fireEvent } from '@testing-library/react'; @@ -34,7 +33,7 @@ describe('', () => { kind: 'Component', metadata: { name: 'software', - description: 'This is the decription', + description: 'This is the description', }, spec: { owner: 'guest', @@ -53,10 +52,12 @@ describe('', () => { ], }; const apis = ApiRegistry.with( - configApiRef, - new ConfigReader({ - integrations: {}, - }), + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig( + new ConfigReader({ + integrations: {}, + }), + ), ); const { getByText } = await renderInTestApp( @@ -70,7 +71,7 @@ describe('', () => { expect(getByText('service')).toBeInTheDocument(); expect(getByText('user:guest')).toBeInTheDocument(); expect(getByText('production')).toBeInTheDocument(); - expect(getByText('This is the decription')).toBeInTheDocument(); + expect(getByText('This is the description')).toBeInTheDocument(); }); it('renders "view source" link', async () => { @@ -91,17 +92,19 @@ describe('', () => { }, }; const apis = ApiRegistry.with( - configApiRef, - new ConfigReader({ - integrations: { - github: [ - { - host: 'github.com', - token: '...', - }, - ], - }, - }), + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: '...', + }, + ], + }, + }), + ), ); const { getByText } = await renderInTestApp( @@ -135,17 +138,19 @@ describe('', () => { }, }; const apis = ApiRegistry.with( - configApiRef, - new ConfigReader({ - integrations: { - github: [ - { - host: 'github.com', - token: '...', - }, - ], - }, - }), + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: '...', + }, + ], + }, + }), + ), ); const { getByTitle } = await renderInTestApp( @@ -180,7 +185,10 @@ describe('', () => { lifecycle: 'production', }, }; - const apis = ApiRegistry.with(configApiRef, new ConfigReader({})); + const apis = ApiRegistry.with( + scmIntegrationsApiRef, + ScmIntegrationsApi.fromConfig(new ConfigReader({})), + ); const { getByText } = await renderInTestApp( diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index c8bac651ce..e0cd0c2160 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -21,11 +21,11 @@ import { RELATION_PROVIDES_API, } from '@backstage/catalog-model'; import { - configApiRef, HeaderIconLinkRow, IconLinkVerticalProps, useApi, } from '@backstage/core'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { getEntityRelations, useEntity } from '@backstage/plugin-catalog-react'; import { Card, @@ -64,8 +64,11 @@ type AboutCardProps = { export function AboutCard({ variant }: AboutCardProps) { const classes = useStyles(); const { entity } = useEntity(); - const configApi = useApi(configApiRef); - const entitySourceLocation = getEntitySourceLocation(entity, configApi); + const scmIntegrationsApi = useApi(scmIntegrationsApiRef); + const entitySourceLocation = getEntitySourceLocation( + entity, + scmIntegrationsApi, + ); const entityMetadataEditUrl = getEntityMetadataEditUrl(entity); const providesApiRelations = getEntityRelations( entity, diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index 57ea894570..695595285b 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -39,10 +39,10 @@ type Props = { export const AboutContent = ({ entity }: Props) => { const classes = useStyles(); - const isSystem = entity.kind.toLowerCase() === 'system'; - const isDomain = entity.kind.toLowerCase() === 'domain'; - const isResource = entity.kind.toLowerCase() === 'resource'; - const isComponent = entity.kind.toLowerCase() === 'component'; + const isSystem = entity.kind.toLocaleLowerCase('en-US') === 'system'; + const isDomain = entity.kind.toLocaleLowerCase('en-US') === 'domain'; + const isResource = entity.kind.toLocaleLowerCase('en-US') === 'resource'; + const isComponent = entity.kind.toLocaleLowerCase('en-US') === 'component'; const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { kind: 'system', }); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index accfe6eda4..03f211a7bb 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -134,6 +134,12 @@ describe('CatalogPage', () => { fireEvent.click(getByText(/All/)); expect(await findByText(/All \(2\)/)).toBeInTheDocument(); }); + it('should set initial filter correctly', async () => { + const { findByText } = renderWrapped( + , + ); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); + }); // this test is for fixing the bug after favoriting an entity, the matching entities defaulting // to "owned" filter and not based on the selected filter it('should render the correct entities filtered on the selectedfilter', async () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 4679c10096..fd3d63edd7 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -59,7 +59,11 @@ const useStyles = makeStyles(theme => ({ }, })); -const CatalogPageContents = () => { +export type CatalogPageProps = { + initiallySelectedFilter?: string; +}; + +const CatalogPageContents = (props: CatalogPageProps) => { const styles = useStyles(); const { loading, @@ -79,6 +83,8 @@ const CatalogPageContents = () => { setSelectedSidebarItem, ] = useState(); const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; + const initiallySelectedFilter = + selectedSidebarItem?.id ?? props.initiallySelectedFilter ?? 'owned'; const createComponentLink = useRouteRef(createComponentRouteRef); const addMockData = useCallback(async () => { try { @@ -197,7 +203,7 @@ const CatalogPageContents = () => { onChange={({ label, id }) => setSelectedSidebarItem({ label, id }) } - initiallySelected={selectedSidebarItem?.id ?? 'owned'} + initiallySelected={initiallySelectedFilter} />
@@ -214,8 +220,8 @@ const CatalogPageContents = () => { ); }; -export const CatalogPage = () => ( +export const CatalogPage = (props: CatalogPageProps) => ( - + ); diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx index 68306944a3..4d7a969542 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx @@ -14,22 +14,53 @@ * limitations under the License. */ -import { render, fireEvent } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import SearchIcon from '@material-ui/icons/Search'; +import { fireEvent, screen } from '@testing-library/react'; import * as React from 'react'; -import { act } from 'react-dom/test-utils'; import { EntityContextMenu } from './EntityContextMenu'; describe('ComponentContextMenu', () => { it('should call onUnregisterEntity on button click', async () => { - await act(async () => { - const mockCallback = jest.fn(); - const menu = render( - , - ); - const button = await menu.findByTestId('menu-button'); - fireEvent.click(button); - const unregister = await menu.findByText('Unregister entity'); - expect(unregister).toBeInTheDocument(); - }); + const mockCallback = jest.fn(); + + await renderInTestApp( + , + ); + + const button = await screen.findByTestId('menu-button'); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + + const unregister = await screen.findByText('Unregister entity'); + expect(unregister).toBeInTheDocument(); + fireEvent.click(unregister); + + expect(mockCallback).toBeCalled(); + }); + + it('supports extra items', async () => { + const extra = { + title: 'HELLO', + Icon: SearchIcon, + onClick: jest.fn(), + }; + + await renderInTestApp( + , + ); + + const button = await screen.findByTestId('menu-button'); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + + const item = await screen.findByText('HELLO'); + expect(item).toBeInTheDocument(); + fireEvent.click(item); + + expect(extra.onClick).toBeCalled(); }); }); diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 82cf556281..809a4fd16e 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -14,13 +14,15 @@ * limitations under the License. */ +import { IconComponent } from '@backstage/core'; import { + Divider, IconButton, ListItemIcon, + ListItemText, MenuItem, MenuList, Popover, - Typography, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import Cancel from '@material-ui/icons/Cancel'; @@ -34,11 +36,23 @@ const useStyles = makeStyles({ }, }); +// NOTE(freben): Intentionally not exported at this point, since it's part of +// the unstable extra context menu items concept below +type ExtraContextMenuItem = { + title: string; + Icon: IconComponent; + onClick: () => void; +}; + type Props = { + UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[]; onUnregisterEntity: () => void; }; -export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { +export const EntityContextMenu = ({ + UNSTABLE_extraContextMenuItems, + onUnregisterEntity, +}: Props) => { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); @@ -50,6 +64,24 @@ export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { setAnchorEl(undefined); }; + const extraItems = UNSTABLE_extraContextMenuItems && [ + ...UNSTABLE_extraContextMenuItems.map(item => ( + { + onClose(); + item.onClick(); + }} + > + + + + + + )), + , + ]; + return ( <> { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > + {extraItems} { onClose(); @@ -79,7 +112,7 @@ export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { - Unregister entity + diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index c4cd4c5c71..87a1a48e92 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -19,6 +19,7 @@ import { Content, Header, HeaderLabel, + IconComponent, Page, Progress, TabbedLayout, @@ -29,12 +30,7 @@ import { } from '@backstage/plugin-catalog-react'; import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import { - default as React, - PropsWithChildren, - useContext, - useState, -} from 'react'; +import { default as React, useContext, useState } from 'react'; import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; @@ -69,16 +65,29 @@ const headerProps = ( : '' }`, headerType: (() => { - let t = kind.toLowerCase(); + let t = kind.toLocaleLowerCase('en-US'); if (entity && entity.spec && 'type' in entity.spec) { t += ' — '; - t += (entity.spec as { type: string }).type.toLowerCase(); + t += (entity.spec as { type: string }).type.toLocaleLowerCase('en-US'); } return t; })(), }; }; +// NOTE(freben): Intentionally not exported at this point, since it's part of +// the unstable extra context menu items concept below +type ExtraContextMenuItem = { + title: string; + Icon: IconComponent; + onClick: () => void; +}; + +type EntityLayoutProps = { + UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[]; + children?: React.ReactNode; +}; + /** * EntityLayout is a compound component, which allows you to define a layout for * entities using a sub-navigation mechanism. @@ -94,7 +103,10 @@ const headerProps = ( * * ``` */ -export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { +export const EntityLayout = ({ + UNSTABLE_extraContextMenuItems, + children, +}: EntityLayoutProps) => { const { kind, namespace, name } = useEntityCompoundName(); const { entity, loading, error } = useContext(EntityContext); @@ -132,7 +144,10 @@ export const EntityLayout = ({ children }: PropsWithChildren<{}>) => { label="Lifecycle" value={entity.spec?.lifecycle || 'unknown'} /> - + )} diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 51c8f85116..44b1329938 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -27,6 +27,7 @@ import { Progress, ResponseErrorPanel, WarningPanel, + IconComponent, } from '@backstage/core'; import { EntityContext, @@ -35,7 +36,7 @@ import { useEntityCompoundName, } from '@backstage/plugin-catalog-react'; import { Box } from '@material-ui/core'; -import React, { PropsWithChildren, useContext, useState } from 'react'; +import React, { useContext, useState } from 'react'; import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; @@ -88,17 +89,33 @@ const headerProps = ( : '' }`, headerType: (() => { - let t = kind.toLowerCase(); + let t = kind.toLocaleLowerCase('en-US'); if (entity && entity.spec && 'type' in entity.spec) { t += ' — '; - t += (entity.spec as { type: string }).type.toLowerCase(); + t += (entity.spec as { type: string }).type.toLocaleLowerCase('en-US'); } return t; })(), }; }; -export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { +// NOTE(freben): Intentionally not exported at this point, since it's part of +// the unstable extra context menu items concept below +type ExtraContextMenuItem = { + title: string; + Icon: IconComponent; + onClick: () => void; +}; + +type EntityPageLayoutProps = { + UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[]; + children?: React.ReactNode; +}; + +export const EntityPageLayout = ({ + children, + UNSTABLE_extraContextMenuItems, +}: EntityPageLayoutProps) => { const { kind, namespace, name } = useEntityCompoundName(); const { entity, loading, error } = useContext(EntityContext); const { headerTitle, headerType } = headerProps( @@ -128,7 +145,10 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { {entity && ( <> - + )} diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index 3e51b74dc2..f86f5959cb 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -63,7 +63,8 @@ const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => { const OldEntityRouteRedirect = () => { const { optionalNamespaceAndName, '*': rest } = useParams() as any; const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); - const namespaceLower = namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE; + const namespaceLower = + namespace?.toLocaleLowerCase('en-US') ?? ENTITY_DEFAULT_NAMESPACE; const restWithSlash = rest ? `/${rest}` : ''; return ( { - switch (status.toLowerCase()) { + switch (status.toLocaleLowerCase('en-US')) { case 'queued': case 'scheduled': return ; diff --git a/plugins/cloudbuild/CHANGELOG.md b/plugins/cloudbuild/CHANGELOG.md index 799a8a4bc6..dea818c97d 100644 --- a/plugins/cloudbuild/CHANGELOG.md +++ b/plugins/cloudbuild/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cloudbuild +## 0.2.13 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.2.12 ### Patch Changes diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 99730b22fd..ecf0414011 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cloudbuild", - "version": "0.2.12", + "version": "0.2.13", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/core": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/cloudbuild/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx b/plugins/cloudbuild/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx index 45a8fc662c..931bcd7b2d 100644 --- a/plugins/cloudbuild/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx +++ b/plugins/cloudbuild/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx @@ -29,7 +29,7 @@ export const WorkflowRunStatus = ({ status: string | undefined; }) => { if (status === undefined) return null; - switch (status.toLowerCase()) { + switch (status.toLocaleLowerCase('en-US')) { case 'queued': return ( <> diff --git a/plugins/cost-insights/README.md b/plugins/cost-insights/README.md index a103b58a77..bbd047be14 100644 --- a/plugins/cost-insights/README.md +++ b/plugins/cost-insights/README.md @@ -144,6 +144,6 @@ costInsights: The CostInsightsApi `getAlerts` method may return any type of alert or recommendation (called collectively "Action Items" in Cost Insights) that implements the [Alert type](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/types/Alert.ts). This allows you to deliver any alerts or recommendations specific to your infrastructure or company migrations. -The Alert type includes an `element` field to supply the JSX Element that will be rendered in the Cost Insights "Action Items" section; we recommend using Backstage's [InfoCard](https://backstage.io/storybook/?path=/story/layout-information-card--default) and [Recharts](http://recharts.org/en-US/) to show actionable visualizations. +To learn more about using Cost Insights' ready-to-use alerts, see the alerts [README](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/alerts/README.md). -The Alert `url` should link to documentation or instructions for resolving the alert. This may be omitted if no external link is needed. +Example implementations of custom alerts, forms and components can be found in the [examples](https://github.com/backstage/backstage/tree/master/plugins/cost-insights/src/example) directory. diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 1dc89303de..5d5022790e 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -55,9 +55,9 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/cost-insights/src/alerts/README.md b/plugins/cost-insights/src/alerts/README.md new file mode 100644 index 0000000000..4b24e4005f --- /dev/null +++ b/plugins/cost-insights/src/alerts/README.md @@ -0,0 +1,125 @@ +# Cost Insights Alerts + +Cost Insights supports custom and ready-to-use alerts such as [project growth](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx) and [unlabeled dataflow](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx) alerts. These exported alerts do not require any UI or additional configuration but are extendable for custom implementations. + +### Basic Setup + +Project growth alerts, for example, can be used to alert users to increased cost growth in a Google Cloud project within the past 30 days. + +![project-growth-alert-basic](../assets/project-growth-alert-basic.png) + +```ts +// client.ts +import { ProjectGrowthAlert, ProjectGrowthData } from '@backstage/plugin-cost-insights'; + +export class CostInsightsClient extends CostInsightsApi { + + ... + + async getAlerts(group: string): Promise { + const data: ProjectGrowthData = await getAlertDataSomehow(group); + return [ + new ProjectGrowthAlert({ + project: data.project, + products: data.products, + periodEnd: data.periodEnd, + periodStart: data.periodStart, + aggregation: data.aggregation, + change: data.change + }) + ] + } +} + +``` + +### Custom Setup + +Default properties such as the title, subtitle and instructions page url can be overridden - even default UI such as chart itself. Additionally, alerts can be extended to support actions such as snoozing or dismissing. + +![project-growth-alert-custom](../assets/project-growth-alert-custom.png) + +```ts +// ./ProjectGrowthAlert.ts + +import { + Alert, + AlertOptions, + AlertDismissFormData, + AlertSnoozeFormData, + ProjectGrowthAlert as DefaultProjectGrowthAlert, + ProjectGrowthData +} from '@backstage/plugin-cost-insights'; + +export class ProjectGrowthAlert extends DefaultProjectGrowthAlert { + + constructor(data: ProjectGrowthData){ + super(data); + } + + get url(){ + return '/path/to/your/docs'; + } + + get title(){ + return `Custom title for ${this.data.project}`; + } + + get subtitle(){ + return 'A custom subtitle for a project growth alert'; + } + + // render a custom component in the Action Items section + get element(){ + return + } + + async onAccepted(options: AlertOptions): Promise{ + ... + } + + async onDismissed(options: AlertOptions): Promise{ + ... + } + + async onSnoozed(options: AlertOptions): Promise{ + ... + } +} +``` + +```ts +// client.ts +import { ProjectGrowthAlert } from './ProjectGrowthAlert'; + +export class CostInsightsClient extends CostInsightsApi { + + ... + + async getAlerts(group: string): Promise { + const data: ProjectGrowthData = await getAlertDataSomehow(group); + return [ + new ProjectGrowthAlert({ + project: data.project, + products: data.products, + periodEnd: data.periodEnd, + periodStart: data.periodStart, + aggregation: data.aggregation, + change: data.change + }) + ] + } +} +``` + +### Advanced Setup + +Alerts can render their own custom forms for actions such as snoozing or dismissing if the default UI is insufficient. + +Cost Insights exports several core UI components such as the `BarChart` and `LegendItem` to support custom implementations. + +**Note**: We recommend using Backstage's [InfoCard](https://backstage.io/storybook/?path=/story/layout-information-card--default) and [Recharts](http://recharts.org/en-US/) to show actionable visualizations. + +For more advanced usage, see example [KubernetesMigrationAlert](https://github.com/backstage/backstage/blob/master/plugins/cost-insights/src/example/alerts/KubernetesMigrationAlert.tsx). + +![project-growth-alert-advanced](../assets/project-growth-alert-advanced.png) diff --git a/plugins/cost-insights/src/assets/project-growth-alert-advanced.png b/plugins/cost-insights/src/assets/project-growth-alert-advanced.png new file mode 100644 index 0000000000..1fc3e58881 Binary files /dev/null and b/plugins/cost-insights/src/assets/project-growth-alert-advanced.png differ diff --git a/plugins/cost-insights/src/assets/project-growth-alert-basic.png b/plugins/cost-insights/src/assets/project-growth-alert-basic.png new file mode 100644 index 0000000000..fe192332b3 Binary files /dev/null and b/plugins/cost-insights/src/assets/project-growth-alert-basic.png differ diff --git a/plugins/cost-insights/src/assets/project-growth-alert-custom.png b/plugins/cost-insights/src/assets/project-growth-alert-custom.png new file mode 100644 index 0000000000..8a672d559e Binary files /dev/null and b/plugins/cost-insights/src/assets/project-growth-alert-custom.png differ diff --git a/plugins/cost-insights/src/components/CostGrowth/index.ts b/plugins/cost-insights/src/components/CostGrowth/index.ts index 569c343380..09f9991a3a 100644 --- a/plugins/cost-insights/src/components/CostGrowth/index.ts +++ b/plugins/cost-insights/src/components/CostGrowth/index.ts @@ -14,7 +14,5 @@ * limitations under the License. */ -export { CostGrowth } from './CostGrowth'; -export type { CostGrowthProps } from './CostGrowth'; -export { CostGrowthIndicator } from './CostGrowthIndicator'; -export type { CostGrowthIndicatorProps } from './CostGrowthIndicator'; +export * from './CostGrowth'; +export * from './CostGrowthIndicator'; diff --git a/plugins/cost-insights/src/components/LegendItem/index.ts b/plugins/cost-insights/src/components/LegendItem/index.ts index e97af0c97f..8deef5e51b 100644 --- a/plugins/cost-insights/src/components/LegendItem/index.ts +++ b/plugins/cost-insights/src/components/LegendItem/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { LegendItem } from './LegendItem'; +export * from './LegendItem'; diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 2d12eec9d9..0a7d6388af 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -22,8 +22,28 @@ export { CostInsightsLabelDataflowInstructionsPage, } from './plugin'; export { ExampleCostInsightsClient } from './example'; -export { BarChart, LegendItem, CostGrowth } from './components'; +export { + BarChart, + BarChartLegend, + BarChartTooltip, + BarChartTooltipItem, + CostGrowth, + CostGrowthIndicator, + LegendItem, +} from './components'; export { MockConfigProvider, MockCurrencyProvider } from './testUtils'; export * from './api'; export * from './alerts'; export * from './types'; + +export type { + BarChartProps, + BarChartLegendOptions, + BarChartLegendProps, + BarChartTooltipProps, + BarChartTooltipItemProps, + CostGrowthProps, + CostGrowthIndicatorProps, + TooltipItem, + LegendItemProps, +} from './components'; diff --git a/plugins/explore/CHANGELOG.md b/plugins/explore/CHANGELOG.md index 6c832136f3..f9cbe6850b 100644 --- a/plugins/explore/CHANGELOG.md +++ b/plugins/explore/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-explore +## 0.3.2 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.3.1 ### Patch Changes diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 7f9fe5cb2c..be87276bed 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-explore", - "version": "0.3.1", + "version": "0.3.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,8 +31,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/core": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/plugin-explore-react": "^0.0.4", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -45,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/explore/src/components/ToolCard/ToolCard.tsx b/plugins/explore/src/components/ToolCard/ToolCard.tsx index 60f467ca80..b4a30b3e51 100644 --- a/plugins/explore/src/components/ToolCard/ToolCard.tsx +++ b/plugins/explore/src/components/ToolCard/ToolCard.tsx @@ -76,13 +76,13 @@ export const ToolCard = ({ card, objectFit }: Props) => { {title}{' '} - {lifecycle && lifecycle.toLowerCase() !== 'ga' && ( + {lifecycle && lifecycle.toLocaleLowerCase('en-US') !== 'ga' && ( )} diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 496b3c2dfe..f6aa7392bf 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -44,9 +44,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index cff83b7de6..42a9a28690 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-actions/CHANGELOG.md b/plugins/github-actions/CHANGELOG.md index 7e144000ce..1f3d0b7198 100644 --- a/plugins/github-actions/CHANGELOG.md +++ b/plugins/github-actions/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-github-actions +## 0.4.1 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.4.0 ### Minor Changes diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 89c6b37e98..e01ab24940 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.0", + "version": "0.4.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,8 +33,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/core": "^0.7.1", + "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/core": "^0.7.2", "@backstage/integration": "^0.5.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -50,9 +50,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx index b5915f400c..38d84a4b6d 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx +++ b/plugins/github-actions/src/components/WorkflowRunDetails/WorkflowRunDetails.tsx @@ -88,8 +88,8 @@ const StepView = ({ step }: { step: Step }) => { diff --git a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts index d0141c27fa..3e2a5c4382 100644 --- a/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts +++ b/plugins/github-actions/src/components/WorkflowRunDetails/useWorkflowRunsDetails.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useApi } from '@backstage/core'; -import { useParams } from 'react-router-dom'; +import { useApi, useRouteRefParams } from '@backstage/core'; import { useAsync } from 'react-use'; import { githubActionsApiRef } from '../../api'; +import { buildRouteRef } from '../../plugin'; export const useWorkflowRunsDetails = ({ hostname, @@ -28,7 +28,7 @@ export const useWorkflowRunsDetails = ({ repo: string; }) => { const api = useApi(githubActionsApiRef); - const { id } = useParams(); + const { id } = useRouteRefParams(buildRouteRef); const details = useAsync(async () => { return repo && owner ? api.getWorkflowRun({ diff --git a/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx b/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx index b1a71ea3ed..c6beff00cd 100644 --- a/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx +++ b/plugins/github-actions/src/components/WorkflowRunStatus/WorkflowRunStatus.tsx @@ -32,7 +32,7 @@ export const WorkflowRunStatus = ({ conclusion: string | undefined; }) => { if (status === undefined) return null; - switch (status.toLowerCase()) { + switch (status.toLocaleLowerCase('en-US')) { case 'queued': return ( <> @@ -46,7 +46,7 @@ export const WorkflowRunStatus = ({ ); case 'completed': - switch (conclusion?.toLowerCase()) { + switch (conclusion?.toLocaleLowerCase('en-US')) { case 'skipped' || 'canceled': return ( <> diff --git a/plugins/github-actions/src/plugin.ts b/plugins/github-actions/src/plugin.ts index d296b4ad30..f66e96a981 100644 --- a/plugins/github-actions/src/plugin.ts +++ b/plugins/github-actions/src/plugin.ts @@ -33,6 +33,7 @@ export const rootRouteRef = createRouteRef({ export const buildRouteRef = createRouteRef({ path: ':id', + params: ['id'], title: 'GitHub Actions Workflow Run', }); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index c8ebda0dc7..c66fb814e4 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -42,9 +42,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/graphiql/CHANGELOG.md b/plugins/graphiql/CHANGELOG.md index 60b1865427..67e39d6600 100644 --- a/plugins/graphiql/CHANGELOG.md +++ b/plugins/graphiql/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-graphiql +## 0.2.9 + +### Patch Changes + +- 09eb54e01: Export `GraphiQLIcon`. +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + ## 0.2.8 ### Patch Changes diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index df32ea075b..78ad4ec6f2 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.8", + "version": "0.2.9", "private": false, "publishConfig": { "access": "public", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,9 +43,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/graphql/CHANGELOG.md b/plugins/graphql/CHANGELOG.md index 4a0ac8daca..dfcddfbcf9 100644 --- a/plugins/graphql/CHANGELOG.md +++ b/plugins/graphql/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-graphql-backend +## 0.1.6 + +### Patch Changes + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + - @backstage/plugin-catalog-graphql@0.2.7 + ## 0.1.5 ### Patch Changes diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index ab12ee5278..84b9731e85 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-graphql-backend", - "version": "0.1.5", + "version": "0.1.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,15 +29,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.2", - "@backstage/config": "^0.1.2", - "@backstage/plugin-catalog-graphql": "^0.2.6", + "@backstage/backend-common": "^0.6.0", + "@backstage/config": "^0.1.4", + "@backstage/plugin-catalog-graphql": "^0.2.7", "@graphql-modules/core": "^0.7.17", "@types/express": "^4.17.6", "apollo-server": "^2.16.1", "apollo-server-express": "^2.16.1", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "graphql": "^15.3.0", "helmet": "^4.0.0", "reflect-metadata": "^0.1.13", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.0", + "@backstage/cli": "^0.6.5", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/CHANGELOG.md b/plugins/jenkins/CHANGELOG.md index 7f69ba133e..680e67c992 100644 --- a/plugins/jenkins/CHANGELOG.md +++ b/plugins/jenkins/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-jenkins +## 0.4.0 + +### Minor Changes + +- e8b2ed9cc: Changed the way project slug is extracted from an entity. Up until now, the plugin assumed that the project slug is always of the format "owner/repo". However, this is not something that is enforced by Jenkins and sometimes the project name doesn't contain an owner. + Since this split is not used anywhere and the entire project slug is always used as-is, removed this distinction and just read the project slug from the annotation as-is. + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.3.12 ### Patch Changes diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index dc840153a6..54e3b45208 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-jenkins", - "version": "0.3.12", + "version": "0.4.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,8 +32,8 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/core": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx index 5d9d81c985..1b7ccb8121 100644 --- a/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/jenkins/src/components/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -13,25 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { useParams } from 'react-router-dom'; -import { Breadcrumbs, Content, Link } from '@backstage/core'; +import { Breadcrumbs, Content, Link, useRouteRefParams } from '@backstage/core'; import { Box, - Typography, - Paper, - TableContainer, - Table, - TableRow, - TableCell, - TableBody, Link as MaterialLink, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + Typography, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import ExternalLinkIcon from '@material-ui/icons/Launch'; +import React from 'react'; +import { buildRouteRef } from '../../plugin'; +import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; import { useBuildWithSteps } from '../useBuildWithSteps'; import { useProjectSlugFromEntity } from '../useProjectSlugFromEntity'; -import { JenkinsRunStatus } from '../BuildsPage/lib/Status'; -import ExternalLinkIcon from '@material-ui/icons/Launch'; const useStyles = makeStyles(theme => ({ root: { @@ -47,10 +47,10 @@ const useStyles = makeStyles(theme => ({ })); const BuildWithStepsView = () => { - const { owner, repo } = useProjectSlugFromEntity(); - const { branch, buildNumber } = useParams(); + const projectName = useProjectSlugFromEntity(); + const { branch, buildNumber } = useRouteRefParams(buildRouteRef); const classes = useStyles(); - const buildPath = `${owner}/${repo}/${branch}/${buildNumber}`; + const buildPath = `${projectName}/${branch}/${buildNumber}`; const [{ value }] = useBuildWithSteps(buildPath); return ( diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 3044094ccc..57ffe48649 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -242,9 +242,9 @@ export const CITableView = ({ }; export const CITable = () => { - const { owner, repo } = useProjectSlugFromEntity(); + const projectName = useProjectSlugFromEntity(); - const [tableProps, { setPage, retry, setPageSize }] = useBuilds(owner, repo); + const [tableProps, { setPage, retry, setPageSize }] = useBuilds(projectName); return ( { if (status === undefined) return null; - switch (status.toLowerCase()) { + switch (status.toLocaleLowerCase('en-US')) { case 'queued': case 'scheduled': return ( diff --git a/plugins/jenkins/src/components/Cards/Cards.tsx b/plugins/jenkins/src/components/Cards/Cards.tsx index 0d7c2fe919..f19ed608ce 100644 --- a/plugins/jenkins/src/components/Cards/Cards.tsx +++ b/plugins/jenkins/src/components/Cards/Cards.tsx @@ -82,8 +82,8 @@ export const LatestRunCard = ({ branch: string; variant?: InfoCardVariants; }) => { - const { owner, repo } = useProjectSlugFromEntity(); - const [{ builds, loading }] = useBuilds(owner, repo, branch); + const projectName = useProjectSlugFromEntity(); + const [{ builds, loading }] = useBuilds(projectName, branch); const latestRun = builds ?? {}; return ( diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index a1ff3a1eef..5a6f8b9f08 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -18,7 +18,7 @@ import { useState } from 'react'; import { useAsyncRetry } from 'react-use'; import { jenkinsApiRef } from '../api'; -export function useBuilds(owner: string, repo: string, branch?: string) { +export function useBuilds(projectName: string, branch?: string) { const api = useApi(jenkinsApiRef); const errorApi = useApi(errorApiRef); @@ -38,9 +38,9 @@ export function useBuilds(owner: string, repo: string, branch?: string) { try { let build; if (branch) { - build = await api.getLastBuild(`${owner}/${repo}/${branch}`); + build = await api.getLastBuild(`${projectName}/${branch}`); } else { - build = await api.getFolder(`${owner}/${repo}`); + build = await api.getFolder(`${projectName}`); } const size = Array.isArray(build) ? build?.[0].build_num! : 1; @@ -51,9 +51,8 @@ export function useBuilds(owner: string, repo: string, branch?: string) { errorApi.post(e); throw e; } - }, [api, errorApi, owner, repo, branch]); + }, [api, errorApi, projectName, branch]); - const projectName = `${owner}/${repo}`; return [ { page, diff --git a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts index ac2d6c7e8f..06261d3645 100644 --- a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts +++ b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts @@ -19,8 +19,5 @@ import { JENKINS_ANNOTATION } from '../constants'; export const useProjectSlugFromEntity = () => { const { entity } = useEntity(); - const [owner, repo] = ( - entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? '' - ).split('/'); - return { owner, repo }; + return entity.metadata.annotations?.[JENKINS_ANNOTATION] ?? ''; }; diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 85001d9bbc..db4122349c 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -15,14 +15,14 @@ */ import { - createPlugin, - createRouteRef, createApiFactory, - discoveryApiRef, - createRoutableExtension, createComponentExtension, + createPlugin, + createRoutableExtension, + createRouteRef, + discoveryApiRef, } from '@backstage/core'; -import { jenkinsApiRef, JenkinsApi } from './api'; +import { JenkinsApi, jenkinsApiRef } from './api'; export const rootRouteRef = createRouteRef({ path: '', @@ -31,6 +31,7 @@ export const rootRouteRef = createRouteRef({ export const buildRouteRef = createRouteRef({ path: 'run/:branch/:buildNumber', + params: ['branch', 'buildNumber'], title: 'Jenkins run', }); diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index be1b53f710..8de69d3401 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-kafka-backend +## 0.2.2 + +### Patch Changes + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.2.1 ### Patch Changes diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index ce9e03a617..e270e92b2f 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.1", + "version": "0.2.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,18 +31,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.5", + "@backstage/backend-common": "^0.6.0", "@backstage/catalog-model": "^0.7.3", - "@backstage/config": "^0.1.2", + "@backstage/config": "^0.1.4", "@types/express": "^4.17.6", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "kafkajs": "^1.16.0-beta.6", "lodash": "^4.17.15", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.3", + "@backstage/cli": "^0.6.5", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 3f8de137d4..1bdbdc5b4d 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -33,9 +33,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 392fc1bfad..d2cd6fc3c1 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-kubernetes-backend +## 0.3.1 + +### Patch Changes + +- 1f98a6ff8: Filter out k8s cluster with no resources or errors +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.3.0 ### Minor Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index 10d2fa5b47..8d67d854e1 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.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,18 +31,18 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.6", + "@backstage/backend-common": "^0.6.0", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@google-cloud/container": "^2.2.0", - "@kubernetes/client-node": "^0.13.2", + "@kubernetes/client-node": "^0.14.0", "@types/express": "^4.17.6", "aws-sdk": "^2.840.0", "aws4": "^1.11.0", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "fs-extra": "^9.0.0", "helmet": "^4.0.0", "lodash": "^4.17.15", @@ -52,7 +52,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@types/aws4": "^1.5.1", "supertest": "^4.0.2" }, diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts index a45b1252fe..e76a114d54 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.test.ts @@ -24,43 +24,93 @@ const getClustersByServiceId = jest.fn(); const mockFetch = (mock: jest.Mock) => { mock.mockImplementation((params: ObjectFetchParams) => - Promise.resolve({ + Promise.resolve( + generateMockResourcesAndErrors( + params.serviceId, + params.clusterDetails.name, + ), + ), + ); +}; + +function generateMockResourcesAndErrors( + serviceId: String, + clusterName: String, +) { + if (clusterName === 'empty-cluster') { + return { errors: [], responses: [ { type: 'pods', - resources: [ - { - metadata: { - name: `my-pods-${params.serviceId}-${params.clusterDetails.name}`, - }, - }, - ], + resources: [], }, { type: 'configmaps', - resources: [ - { - metadata: { - name: `my-configmaps-${params.serviceId}-${params.clusterDetails.name}`, - }, - }, - ], + resources: [], }, { type: 'services', - resources: [ - { - metadata: { - name: `my-services-${params.serviceId}-${params.clusterDetails.name}`, - }, - }, - ], + resources: [], }, ], - }), - ); -}; + }; + } else if (clusterName === 'error-cluster') { + return { + errors: ['some random cluster error'], + responses: [ + { + type: 'pods', + resources: [], + }, + { + type: 'configmaps', + resources: [], + }, + { + type: 'services', + resources: [], + }, + ], + }; + } + + return { + errors: [], + responses: [ + { + type: 'pods', + resources: [ + { + metadata: { + name: `my-pods-${serviceId}-${clusterName}`, + }, + }, + ], + }, + { + type: 'configmaps', + resources: [ + { + metadata: { + name: `my-configmaps-${serviceId}-${clusterName}`, + }, + }, + ], + }, + { + type: 'services', + resources: [ + { + metadata: { + name: `my-services-${serviceId}-${clusterName}`, + }, + }, + ], + }, + ], + }; +} describe('handleGetKubernetesObjectsForService', () => { beforeEach(() => { @@ -287,4 +337,300 @@ describe('handleGetKubernetesObjectsForService', () => { ], }); }); + it('retrieve objects for three clusters, only two have resources and show in ui', async () => { + getClustersByServiceId.mockImplementation(() => + Promise.resolve([ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + { + name: 'other-cluster', + authProvider: 'google', + }, + { + name: 'empty-cluster', + authProvider: 'google', + }, + ]), + ); + + mockFetch(fetchObjectsForService); + + const sut = new KubernetesFanOutHandler( + getVoidLogger(), + { + fetchObjectsForService, + }, + { + getClustersByServiceId, + }, + [], + ); + + const result = await sut.getKubernetesObjectsByEntity({ + auth: { + google: 'google_token_123', + }, + entity: { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + }, + }, + }); + + expect(getClustersByServiceId.mock.calls.length).toBe(1); + expect(fetchObjectsForService.mock.calls.length).toBe(3); + expect(result).toStrictEqual({ + items: [ + { + cluster: { + name: 'test-cluster', + }, + errors: [], + resources: [ + { + resources: [ + { + metadata: { + name: 'my-pods-test-component-test-cluster', + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: 'my-configmaps-test-component-test-cluster', + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: 'my-services-test-component-test-cluster', + }, + }, + ], + type: 'services', + }, + ], + }, + { + cluster: { + name: 'other-cluster', + }, + errors: [], + resources: [ + { + resources: [ + { + metadata: { + name: 'my-pods-test-component-other-cluster', + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: 'my-configmaps-test-component-other-cluster', + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: 'my-services-test-component-other-cluster', + }, + }, + ], + type: 'services', + }, + ], + }, + ], + }); + }); + it('retrieve objects for four clusters, two have resources and one error cluster', async () => { + getClustersByServiceId.mockImplementation(() => + Promise.resolve([ + { + name: 'test-cluster', + authProvider: 'serviceAccount', + }, + { + name: 'other-cluster', + authProvider: 'google', + }, + { + name: 'empty-cluster', + authProvider: 'google', + }, + { + name: 'error-cluster', + authProvider: 'google', + }, + ]), + ); + + mockFetch(fetchObjectsForService); + + const sut = new KubernetesFanOutHandler( + getVoidLogger(), + { + fetchObjectsForService, + }, + { + getClustersByServiceId, + }, + [], + ); + + const result = await sut.getKubernetesObjectsByEntity({ + auth: { + google: 'google_token_123', + }, + entity: { + apiVersion: 'backstage.io/v1beta1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/kubernetes-labels-selector': + 'backstage.io/test-label=test-component', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'joe', + }, + }, + }); + + expect(getClustersByServiceId.mock.calls.length).toBe(1); + expect(fetchObjectsForService.mock.calls.length).toBe(4); + expect(result).toStrictEqual({ + items: [ + { + cluster: { + name: 'test-cluster', + }, + errors: [], + resources: [ + { + resources: [ + { + metadata: { + name: 'my-pods-test-component-test-cluster', + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: 'my-configmaps-test-component-test-cluster', + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: 'my-services-test-component-test-cluster', + }, + }, + ], + type: 'services', + }, + ], + }, + { + cluster: { + name: 'other-cluster', + }, + errors: [], + resources: [ + { + resources: [ + { + metadata: { + name: 'my-pods-test-component-other-cluster', + }, + }, + ], + type: 'pods', + }, + { + resources: [ + { + metadata: { + name: 'my-configmaps-test-component-other-cluster', + }, + }, + ], + type: 'configmaps', + }, + { + resources: [ + { + metadata: { + name: 'my-services-test-component-other-cluster', + }, + }, + ], + type: 'services', + }, + ], + }, + { + cluster: { + name: 'error-cluster', + }, + errors: ['some random cluster error'], + resources: [ + { + type: 'pods', + resources: [], + }, + { + type: 'configmaps', + resources: [], + }, + { + type: 'services', + resources: [], + }, + ], + }, + ], + }); + }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 489b0631f6..9427ce9553 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -109,6 +109,14 @@ export class KubernetesFanOutHandler { }; }); }), - ).then(r => ({ items: r })); + ).then(r => ({ + items: r.filter( + item => + (item.errors !== undefined && item.errors.length >= 1) || + (item.resources !== undefined && + item.resources.length >= 1 && + item.resources.some(fr => fr.resources.length >= 1)), + ), + })); } } diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index fc5310de2a..2837b8e290 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,22 @@ # @backstage/plugin-kubernetes +## 0.4.1 + +### Patch Changes + +- 1df417bd3: Add tests for kubernetes custom hook +- 9ca0e4009: use local version of lowerCase and upperCase methods +- 1f98a6ff8: Filter out k8s cluster with no resources or errors +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] +- Updated dependencies [1f98a6ff8] + - @backstage/config@0.1.4 + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + - @backstage/plugin-kubernetes-backend@0.3.1 + ## 0.4.0 ### Minor Changes diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 560e8a24a2..accbe428dc 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.4.0", + "version": "0.4.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,12 +32,12 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/plugin-kubernetes-backend": "^0.3.0", + "@backstage/config": "^0.1.4", + "@backstage/core": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/plugin-kubernetes-backend": "^0.3.1", "@backstage/theme": "^0.2.4", - "@kubernetes/client-node": "^0.13.2", + "@kubernetes/client-node": "^0.14.0", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -50,9 +50,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.4.2", diff --git a/plugins/kubernetes/src/components/CustomResources/DefaultCustomResourceDrawer.tsx b/plugins/kubernetes/src/components/CustomResources/DefaultCustomResourceDrawer.tsx index db109ad096..57836ac673 100644 --- a/plugins/kubernetes/src/components/CustomResources/DefaultCustomResourceDrawer.tsx +++ b/plugins/kubernetes/src/components/CustomResources/DefaultCustomResourceDrawer.tsx @@ -18,7 +18,8 @@ import React from 'react'; import { KubernetesDrawer } from '../KubernetesDrawer/KubernetesDrawer'; import { Typography, Grid } from '@material-ui/core'; -const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); +const capitalize = (str: string) => + str.charAt(0).toLocaleUpperCase('en-US') + str.slice(1); export const DefaultCustomResourceDrawer = ({ customResource, diff --git a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx index aa9df3e8fa..cda8dd7e6b 100644 --- a/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx +++ b/plugins/kubernetes/src/components/KubernetesContent/KubernetesContent.tsx @@ -41,6 +41,8 @@ import { DetectedError, detectErrors } from '../../error-detection'; import { IngressesAccordions } from '../IngressesAccordions'; import { ServicesAccordions } from '../ServicesAccordions'; import { CustomResources } from '../CustomResources'; +import EmptyStateImage from '../../assets/emptystate.svg'; + import { GroupedResponsesContext, PodNamesWithErrorsContext, @@ -203,24 +205,48 @@ export const KubernetesContent = ({ entity }: KubernetesContentProps) => { Your Clusters - {kubernetesObjects?.items.map((item, i) => { - const podsWithErrors = new Set( - detectedErrors - .get(item.cluster.name) - ?.filter(de => de.kind === 'Pod') - .map(de => de.names) - .flat() ?? [], - ); - - return ( - - + + + No resources on any known clusters for{' '} + {entity.metadata.name} + + + + EmptyState - ); - })} + + )} + {kubernetesObjects?.items.length > 0 && + kubernetesObjects?.items.map((item, i) => { + const podsWithErrors = new Set( + detectedErrors + .get(item.cluster.name) + ?.filter(de => de.kind === 'Pod') + .map(de => de.names) + .flat() ?? [], + ); + + return ( + + + + ); + })} )} diff --git a/plugins/lighthouse/CHANGELOG.md b/plugins/lighthouse/CHANGELOG.md index a026aee2ca..26eb041442 100644 --- a/plugins/lighthouse/CHANGELOG.md +++ b/plugins/lighthouse/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-lighthouse +## 0.2.14 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/config@0.1.4 + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.2.13 ### Patch Changes diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index ad47bd8d38..1cb41f65be 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,9 +32,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/config": "^0.1.4", + "@backstage/core": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 8a59869266..a85a5bfa0b 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -106,7 +106,7 @@ export const AuditListTable = ({ items }: { items: Website[] }) => { lastReport: ( <> {' '} - {website.lastAudit.status.toUpperCase()} + {website.lastAudit.status.toLocaleUpperCase('en-US')} ), lastAuditTriggered: formatTime(website.lastAudit.timeCreated), diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx index 693a8b6ec6..8940bb3773 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx @@ -59,7 +59,7 @@ const LighthouseCategoryScoreStatus = ({ score }: { score: number }) => { const LighthouseAuditStatus = ({ audit }: { audit: Audit }) => ( <> - {audit.status.toUpperCase()} + {audit.status.toLocaleUpperCase('en-US')} ); diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 081c4eb1cd..3754dacf00 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/org/CHANGELOG.md b/plugins/org/CHANGELOG.md index 22cea18cdb..fcc99eb40a 100644 --- a/plugins/org/CHANGELOG.md +++ b/plugins/org/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-org +## 0.3.10 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [a51dc0006] +- Updated dependencies [e7f9b9435] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] +- Updated dependencies [d88dd219e] +- Updated dependencies [c8b54c370] + - @backstage/core-api@0.2.14 + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.3.9 ### Patch Changes diff --git a/plugins/org/package.json b/plugins/org/package.json index 2b83cdf1de..eba629ade0 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-org", - "version": "0.3.9", + "version": "0.3.10", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -21,9 +21,9 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", - "@backstage/core-api": "^0.2.12", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/core": "^0.7.2", + "@backstage/core-api": "^0.2.14", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -35,9 +35,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index a73216b23b..812f74a4bb 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -130,7 +130,8 @@ export const MembersListCard = (_props: { member?.relations?.some( r => r.type === RELATION_MEMBER_OF && - r.target.name.toLowerCase() === groupName.toLowerCase(), + r.target.name.toLocaleLowerCase('en-US') === + groupName.toLocaleLowerCase('en-US'), ), ); return groupMembersList; diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md index 0e5d75d41c..965a719020 100644 --- a/plugins/pagerduty/README.md +++ b/plugins/pagerduty/README.md @@ -28,12 +28,12 @@ Add it to the app in `plugins.ts`: export { plugin as Pagerduty } from '@backstage/plugin-pagerduty'; ``` -Add it to the `EntityPage.ts`: +Add it to the `EntityPage.tsx`: ```ts import { isPluginApplicableToEntity as isPagerDutyAvailable, - PagerDutyCard, + EntityPagerDutyCard, } from '@backstage/plugin-pagerduty'; // add to code { @@ -60,6 +60,17 @@ pagerduty: In order for the client to make requests to the [PagerDuty API](https://developer.pagerduty.com/docs/rest-api-v2/rest-api/) it needs an [API Token](https://support.pagerduty.com/docs/generating-api-keys#generating-a-general-access-rest-api-key). +Add the proxy configuration in `app-config.yaml` + +```yaml +proxy: + ... + '/pagerduty': + target: https://api.pagerduty.com + headers: + Authorization: Token token=${PAGERDUTY_TOKEN} +``` + Then start the backend passing the token as an environment variable: ```bash diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 029de2d760..18dec50a26 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/proxy-backend/CHANGELOG.md b/plugins/proxy-backend/CHANGELOG.md index e0ec770106..0fada2e81f 100644 --- a/plugins/proxy-backend/CHANGELOG.md +++ b/plugins/proxy-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-proxy-backend +## 0.2.6 + +### Patch Changes + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.2.5 ### Patch Changes diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index eb814396a0..4509d5772e 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.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -28,11 +28,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.5", - "@backstage/config": "^0.1.2", + "@backstage/backend-common": "^0.6.0", + "@backstage/config": "^0.1.4", "@types/express": "^4.17.6", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "http-proxy-middleware": "^0.19.1", "morgan": "^1.10.0", "uuid": "^8.0.0", @@ -42,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.6.3", + "@backstage/cli": "^0.6.5", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index e6ed3554ef..8136c548d6 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -45,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/rollbar-backend/CHANGELOG.md b/plugins/rollbar-backend/CHANGELOG.md index 08b4b9da77..a64fa25a81 100644 --- a/plugins/rollbar-backend/CHANGELOG.md +++ b/plugins/rollbar-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-rollbar-backend +## 0.1.8 + +### Patch Changes + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.1.7 ### Patch Changes diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index ff7a4f06f5..341ba402a2 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.7", + "version": "0.1.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,15 +30,15 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.2", - "@backstage/config": "^0.1.2", + "@backstage/backend-common": "^0.6.0", + "@backstage/config": "^0.1.4", "@types/express": "^4.17.6", "axios": "^0.21.1", "camelcase-keys": "^6.2.2", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "fs-extra": "^9.0.0", "helmet": "^4.0.0", "lodash": "^4.17.15", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.6.0", + "@backstage/cli": "^0.6.5", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index 489d42d49d..ada61bd6a7 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/react-hooks": "^3.3.0", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index bce51d97a2..47bfb97e44 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,19 @@ # @backstage/plugin-scaffolder-backend +## 0.9.2 + +### Patch Changes + +- 8b4f7e42a: Forward authorization on scaffolder backend requests +- 8686eb38c: Use errors from `@backstage/errors` +- Updated dependencies [8686eb38c] +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/catalog-client@0.3.8 + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + ## 0.9.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 9a23c588db..bb442f79a4 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.9.1", + "version": "0.9.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.6", - "@backstage/catalog-client": "^0.3.7", + "@backstage/backend-common": "^0.6.0", + "@backstage/catalog-client": "^0.3.8", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.1", "@gitbeaker/core": "^28.0.2", @@ -48,7 +48,7 @@ "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "fs-extra": "^9.0.0", "git-url-parse": "^11.4.4", "globby": "^11.0.0", @@ -64,8 +64,8 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.6.4", - "@backstage/test-utils": "^0.1.8", + "@backstage/cli": "^0.6.5", + "@backstage/test-utils": "^0.1.9", "@types/fs-extra": "^9.0.1", "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", 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 0de7d94fda..628107155d 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 @@ -108,6 +108,58 @@ describe('fetch:cookiecutter', () => { }); }); + it('should execute the cookiecutter templater with optional inputs if they are present and valid', async () => { + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + copyWithoutRender: ['goreleaser.yml'], + extensions: [ + 'jinja2_custom_filters_extension.string_filters_extension.StringFilterExtension', + ], + imageName: 'foo/cookiecutter-image-with-extensions', + }, + }); + + expect(cookiecutterTemplater.run).toHaveBeenCalledWith({ + workspacePath: mockTmpDir, + dockerClient: mockDockerClient, + logStream: mockContext.logStream, + values: { + ...mockContext.input.values, + _copy_without_render: ['goreleaser.yml'], + _extensions: [ + 'jinja2_custom_filters_extension.string_filters_extension.StringFilterExtension', + ], + imageName: 'foo/cookiecutter-image-with-extensions', + }, + }); + }); + + it('should throw if copyWithoutRender is not an Array', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + copyWithoutRender: 'xyz', + }, + }), + ).rejects.toThrow(/copyWithoutRender must be an Array/); + }); + + it('should throw if extensions is not an Array', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + extensions: 'xyz', + }, + }), + ).rejects.toThrow(/extensions must be an Array/); + }); + it('should throw if there is no cookiecutter templater initialized', async () => { const templatersWithoutCookiecutter = new Templaters(); 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 814146cc50..fca791c7bd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/cookiecutter.ts @@ -37,6 +37,9 @@ export function createFetchCookiecutterAction(options: { url: string; targetPath?: string; values: JsonObject; + copyWithoutRender?: string[]; + extensions?: string[]; + imageName?: string; }>({ id: 'fetch:cookiecutter', description: @@ -63,6 +66,30 @@ export function createFetchCookiecutterAction(options: { description: 'Values to pass on to cookiecutter for templating', type: 'object', }, + copyWithoutRender: { + title: 'Copy Without Render', + description: + 'Avoid rendering directories and files in the template', + type: 'array', + items: { + type: 'string', + }, + }, + extensions: { + title: 'Template Extensions', + description: + "Jinja2 extensions to add filters, tests, globals or extend the parser. Extensions must be installed in the container or on the host where Cookiecutter executes. See the contrib directory in Backstage's repo for more information", + type: 'array', + items: { + type: 'string', + }, + }, + imageName: { + title: 'Cookiecutter Docker image', + description: + "Specify a custom Docker image to run cookiecutter, to override the default: 'spotify/backstage-cookiecutter'. This can be used to execute cookiecutter with Template Extensions. Used only when a local cookiecutter is not found.", + type: 'string', + }, }, }, }, @@ -76,6 +103,18 @@ export function createFetchCookiecutterAction(options: { ); const resultDir = resolvePath(workDir, 'result'); + if ( + ctx.input.copyWithoutRender && + !Array.isArray(ctx.input.copyWithoutRender) + ) { + throw new InputError( + 'Fetch action input copyWithoutRender must be an Array', + ); + } + if (ctx.input.extensions && !Array.isArray(ctx.input.extensions)) { + throw new InputError('Fetch action input extensions must be an Array'); + } + await fetchContents({ reader, integrations, @@ -85,13 +124,19 @@ export function createFetchCookiecutterAction(options: { }); const cookiecutter = templaters.get('cookiecutter'); + const values = { + ...(ctx.input.values as TemplaterValues), + _copy_without_render: ctx.input.copyWithoutRender, + _extensions: ctx.input.extensions, + imageName: ctx.input.imageName, + }; // Will execute the template in ./template and put the result in ./result await cookiecutter.run({ workspacePath: workDir, dockerClient, logStream: ctx.logStream, - values: ctx.input.values as TemplaterValues, + values, }); // Finally move the template result into the task workspace diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/file.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/file.ts new file mode 100644 index 0000000000..5c564147ad --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/file.ts @@ -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 fs from 'fs-extra'; +import { dirname } from 'path'; +import { InputError } from '@backstage/errors'; +import { createTemplateAction } from '../../createTemplateAction'; + +/** + * This task is useful for local development and testing of both the scaffolder + * and scaffolder templates. + * + * This action is not installed by default and should not be installed in + * production, as it writes the files to the local filesystem of the scaffolder. + */ +export function createPublishFileAction() { + return createTemplateAction<{ path: string }>({ + id: 'publish:file', + description: 'Writes contents of the workspace to a local directory', + schema: { + input: { + type: 'object', + required: ['path'], + properties: { + path: { + title: 'Path to a directory where the output will be written', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { path } = ctx.input; + + const exists = await fs.pathExists(path); + if (exists) { + throw new InputError('Output path already exists'); + } + await fs.ensureDir(dirname(path)); + await fs.copy(ctx.workspacePath, path); + }, + }); +} 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 4f9b6038a1..808d76c8cf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.ts @@ -126,7 +126,7 @@ export function createPublishGithubAction(options: { ? client.repos.createInOrg({ name: repo, org: owner, - private: repoVisibility !== 'public', + private: repoVisibility === 'private', visibility: repoVisibility, description: description, }) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index 70a7909144..419922704d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -18,3 +18,4 @@ export { createPublishGithubAction } from './github'; export { createPublishAzureAction } from './azure'; export { createPublishGitlabAction } from './gitlab'; export { createPublishBitbucketAction } from './bitbucket'; +export { createPublishFileAction } from './file'; 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 537aac3088..7806f089ff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -48,7 +48,7 @@ describe('CookieCutter Templater', () => { }, }; - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); const templater = new CookieCutter(); await templater.run({ @@ -72,7 +72,7 @@ describe('CookieCutter Templater', () => { jest .spyOn(fs, 'readJSON') .mockImplementationOnce(() => Promise.resolve(existingJson)); - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); const values = { owner: 'blobby', @@ -135,12 +135,10 @@ describe('CookieCutter Templater', () => { }, }; - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); jest .spyOn(fs, 'realpath') - .mockImplementation((filePath: string | Buffer) => - Promise.resolve(filePath as string), - ); + .mockImplementation(x => Promise.resolve(x.toString())); const templater = new CookieCutter(); await templater.run({ @@ -170,6 +168,29 @@ describe('CookieCutter Templater', () => { }); }); + it('should run the docker container mentioned in configs, overriding the default', async () => { + const values = { + owner: 'blobby', + storePath: 'https://github.com/org/repo', + imageName: 'foo/cookiecutter-image-with-extensions', + }; + + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); + + expect(runDockerContainer).toHaveBeenCalledWith( + expect.objectContaining({ + imageName: 'foo/cookiecutter-image-with-extensions', + }), + ); + }); + it('should pass through the streamer to the run docker helper', async () => { const stream = new PassThrough(); @@ -182,7 +203,7 @@ describe('CookieCutter Templater', () => { }, }; - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); const templater = new CookieCutter(); await templater.run({ @@ -226,7 +247,7 @@ describe('CookieCutter Templater', () => { }, }; - jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing'] as any); commandExists.mockImplementationOnce(() => () => true); const templater = new CookieCutter(); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index f0fbf4a708..e60c5efb4d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -52,9 +52,10 @@ export class CookieCutter implements TemplaterBase { // First lets grab the default cookiecutter.json file const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir); + const { imageName, ...valuesForCookieCutterJson } = values; const cookieInfo = { ...cookieCutterJson, - ...values, + ...valuesForCookieCutterJson, }; await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo); @@ -74,7 +75,7 @@ export class CookieCutter implements TemplaterBase { }); } else { await runDockerContainer({ - imageName: 'spotify/backstage-cookiecutter', + imageName: imageName || 'spotify/backstage-cookiecutter', args: [ 'cookiecutter', '--no-input', diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ec7e79bbc8..1e2891cfb8 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -288,7 +288,9 @@ export async function createRouter( ); } - const template = await entityClient.findTemplate(name); + const template = await entityClient.findTemplate(name, { + token: getBearerToken(req.headers.authorization), + }); if (isBeta2Template(template)) { const parameters = [template.spec.parameters ?? []].flat(); res.json({ @@ -356,7 +358,9 @@ export async function createRouter( .post('/v2/tasks', async (req, res) => { const templateName: string = req.body.templateName; const values: TemplaterValues = req.body.values; - const template = await entityClient.findTemplate(templateName); + const template = await entityClient.findTemplate(templateName, { + token: getBearerToken(req.headers.authorization), + }); let taskSpec; if (isAlpha1Template(template)) { diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 27227fe3b1..658d64e176 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,24 @@ # @backstage/plugin-scaffolder +## 0.8.0 + +### Minor Changes + +- 3385b374b: Use `scmIntegrationsApiRef` from the new `@backstage/integration-react`. + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/catalog-client@0.3.8 + - @backstage/config@0.1.4 + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.7.1 ### Patch Changes diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index a41d088329..d2b003e0ca 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import React from 'react'; -import { createDevApp } from '@backstage/dev-utils'; -import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core'; import { CatalogClient } from '@backstage/catalog-client'; +import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core'; +import { createDevApp } from '@backstage/dev-utils'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { scaffolderApiRef, ScaffolderClient } from '../src'; import { ScaffolderPage } from '../src/plugin'; -import { ScaffolderClient, scaffolderApiRef } from '../src'; createDevApp() .registerApi({ @@ -34,9 +35,10 @@ createDevApp() discoveryApi: discoveryApiRef, identityApi: identityApiRef, configApi: configApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, }, - factory: ({ discoveryApi, identityApi, configApi }) => - new ScaffolderClient({ discoveryApi, identityApi, configApi }), + factory: ({ discoveryApi, identityApi, scmIntegrationsApi }) => + new ScaffolderClient({ discoveryApi, identityApi, scmIntegrationsApi }), }) .addPage({ path: '/create', diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index a1fd3919c6..e5205ccbc9 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.7.1", + "version": "0.8.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,12 +30,13 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/catalog-client": "^0.3.7", + "@backstage/catalog-client": "^0.3.8", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", - "@backstage/core": "^0.7.1", + "@backstage/config": "^0.1.4", + "@backstage/core": "^0.7.2", "@backstage/integration": "^0.5.1", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/integration-react": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -58,9 +59,9 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts new file mode 100644 index 0000000000..7739371348 --- /dev/null +++ b/plugins/scaffolder/src/api.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { ConfigReader } from '@backstage/core'; +import { ScmIntegrations } from '@backstage/integration'; +import { ScaffolderClient } from './api'; + +describe('api', () => { + const discoveryApi = {} as any; + const identityApi = {} as any; + const scmIntegrationsApi = ScmIntegrations.fromConfig( + new ConfigReader({ + integrations: { + github: [ + { + host: 'hello.com', + }, + ], + }, + }), + ); + const apiClient = new ScaffolderClient({ + scmIntegrationsApi, + discoveryApi, + identityApi, + }); + + it('should return default and custom integrations', async () => { + const allowedHosts = [ + 'hello.com', + 'gitlab.com', + 'github.com', + 'dev.azure.com', + 'bitbucket.org', + ]; + const integrations = await apiClient.getIntegrationsList({ allowedHosts }); + integrations.forEach(integration => + expect(allowedHosts).toContain(integration.host), + ); + }); +}); diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 758cd555e3..e266d06a91 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -19,11 +19,10 @@ import { JsonObject } from '@backstage/config'; import { createApiRef, DiscoveryApi, - Observable, - ConfigApi, IdentityApi, + Observable, } from '@backstage/core'; -import { ScmIntegrations } from '@backstage/integration'; +import { ScmIntegrationRegistry } from '@backstage/integration'; import ObservableImpl from 'zen-observable'; import { ListActionsResponse, ScaffolderTask, Status } from './types'; @@ -83,31 +82,28 @@ export interface ScaffolderApi { after?: number; }): Observable; } + export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; - private readonly configApi: ConfigApi; + private readonly scmIntegrationsApi: ScmIntegrationRegistry; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; - configApi: ConfigApi; + scmIntegrationsApi: ScmIntegrationRegistry; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; - this.configApi = options.configApi; + this.scmIntegrationsApi = options.scmIntegrationsApi; } async getIntegrationsList(options: { allowedHosts: string[] }) { - const integrations = ScmIntegrations.fromConfig( - this.configApi.getConfig('integrations'), - ); - return [ - ...integrations.azure.list(), - ...integrations.bitbucket.list(), - ...integrations.github.list(), - ...integrations.gitlab.list(), + ...this.scmIntegrationsApi.azure.list(), + ...this.scmIntegrationsApi.bitbucket.list(), + ...this.scmIntegrationsApi.github.list(), + ...this.scmIntegrationsApi.gitlab.list(), ] .map(c => ({ type: c.type, title: c.title, host: c.config.host })) .filter(c => options.allowedHosts.includes(c.host)); diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx index 22de59b6ef..406515985c 100644 --- a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -100,7 +100,9 @@ describe('Results Filter', () => { ); for (const category of categories) { expect( - await findByText(category.charAt(0).toUpperCase() + category.slice(1)), + await findByText( + category.charAt(0).toLocaleUpperCase('en-US') + category.slice(1), + ), ).toBeInTheDocument(); } }); diff --git a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx index 301c6b01d8..4325d2d0d4 100644 --- a/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx +++ b/plugins/scaffolder/src/components/ResultsFilter/ResultsFilter.tsx @@ -107,7 +107,10 @@ export const ResultsFilter = ({ availableCategories }: Props) => { /> ); diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 8e8ba15ddc..0353dff798 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -105,8 +105,8 @@ export const ScaffolderPageContents = () => { ); const matchesQuery = (metadata: EntityMeta, query: string) => - `${metadata.title}`.toUpperCase().includes(query) || - metadata.tags?.join('').toUpperCase().indexOf(query) !== -1; + `${metadata.title}`.toLocaleUpperCase('en-US').includes(query) || + metadata.tags?.join('').toLocaleUpperCase('en-US').indexOf(query) !== -1; useEffect(() => { if (search.length === 0) { @@ -114,7 +114,7 @@ export const ScaffolderPageContents = () => { } return setMatchingEntities( filteredEntities.filter(template => - matchesQuery(template.metadata, search.toUpperCase()), + matchesQuery(template.metadata, search.toLocaleUpperCase('en-US')), ), ); }, [search, filteredEntities]); diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 8ba03f4682..1aa25d8e18 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -15,15 +15,15 @@ */ import { - createPlugin, createApiFactory, + createPlugin, + createRoutableExtension, discoveryApiRef, identityApiRef, - configApiRef, - createRoutableExtension, } from '@backstage/core'; -import { rootRouteRef } from './routes'; +import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; +import { rootRouteRef } from './routes'; export const scaffolderPlugin = createPlugin({ id: 'scaffolder', @@ -33,10 +33,10 @@ export const scaffolderPlugin = createPlugin({ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef, - configApi: configApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, }, - factory: ({ discoveryApi, identityApi, configApi }) => - new ScaffolderClient({ discoveryApi, identityApi, configApi }), + factory: ({ discoveryApi, identityApi, scmIntegrationsApi }) => + new ScaffolderClient({ discoveryApi, identityApi, scmIntegrationsApi }), }), ], routes: { diff --git a/plugins/search-backend-node/.eslintrc.js b/plugins/search-backend-node/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/search-backend-node/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/search-backend-node/README.md b/plugins/search-backend-node/README.md new file mode 100644 index 0000000000..126e8b9988 --- /dev/null +++ b/plugins/search-backend-node/README.md @@ -0,0 +1,21 @@ +# search-backend-node + +This plugin is part of a suite of plugins that comprise the Backstage search +platform, which is still very much under development. This plugin specifically +is responsible for: + +- Allowing other backend plugins to register the fact that they have documents + that they'd like to be indexed by a search engine (known as `collators`) +- Allowing other backend plugins to register the fact that they have metadata + that they'd like to augment existing documents in the search index with + (known as `decorators`) +- A scheduler that, at configurable intervals, compiles documents to be indexed + and passes them to a search engine for indexing +- Types for all of the above + +Documentation on how to develop and improve the search platform is currently +centralized in the `search` plugin README.md. + +For a better overview of how the search platform is put together, check the +[Backstage Search Architecture](https://backstage.io/docs/features/search/architecture) +documentation. diff --git a/plugins/search-backend-node/package.json b/plugins/search-backend-node/package.json new file mode 100644 index 0000000000..cc6cc0a7dd --- /dev/null +++ b/plugins/search-backend-node/package.json @@ -0,0 +1,32 @@ +{ + "name": "@backstage/plugin-search-backend-node", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/search-common": "^0.1.1", + "winston": "^3.2.1" + }, + "devDependencies": { + "@backstage/backend-common": "^0.6.0", + "@backstage/cli": "^0.6.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/search-backend-node/src/IndexBuilder.ts b/plugins/search-backend-node/src/IndexBuilder.ts new file mode 100644 index 0000000000..43bf84a59d --- /dev/null +++ b/plugins/search-backend-node/src/IndexBuilder.ts @@ -0,0 +1,113 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DocumentCollator, DocumentDecorator } from '@backstage/search-common'; +import { Logger } from 'winston'; +import { + RegisterCollatorParameters, + RegisterDecoratorParameters, +} from './types'; + +interface CollatorEnvelope { + collate: DocumentCollator; + refreshInterval: number; +} + +type IndexBuilderOptions = { + logger: Logger; +}; + +export class IndexBuilder { + private collators: Record; + private decorators: Record; + private logger: Logger; + + constructor({ logger }: IndexBuilderOptions) { + this.collators = {}; + this.decorators = {}; + this.logger = logger; + } + + /** + * Makes the index builder aware of a collator that should be executed at the + * given refresh interval. + */ + addCollator({ + type, + collator, + defaultRefreshIntervalSeconds, + }: RegisterCollatorParameters): void { + this.logger.info( + `Added ${collator.constructor.name} collator for type ${type}`, + ); + this.collators[type] = { + refreshInterval: defaultRefreshIntervalSeconds, + collate: collator, + }; + } + + /** + * Makes the index builder aware of a decorator. If no types are provided, it + * will be applied to documents from all known collators, otherwise it will + * only be applied to documents of the given types. + */ + addDecorator({ + types = ['*'], + decorator, + }: RegisterDecoratorParameters): void { + this.logger.info( + `Added decorator ${decorator.constructor.name} to types ${types.join( + ', ', + )}`, + ); + types.forEach(type => { + if (this.decorators.hasOwnProperty(type)) { + this.decorators[type].push(decorator); + } else { + this.decorators[type] = [decorator]; + } + }); + } + + /** + * Starts the process of executing collators and decorators and building the + * search index. + * + * TODO: But like with coordination, timing, error handling, and what have you. + */ + async build() { + return Promise.all( + Object.keys(this.collators).map(async type => { + const decorators: DocumentDecorator[] = ( + this.decorators['*'] || [] + ).concat(this.decorators[type] || []); + + this.logger.info( + `Collating documents for ${type} via ${this.collators[type].collate.constructor.name}`, + ); + let documents = await this.collators[type].collate.execute(); + for (let i = 0; i < decorators.length; i++) { + this.logger.info( + `Decorating ${type} documents via ${decorators[i].constructor.name}`, + ); + documents = await decorators[i].execute(documents); + } + + // TODO: push documents to a configured search engine. + }), + ); + } +} diff --git a/plugins/search-backend-node/src/index.test.ts b/plugins/search-backend-node/src/index.test.ts new file mode 100644 index 0000000000..0941f23540 --- /dev/null +++ b/plugins/search-backend-node/src/index.test.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 { getVoidLogger } from '@backstage/backend-common'; +import { + DocumentCollator, + DocumentDecorator, + IndexableDocument, +} from '@backstage/search-common'; +import { IndexBuilder } from './IndexBuilder'; + +class TestDocumentCollator implements DocumentCollator { + async execute() { + return []; + } +} + +class TestDocumentDecorator implements DocumentDecorator { + async execute(documents: IndexableDocument[]) { + return documents; + } +} + +describe('IndexBuilder', () => { + let testIndexBuilder: IndexBuilder; + let testCollator: DocumentCollator; + let testDecorator: DocumentDecorator; + + beforeEach(() => { + testIndexBuilder = new IndexBuilder({ logger: getVoidLogger() }); + testCollator = new TestDocumentCollator(); + testDecorator = new TestDocumentDecorator(); + }); + + describe('addCollator', () => { + it('adds a collator', async () => { + const collatorSpy = jest.spyOn(testCollator, 'execute'); + + // Add a collator. + testIndexBuilder.addCollator({ + type: 'anything', + defaultRefreshIntervalSeconds: 600, + collator: testCollator, + }); + + // Build the index and ensure the collator was invoked. + await testIndexBuilder.build(); + expect(collatorSpy).toHaveBeenCalled(); + }); + }); + + describe('addDecorator', () => { + it('adds a decorator', async () => { + const decoratorSpy = jest.spyOn(testDecorator, 'execute'); + + // Add a collator. + testIndexBuilder.addCollator({ + type: 'anything', + defaultRefreshIntervalSeconds: 600, + collator: testCollator, + }); + + // Add a decorator. + testIndexBuilder.addDecorator({ + decorator: testDecorator, + }); + + // Build the index and ensure the decorator was invoked. + await testIndexBuilder.build(); + expect(decoratorSpy).toHaveBeenCalled(); + }); + + it('adds a type-specific decorator', async () => { + const expectedType = 'an-expected-type'; + const docFixture = { + title: 'Test', + text: 'Test text.', + location: '/test/location', + }; + jest + .spyOn(testCollator, 'execute') + .mockImplementation(async () => [docFixture]); + const decoratorSpy = jest.spyOn(testDecorator, 'execute'); + + // Add a collator. + testIndexBuilder.addCollator({ + type: expectedType, + defaultRefreshIntervalSeconds: 600, + collator: testCollator, + }); + + // Add a decorator for the same type. + testIndexBuilder.addDecorator({ + types: [expectedType], + decorator: testDecorator, + }); + + // Build the index and ensure the decorator was invoked. + await testIndexBuilder.build(); + expect(decoratorSpy).toHaveBeenCalled(); + expect(decoratorSpy).toHaveBeenCalledWith([docFixture]); + }); + + it('adds a type-specific decorator that should not be called', async () => { + const expectedType = 'an-expected-type'; + const docFixture = { + title: 'Test', + text: 'Test text.', + location: '/test/location', + }; + jest + .spyOn(testCollator, 'execute') + .mockImplementation(async () => [docFixture]); + const decoratorSpy = jest.spyOn(testDecorator, 'execute'); + + // Add a collator. + testIndexBuilder.addCollator({ + type: expectedType, + defaultRefreshIntervalSeconds: 600, + collator: testCollator, + }); + + // Add a decorator for a different type. + testIndexBuilder.addDecorator({ + types: ['not-the-expected-type'], + decorator: testDecorator, + }); + + // Build the index and ensure the decorator was not invoked. + await testIndexBuilder.build(); + expect(decoratorSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/search-backend-node/src/index.ts b/plugins/search-backend-node/src/index.ts new file mode 100644 index 0000000000..924dfbc9dc --- /dev/null +++ b/plugins/search-backend-node/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { IndexBuilder } from './IndexBuilder'; diff --git a/plugins/search-backend-node/src/setupTests.ts b/plugins/search-backend-node/src/setupTests.ts new file mode 100644 index 0000000000..4e230aca20 --- /dev/null +++ b/plugins/search-backend-node/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. + */ + +export {}; diff --git a/plugins/search-backend-node/src/types.ts b/plugins/search-backend-node/src/types.ts new file mode 100644 index 0000000000..e55faccfb4 --- /dev/null +++ b/plugins/search-backend-node/src/types.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DocumentCollator, DocumentDecorator } from '@backstage/search-common'; + +/** + * Parameters required to register a collator. + */ +export interface RegisterCollatorParameters { + /** + * The type of document to be indexed (used to name indices, to configure refresh loop, etc). + */ + type: string; + + /** + * The default interval (in seconds) that the provided collator will be called (can be overridden in config). + */ + defaultRefreshIntervalSeconds: number; + + /** + * The collator class responsible for returning all documents of the given type. + */ + collator: DocumentCollator; +} + +/** + * Parameters required to register a decorator + */ +export interface RegisterDecoratorParameters { + /** + * The decorator class responsible for appending or modifying documents of the given type(s). + */ + decorator: DocumentDecorator; + + /** + * (Optional) An array of document types that the given decorator should apply to. If none are provided, + * the decorator will be applied to all types. + */ + types?: string[]; +} diff --git a/plugins/search-backend/.eslintrc.js b/plugins/search-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/search-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/search-backend/README.md b/plugins/search-backend/README.md new file mode 100644 index 0000000000..678639d36b --- /dev/null +++ b/plugins/search-backend/README.md @@ -0,0 +1,15 @@ +# search-backend + +This plugin is part of a suite of plugins that comprise the Backstage search +platform, which is still very much under development. This plugin specifically +is responsible for: + +- Exposing a JSON API for querying a search engine +- Types related to interacting with that API + +Documentation on how to develop and improve the search platform is currently +centralized in the `search` plugin README.md. + +For a better overview of how the search platform is put together, check the +[Backstage Search Architecture](https://backstage.io/docs/features/search/architecture) +documentation. diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json new file mode 100644 index 0000000000..ed65221cbb --- /dev/null +++ b/plugins/search-backend/package.json @@ -0,0 +1,38 @@ +{ + "name": "@backstage/plugin-search-backend", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "start": "backstage-cli backend:dev", + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.6.0", + "@backstage/search-common": "^0.1.1", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.6.0", + "@types/supertest": "^2.0.8", + "supertest": "^4.0.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/search-backend/src/index.ts b/plugins/search-backend/src/index.ts new file mode 100644 index 0000000000..38e2cdf4cb --- /dev/null +++ b/plugins/search-backend/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './service/router'; diff --git a/plugins/search-backend/src/run.ts b/plugins/search-backend/src/run.ts new file mode 100644 index 0000000000..a59d90d09a --- /dev/null +++ b/plugins/search-backend/src/run.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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts new file mode 100644 index 0000000000..ba7032eb93 --- /dev/null +++ b/plugins/search-backend/src/service/router.test.ts @@ -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 { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /query', () => { + it('returns empty results array', async () => { + const response = await request(app).get('/query'); + + expect(response.status).toEqual(200); + expect(response.body).toMatchObject({ results: [] }); + }); + }); +}); diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts new file mode 100644 index 0000000000..1a0c67bdb8 --- /dev/null +++ b/plugins/search-backend/src/service/router.ts @@ -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 express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { SearchQuery, SearchResultSet } from '@backstage/search-common'; + +type RouterOptions = { + logger: Logger; +}; + +export async function createRouter({ + logger, +}: RouterOptions): Promise { + const router = Router(); + + router.get( + '/query', + async ( + req: express.Request, + res: express.Response, + ) => { + // TODO: Actually transform req.params into search engine specific query. + const { term, filters = {}, pageCursor = '' } = req.query; + logger.info( + `Search request received: ${term}, ${JSON.stringify( + filters, + )}, ${pageCursor}`, + ); + + try { + // TODO: Actually query search engine. + // TODO: And actually transform results into frontend-readable result + res.send({ + results: [], + }); + } catch (err) { + throw new Error(`There was a problem performing the search query.`); + } + }, + ); + + return router; +} diff --git a/plugins/search-backend/src/service/standaloneServer.ts b/plugins/search-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..b0c53a6adc --- /dev/null +++ b/plugins/search-backend/src/service/standaloneServer.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 { createServiceBuilder } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'search-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + }); + + const service = createServiceBuilder(module) + .enableCors({ origin: 'http://localhost:3000' }) + .addRouter('/search', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/search-backend/src/setupTests.ts b/plugins/search-backend/src/setupTests.ts new file mode 100644 index 0000000000..ba33cf996b --- /dev/null +++ b/plugins/search-backend/src/setupTests.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 {}; diff --git a/plugins/search/CHANGELOG.md b/plugins/search/CHANGELOG.md index c582a582ee..35f3ee97c1 100644 --- a/plugins/search/CHANGELOG.md +++ b/plugins/search/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-search +## 0.3.4 + +### Patch Changes + +- 9ca0e4009: use local version of lowerCase and upperCase methods +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + ## 0.3.3 ### Patch Changes diff --git a/plugins/search/README.md b/plugins/search/README.md index fb4b047811..d73995dcf5 100644 --- a/plugins/search/README.md +++ b/plugins/search/README.md @@ -2,8 +2,22 @@ **This plugin is still under development.** -You can follow the progress under the Global search in Backstage [milestone](https://github.com/backstage/backstage/milestone/21) or reach out to us in the #search Discord channel. +You can follow the progress and contribute at the Backstage [Search Project Board](https://github.com/backstage/backstage/projects/6) or reach out to us in the [`#search` Discord channel](https://discord.com/channels/687207715902193673/770283289327566848). ## Getting started -Run `yarn start` in the root directory, and then navigate to [/search](http://localhost:3000/search)to check out the plugin. +Run `yarn start` in the root directory, and then navigate to [/search](http://localhost:3000/search) to check out the plugin. + +### Working on the search platform + +The above search experience is 100% client-side and only looks at the Software Catalog. We are actively developing [a more complete search platform](https://backstage.io/docs/features/search/search-overview), which will replace the current experience when ready. + +In order to work on this new search platform, you will need to be aware of the following: + +- **In-development app search route**: The new search platform will move the primary search page to the App-level, out of the search plugin. For now, to ensure the old experience is still easy to test, you can work on the new platform at `/search-next` instead of `/search`. +- **App SearchPage Component**: You'll find a new search page under `/packages/app/src/components/search`. When ready, we'll add an equivalent page to the `@backstage/create-app` template. +- **Backend**: Don't forget, a lot of functionality will be made available in backend plugins: + - `@backstage/plugin-search-backend-node`, which is responsible for the search index management + - `@backstage/plugin-search-backend`, which is responsible for query processing + +As you work, be sure not to break the existing, frontend-only search page. diff --git a/plugins/search/package.json b/plugins/search/package.json index 378cf99d20..d88ed9b241 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-search", - "version": "0.3.3", + "version": "0.3.4", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,9 +29,11 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/catalog-model": "^0.7.3", - "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/plugin-search-backend": "^0.1.1", + "@backstage/search-common": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index 9d88227a44..b15be349cf 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -14,8 +14,17 @@ * limitations under the License. */ +import { createApiRef, DiscoveryApi } from '@backstage/core'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; + import { CatalogApi } from '@backstage/plugin-catalog-react'; +import { SearchQuery, SearchResultSet } from '@backstage/search-common'; +import qs from 'qs'; + +export const searchApiRef = createApiRef({ + id: 'plugin.search.queryservice', + description: 'Used to make requests against the search API', +}); export type Result = { name: string; @@ -28,11 +37,18 @@ export type Result = { export type SearchResults = Array; -class SearchApi { - private catalogApi: CatalogApi; +export interface SearchApi { + getSearchResult(): Promise; + _alphaPerformSearch(query: SearchQuery): Promise; +} - constructor(catalogApi: CatalogApi) { - this.catalogApi = catalogApi; +export class SearchClient implements SearchApi { + private readonly catalogApi: CatalogApi; + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { catalogApi: CatalogApi; discoveryApi: DiscoveryApi }) { + this.catalogApi = options.catalogApi; + this.discoveryApi = options.discoveryApi; } private async entities() { @@ -53,9 +69,17 @@ class SearchApi { })); } - public getSearchResult(): Promise { + getSearchResult(): Promise { return this.entities(); } -} -export default SearchApi; + // TODO: Productionalize as we implement search milestones. + async _alphaPerformSearch(query: SearchQuery): Promise { + const queryString = qs.stringify(query); + const url = `${await this.discoveryApi.getBaseUrl( + 'search/query', + )}?${queryString}`; + const response = await fetch(url); + return response.json(); + } +} diff --git a/plugins/search/src/components/SearchPage/SearchPage.tsx b/plugins/search/src/components/SearchPage/SearchPage.tsx index 33e89e90dc..7c216fc908 100644 --- a/plugins/search/src/components/SearchPage/SearchPage.tsx +++ b/plugins/search/src/components/SearchPage/SearchPage.tsx @@ -56,7 +56,9 @@ export const SearchPage = () => { /> - + diff --git a/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx b/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx new file mode 100644 index 0000000000..a6655e37ac --- /dev/null +++ b/plugins/search/src/components/SearchPageNext/SearchPageNext.tsx @@ -0,0 +1,71 @@ +/* + * 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 { + Content, + Header, + Lifecycle, + Page, + useQueryParamState, +} from '@backstage/core'; +import { Grid } from '@material-ui/core'; +import React, { useEffect, useState } from 'react'; +import { useDebounce } from 'react-use'; +import { SearchBar } from '../SearchBar'; +import { SearchResult } from '../SearchResult'; + +export const SearchPageNext = () => { + const [queryString, setQueryString] = useQueryParamState('query'); + const [searchQuery, setSearchQuery] = useState(queryString ?? ''); + + const handleSearch = (event: React.ChangeEvent) => { + event.preventDefault(); + setSearchQuery(event.target.value); + }; + + useEffect(() => setSearchQuery(queryString ?? ''), [queryString]); + + useDebounce( + () => { + setQueryString(searchQuery); + }, + 200, + [searchQuery], + ); + + const handleClearSearchBar = () => { + setSearchQuery(''); + }; + + return ( + +
} /> + + + + + + + + + + + + ); +}; diff --git a/plugins/search/src/components/SearchPageNext/index.tsx b/plugins/search/src/components/SearchPageNext/index.tsx new file mode 100644 index 0000000000..464ba28750 --- /dev/null +++ b/plugins/search/src/components/SearchPageNext/index.tsx @@ -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 { SearchPageNext } from './SearchPageNext'; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index c4d8809732..0aa209e6fe 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -21,12 +21,12 @@ import { TableColumn, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Divider, Grid, makeStyles, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { useEffect, useState } from 'react'; import { useAsync } from 'react-use'; -import SearchApi, { Result, SearchResults } from '../../apis'; +import { Result, SearchResults, searchApiRef } from '../../apis'; + import { Filters, FiltersButton, FiltersState } from '../Filters'; const useStyles = makeStyles(theme => ({ @@ -116,7 +116,7 @@ const TableHeader = ({ }; export const SearchResult = ({ searchQuery }: SearchResultProps) => { - const catalogApi = useApi(catalogApiRef); + const searchApi = useApi(searchApiRef); const [showFilters, toggleFilters] = useState(false); const [selectedFilters, setSelectedFilters] = useState({ @@ -126,8 +126,6 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { const [filteredResults, setFilteredResults] = useState([]); - const searchApi = new SearchApi(catalogApi); - const { loading, error, value: results } = useAsync(() => { return searchApi.getSearchResult(); }, []); @@ -158,11 +156,13 @@ export const SearchResult = ({ searchQuery }: SearchResultProps) => { if (searchQuery) { withFilters = withFilters.filter( (result: Result) => - result.name?.toLowerCase().includes(searchQuery) || + result.name?.toLocaleLowerCase('en-US').includes(searchQuery) || result.name - ?.toLowerCase() + ?.toLocaleLowerCase('en-US') .includes(searchQuery.split(' ').join('-')) || - result.description?.toLowerCase().includes(searchQuery), + result.description + ?.toLocaleLowerCase('en-US') + .includes(searchQuery), ); } diff --git a/plugins/search/src/components/index.tsx b/plugins/search/src/components/index.tsx index ac47860dc2..f571c61373 100644 --- a/plugins/search/src/components/index.tsx +++ b/plugins/search/src/components/index.tsx @@ -17,5 +17,6 @@ export * from './Filters'; export * from './SearchBar'; export * from './SearchPage'; +export * from './SearchPageNext'; export * from './SearchResult'; export * from './SidebarSearch'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 572d75bea5..519d7fcf66 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -13,7 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { searchPlugin, searchPlugin as plugin, SearchPage } from './plugin'; + +// TODO: export searchApiRef from ./apis once interface is stable and settled. +export { + searchPlugin, + searchPlugin as plugin, + SearchPage, + SearchPageNext, +} from './plugin'; export { Filters, FiltersButton, diff --git a/plugins/search/src/plugin.ts b/plugins/search/src/plugin.ts index 37503b7751..e3e953e07d 100644 --- a/plugins/search/src/plugin.ts +++ b/plugins/search/src/plugin.ts @@ -14,24 +14,45 @@ * limitations under the License. */ import { + createApiFactory, createPlugin, createRouteRef, createRoutableExtension, + discoveryApiRef, } from '@backstage/core'; +import { SearchClient, searchApiRef } from './apis'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { SearchPage as SearchPageComponent } from './components/SearchPage'; +import { SearchPageNext as SearchPageNextComponent } from './components/SearchPageNext'; export const rootRouteRef = createRouteRef({ path: '/search', title: 'search', }); +export const rootNextRouteRef = createRouteRef({ + path: '/search-next', + title: 'search', +}); + export const searchPlugin = createPlugin({ id: 'search', + apis: [ + createApiFactory({ + api: searchApiRef, + deps: { catalogApi: catalogApiRef, discoveryApi: discoveryApiRef }, + factory: ({ catalogApi, discoveryApi }) => { + return new SearchClient({ catalogApi, discoveryApi }); + }, + }), + ], register({ router }) { router.addRoute(rootRouteRef, SearchPageComponent); + router.addRoute(rootNextRouteRef, SearchPageNextComponent); }, routes: { root: rootRouteRef, + nextRoot: rootNextRouteRef, }, }); @@ -41,3 +62,11 @@ export const SearchPage = searchPlugin.provide( mountPoint: rootRouteRef, }), ); + +export const SearchPageNext = searchPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/SearchPageNext').then(m => m.SearchPageNext), + mountPoint: rootNextRouteRef, + }), +); diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 0046de81b6..e10cc91a23 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.3", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -46,9 +46,9 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 10ceb50917..c798e99bbb 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -34,7 +34,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.3", "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,9 +47,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/sonarqube/src/api/SonarQubeClient.test.ts b/plugins/sonarqube/src/api/SonarQubeClient.test.ts index 3d488aa9a5..74df48a9e8 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.test.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.test.ts @@ -71,7 +71,7 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/sonarqube/components/show`, (req, res, ctx) => { - expect(req.url.searchParams.toString()).toBe('component=our-service'); + expect(req.url.searchParams.toString()).toBe('component=our%3Aservice'); return res( ctx.json({ component: { @@ -85,7 +85,7 @@ describe('SonarQubeClient', () => { server.use( rest.get(`${mockBaseUrl}/sonarqube/measures/search`, (req, res, ctx) => { expect(req.url.searchParams.toString()).toBe( - `projectKeys=our-service&metricKeys=${metricKeys.join('%2C')}`, + `projectKeys=our%3Aservice&metricKeys=${metricKeys.join('%2C')}`, ); return res( @@ -94,7 +94,7 @@ describe('SonarQubeClient', () => { { metric: 'alert_status', value: 'OK', - component: 'our-service', + component: 'our:service', }, { metric: 'alert_status', @@ -104,52 +104,52 @@ describe('SonarQubeClient', () => { { metric: 'bugs', value: '2', - component: 'our-service', + component: 'our:service', }, { metric: 'reliability_rating', value: '3.0', - component: 'our-service', + component: 'our:service', }, { metric: 'vulnerabilities', value: '4', - component: 'our-service', + component: 'our:service', }, { metric: 'security_rating', value: '1.0', - component: 'our-service', + component: 'our:service', }, { metric: 'security_hotspots_reviewed', value: '100', - component: 'our-service', + component: 'our:service', }, { metric: 'security_review_rating', value: '1.0', - component: 'our-service', + component: 'our:service', }, { metric: 'code_smells', value: '100', - component: 'our-service', + component: 'our:service', }, { metric: 'sqale_rating', value: '2.0', - component: 'our-service', + component: 'our:service', }, { metric: 'coverage', value: '55.5', - component: 'our-service', + component: 'our:service', }, { metric: 'duplicated_lines_density', value: '1.0', - component: 'our-service', + component: 'our:service', }, ].filter(m => metricKeys.includes(m.metric)), } as MeasuresWrapper), @@ -163,7 +163,7 @@ describe('SonarQubeClient', () => { const client = new SonarQubeClient({ discoveryApi }); - const summary = await client.getFindingSummary('our-service'); + const summary = await client.getFindingSummary('our:service'); expect(summary).toEqual( expect.objectContaining({ lastAnalysis: '2020-01-01T00:00:00Z', @@ -180,14 +180,14 @@ describe('SonarQubeClient', () => { coverage: '55.5', duplicated_lines_density: '1.0', }, - projectUrl: 'https://sonarcloud.io/dashboard?id=our-service', + projectUrl: 'https://sonarcloud.io/dashboard?id=our%3Aservice', }), ); expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( - 'https://sonarcloud.io/project/issues?id=our-service&types=CODE_SMELL&resolved=false', + 'https://sonarcloud.io/project/issues?id=our%3Aservice&types=CODE_SMELL&resolved=false', ); expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( - 'https://sonarcloud.io/component_measures?id=our-service&metric=coverage&resolved=false&view=list', + 'https://sonarcloud.io/component_measures?id=our%3Aservice&metric=coverage&resolved=false&view=list', ); }); @@ -199,7 +199,7 @@ describe('SonarQubeClient', () => { baseUrl: 'http://a.instance.local', }); - const summary = await client.getFindingSummary('our-service'); + const summary = await client.getFindingSummary('our:service'); expect(summary).toEqual( expect.objectContaining({ @@ -217,14 +217,14 @@ describe('SonarQubeClient', () => { coverage: '55.5', duplicated_lines_density: '1.0', }, - projectUrl: 'http://a.instance.local/dashboard?id=our-service', + projectUrl: 'http://a.instance.local/dashboard?id=our%3Aservice', }) as FindingSummary, ); expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( - 'http://a.instance.local/project/issues?id=our-service&types=CODE_SMELL&resolved=false', + 'http://a.instance.local/project/issues?id=our%3Aservice&types=CODE_SMELL&resolved=false', ); expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( - 'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list', + 'http://a.instance.local/component_measures?id=our%3Aservice&metric=coverage&resolved=false&view=list', ); }); @@ -236,7 +236,7 @@ describe('SonarQubeClient', () => { baseUrl: 'http://a.instance.local', }); - const summary = await client.getFindingSummary('our-service'); + const summary = await client.getFindingSummary('our:service'); expect(summary).toEqual( expect.objectContaining({ @@ -245,14 +245,14 @@ describe('SonarQubeClient', () => { alert_status: 'OK', bugs: '2', }, - projectUrl: 'http://a.instance.local/dashboard?id=our-service', + projectUrl: 'http://a.instance.local/dashboard?id=our%3Aservice', }) as FindingSummary, ); expect(summary?.getIssuesUrl('CODE_SMELL')).toEqual( - 'http://a.instance.local/project/issues?id=our-service&types=CODE_SMELL&resolved=false', + 'http://a.instance.local/project/issues?id=our%3Aservice&types=CODE_SMELL&resolved=false', ); expect(summary?.getComponentMeasuresUrl('COVERAGE')).toEqual( - 'http://a.instance.local/component_measures?id=our-service&metric=coverage&resolved=false&view=list', + 'http://a.instance.local/component_measures?id=our%3Aservice&metric=coverage&resolved=false&view=list', ); }); }); diff --git a/plugins/sonarqube/src/api/SonarQubeClient.ts b/plugins/sonarqube/src/api/SonarQubeClient.ts index 4f149d8d1f..10b9def200 100644 --- a/plugins/sonarqube/src/api/SonarQubeClient.ts +++ b/plugins/sonarqube/src/api/SonarQubeClient.ts @@ -120,17 +120,21 @@ export class SonarQubeClient implements SonarQubeApi { return { lastAnalysis: component.component.analysisDate, metrics, - projectUrl: `${this.baseUrl}dashboard?id=${componentKey}`, + projectUrl: `${this.baseUrl}dashboard?id=${encodeURIComponent( + componentKey, + )}`, getIssuesUrl: identifier => - `${ - this.baseUrl - }project/issues?id=${componentKey}&types=${identifier.toUpperCase()}&resolved=false`, + `${this.baseUrl}project/issues?id=${encodeURIComponent( + componentKey, + )}&types=${identifier.toUpperCase()}&resolved=false`, getComponentMeasuresUrl: identifier => - `${ - this.baseUrl - }component_measures?id=${componentKey}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, + `${this.baseUrl}component_measures?id=${encodeURIComponent( + componentKey, + )}&metric=${identifier.toLowerCase()}&resolved=false&view=list`, getSecurityHotspotsUrl: () => - `${this.baseUrl}project/security_hotspots?id=${componentKey}`, + `${this.baseUrl}project/security_hotspots?id=${encodeURIComponent( + componentKey, + )}`, }; } } diff --git a/plugins/splunk-on-call/package.json b/plugins/splunk-on-call/package.json index 6539e47f8a..2619204ed6 100644 --- a/plugins/splunk-on-call/package.json +++ b/plugins/splunk-on-call/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/plugin-catalog-react": "^0.1.1", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", @@ -45,9 +45,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/CHANGELOG.md b/plugins/tech-radar/CHANGELOG.md index a176147fdc..52223a95b1 100644 --- a/plugins/tech-radar/CHANGELOG.md +++ b/plugins/tech-radar/CHANGELOG.md @@ -1,5 +1,21 @@ # @backstage/plugin-tech-radar +## 0.3.8 + +### Patch Changes + +- 34e6bb409: Map description in API RadarEntry to Entry + + The description in the Entry was mapped to the latest timeline entry, which is a changelog. This + change maps the description in the API to the entry. To maintain backwards compatibility it + will set the description to the last timeline entry if no description exists at the entry level. + +- b56815b40: Fixes issue where radar description dialog is not shown when the entry has an url external to the radar page +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + ## 0.3.7 ### Patch Changes diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 32490209e2..1f2e8310a8 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.7", + "version": "0.3.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -43,9 +43,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 0c30c9374c..eca80420bf 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -38,6 +38,7 @@ export interface RadarEntry { title: string; url: string; timeline: Array; + description?: string; } export interface RadarEntrySnapshot { diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index bed45e63fc..a6e033a08e 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -68,7 +68,7 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { }; }), moved: entry.timeline[0].moved, - description: entry.timeline[0].description, + description: entry.description || entry.timeline[0].description, url: entry.url, }; }); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx index 0424624007..8cadd33da4 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -79,4 +79,20 @@ describe('RadarEntry', () => { expect(radarDescription).toBeInTheDocument(); expect(screen.getByText(String(minProps.value))).toBeInTheDocument(); }); + + it('should render blip with url equal to # if description present', () => { + const withUrl = { + ...optionalProps, + url: 'http://backstage.io', + }; + render( + + + + + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('href', '#'); + }); }); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index fb47a75c71..c3500a8c2d 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -111,11 +111,12 @@ const RadarEntry = (props: Props): JSX.Element => { /> )} {description ? ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts index 613e7b3cdc..8457d1bc6d 100644 --- a/plugins/tech-radar/src/sampleData.ts +++ b/plugins/tech-radar/src/sampleData.ts @@ -49,6 +49,8 @@ entries.push({ id: 'javascript', title: 'JavaScript', quadrant: 'languages', + description: + 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', }); entries.push({ timeline: [ @@ -65,6 +67,8 @@ entries.push({ id: 'typescript', title: 'TypeScript', quadrant: 'languages', + description: + 'Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat', }); entries.push({ timeline: [ @@ -72,9 +76,11 @@ entries.push({ moved: 1, ringId: 'use', date: new Date('2020-08-06'), + description: + 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur', }, ], - url: '#', + url: 'https://webpack.js.org/', key: 'webpack', id: 'webpack', title: 'Webpack', diff --git a/plugins/techdocs-backend/CHANGELOG.md b/plugins/techdocs-backend/CHANGELOG.md index 683573b8df..778750b6dd 100644 --- a/plugins/techdocs-backend/CHANGELOG.md +++ b/plugins/techdocs-backend/CHANGELOG.md @@ -1,5 +1,31 @@ # @backstage/plugin-techdocs-backend +## 0.6.5 + +### Patch Changes + +- e7baa0d2e: Separate techdocs-backend and frontend config schema declarations +- 8686eb38c: Use errors from `@backstage/errors` +- 424742dc1: Applies only if you use TechDocs local builder instead of building on CI/CD i.e. if `techdocs.builder` in your `app-config.yaml` is set to `'local'` + + Improvements + + 1. Do not check for updates in the repository if a check has been made in the last 60 seconds. This is to prevent the annoying check for update on every page switch or load. + 2. No need to maintain an in-memory etag storage, and use the one stored in `techdocs_metadata.json` file alongside generated docs. + + New feature + + 1. You can now use a mix of basic and recommended setup i.e. `techdocs.builder` is `'local'` but using an external cloud storage instead of local storage. Previously, in this setup, the docs would never get updated. + +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] +- Updated dependencies [424742dc1] +- Updated dependencies [8686eb38c] + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 + - @backstage/techdocs-common@0.4.5 + ## 0.6.4 ### Patch Changes diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index c704014b14..83b79f2f6c 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -/** - * TechDocs schema below is an abstract of what's used within techdocs-backend and for its visibility - * to view the complete TechDocs schema please refer: plugins/techdocs/config.d.ts - */ export interface Config { /** * Configuration options for the techdocs-backend plugin @@ -26,15 +22,193 @@ export interface Config { techdocs: { /** * Documentation building process depends on the builder attr + * @visibility frontend */ builder: 'local' | 'external'; + /** + * Techdocs generator information + */ + generators?: { + techdocs: 'local' | 'docker'; + }; + /** * Techdocs publisher information */ - publisher: { - type: 'local' | 'googleGcs' | 'awsS3' | 'openStackSwift'; - }; + publisher?: + | { + type: 'local'; + } + | { + type: 'awsS3'; + + /** + * Required when 'type' is set to awsS3 + */ + awsS3?: { + /** + * (Optional) Credentials used to access a storage bucket. + * If not set, environment variables or aws config file will be used to authenticate. + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + * @visibility secret + */ + credentials?: { + /** + * User access key id + * @visibility secret + */ + accessKeyId: string; + /** + * User secret access key + * @visibility secret + */ + secretAccessKey: string; + /** + * ARN of role to be assumed + * @visibility backend + */ + roleArn?: string; + }; + /** + * (Required) Cloud Storage Bucket Name + * @visibility backend + */ + bucketName: string; + /** + * (Optional) AWS Region. + * If not set, AWS_REGION environment variable or aws config file will be used. + * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html + * @visibility secret + */ + region?: string; + /** + * (Optional) AWS Endpoint. + * The endpoint URI to send requests to. The default endpoint is built from the configured region. + * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property + * @visibility secret + */ + endpoint?: string; + }; + } + | { + type: 'openStackSwift'; + + /** + * Required when 'type' is set to openStackSwift + */ + openStackSwift?: { + /** + * (Required) Credentials used to access a storage bucket. + * @see https://docs.openstack.org/api-ref/identity/v3/?expanded=password-authentication-with-unscoped-authorization-detail#password-authentication-with-unscoped-authorization + * @visibility secret + */ + credentials: { + /** + * (Required) Root user name + * @visibility secret + */ + username: string; + /** + * (Required) Root user password + * @visibility secret + */ + password: string; // required + }; + /** + * (Required) Cloud Storage Container Name + * @visibility backend + */ + containerName: string; + /** + * (Required) Auth url sometimes OpenStack uses different port check your OpenStack apis. + * @visibility backend + */ + authUrl: string; + /** + * (Optional) Auth version + * If not set, 'v2.0' will be used. + * @visibility backend + */ + keystoneAuthVersion: string; + /** + * (Required) Domain Id + * @visibility backend + */ + domainId: string; + /** + * (Required) Domain Name + * @visibility backend + */ + domainName: string; + /** + * (Required) Region + * @visibility backend + */ + region: string; + }; + } + | { + type: 'azureBlobStorage'; + + /** + * Required when 'type' is set to azureBlobStorage + */ + azureBlobStorage?: { + /** + * (Required) Credentials used to access a storage container. + * @visibility secret + */ + credentials: { + /** + * Account access name + * @visibility secret + */ + accountName: string; + /** + * (Optional) Account secret primary key + * If not set, environment variables will be used to authenticate. + * @see https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + * @visibility secret + */ + accountKey?: string; + }; + /** + * (Required) Cloud Storage Container Name + * @visibility backend + */ + containerName: string; + }; + } + | { + type: 'googleGcs'; + + /** + * Required when 'type' is set to googleGcs + */ + googleGcs?: { + /** + * (Required) Cloud Storage Bucket Name + * @visibility backend + */ + bucketName: string; + /** + * (Optional) API key used to write to a storage bucket. + * If not set, environment variables will be used to authenticate. + * @see https://cloud.google.com/docs/authentication/production + * @visibility secret + */ + credentials?: string; + }; + }; + + /** + * @example http://localhost:7000/api/techdocs + * @visibility frontend + * @deprecated + */ + requestUrl?: string; /** * @example http://localhost:7000/api/techdocs/static/docs diff --git a/plugins/techdocs-backend/examples/documented-component/docs/code/code-sample.md b/plugins/techdocs-backend/examples/documented-component/docs/code/code-sample.md index ed430edb15..9f8e1bd739 100644 --- a/plugins/techdocs-backend/examples/documented-component/docs/code/code-sample.md +++ b/plugins/techdocs-backend/examples/documented-component/docs/code/code-sample.md @@ -4,7 +4,7 @@ This page provides some sample code which may be used in your example component. This code uses TypeScript, and the Markdown code fence to wrap the code. -```tsx +```typescript const DefaultEntityPage = ({ entity }: { entity: Entity }) => ( ( ); ``` + +Here is an example of Python code: + +```python +def getUsersInGroup(targetGroup, secure=False): + + if __debug__: + print('targetGroup=[' + targetGroup + ']') +``` diff --git a/plugins/techdocs-backend/examples/documented-component/docs/extensions.md b/plugins/techdocs-backend/examples/documented-component/docs/extensions.md new file mode 100644 index 0000000000..40565bd42f --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/docs/extensions.md @@ -0,0 +1,89 @@ +# Plugins & Extensions + +Just by including the TechDocs Core Plugin to your MkDocs site included with Backstage, +you gain the immediate use of a variety of popular plugins and extensions to MkDocs. + +For more information and full details of the available features, see the +[`mkdocs-techdocs-core` repository](https://github.com/backstage/mkdocs-techdocs-core#mkdocs-plugins-and-extensions). + +This page provides a demonstration of some of the available features. + +## Admonitions + +Admonitions are call outs that help catch a users attention. + +To define an admonition simply put the following Markdown into your content: + +``` +!!! warn + Defining admonitions can be addicting. +``` + +And they end up looking like this: + + +!!! warn + Defining admonitions can be addicting. + + +!!! note + You can learn a lot about TechDocs by just visiting the Backstage web site at + https://backstage.io/docs. + + +!!! info + TechDocs is the core feature that supports documentation as code in Backstage. + + +!!! tip + Don't forget to spell check your documentation. + +## PlantUML + +You can create dynamic UML diagrams on the fly by just specifying flow via text, +using [PlantUML](https://pypi.org/project/plantuml-markdown/). + +```plantuml format="svg" classes="uml myDiagram" alt="Backstage sample PlantUML" title="Backstage sample PlantUML" width="500px" height="250px" +User -> SCMProvider: stores +TechDocs -> SCMProvider: prepares +TechDocs -> TechDocs: generates +TechDocs -> CloudStorage: publishes +CloudStorage -> Backstage: displays +``` + +## Pymdownx Extensions + +Pymdownx (Python Markdown extensions) are a variety of smaller additions. + +### Details + + +??? note "What is the answer to life, the universe, and everything? (click me for the answer)" + The answer is 42. + + +??? note "What is 4 plus 4?" + The answer is 8. + + +???+ note "How do I get support?" + You can get support by opening an issue in this repository. This detail is open by default + so it's more easily visible without requiring the user to click to open it. + +### Task Lists + +Automatic rendering of Markdown task lists. + +- [x] Phase 1 +- [x] Phase 2 +- [ ] Phase 3 + +### Emojis + +Very nice job on documentation! :thumbsup: + +I've read a lot of documentation, but I love :heart: this document. + +Weather: :sunny: :umbrella: :cloud: :snowflake: + +Animals: :tiger: :horse: :turtle: :wolf: :frog: diff --git a/plugins/techdocs-backend/examples/documented-component/docs/index.md b/plugins/techdocs-backend/examples/documented-component/docs/index.md index 5a9c23d9f6..48a903445a 100644 --- a/plugins/techdocs-backend/examples/documented-component/docs/index.md +++ b/plugins/techdocs-backend/examples/documented-component/docs/index.md @@ -1,17 +1,27 @@ # Welcome! -This is a basic example of documentation. +This is a basic example of documentation. It is intended as a showcase of some of the +features that TechDocs provides out of the box. + +You can see also: + +- [A sub page](sub-page.md) +- [Inline code examples](code/code-sample.md) +- [Plugin & Extension examples](extensions.md) - Diagrams, emojis, visual formatting. + +## Basic Markdown Here is a bulleted list: - Item one - Item two -- Item three +- Item Three -Check out the [Markdown Guide](https://www.markdownguide.org/) to learn more about how to simply create documentation. +Check out the [Markdown Guide](https://www.markdownguide.org/) to learn more about how to +simply create documentation. -You can also learn more about how to configure and setup this documentation in Backstage by -[reading up on the TechDocs Overview](https://backstage.io/docs/features/techdocs/techdocs-overview). +You can also learn more about how to configure and setup this documentation in Backstage, +[read up on the TechDocs Overview](https://backstage.io/docs/features/techdocs/techdocs-overview). ## Image Example @@ -21,7 +31,8 @@ This documentation is powered by Backstage's TechDocs feature: ## Table Example -While this documentation isn't comprehensive, in the future it should cover the following topics outlined in this example table: +While this documentation isn't comprehensive, in the future it should cover the following +topics outlined in this example table: | Topic | Description | | ------- | ------------------------------------------------------------ | diff --git a/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md b/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md index 8e1870c7b4..987f5e8a95 100644 --- a/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md +++ b/plugins/techdocs-backend/examples/documented-component/docs/sub-page.md @@ -4,7 +4,8 @@ This sub-page can be used to elaborate on a specific part of the component. ## Details -It is linked off the main page, and due to its inclusion in the `mkdocs.yml` file, becomes part of the auto-generated site navigation. +It is linked off the main page, and due to its inclusion in the `mkdocs.yml` file, +becomes part of the auto-generated site navigation. ## MkDocs diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml index 4c7d018690..2087979d22 100644 --- a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -4,6 +4,7 @@ nav: - Home: index.md - Subpage: sub-page.md - 'Code Sample': code/code-sample.md + - Extensions: extensions.md plugins: - techdocs-core diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 4e716dab32..5fdff3e66b 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs-backend", - "version": "0.6.4", + "version": "0.6.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,23 +30,23 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.6", + "@backstage/backend-common": "^0.6.0", "@backstage/catalog-model": "^0.7.4", - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/errors": "^0.1.1", - "@backstage/techdocs-common": "^0.4.4", + "@backstage/techdocs-common": "^0.4.5", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", "dockerode": "^3.2.1", "express": "^4.17.1", - "express-promise-router": "^3.0.3", + "express-promise-router": "^4.1.0", "fs-extra": "^9.0.1", "knex": "^0.95.1", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index 5d8ee3612d..9dd53f159e 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -101,7 +101,7 @@ export class DocsBuilder { } catch (err) { // Proceed with a fresh build this.logger.warn( - `Unable to read techdocs_metadata.json, error ${err}.`, + `Unable to read techdocs_metadata.json, proceeding with fresh build, error ${err}.`, ); } } diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 4b10063e03..9456e8a277 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -31,6 +31,7 @@ import { Logger } from 'winston'; import { DocsBuilder } from '../DocsBuilder'; import { shouldCheckForUpdate } from '../DocsBuilder/BuildMetadataStorage'; import { getEntityNameFromUrlPath } from './helpers'; +import { NotFoundError } from '@backstage/errors'; type RouterOptions = { preparers: PreparerBuilder; @@ -105,12 +106,11 @@ export async function createRouter({ } }); - router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { + // Check if docs are the latest version and trigger rebuilds if not + // Responds with immediate success if rebuild not needed + // If a build is required, responds with a success when finished + router.get('/sync/:namespace/:kind/:name', async (req, res) => { const { kind, namespace, name } = req.params; - const storageUrl = - config.getOptionalString('techdocs.storageUrl') ?? - `${await discovery.getExternalBaseUrl('techdocs')}/static/docs`; - const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); @@ -127,6 +127,16 @@ export async function createRouter({ const entity: Entity = await catalogRes.json(); + if (!entity.metadata.uid) { + throw new NotFoundError('Entity metadata UID missing'); + } + if (!shouldCheckForUpdate(entity.metadata.uid)) { + res.status(200).json({ + message: `Last check for documentation update is recent, did not retry.`, + }); + return; + } + let publisherType = ''; try { publisherType = config.getString('techdocs.publisher.type'); @@ -137,63 +147,61 @@ export async function createRouter({ 'https://backstage.io/docs/features/techdocs/architecture', ); } - // techdocs-backend will only try to build documentation for an entity if techdocs.builder is set to 'local' - // If set to 'external', it will only try to fetch and assume that an external process (e.g. CI/CD pipeline - // of the repository) is responsible for building and publishing documentation to the storage provider. - if ( - config.getString('techdocs.builder') === 'local' && - entity.metadata.uid && - shouldCheckForUpdate(entity.metadata.uid) - ) { - const docsBuilder = new DocsBuilder({ - preparers, - generators, - publisher, - dockerClient, - logger, - entity, + // If set to 'external', it will assume that an external process (e.g. CI/CD pipeline + // of the repository) is responsible for building and publishing documentation to the storage provider + if (config.getString('techdocs.builder') !== 'local') { + res.status(200).json({ + message: + '`techdocs.builder` app config is not set to `local`, so docs will not be generated locally and sync is not required.', }); - let foundDocs = false; - switch (publisherType) { - case 'local': - case 'awsS3': - case 'azureBlobStorage': - case 'openStackSwift': - case 'googleGcs': - // This block should be valid for all storage implementations. So no need to duplicate in future, - // add the publisher type in the list here. - await docsBuilder.build(); - // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched - // on the user's page. If not, respond with a message asking them to check back later. - // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second - for (let attempt = 0; attempt < 5; attempt++) { - if (await publisher.hasDocsBeenGenerated(entity)) { - foundDocs = true; - break; - } - await new Promise(r => setTimeout(r, 1000)); - } - if (!foundDocs) { - logger.error( - 'Published files are taking longer to show up in storage. Something went wrong.', - ); - res.status(408).json({ - error: - 'Sorry! It took too long for the generated docs to show up in storage. Check back later.', - }); - return; - } - break; - default: - res.status(400).json({ - error: `Publisher type ${publisherType} is not supported by techdocs-backend docs builder.`, - }); - break; - } + return; + } + const docsBuilder = new DocsBuilder({ + preparers, + generators, + publisher, + dockerClient, + logger, + entity, + }); + let foundDocs = false; + switch (publisherType) { + case 'local': + case 'awsS3': + case 'azureBlobStorage': + case 'openStackSwift': + case 'googleGcs': + // This block should be valid for all storage implementations. So no need to duplicate in future, + // add the publisher type in the list here. + await docsBuilder.build(); + // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched + // on the user's page. If not, respond with a message asking them to check back later. + // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second + for (let attempt = 0; attempt < 5; attempt++) { + if (await publisher.hasDocsBeenGenerated(entity)) { + foundDocs = true; + break; + } + await new Promise(r => setTimeout(r, 1000)); + } + if (!foundDocs) { + logger.error( + 'Published files are taking longer to show up in storage. Something went wrong.', + ); + throw new NotFoundError( + 'Sorry! It took too long for the generated docs to show up in storage. Check back later.', + ); + } + res + .status(201) + .json({ message: 'Docs updated or did not need updating' }); + break; + default: + throw new NotFoundError( + `Publisher type ${publisherType} is not supported by techdocs-backend docs builder.`, + ); } - - res.redirect(`${storageUrl}${req.path.replace('/docs', '')}`); }); // Route middleware which serves files from the storage set in the publisher. diff --git a/plugins/techdocs/CHANGELOG.md b/plugins/techdocs/CHANGELOG.md index 3f6393b171..efed0dd923 100644 --- a/plugins/techdocs/CHANGELOG.md +++ b/plugins/techdocs/CHANGELOG.md @@ -1,5 +1,25 @@ # @backstage/plugin-techdocs +## 0.6.2 + +### Patch Changes + +- 83bfc98a3: On TechDocs page header, change the breadcrumbs link to be static and point to TechDocs homepage. +- e7baa0d2e: Separate techdocs-backend and frontend config schema declarations +- c8b54c370: Extended TechDocs HomePage with owned documents +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] +- Updated dependencies [8686eb38c] +- Updated dependencies [424742dc1] +- Updated dependencies [4e0b5055a] + - @backstage/config@0.1.4 + - @backstage/core@0.7.2 + - @backstage/plugin-catalog-react@0.1.2 + - @backstage/techdocs-common@0.4.5 + - @backstage/test-utils@0.1.9 + ## 0.6.1 ### Patch Changes diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index 00d8d2cbbe..8bf4812e5e 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -26,194 +26,11 @@ export interface Config { */ builder: 'local' | 'external'; - /** - * Techdocs generator information - */ - generators?: { - techdocs: 'local' | 'docker'; - }; - - /** - * Techdocs publisher information - */ - publisher?: - | { - type: 'local'; - } - | { - type: 'awsS3'; - - /** - * Required when 'type' is set to awsS3 - */ - awsS3?: { - /** - * (Optional) Credentials used to access a storage bucket. - * If not set, environment variables or aws config file will be used to authenticate. - * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html - * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html - * @visibility secret - */ - credentials?: { - /** - * User access key id - * @visibility secret - */ - accessKeyId: string; - /** - * User secret access key - * @visibility secret - */ - secretAccessKey: string; - /** - * ARN of role to be assumed - * @visibility backend - */ - roleArn?: string; - }; - /** - * (Required) Cloud Storage Bucket Name - * @visibility backend - */ - bucketName: string; - /** - * (Optional) AWS Region. - * If not set, AWS_REGION environment variable or aws config file will be used. - * @see https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html - * @visibility secret - */ - region?: string; - /** - * (Optional) AWS Endpoint. - * The endpoint URI to send requests to. The default endpoint is built from the configured region. - * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property - * @visibility secret - */ - endpoint?: string; - }; - } - | { - type: 'openStackSwift'; - - /** - * Required when 'type' is set to openStackSwift - */ - openStackSwift?: { - /** - * (Required) Credentials used to access a storage bucket. - * @see https://docs.openstack.org/api-ref/identity/v3/?expanded=password-authentication-with-unscoped-authorization-detail#password-authentication-with-unscoped-authorization - * @visibility secret - */ - credentials: { - /** - * (Required) Root user name - * @visibility secret - */ - username: string; - /** - * (Required) Root user password - * @visibility secret - */ - password: string; // required - }; - /** - * (Required) Cloud Storage Container Name - * @visibility backend - */ - containerName: string; - /** - * (Required) Auth url sometimes OpenStack uses different port check your OpenStack apis. - * @visibility backend - */ - authUrl: string; - /** - * (Optional) Auth version - * If not set, 'v2.0' will be used. - * @visibility backend - */ - keystoneAuthVersion: string; - /** - * (Required) Domain Id - * @visibility backend - */ - domainId: string; - /** - * (Required) Domain Name - * @visibility backend - */ - domainName: string; - /** - * (Required) Region - * @visibility backend - */ - region: string; - }; - } - | { - type: 'azureBlobStorage'; - - /** - * Required when 'type' is set to azureBlobStorage - */ - azureBlobStorage?: { - /** - * (Required) Credentials used to access a storage container. - * @visibility secret - */ - credentials: { - /** - * Account access name - * @visibility secret - */ - accountName: string; - /** - * (Optional) Account secret primary key - * If not set, environment variables will be used to authenticate. - * @see https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json - * @visibility secret - */ - accountKey?: string; - }; - /** - * (Required) Cloud Storage Container Name - * @visibility backend - */ - containerName: string; - }; - } - | { - type: 'googleGcs'; - - /** - * Required when 'type' is set to googleGcs - */ - googleGcs?: { - /** - * (Required) Cloud Storage Bucket Name - * @visibility backend - */ - bucketName: string; - /** - * (Optional) API key used to write to a storage bucket. - * If not set, environment variables will be used to authenticate. - * @see https://cloud.google.com/docs/authentication/production - * @visibility secret - */ - credentials?: string; - }; - }; - /** * @example http://localhost:7000/api/techdocs * @visibility frontend * @deprecated */ requestUrl?: string; - - /** - * @example http://localhost:7000/api/techdocs/static/docs - * @deprecated - */ - storageUrl?: string; }; } diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index 5fa494a973..7acf3c7032 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -17,6 +17,7 @@ import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsStorage } from '../src/api'; +import { NotFoundError } from '@backstage/errors'; export class TechDocsDevStorageApi implements TechDocsStorage { public configApi: Config; @@ -44,11 +45,29 @@ export class TechDocsDevStorageApi implements TechDocsStorage { ); } - async getEntityDocs(entityId: EntityName, path: string) { - const { name } = entityId; + async getStorageUrl() { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } - const apiOrigin = await this.getApiOrigin(); - const url = `${apiOrigin}/${name}/${path}`; + async getBuilder() { + return this.configApi.getString('techdocs.builder'); + } + + async fetchUrl(url: string) { + const token = await this.identityApi.getIdToken(); + return fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + + async getEntityDocs(entityId: EntityName, path: string) { + const { kind, namespace, name } = entityId; + + const storageUrl = await this.getStorageUrl(); + const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; const token = await this.identityApi.getIdToken(); const request = await fetch( @@ -58,13 +77,66 @@ export class TechDocsDevStorageApi implements TechDocsStorage { }, ); - if (request.status === 404) { - throw new Error('Page not found'); + let errorMessage = ''; + switch (request.status) { + case 404: + errorMessage = 'Page not found. '; + // path is empty for the home page of an entity's docs site + if (!path) { + errorMessage += + 'This could be because there is no index.md file in the root of the docs directory of this repository.'; + } + throw new NotFoundError(errorMessage); + case 500: + errorMessage = + 'Could not generate documentation or an error in the TechDocs backend. '; + throw new Error(errorMessage); + default: + // Do nothing + break; } return request.text(); } + /** + * Check if docs are the latest version and trigger rebuilds if not + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + * @returns {boolean} Whether documents are currently synchronized to newest version + * @throws {Error} Throws error on error from sync endpoint + */ + async syncEntityDocs(entityId: EntityName) { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; + let request; + let attempts: number = 0; + // retry if request times out, up to 5 times + // can happen due to docs taking too long to generate + while (!request || (request.status === 408 && attempts < 5)) { + attempts++; + request = await this.fetchUrl( + `${url.endsWith('/') ? url : `${url}/`}index.html`, + ); + } + + switch (request.status) { + case 404: + throw (await request.json()).error; + case 200: + case 201: + return true; + // for timeout and misc errors, handle without error to allow viewing older docs + // if older docs not available, + // Reader will show 404 error coming from getEntityDocs + case 408: + default: + return false; + } + } + async getBaseUrl( oldBaseUrl: string, entityId: EntityName, diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index d5d737f808..42e2ac0b6e 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-techdocs", - "version": "0.6.1", + "version": "0.6.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,17 +31,17 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.3", + "@backstage/config": "^0.1.4", "@backstage/catalog-model": "^0.7.4", - "@backstage/core": "^0.7.1", - "@backstage/plugin-catalog-react": "^0.1.1", - "@backstage/test-utils": "^0.1.8", + "@backstage/core": "^0.7.2", + "@backstage/plugin-catalog-react": "^0.1.2", + "@backstage/test-utils": "^0.1.9", "@backstage/theme": "^0.2.4", - "@backstage/techdocs-common": "^0.4.4", + "@backstage/techdocs-common": "^0.4.5", + "@backstage/errors": "^0.1.1", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -50,14 +50,16 @@ "sanitize-html": "^2.3.2" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", + "@types/react": "^16.9", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", + "@types/react": "^16.9", "canvas": "^2.6.1", "cross-fetch": "^3.0.6", "msw": "^0.21.2" diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts index 734650d7c8..2260cc7d39 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/api.test.ts @@ -37,7 +37,7 @@ describe('TechDocsStorageApi', () => { await expect( storageApi.getBaseUrl('test.js', mockEntity, ''), ).resolves.toEqual( - `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, + `${mockBaseUrl}/static/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, ); }); @@ -48,7 +48,7 @@ describe('TechDocsStorageApi', () => { await expect( storageApi.getBaseUrl('test/', mockEntity, ''), ).resolves.toEqual( - `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, + `${mockBaseUrl}/static/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, ); }); }); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 6de94b63bb..0be530cc08 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -18,6 +18,7 @@ import { createApiRef, DiscoveryApi, IdentityApi } from '@backstage/core'; import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsMetadata } from './types'; +import { NotFoundError } from '@backstage/errors'; export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', @@ -31,6 +32,7 @@ export const techdocsApiRef = createApiRef({ export interface TechDocsStorage { getEntityDocs(entityId: EntityName, path: string): Promise; + syncEntityDocs(entityId: EntityName): Promise; getBaseUrl( oldBaseUrl: string, entityId: EntityName, @@ -153,6 +155,17 @@ export class TechDocsStorageApi implements TechDocsStorage { ); } + async getStorageUrl() { + return ( + this.configApi.getOptionalString('techdocs.storageUrl') ?? + `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` + ); + } + + async getBuilder() { + return this.configApi.getString('techdocs.builder'); + } + /** * Fetch HTML content as text for an individual docs page in an entity's docs site. * @@ -164,8 +177,8 @@ export class TechDocsStorageApi implements TechDocsStorage { async getEntityDocs(entityId: EntityName, path: string) { const { kind, namespace, name } = entityId; - const apiOrigin = await this.getApiOrigin(); - const url = `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; + const storageUrl = await this.getStorageUrl(); + const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; const token = await this.identityApi.getIdToken(); const request = await fetch( @@ -184,7 +197,7 @@ export class TechDocsStorageApi implements TechDocsStorage { errorMessage += 'This could be because there is no index.md file in the root of the docs directory of this repository.'; } - throw new Error(errorMessage); + throw new NotFoundError(errorMessage); case 500: errorMessage = 'Could not generate documentation or an error in the TechDocs backend. '; @@ -197,6 +210,46 @@ export class TechDocsStorageApi implements TechDocsStorage { return request.text(); } + /** + * Check if docs are on the latest version and trigger rebuild if not + * + * @param {EntityName} entityId Object containing entity data like name, namespace, etc. + * @returns {boolean} Whether documents are currently synchronized to newest version + * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend + */ + async syncEntityDocs(entityId: EntityName) { + const { kind, namespace, name } = entityId; + + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; + const token = await this.identityApi.getIdToken(); + let request; + let attempts: number = 0; + // retry if request times out, up to 5 times + // can happen due to docs taking too long to generate + while (!request || (request.status === 408 && attempts < 5)) { + attempts++; + request = await fetch(url, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + + switch (request.status) { + case 404: + throw new NotFoundError((await request.json()).error); + case 200: + case 201: + case 304: + return true; + // for timeout and misc errors, handle without error to allow viewing older docs + // if older docs not available, + // Reader will show 404 error coming from getEntityDocs + case 408: + default: + return false; + } + } + async getBaseUrl( oldBaseUrl: string, entityId: EntityName, @@ -205,10 +258,9 @@ export class TechDocsStorageApi implements TechDocsStorage { const { kind, namespace, name } = entityId; const apiOrigin = await this.getApiOrigin(); - return new URL( oldBaseUrl, - `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, + `${apiOrigin}/static/docs/${namespace}/${kind}/${name}/${path}`, ).toString(); } } diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 984e310bfb..67b39d0ef7 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -17,11 +17,11 @@ import { EntityName } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; import { BackstageTheme } from '@backstage/theme'; import { useTheme } from '@material-ui/core'; -import React from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; +import React, { useEffect, useRef, useState } from 'react'; +import { Alert } from '@material-ui/lab'; import { techdocsStorageApiRef } from '../../api'; -import { useShadowDom } from '../hooks'; import transformer, { addBaseUrl, addLinkClickListener, @@ -46,22 +46,93 @@ export const Reader = ({ entityId, onReady }: Props) => { const theme = useTheme(); const techdocsStorageApi = useApi(techdocsStorageApiRef); - const [shadowDomRef, shadowRoot] = useShadowDom(); + const [sidebars, setSidebars] = useState(); const navigate = useNavigate(); + const shadowDomRef = useRef(null); + const [loadedPath, setLoadedPath] = useState(''); + const [atInitialLoad, setAtInitialLoad] = useState(true); + const [newerDocsExist, setNewerDocsExist] = useState(false); - const { value, loading, error } = useAsync(async () => { + const { + value: isSynced, + loading: syncInProgress, + error: syncError, + } = useAsync(async () => { + // Attempt to sync only if `techdocs.builder` in app config is set to 'local' + if ((await techdocsStorageApi.getBuilder()) !== 'local') { + return Promise.resolve({ + value: true, + loading: null, + error: null, + }); + } + return techdocsStorageApi.syncEntityDocs({ kind, namespace, name }); + }); + + const { + value: rawPage, + loading: docLoading, + error: docLoadError, + } = useAsync(async () => { + // do not automatically load same page again if URL has not changed, + // happens when generating new docs finishes + if (newerDocsExist && path === loadedPath) { + return null; + } return techdocsStorageApi.getEntityDocs({ kind, namespace, name }, path); - }, [techdocsStorageApi, kind, namespace, name, path]); + }, [techdocsStorageApi, kind, namespace, name, path, isSynced]); - React.useEffect(() => { - if (!shadowRoot || loading || error) { - return; // Shadow DOM isn't ready / It's not ready / Docs was not found + useEffect(() => { + const updateSidebarPosition = () => { + if (!!shadowDomRef.current && !!sidebars) { + sidebars!.forEach(sidebar => { + const newTop = Math.max( + shadowDomRef.current!.getBoundingClientRect().top, + 0, + ); + sidebar.style.top = `${newTop}px`; + }); + } + }; + updateSidebarPosition(); + window.addEventListener('scroll', updateSidebarPosition); + window.addEventListener('resize', updateSidebarPosition); + return () => { + window.removeEventListener('scroll', updateSidebarPosition); + window.removeEventListener('resize', updateSidebarPosition); + }; + }, [shadowDomRef, sidebars]); + + useEffect(() => { + if (rawPage) { + setLoadedPath(path); + } + }, [rawPage, path]); + + useEffect(() => { + if (atInitialLoad === false) { + return; + } + setTimeout(() => { + setAtInitialLoad(false); + }, 5000); + }); + + useEffect(() => { + if (!atInitialLoad && !!rawPage && syncInProgress) { + setNewerDocsExist(true); + } + }, [atInitialLoad, rawPage, syncInProgress]); + + useEffect(() => { + if (!rawPage || !shadowDomRef.current) { + return; } if (onReady) { onReady(); } // Pre-render - const transformedElement = transformer(value as string, [ + const transformedElement = transformer(rawPage as string, [ sanitizeDOM(), addBaseUrl({ techdocsStorageApi, @@ -82,10 +153,74 @@ export const Reader = ({ entityId, onReady }: Props) => { --md-code-bg-color: ${theme.palette.background.paper}; } .md-main__inner { margin-top: 0; } - .md-sidebar { top: 0; width: 20rem; } + .md-sidebar { position: fixed; bottom: 100px; width: 20rem; } + .md-sidebar--secondary { right: 2rem; } + .md-content { margin-bottom: 50px } + .md-footer { position: fixed; bottom: 0px; width: 100vw; } + .md-footer-nav__link { width: 20rem;} + .md-content { margin-left: 20rem; max-width: calc(100% - 20rem * 2 - 3rem); } .md-typeset { font-size: 1rem; } .md-nav { font-size: 1rem; } - .md-grid { max-width: 80vw; } + .md-grid { max-width: 90vw; margin: 0 } + @media screen and (max-width: 76.1875em) { + .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-nav--primary .md-nav__title { + white-space: normal; + height: auto; + line-height: 1rem; + cursor: auto; + } + .md-nav--primary > .md-nav__title [for="none"] { + padding-top: 0; + } + } + `, + }), + injectCss({ + // Admonitions and others are using SVG masks to define icons. These + // masks are defined as CSS variables. + // As the MkDocs output is rendered in shadow DOM, the CSS variable + // definitions on the root selector are not applied. Instead, the have + // to be applied on :host. + // As there is no way to transform the served main*.css yet (for + // example in the backend), we have to copy from main*.css and modify + // them. + css: ` + :host { + --md-admonition-icon--note: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--abstract: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--info: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--tip: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--success: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--question: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--warning: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--failure: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--danger: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--bug: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--example: url('data:image/svg+xml;charset=utf-8,'); + --md-admonition-icon--quote: url('data:image/svg+xml;charset=utf-8,'); + } + :host { + --md-footnotes-icon: url('data:image/svg+xml;charset=utf-8,'); + } + :host { + --md-details-icon: url('data:image/svg+xml;charset=utf-8,'); + } + :host { + --md-tasklist-icon: url('data:image/svg+xml;charset=utf-8,'); + --md-tasklist-icon--checked: url('data:image/svg+xml;charset=utf-8,'); `, }), ]); @@ -94,6 +229,9 @@ export const Reader = ({ entityId, onReady }: Props) => { return; // An unexpected error occurred } + const shadowDiv: HTMLElement = shadowDomRef.current!; + const shadowRoot = + shadowDiv.shadowRoot || shadowDiv.attachShadow({ mode: 'open' }); Array.from(shadowRoot.children).forEach(child => shadowRoot.removeChild(child), ); @@ -113,18 +251,17 @@ 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) { - history.pushState( - null, - '', - `${parsedUrl.pathname}${parsedUrl.hash}`, - ); + navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); } else { navigate(parsedUrl.pathname); } - - shadowRoot?.querySelector(parsedUrl.hash)?.scrollIntoView(); }, }), onCssReady({ @@ -134,34 +271,67 @@ export const Reader = ({ entityId, onReady }: Props) => { }, onLoaded: (dom: Element) => { (dom as HTMLElement).style.removeProperty('opacity'); + // disable MkDocs drawer toggling ('for' attribute => checkbox mechanism) + (dom as HTMLElement) + .querySelector('.md-nav__title') + ?.removeAttribute('for'); + const sideDivs: HTMLElement[] = Array.from( + shadowRoot!.querySelectorAll('.md-sidebar'), + ); + setSidebars(sideDivs); + // set sidebar height so they don't initially render in wrong position + const docTopPosition = (dom as HTMLElement).getBoundingClientRect() + .top; + sideDivs!.forEach(sidebar => { + sidebar.style.top = `${docTopPosition}px`; + }); }, }), ]); }, [ - name, - path, - shadowRoot, - value, - error, - loading, - namespace, - kind, + rawPage, entityId, navigate, + onReady, + shadowDomRef, + path, techdocsStorageApi, theme, - onReady, + kind, + namespace, + name, + newerDocsExist, + isSynced, ]); - if (error) { - // TODO Enhance API call to return customize error objects so we can identify which we ran into - // For now this defaults to display error code 404 - return ; + // docLoadError not considered an error state if sync request is still ongoing + // or sync just completed and doc is loading again + if ((docLoadError && !syncInProgress && !docLoading) || syncError) { + let errMessage = ''; + if (docLoadError) { + errMessage += ` Load error: ${docLoadError}`; + } + if (syncError) errMessage += ` Build error: ${syncError}`; + return ; } return ( <> - {loading ? : null} + {newerDocsExist && !isSynced ? ( + + A newer version of this documentation is being prepared and will be + available shortly. + + ) : null} + {newerDocsExist && isSynced ? ( + + A newer version of this documentation is now available, please refresh + to view. + + ) : null} + {docLoading || (docLoadError && syncInProgress) ? ( + + ) : null}
); diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx index c6562ec8ad..841ccfcc66 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.test.tsx @@ -43,17 +43,14 @@ describe('', ( }); describe('', () => { - it('should render with a custom status code, custom error message and go back link', () => { + it('should render with a 404 code, custom error message and go back link', () => { const rendered = render( wrapInTestApp( - , + , ), ); rendered.getByText(/This is a custom error message/i); - rendered.getByText(/500/i); + rendered.getByText(/404/i); rendered.getByText(/Looks like someone dropped the mic!/i); expect(rendered.getByTestId('go-back-link')).toBeDefined(); }); diff --git a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx index 04f2106161..cdacc1cb7e 100644 --- a/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsNotFound.tsx @@ -19,10 +19,9 @@ import { ErrorPage, useApi, configApiRef } from '@backstage/core'; type Props = { errorMessage?: string; - statusCode?: number; }; -export const TechDocsNotFound = ({ errorMessage, statusCode }: Props) => { +export const TechDocsNotFound = ({ errorMessage }: Props) => { const techdocsBuilder = useApi(configApiRef).getOptionalString( 'techdocs.builder', ); @@ -38,7 +37,7 @@ export const TechDocsNotFound = ({ errorMessage, statusCode }: Props) => { return ( diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx index 8128366392..082caeeb09 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx @@ -18,6 +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'; describe('', () => { it('should render a techdocs page header', async () => { @@ -54,6 +55,7 @@ describe('', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name/*': entityRouteRef, + '/docs': rootRouteRef, }, }, ); @@ -85,6 +87,7 @@ describe('', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name/*': entityRouteRef, + '/docs': rootRouteRef, }, }, ); @@ -118,6 +121,7 @@ describe('', () => { { mountedRoutes: { '/catalog/:namespace/:kind/:name/*': entityRouteRef, + '/docs': rootRouteRef, }, }, ); diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx index bfab2f8515..1d4321001d 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -21,6 +21,7 @@ import { EntityName, parseEntityName } from '@backstage/catalog-model'; import { Header, HeaderLabel, Link, useRouteRef } from '@backstage/core'; import { TechDocsMetadata } from '../../types'; import { EntityRefLink, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { rootRouteRef } from '../../plugin'; type TechDocsPageHeaderProps = { entityId: EntityName; @@ -59,6 +60,8 @@ export const TechDocsPageHeader = ({ ownerEntity = parseEntityName(owner, { defaultKind: 'group' }); } + const docsRootLink = useRouteRef(rootRouteRef)(); + const labels = ( <> {labels}
diff --git a/plugins/techdocs/src/reader/hooks/shadowDom.test.tsx b/plugins/techdocs/src/reader/hooks/shadowDom.test.tsx deleted file mode 100644 index c4fab0dbc6..0000000000 --- a/plugins/techdocs/src/reader/hooks/shadowDom.test.tsx +++ /dev/null @@ -1,44 +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 React from 'react'; -import { renderWithEffects } from '@backstage/test-utils'; -import { useShadowDom } from './shadowDom'; - -const ComponentWithoutHook = () => { - return
; -}; - -const ComponentWithHook = () => { - const [ref] = useShadowDom(); - return
; -}; - -describe('useShadowDom', () => { - it('does not create a Shadow DOM instance', async () => { - const rendered = await renderWithEffects(); - - const divElement = rendered.getByTestId('shadow-dom'); - expect(divElement.shadowRoot).not.toBeInstanceOf(ShadowRoot); - }); - - it('create a Shadow DOM instance', async () => { - const rendered = await renderWithEffects(); - - const divElement = rendered.getByTestId('shadow-dom'); - expect(divElement.shadowRoot).toBeInstanceOf(ShadowRoot); - }); -}); diff --git a/plugins/techdocs/src/reader/index.tsx b/plugins/techdocs/src/reader/index.tsx index a3ca8f394b..6b66289fa5 100644 --- a/plugins/techdocs/src/reader/index.tsx +++ b/plugins/techdocs/src/reader/index.tsx @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './hooks'; export * from './components'; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 10c7191b63..5ba86e42ca 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -23,6 +23,7 @@ const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; const techdocsStorageApi: TechDocsStorage = { getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)), getEntityDocs: () => new Promise(resolve => resolve('yes!')), + syncEntityDocs: () => new Promise(resolve => resolve(true)), }; const fixture = ` diff --git a/plugins/todo-backend/.eslintrc.js b/plugins/todo-backend/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/plugins/todo-backend/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/plugins/todo-backend/CHANGELOG.md b/plugins/todo-backend/CHANGELOG.md new file mode 100644 index 0000000000..e03798c865 --- /dev/null +++ b/plugins/todo-backend/CHANGELOG.md @@ -0,0 +1,13 @@ +# @backstage/plugin-todo-backend + +## 0.1.1 + +### Patch Changes + +- Updated dependencies [8686eb38c] +- Updated dependencies [8686eb38c] +- Updated dependencies [0434853a5] +- Updated dependencies [8686eb38c] + - @backstage/catalog-client@0.3.8 + - @backstage/backend-common@0.6.0 + - @backstage/config@0.1.4 diff --git a/plugins/todo-backend/README.md b/plugins/todo-backend/README.md new file mode 100644 index 0000000000..61ca5202d0 --- /dev/null +++ b/plugins/todo-backend/README.md @@ -0,0 +1,63 @@ +# @backstage/plugin-todo-backend + +Backend for the `@backstage/plugin-todo` plugin. Assists in scanning for and listing `// TODO` comments in source code repositories. + +## Installation + +Install the `@backstage/plugin-todo-backend` package in your backend packages, and then integrate the plugin using the following default setup for `src/plugins/todo.ts`: + +```ts +import { Router } from 'express'; +import { CatalogClient } from '@backstage/catalog-client'; +import { + createRouter, + TodoReaderService, + TodoScmReader, +} from '@backstage/plugin-todo-backend'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + logger, + reader, + config, + discovery, +}: PluginEnvironment): Promise { + const todoReader = TodoScmReader.fromConfig(config, { + logger, + reader, + }); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const todoService = new TodoReaderService({ + todoReader, + catalogClient, + }); + + return await createRouter({ todoService }); +} +``` + +## Scanned Files + +The included `TodoReaderService` and `TodoScmReader` works by reading source code of to the entity that is being viewed. The location source code is determined by the value of the [`backstage.io/source-location` +](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiosource-location) annotation of the entity, and if that is missing it falls back to the [`backstage.io/managed-by-location `](https://backstage.io/docs/features/software-catalog/well-known-annotations#backstageiomanaged-by-location) annotation. Only `url` locations are currently supported, meaning locally configured `file` locations won't work. Also note that dot-files and folders are ignored. + +## Parser Configuration + +The `TodoScmReader` accepts a `TodoParser` option, which can be used to configure your own parser. The default one is based on [Leasot](https://github.com/pgilad/leasot) and supports a wide range of languages. You can add to the list of supported tags by configuring your own version of the built-in parser, for example: + +```ts +import { + TodoScmReader, + createTodoParser, +} from '@backstage/plugin-todo-backend'; + +// ... + +const todoReader = TodoScmReader.fromConfig(config, { + logger, + reader, + parser: createTodoParser({ + additionalTags: ['NOTE', 'XXX'], + }), +}); +``` diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json new file mode 100644 index 0000000000..3427ead176 --- /dev/null +++ b/plugins/todo-backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/plugin-todo-backend", + "version": "0.1.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/todo-backend" + }, + "scripts": { + "build": "backstage-cli backend:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.6.0", + "@backstage/catalog-client": "^0.3.8", + "@backstage/catalog-model": "^0.7.3", + "@backstage/config": "^0.1.4", + "@backstage/errors": "^0.1.1", + "@backstage/integration": "^0.5.0", + "@types/express": "^4.17.6", + "cross-fetch": "^3.0.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "leasot": "^11.5.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.6.5", + "@types/supertest": "^2.0.8", + "msw": "^0.21.2", + "supertest": "^4.0.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/todo-backend/src/index.test.ts b/plugins/todo-backend/src/index.test.ts new file mode 100644 index 0000000000..5b43267573 --- /dev/null +++ b/plugins/todo-backend/src/index.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 index from './index'; + +describe('imports', () => { + it('should export known symbols only', () => { + expect(index).toEqual({ + createRouter: expect.any(Function), + createTodoParser: expect.any(Function), + TodoScmReader: expect.any(Function), + TodoReaderService: expect.any(Function), + }); + }); +}); diff --git a/plugins/todo-backend/src/index.ts b/plugins/todo-backend/src/index.ts new file mode 100644 index 0000000000..d0406483c8 --- /dev/null +++ b/plugins/todo-backend/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './lib'; +export * from './service'; diff --git a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.test.ts b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.test.ts new file mode 100644 index 0000000000..83cc8b5ecf --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + getVoidLogger, + ReadTreeResponse, + UrlReader, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { NotFoundError, NotModifiedError } from '@backstage/errors'; +import { ScmIntegrations } from '@backstage/integration'; +import { createTodoParser } from './createTodoParser'; +import { TodoScmReader } from './TodoScmReader'; + +function mockReader(): jest.Mocked { + return { + read: jest.fn(), + readTree: jest.fn(), + search: jest.fn(), + } as jest.Mocked; +} + +describe('TodoScmReader', () => { + it('should be created from config', () => { + const todoReader = TodoScmReader.fromConfig(new ConfigReader({}), { + logger: getVoidLogger(), + reader: mockReader(), + }); + expect(todoReader).toEqual(expect.any(TodoScmReader)); + }); + + it('should read TODOs', async () => { + const reader = mockReader(); + const todoReader = new TodoScmReader({ + logger: getVoidLogger(), + reader, + integrations: ScmIntegrations.fromConfig(new ConfigReader({})), + }); + + reader.readTree.mockResolvedValueOnce({ + etag: 'my-etag', + files: async () => [ + { + content: async () => Buffer.from('// TODO: my-todo', 'utf8'), + path: 'my-folder/my-file.js', + }, + ], + } as ReadTreeResponse); + + const url = 'https://github.com/backstage/backstage/catalog-info.yaml'; + const expected = { + items: [ + { + text: 'my-todo', + tag: 'TODO', + lineNumber: 1, + repoFilePath: 'my-folder/my-file.js', + viewUrl: + 'https://github.com/backstage/backstage/my-folder/my-file.js#L1', + }, + ], + }; + + await expect(todoReader.readTodos({ url })).resolves.toEqual(expected); + + expect(reader.readTree).toHaveBeenCalledTimes(1); + expect(reader.readTree).toHaveBeenCalledWith( + 'https://github.com/backstage/backstage/catalog-info.yaml', + { + etag: undefined, + filter: expect.any(Function), + }, + ); + + // Filter function should filter out dotfiles + const filterFunc = reader.readTree.mock.calls[0][1]!.filter!; + expect(filterFunc('my-file.js')).toBe(true); + expect(filterFunc('my-folder/my-file.js')).toBe(true); + expect(filterFunc('.my-file.js')).toBe(false); + expect(filterFunc('.my-folder/my-file.js')).toBe(false); + expect(filterFunc('my-folder/.my-file.js')).toBe(false); + + // A NotModifiedError should return a cached result + reader.readTree.mockRejectedValueOnce(new NotModifiedError('nope')); + await expect(todoReader.readTodos({ url })).resolves.toEqual(expected); + + expect(reader.readTree).toHaveBeenCalledTimes(2); + expect(reader.readTree).toHaveBeenLastCalledWith( + 'https://github.com/backstage/backstage/catalog-info.yaml', + { + etag: 'my-etag', + filter: expect.any(Function), + }, + ); + + // Other errors should re-throw + reader.readTree.mockRejectedValueOnce(new NotFoundError('not found')); + await expect(todoReader.readTodos({ url })).rejects.toThrow('not found'); + }); + + it('should use custom parser', async () => { + const parser = jest.fn(createTodoParser({ additionalTags: ['XXX'] })); + const reader = mockReader(); + const todoReader = new TodoScmReader({ + logger: getVoidLogger(), + reader, + parser, + integrations: ScmIntegrations.fromConfig(new ConfigReader({})), + }); + + reader.readTree.mockResolvedValueOnce({ + files: async () => [ + { + content: async () => Buffer.from('-- XXX: my-todo', 'utf8'), + path: 'my-file.lua', + }, + ], + } as ReadTreeResponse); + + await expect( + todoReader.readTodos({ + url: 'https://github.com/backstage/backstage/catalog-info.yaml', + }), + ).resolves.toEqual({ + items: [ + { + text: 'my-todo', + tag: 'XXX', + lineNumber: 1, + repoFilePath: 'my-file.lua', + viewUrl: 'https://github.com/backstage/backstage/my-file.lua#L1', + }, + ], + }); + expect(parser).toHaveBeenCalledTimes(1); + expect(parser).toHaveBeenCalledWith({ + content: '-- XXX: my-todo', + path: 'my-file.lua', + }); + }); + + it('should log and ignore parser errors', async () => { + const logger = getVoidLogger(); + const errorSpy = jest.spyOn(logger, 'error'); + const reader = mockReader(); + const todoReader = new TodoScmReader({ + logger, + reader, + parser() { + throw new Error('failed to parse'); + }, + integrations: ScmIntegrations.fromConfig(new ConfigReader({})), + }); + + reader.readTree.mockResolvedValueOnce({ + files: async () => [ + { + content: async () => Buffer.from('# XXX: my-todo', 'utf8'), + path: 'my-file.sh', + }, + ], + } as ReadTreeResponse); + + await expect( + todoReader.readTodos({ + url: 'https://github.com/o/r/catalog-info.yaml', + }), + ).resolves.toEqual({ items: [] }); + expect(errorSpy).toHaveBeenCalledWith( + 'Failed to parse TODO in https://github.com/o/r/catalog-info.yaml at my-file.sh, Error: failed to parse', + ); + }); +}); diff --git a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts new file mode 100644 index 0000000000..f13b0c05df --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts @@ -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 { UrlReader } from '@backstage/backend-common'; +import { ScmIntegrations } from '@backstage/integration'; +import { Logger } from 'winston'; + +import { + ReadTodosOptions, + ReadTodosResult, + TodoItem, + TodoParser, + TodoReader, +} from './types'; +import { Config } from '@backstage/config'; +import { createTodoParser } from './createTodoParser'; + +type Options = { + logger: Logger; + reader: UrlReader; + integrations: ScmIntegrations; + parser?: TodoParser; +}; + +type CacheItem = { + etag: string; + result: ReadTodosResult; +}; + +export class TodoScmReader implements TodoReader { + private readonly logger: Logger; + private readonly reader: UrlReader; + private readonly parser: TodoParser; + private readonly integrations: ScmIntegrations; + + private readonly cache = new Map(); + + static fromConfig(config: Config, options: Omit) { + return new TodoScmReader({ + ...options, + integrations: ScmIntegrations.fromConfig(config), + }); + } + + constructor(options: Options) { + this.logger = options.logger; + this.reader = options.reader; + this.parser = options.parser ?? createTodoParser(); + this.integrations = options.integrations; + } + + async readTodos({ url }: ReadTodosOptions): Promise { + const cacheItem = this.cache.get(url); + try { + const newCacheItem = await this.doReadTodos({ url }, cacheItem?.etag); + this.cache.set(url, newCacheItem); + return newCacheItem.result; + } catch (error) { + if (cacheItem && error.name === 'NotModifiedError') { + return cacheItem.result; + } + throw error; + } + } + + private async doReadTodos( + { url }: ReadTodosOptions, + etag?: string, + ): Promise { + const tree = await this.reader.readTree(url, { + etag, + filter(path) { + return !path.startsWith('.') && !path.includes('/.'); + }, + }); + + const files = await tree.files(); + this.logger.info(`Read ${files.length} files from ${url}`); + + const todos = new Array(); + for (const file of files) { + const content = await file.content(); + try { + const items = this.parser({ + path: file.path, + content: content.toString('utf8'), + }); + + todos.push( + ...items.map(({ lineNumber, text, tag, author }) => ({ + text, + tag, + author, + lineNumber, + repoFilePath: file.path, + viewUrl: this.integrations.resolveUrl({ + url: file.path, + base: url, + lineNumber, + }), + })), + ); + } catch (error) { + this.logger.error( + `Failed to parse TODO in ${url} at ${file.path}, ${error}`, + ); + } + } + + return { result: { items: todos }, etag: tree.etag }; + } +} diff --git a/plugins/todo-backend/src/lib/TodoReader/createTodoParser.test.ts b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.test.ts new file mode 100644 index 0000000000..0afe2c3420 --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.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 { createTodoParser } from './createTodoParser'; + +const comment = '//'; // just so we don't have all of these should up as TODOs :D + +describe('createTodoParser', () => { + it('should create a parser that parses TODOs', () => { + const parser = createTodoParser(); + + const output = parser({ + path: 'my-file.js', + content: ` + ${comment} TODO(user1): todo 1 + ${comment}TODO(user2): todo 2 + ${comment}todo: todo 3 + ${comment}@todo: todo 4 + + ${comment}@fixme(user5): todo 5 + ${comment} @TODO: todo 6 /user6 + ${comment} FIXME: todo 7 /user7 + `, + }); + expect(output).toEqual([ + { + text: 'todo 1', + author: 'user1', + lineNumber: 2, + tag: 'TODO', + }, + { + text: 'todo 2', + author: 'user2', + lineNumber: 3, + tag: 'TODO', + }, + { + text: 'todo 3', + lineNumber: 4, + tag: 'TODO', + }, + { + text: 'todo 4', + lineNumber: 5, + tag: 'TODO', + }, + { + text: 'todo 5', + author: 'user5', + lineNumber: 7, + tag: 'FIXME', + }, + { + text: 'todo 6', + author: 'user6', + lineNumber: 8, + tag: 'TODO', + }, + { + text: 'todo 7', + author: 'user7', + lineNumber: 9, + tag: 'FIXME', + }, + ]); + }); + + it('should support custom tags', () => { + const content = ` + ${comment} TODO: todo 1 + ${comment} MYTAG: todo 2 + ${comment} MYTAG(user): todo 3 + `; + expect( + createTodoParser()({ + path: 'my-file.js', + content, + }), + ).toEqual([{ text: 'todo 1', tag: 'TODO', lineNumber: 2 }]); + expect( + createTodoParser({ additionalTags: ['MYTAG'] })({ + path: 'my-file.js', + content, + }), + ).toEqual([ + { text: 'todo 1', tag: 'TODO', lineNumber: 2 }, + { text: 'todo 2', tag: 'MYTAG', lineNumber: 3 }, + { text: 'todo 3', author: 'user', tag: 'MYTAG', lineNumber: 4 }, + ]); + }); + + it('should support multiple languages', () => { + const parser = createTodoParser(); + + const content = ` + -- TODO: todo 1 + # TODO: todo 2 + ${comment} TODO: todo 3 + ${comment[0]}* TODO: todo 4 */ + `; + const todo1 = { text: 'todo 1', tag: 'TODO', lineNumber: 2 }; + const todo2 = { text: 'todo 2', tag: 'TODO', lineNumber: 3 }; + const todo3 = { text: 'todo 3', tag: 'TODO', lineNumber: 4 }; + const todo4 = { text: 'todo 4', tag: 'TODO', lineNumber: 5 }; + + expect(parser({ path: 'f.py', content })).toEqual([todo2]); + expect(parser({ path: 'f.lua', content })).toEqual([todo1]); + expect(parser({ path: 'f.java', content })).toEqual([todo3, todo4]); + expect(parser({ path: 'f.sh', content })).toEqual([todo2]); + expect(parser({ path: 'f.sql', content })).toEqual([todo1, todo3, todo4]); + expect(parser({ path: 'f.json', content })).toEqual([]); + }); +}); diff --git a/plugins/todo-backend/src/lib/TodoReader/createTodoParser.ts b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.ts new file mode 100644 index 0000000000..79d54e2769 --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/createTodoParser.ts @@ -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 { extname } from 'path'; +import { parse } from 'leasot'; +import { TodoParser } from './types'; + +export type TodoParserOptions = { + /** Custom tags to support in addition to TODO and FIXME */ + additionalTags?: string[]; +}; + +export function createTodoParser(options: TodoParserOptions = {}): TodoParser { + return ({ content, path }) => { + try { + const comments = parse(content, { + customTags: options.additionalTags, + extension: extname(path), + }); + + return comments.map(comment => ({ + text: comment.text, + tag: comment.tag, + author: comment.ref || undefined, + lineNumber: comment.line, + })); + } catch /* ignore unsupported extensions */ { + return []; + } + }; +} diff --git a/plugins/todo-backend/src/lib/TodoReader/index.ts b/plugins/todo-backend/src/lib/TodoReader/index.ts new file mode 100644 index 0000000000..58982385c8 --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/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 { TodoScmReader } from './TodoScmReader'; +export { createTodoParser } from './createTodoParser'; +export type { + TodoItem, + TodoReader, + ReadTodosOptions, + ReadTodosResult, +} from './types'; diff --git a/plugins/todo-backend/src/lib/TodoReader/types.ts b/plugins/todo-backend/src/lib/TodoReader/types.ts new file mode 100644 index 0000000000..a6f046d2ea --- /dev/null +++ b/plugins/todo-backend/src/lib/TodoReader/types.ts @@ -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. + */ + +export type TodoItem = { + /** The contents of the TODO comment */ + text: string; + + /** The tag used, e.g. TODO, FIXME */ + tag: string; + + /** References author, if any */ + author?: string; + + /** URL used to view the file */ + viewUrl?: string; + + /** The line number of the file that the TODO occurs at */ + lineNumber?: number; + + /** The path of the file containing the TODO within the repo */ + repoFilePath?: string; +}; + +export type ReadTodosOptions = { + /** + * Base URLs defining the root at which to search for TODOs + */ + url: string; +}; + +export type ReadTodosResult = { + /** + * TODO items found at the given locations + */ + items: TodoItem[]; +}; + +export interface TodoReader { + /** + * Searches for TODO items in code at a given location + */ + readTodos(options: ReadTodosOptions): Promise; +} + +type TodoParserContext = { + content: string; + path: string; +}; + +type TodoParserResult = { + text: string; + tag: string; + author?: string; + lineNumber: number; +}; + +export type TodoParser = (ctx: TodoParserContext) => TodoParserResult[]; diff --git a/plugins/todo-backend/src/lib/index.ts b/plugins/todo-backend/src/lib/index.ts new file mode 100644 index 0000000000..85c100c959 --- /dev/null +++ b/plugins/todo-backend/src/lib/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 './TodoReader'; diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts new file mode 100644 index 0000000000..b79ffd1333 --- /dev/null +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -0,0 +1,366 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { TodoItem, TodoReader } from '../lib'; +import { TodoReaderService } from './TodoReaderService'; + +const entityName = { + kind: 'Component', + namespace: 'default', + name: 'my-component', +}; + +const mockEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }, + }, + spec: { + type: 'library', + }, +}; + +function mockCatalogClient(entity?: Entity): jest.Mocked { + const mock = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + if (entity) { + mock.getEntityByName.mockReturnValue(entity); + } + return mock; +} + +function mockTodoReader(items?: TodoItem[]): jest.Mocked { + const mock = { + readTodos: jest.fn(), + }; + if (items) { + mock.readTodos.mockReturnValue({ items }); + } + return mock; +} + +function makeTodo(text: string, extra?: Partial): TodoItem { + return { text, tag: 'TODO', ...extra }; +} + +describe('TodoReaderService', () => { + it('should be created', () => { + const service = new TodoReaderService({ + todoReader: mockTodoReader(), + catalogClient: mockCatalogClient(), + }); + expect(service.listTodos).toEqual(expect.any(Function)); + }); + + it('should list 0 todos', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient(mockEntity); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [], + totalCount: 0, + offset: 0, + limit: 10, + }); + expect(catalogClient.getEntityByName).toHaveBeenCalledWith(entityName, { + token: undefined, + }); + }); + + it('should list, order, and filter todos', async () => { + const todo1 = makeTodo('todo1'); + const todo2 = makeTodo('todo2', { author: 'foo' }); + const todo3 = makeTodo('todo3', { + author: 'bar', + repoFilePath: 'a/b/c.js', + }); + const todo4 = makeTodo('todo4', { author: 'baz', viewUrl: 'example.com' }); + const todo5 = makeTodo('todo5', { tag: 'FIXME' }); + + const todoReader = mockTodoReader([todo1, todo2, todo3, todo4, todo5]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + ['backstage.io/source-location']: + 'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }, + }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [todo1, todo2, todo3, todo4, todo5], + totalCount: 5, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ entity: entityName, limit: 3 }), + ).resolves.toEqual({ + items: [todo1, todo2, todo3], + totalCount: 5, + offset: 0, + limit: 3, + }); + + await expect( + service.listTodos({ entity: entityName, offset: 2 }), + ).resolves.toEqual({ + items: [todo3, todo4, todo5], + totalCount: 5, + offset: 2, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + orderBy: { field: 'author', direction: 'asc' }, + }), + ).resolves.toEqual({ + items: [todo3, todo4, todo2, todo1, todo5], + totalCount: 5, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + orderBy: { field: 'author', direction: 'desc' }, + offset: -2, + }), + ).resolves.toEqual({ + items: [todo2, todo4, todo3, todo1, todo5], + totalCount: 5, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + orderBy: { field: 'text', direction: 'desc' }, + offset: 2, + limit: 2, + }), + ).resolves.toEqual({ + items: [todo3, todo2], + totalCount: 5, + offset: 2, + limit: 2, + }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'text', value: '*4' }], + }), + ).resolves.toEqual({ items: [todo4], totalCount: 1, offset: 0, limit: 10 }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'author', value: 'ba*' }], + }), + ).resolves.toEqual({ + items: [todo3, todo4], + totalCount: 2, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'author', value: 'ba*' }], + limit: 1, + }), + ).resolves.toEqual({ items: [todo3], totalCount: 2, offset: 0, limit: 1 }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [ + { field: 'author', value: 'ba*' }, + { field: 'tag', value: 'FIXME' }, + ], + }), + ).resolves.toEqual({ items: [], totalCount: 0, offset: 0, limit: 10 }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'tag', value: 'FIXME' }], + limit: 500, + }), + ).resolves.toEqual({ items: [todo5], totalCount: 1, offset: 0, limit: 50 }); + + await expect( + service.listTodos({ entity: entityName, limit: -5 }), + ).resolves.toEqual({ items: [], totalCount: 5, offset: 0, limit: 0 }); + }); + + it('should have configurable pagination', async () => { + const todo1 = makeTodo('todo1'); + const todo2 = makeTodo('todo2'); + const todo3 = makeTodo('todo3'); + const todo4 = makeTodo('todo4'); + const todo5 = makeTodo('todo5'); + + const todoReader = mockTodoReader([todo1, todo2, todo3, todo4, todo5]); + const catalogClient = mockCatalogClient(mockEntity); + const service = new TodoReaderService({ + todoReader, + catalogClient, + defaultPageSize: 2, + maxPageSize: 3, + }); + + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [todo1, todo2], + totalCount: 5, + offset: 0, + limit: 2, + }); + await expect( + service.listTodos({ entity: entityName, limit: 1 }), + ).resolves.toEqual({ + items: [todo1], + totalCount: 5, + offset: 0, + limit: 1, + }); + await expect( + service.listTodos({ entity: entityName, limit: 3 }), + ).resolves.toEqual({ + items: [todo1, todo2, todo3], + totalCount: 5, + offset: 0, + limit: 3, + }); + await expect( + service.listTodos({ entity: entityName, limit: 4 }), + ).resolves.toEqual({ + items: [todo1, todo2, todo3], + totalCount: 5, + offset: 0, + limit: 3, + }); + }); + + it('should require an entity', async () => { + const service = new TodoReaderService({ + todoReader: mockTodoReader(), + catalogClient: mockCatalogClient(), + }); + await expect(service.listTodos({})).rejects.toThrow( + 'Entity filter is required to list TODOs', + ); + }); + + it('should throw if entity is not found', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient(); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'NotFoundError', + message: 'Entity not found, Component:default/my-component', + }), + ); + expect(catalogClient.getEntityByName).toHaveBeenCalledWith(entityName, { + token: undefined, + }); + }); + + it('should throw if entity does not have a location', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { ...mockEntity.metadata, annotations: undefined }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: + 'No entity location annotation found for Component:my-component', + }), + ); + }); + + it('should throw if entity has an invalid location', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + ['backstage.io/managed-by-location']: 'file:../info.yaml', + }, + }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: `Invalid entity location type for Component:my-component, got 'file'`, + }), + ); + }); + + it('should throw if entity has an invalid source location', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + ['backstage.io/source-location']: 'file:../info.yaml', + }, + }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: `Invalid entity source location type for Component:my-component, got 'file'`, + }), + ); + }); +}); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts new file mode 100644 index 0000000000..b6cb258b50 --- /dev/null +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -0,0 +1,158 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError, NotFoundError } from '@backstage/errors'; +import { CatalogApi } from '@backstage/catalog-client'; +import { + LOCATION_ANNOTATION, + SOURCE_LOCATION_ANNOTATION, + serializeEntityRef, + Entity, + parseLocationReference, +} from '@backstage/catalog-model'; +import { TodoReader } from '../lib'; +import { ListTodosRequest, ListTodosResponse, TodoService } from './types'; + +const DEFAULT_DEFAULT_PAGE_SIZE = 10; +const DEFAULT_MAX_PAGE_SIZE = 50; + +type Options = { + todoReader: TodoReader; + catalogClient: CatalogApi; + maxPageSize?: number; + defaultPageSize?: number; +}; + +function wildcardRegex(str: string): RegExp { + const pattern = str + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${pattern}$`, 'i'); +} + +export class TodoReaderService implements TodoService { + private readonly todoReader: TodoReader; + private readonly catalogClient: CatalogApi; + private readonly maxPageSize: number; + private readonly defaultPageSize: number; + + constructor(options: Options) { + this.todoReader = options.todoReader; + this.catalogClient = options.catalogClient; + this.maxPageSize = options.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE; + this.defaultPageSize = options.defaultPageSize ?? DEFAULT_DEFAULT_PAGE_SIZE; + } + + async listTodos( + req: ListTodosRequest, + options?: { token?: string }, + ): Promise { + if (!req.entity) { + throw new InputError('Entity filter is required to list TODOs'); + } + const token = options?.token; + const entity = await this.catalogClient.getEntityByName(req.entity, { + token, + }); + if (!entity) { + throw new NotFoundError( + `Entity not found, ${serializeEntityRef(req.entity)}`, + ); + } + + const url = this.getEntitySourceUrl(entity); + const todos = await this.todoReader.readTodos({ url }); + + let limit = req.limit ?? this.defaultPageSize; + if (limit < 0) { + limit = 0; + } else if (limit > this.maxPageSize) { + limit = this.maxPageSize; + } + + let offset = req.offset ?? 0; + if (offset < 0) { + offset = 0; + } + + let items = todos.items; + const { orderBy, filters } = req; + + if (filters) { + for (const { field, value } of filters) { + const pattern = wildcardRegex(value); + items = items.filter(item => item[field]?.match(pattern)); + } + } + + if (orderBy) { + const dir = orderBy.direction === 'asc' ? 1 : -1; + const field = orderBy.field; + items = items.slice().sort((item1, item2) => { + const field1 = item1[field]; + const field2 = item2[field]; + + if (field1 && field2) { + return dir * field1.localeCompare(field2, 'en-US'); + } else if (field1 && !field2) { + return -1; + } else if (!field1 && field2) { + return 1; + } + return 0; + }); + } + + return { + items: items.slice(offset, offset + limit), + totalCount: items.length, + offset, + limit, + }; + } + + private getEntitySourceUrl(entity: Entity) { + const sourceLocation = + entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION]; + if (sourceLocation) { + const parsed = parseLocationReference(sourceLocation); + if (parsed.type !== 'url') { + throw new InputError( + `Invalid entity source location type for ${serializeEntityRef( + entity, + )}, got '${parsed.type}'`, + ); + } + return parsed.target; + } + + const location = entity.metadata.annotations?.[LOCATION_ANNOTATION]; + if (location) { + const parsed = parseLocationReference(location); + if (parsed.type !== 'url') { + throw new InputError( + `Invalid entity location type for ${serializeEntityRef( + entity, + )}, got '${parsed.type}'`, + ); + } + return parsed.target; + } + throw new InputError( + `No entity location annotation found for ${serializeEntityRef(entity)}`, + ); + } +} diff --git a/plugins/todo-backend/src/service/index.ts b/plugins/todo-backend/src/service/index.ts new file mode 100644 index 0000000000..cbcf591da5 --- /dev/null +++ b/plugins/todo-backend/src/service/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 { createRouter } from './router'; +export type { TodoService, ListTodosRequest, ListTodosResponse } from './types'; +export { TodoReaderService } from './TodoReaderService'; diff --git a/plugins/todo-backend/src/service/router.test.ts b/plugins/todo-backend/src/service/router.test.ts new file mode 100644 index 0000000000..aae2878918 --- /dev/null +++ b/plugins/todo-backend/src/service/router.test.ts @@ -0,0 +1,247 @@ +/* + * 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 express from 'express'; +import request from 'supertest'; +import { errorHandler } from '@backstage/backend-common'; + +import { + createRouter, + parseFilterParam, + parseIntegerParam, + parseOrderByParam, +} from './router'; +import { TodoService } from './types'; + +const mockListBody = { + items: [{ text: 'my todo', tag: 'TODO' }], + totalCount: 1, + offset: 0, + limit: 10, +}; + +function matchErrorResponse(status: number, name: string, message: string) { + return { + status, + body: expect.objectContaining({ + error: { name, message }, + response: { + statusCode: status, + }, + }), + }; +} + +describe('createRouter', () => { + let app: express.Express; + const mockService: jest.Mocked = { + listTodos: jest.fn(), + }; + + beforeAll(async () => { + const router = await createRouter({ + todoService: mockService, + }); + app = express().use(router).use(errorHandler()); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /todos', () => { + it('returns list without query', async () => { + mockService.listTodos.mockResolvedValueOnce(mockListBody); + + const response = await request(app).get('/v1/todos'); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockListBody); + expect(mockService.listTodos).toHaveBeenCalledWith( + { + entity: undefined, + offset: undefined, + limit: undefined, + }, + { token: undefined }, + ); + }); + + it('forwards auth token', async () => { + mockService.listTodos.mockResolvedValueOnce(mockListBody); + + const response = await request(app) + .get('/v1/todos') + .set('Authorization', 'Bearer secret'); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockListBody); + expect(mockService.listTodos).toHaveBeenCalledWith( + { + entity: undefined, + offset: undefined, + limit: undefined, + }, + { token: 'secret' }, + ); + }); + + it('forwards pagination query', async () => { + mockService.listTodos.mockResolvedValueOnce(mockListBody); + + const response = await request(app).get('/v1/todos?offset=5&limit=3'); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockListBody); + expect(mockService.listTodos).toHaveBeenCalledWith( + { + entity: undefined, + offset: 5, + limit: 3, + }, + { token: undefined }, + ); + }); + + it('forwards entity query', async () => { + mockService.listTodos.mockResolvedValueOnce(mockListBody); + + const response = await request(app).get( + '/v1/todos?entity=component:my-component', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockListBody); + expect(mockService.listTodos).toHaveBeenCalledWith( + { + entity: { + name: 'my-component', + kind: 'component', + namespace: 'default', + }, + offset: undefined, + limit: undefined, + }, + { token: undefined }, + ); + }); + + it('rejects invalid queries', async () => { + await expect( + request(app).get('/v1/todos?entity=k:n&entity=k:n'), + ).resolves.toMatchObject( + matchErrorResponse(400, 'InputError', 'entity query must be a string'), + ); + + await expect( + request(app).get('/v1/todos?entity=:n'), + ).resolves.toMatchObject( + matchErrorResponse( + 400, + 'InputError', + 'Invalid entity ref, TypeError: Entity reference ":n" was not on the form [:][/]', + ), + ); + + await expect( + request(app).get('/v1/todos?offset=1.5'), + ).resolves.toMatchObject( + matchErrorResponse( + 400, + 'InputError', + 'invalid offset query, not an integer', + ), + ); + + expect(mockService.listTodos).not.toHaveBeenCalled(); + }); + }); +}); + +describe('parseIntegerParam', () => { + it('should parse a param', () => { + expect(parseIntegerParam('1', 'ctx')).toBe(1); + }); + + it('should reject invalid params', () => { + expect(() => parseIntegerParam(['1'], 'ctx')).toThrow( + 'invalid ctx, must be a string', + ); + expect(() => parseIntegerParam('1.5', 'ctx')).toThrow( + 'invalid ctx, not an integer', + ); + expect(() => parseIntegerParam('foo', 'ctx')).toThrow( + 'invalid ctx, not an integer', + ); + expect(() => parseIntegerParam('1foo', 'ctx')).toThrow( + 'invalid ctx, not an integer', + ); + }); +}); + +describe('parseOrderByParam', () => { + it('should parse a param', () => { + expect(parseOrderByParam('a=asc', ['a'])).toEqual({ + field: 'a', + direction: 'asc', + }); + expect(parseOrderByParam('a=desc', ['a'])).toEqual({ + field: 'a', + direction: 'desc', + }); + }); + + it('should reject invalid params', () => { + expect(() => parseOrderByParam(['a=asc'], ['a'])).toThrow( + 'invalid orderBy query, must be a string', + ); + expect(() => parseOrderByParam('a=desc', ['b', 'c'])).toThrow( + 'invalid orderBy field, must be one of b, c', + ); + expect(() => parseOrderByParam('b=down', ['a'])).toThrow( + `invalid orderBy query, order direction must be 'asc' or 'desc'`, + ); + expect(() => parseOrderByParam('=asc', ['a'])).toThrow( + 'invalid orderBy query, field name is empty', + ); + }); +}); + +describe('parseFilterParam', () => { + it('should parse a param', () => { + expect(parseFilterParam('a=b', ['a'])).toEqual([ + { field: 'a', value: 'b' }, + ]); + expect(parseFilterParam(['a=b=c', 'c=d*'], ['a', 'c'])).toEqual([ + { field: 'a', value: 'b=c' }, + { field: 'c', value: 'd*' }, + ]); + }); + + it('should reject invalid params', () => { + expect(() => parseFilterParam({ a: 'a=b' }, ['a'])).toThrow( + 'invalid filter query, must be a string or list of strings', + ); + expect(() => parseFilterParam('a=b', ['b', 'c'])).toThrow( + 'invalid filter field, must be one of b, c', + ); + expect(() => parseFilterParam('b', ['a'])).toThrow( + `invalid filter query, must separate field from value using '='`, + ); + expect(() => parseFilterParam('=a', ['a'])).toThrow( + `invalid filter query, must separate field from value using '='`, + ); + expect(() => parseFilterParam('a=', ['a'])).toThrow( + 'invalid filter query, value may not be empty', + ); + }); +}); diff --git a/plugins/todo-backend/src/service/router.ts b/plugins/todo-backend/src/service/router.ts new file mode 100644 index 0000000000..2021505520 --- /dev/null +++ b/plugins/todo-backend/src/service/router.ts @@ -0,0 +1,168 @@ +/* + * 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 { EntityName, parseEntityName } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; +import express from 'express'; +import Router from 'express-promise-router'; +import { TodoService } from './types'; + +const TODO_FIELDS = [ + 'text', + 'tag', + 'author', + 'viewUrl', + 'repoFilePath', +] as const; + +export interface RouterOptions { + todoService: TodoService; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { todoService } = options; + + const router = Router(); + router.use(express.json()); + + router.get('/v1/todos', async (req, res) => { + const offset = parseIntegerParam(req.query.offset, 'offset query'); + const limit = parseIntegerParam(req.query.limit, 'limit query'); + const orderBy = parseOrderByParam(req.query.orderBy, TODO_FIELDS); + const filters = parseFilterParam(req.query.filter, TODO_FIELDS); + + const entityRef = req.query.entity; + if (entityRef && typeof entityRef !== 'string') { + throw new InputError(`entity query must be a string`); + } + let entity: EntityName | undefined = undefined; + if (entityRef) { + try { + entity = parseEntityName(entityRef); + } catch (error) { + throw new InputError(`Invalid entity ref, ${error}`); + } + } + + const todos = await todoService.listTodos( + { + entity, + offset, + limit, + orderBy, + filters, + }, + { + token: getBearerToken(req.headers.authorization), + }, + ); + res.json(todos); + }); + + return router; +} + +export function parseIntegerParam( + str: unknown, + ctx: string, +): number | undefined { + if (str === undefined) { + return undefined; + } + if (typeof str !== 'string') { + throw new InputError(`invalid ${ctx}, must be a string`); + } + const parsed = parseInt(str, 10); + if (!Number.isInteger(parsed) || String(parsed) !== str) { + throw new InputError(`invalid ${ctx}, not an integer`); + } + return parsed; +} + +export function parseOrderByParam( + str: unknown, + allowedFields: T, +): { field: T[number]; direction: 'asc' | 'desc' } | undefined { + if (str === undefined) { + return undefined; + } + if (typeof str !== 'string') { + throw new InputError(`invalid orderBy query, must be a string`); + } + const [field, direction] = str.split('='); + if (!field) { + throw new InputError(`invalid orderBy query, field name is empty`); + } + if (direction !== 'asc' && direction !== 'desc') { + throw new InputError( + `invalid orderBy query, order direction must be 'asc' or 'desc'`, + ); + } + + if (field && !allowedFields.includes(field)) { + throw new InputError( + `invalid orderBy field, must be one of ${allowedFields.join(', ')}`, + ); + } + return { field, direction }; +} + +export function parseFilterParam( + str: unknown, + allowedFields: T, +): { field: T[number]; value: string }[] | undefined { + if (str === undefined) { + return undefined; + } + + const filters = new Array<{ field: T[number]; value: string }>(); + + const strs = [str].flat(); + for (const filterStr of strs) { + if (typeof filterStr !== 'string') { + throw new InputError( + `invalid filter query, must be a string or list of strings`, + ); + } + const splitIndex = filterStr.indexOf('='); + if (splitIndex <= 0) { + throw new InputError( + `invalid filter query, must separate field from value using '='`, + ); + } + + const field = filterStr.slice(0, splitIndex); + if (!allowedFields.includes(field)) { + throw new InputError( + `invalid filter field, must be one of ${allowedFields.join(', ')}`, + ); + } + + const value = filterStr.slice(splitIndex + 1); + if (!value) { + throw new InputError(`invalid filter query, value may not be empty`); + } + filters.push({ field, value }); + } + + return filters; +} + +function getBearerToken(header?: string): string | undefined { + return header?.match(/Bearer\s+(\S+)/i)?.[1]; +} diff --git a/plugins/todo-backend/src/service/types.ts b/plugins/todo-backend/src/service/types.ts new file mode 100644 index 0000000000..d6e2d517e8 --- /dev/null +++ b/plugins/todo-backend/src/service/types.ts @@ -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 { EntityName } from '@backstage/catalog-model'; +import { TodoItem } from '../lib'; + +type Fields = 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath'; + +export type ListTodosRequest = { + entity?: EntityName; + offset?: number; + limit?: number; + orderBy?: { + field: Fields; + direction: 'asc' | 'desc'; + }; + filters?: { + field: Fields; + /** Value to filter by, with '*' used as wildcard */ + value: string; + }[]; +}; + +export type ListTodosResponse = { + items: TodoItem[]; + totalCount: number; + offset: number; + limit: number; +}; + +export interface TodoService { + listTodos( + req: ListTodosRequest, + options?: { token?: string }, + ): Promise; +} diff --git a/plugins/todo/.eslintrc.js b/plugins/todo/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/todo/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/todo/README.md b/plugins/todo/README.md new file mode 100644 index 0000000000..4165582b65 --- /dev/null +++ b/plugins/todo/README.md @@ -0,0 +1,35 @@ +# @backstage/plugin-todo + +This plugin lists `// TODO` comments in source code. It currently exports a single component extension for use on entity pages. + +## Format + +The default parser uses [Leasot](https://github.com/pgilad/leasot), which supports a wide range of languages. By default it supports the `TODO` and `FIXME` tags, along with `@` prefix and author reference through with either a `()` suffix or trailing `/`. For more information on how to configure the parser, see `@backstage/plugin-todo-backend`. + +Below are some examples of formats that are supported by default: + +```ts +// TODO: Ideally this would be working + +// TODO(Rugvip): Not sure why this works, investigate + +// @todo: This worked last Monday /Rugvip + +// FIXME Nobody knows why this is here +``` + +Note the trailing comments are not supported, the following TODO would not be listed: + +```ts +function reverse(str: string) { + return str.reverse(); // TODO: optimize +} +``` + +The scanner also ignores all dot-files and directories, meaning TODOs inside of those will not be listed. + +## Extensions + +| name | description | +| ------------------- | ------------------------------------------------------------------------------- | +| `EntityTodoContent` | Content for an entity page, showing a table of TODO items for the given entity. | diff --git a/plugins/todo/dev/index.tsx b/plugins/todo/dev/index.tsx new file mode 100644 index 0000000000..71e5e36355 --- /dev/null +++ b/plugins/todo/dev/index.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 { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + Content, + Header, + HeaderLabel, + Page, +} from '@backstage/core'; +import { createDevApp } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import OnlineIcon from '@material-ui/icons/Cloud'; +import OfflineIcon from '@material-ui/icons/Storage'; +import React from 'react'; +import { EntityTodoContent, todoApiRef, todoPlugin } from '../src'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'backstage', + annotations: { + [LOCATION_ANNOTATION]: + 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }, + }, + spec: { + type: 'library', + }, +}; + +const mockedApi = { + listTodos: async () => ({ + items: [ + { + text: 'Make sure this works', + tag: 'TODO', + author: 'Rugvip', + viewUrl: 'https://github.com/backstage/backstage', + }, + ], + totalCount: 15, + offset: 0, + limit: 10, + }), +}; + +createDevApp() + .registerPlugin(todoPlugin) + .addPage({ + element: ( + + + +
+ +
+ + + +
+
+
+ ), + title: 'Entity Todo Content', + icon: OfflineIcon, + }) + .addPage({ + element: ( + + +
+ +
+ + + +
+
+ ), + title: 'Backend Connected', + icon: OnlineIcon, + }) + .render(); diff --git a/plugins/todo/package.json b/plugins/todo/package.json new file mode 100644 index 0000000000..77364d9906 --- /dev/null +++ b/plugins/todo/package.json @@ -0,0 +1,56 @@ +{ + "name": "@backstage/plugin-todo", + "version": "0.1.0", + "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" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/todo" + }, + "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.3", + "@backstage/core": "^0.7.2", + "@backstage/errors": "^0.1.1", + "@backstage/plugin-catalog-react": "^0.1.1", + "@backstage/theme": "^0.2.4", + "@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" + }, + "devDependencies": { + "@backstage/cli": "^0.6.5", + "@backstage/dev-utils": "^0.1.13", + "@backstage/test-utils": "^0.1.9", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32", + "msw": "^0.21.2", + "cross-fetch": "^3.0.6" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/todo/src/api/TodoClient.ts b/plugins/todo/src/api/TodoClient.ts new file mode 100644 index 0000000000..9d9fff0144 --- /dev/null +++ b/plugins/todo/src/api/TodoClient.ts @@ -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 { serializeEntityRef } from '@backstage/catalog-model'; +import { ResponseError } from '@backstage/errors'; +import { DiscoveryApi, IdentityApi } from '@backstage/core'; +import { TodoApi, TodoListOptions, TodoListResult } from './types'; + +interface Options { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; +} + +export class TodoClient implements TodoApi { + private readonly discoveryApi: DiscoveryApi; + private readonly identityApi: IdentityApi; + + constructor(options: Options) { + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + } + + async listTodos({ + entity, + offset, + limit, + orderBy, + filters, + }: TodoListOptions): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('todo'); + const token = await this.identityApi.getIdToken(); + + const query = new URLSearchParams(); + if (entity) { + query.set('entity', serializeEntityRef(entity) as string); + } + if (typeof offset === 'number') { + query.set('offset', String(offset)); + } + if (typeof limit === 'number') { + query.set('limit', String(limit)); + } + if (orderBy) { + query.set('orderBy', `${orderBy.field}=${orderBy.direction}`); + } + if (filters) { + for (const filter of filters) { + query.append('filter', `${filter.field}=${filter.value}`); + } + } + + const res = await fetch(`${baseUrl}/v1/todos?${query}`, { + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + }); + + if (!res.ok) { + throw await ResponseError.fromResponse(res); + } + + const data: TodoListResult = await res.json(); + return data; + } +} diff --git a/plugins/todo/src/api/index.ts b/plugins/todo/src/api/index.ts new file mode 100644 index 0000000000..813a161469 --- /dev/null +++ b/plugins/todo/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 { TodoClient } from './TodoClient'; +export { todoApiRef } from './types'; +export type { TodoApi } from './types'; diff --git a/plugins/todo/src/api/types.ts b/plugins/todo/src/api/types.ts new file mode 100644 index 0000000000..10c810592c --- /dev/null +++ b/plugins/todo/src/api/types.ts @@ -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 { createApiRef } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; + +export type TodoItem = { + /** The contents of the TODO comment */ + text: string; + + /** The tag used, e.g. TODO, FIXME */ + tag: string; + + /** References author, if any */ + author?: string; + + /** URL used to view the file */ + viewUrl?: string; + + /** The line number of the file that the TODO occurs at */ + lineNumber?: number; + + /** The path of the file containing the TODO within the repo */ + repoFilePath?: string; +}; + +type Fields = 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath'; + +export type TodoListOptions = { + entity?: Entity; + offset?: number; + limit?: number; + orderBy?: { + field: Fields; + direction: 'asc' | 'desc'; + }; + filters?: { + field: Fields; + /** Value to filter by, with '*' used as wildcard */ + value: string; + }[]; +}; + +export type TodoListResult = { + items: TodoItem[]; + totalCount: number; + offset: number; + limit: number; +}; + +export interface TodoApi { + listTodos(options: TodoListOptions): Promise; +} + +export const todoApiRef = createApiRef({ + id: 'plugin.todo.api', + description: 'Lists TODOs', +}); diff --git a/plugins/todo/src/components/TodoList/TodoList.test.tsx b/plugins/todo/src/components/TodoList/TodoList.test.tsx new file mode 100644 index 0000000000..2fb4f780bc --- /dev/null +++ b/plugins/todo/src/components/TodoList/TodoList.test.tsx @@ -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 { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { renderWithEffects } from '@backstage/test-utils'; +import React from 'react'; +import { TodoApi, todoApiRef } from '../../api'; +import { TodoList } from './TodoList'; + +describe('TodoList', () => { + it('should render', async () => { + const mockApi: jest.Mocked = { + listTodos: jest.fn().mockResolvedValue({ + items: [ + { + text: 'My TODO', + tag: 'FIXME', + viewUrl: 'https://example.com', + repoFilePath: '/my-file.js', + }, + ], + totalCount: 1, + limit: 10, + offset: 0, + }), + }; + const mockEntity = { metadata: { name: 'mock' } } as Entity; + + const rendered = await renderWithEffects( + + + + + , + ); + + await expect(rendered.findByText('FIXME')).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/todo/src/components/TodoList/TodoList.tsx b/plugins/todo/src/components/TodoList/TodoList.tsx new file mode 100644 index 0000000000..101f1caac0 --- /dev/null +++ b/plugins/todo/src/components/TodoList/TodoList.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Table, + TableColumn, + useApi, + OverflowTooltip, + Link, + ResponseErrorPanel, +} from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React, { useState } from 'react'; +import { todoApiRef } from '../../api'; +import { TodoItem, TodoListOptions } from '../../api/types'; + +const PAGE_SIZE = 10; + +const columns: TableColumn[] = [ + { + title: 'Tag', + field: 'tag', + filtering: false, + }, + { + title: 'Text', + field: 'text', + width: '100%', + highlight: true, + render: ({ text }) => , + }, + { + title: 'File', + field: 'repoFilePath', + width: '80%', + render: ({ viewUrl, repoFilePath }) => + viewUrl ? ( + + + + ) : ( + + ), + }, + { + title: 'Author', + field: 'author', + width: '20%', + render: ({ author }) => , + }, +]; + +export const TodoList = () => { + const { entity } = useEntity(); + const todoApi = useApi(todoApiRef); + const [error, setError] = useState(); + + if (error) { + return ; + } + + return ( + + title="TODOs" + options={{ + search: false, + pageSize: PAGE_SIZE, + padding: 'dense', + sorting: true, + draggable: false, + paging: true, + filtering: true, + debounceInterval: 500, + filterCellStyle: { padding: '0 16px 0 20px' }, + }} + columns={columns} + data={async query => { + try { + const page = query?.page ?? 0; + const pageSize = query?.pageSize ?? PAGE_SIZE; + const result = await todoApi.listTodos({ + entity, + offset: page * pageSize, + limit: pageSize, + orderBy: + query?.orderBy && + ({ + field: query.orderBy.field, + direction: query.orderDirection, + } as TodoListOptions['orderBy']), + filters: query?.filters?.map(filter => ({ + field: filter.column.field!, + value: `*${filter.value}*`, + })) as TodoListOptions['filters'], + }); + return { + data: result.items, + totalCount: result.totalCount, + page: Math.floor(result.offset / result.limit), + }; + } catch (loadingError) { + setError(loadingError); + return { data: [], totalCount: 0, page: 0 }; + } + }} + /> + ); +}; diff --git a/plugins/todo/src/components/TodoList/index.ts b/plugins/todo/src/components/TodoList/index.ts new file mode 100644 index 0000000000..416a52fd7a --- /dev/null +++ b/plugins/todo/src/components/TodoList/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 { TodoList } from './TodoList'; diff --git a/plugins/todo/src/index.test.ts b/plugins/todo/src/index.test.ts new file mode 100644 index 0000000000..f99455c18b --- /dev/null +++ b/plugins/todo/src/index.test.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 * as index from './index'; + +describe('imports', () => { + it('should export known symbols only', () => { + expect(index).toEqual({ + todoPlugin: expect.any(Object), + todoApiRef: expect.any(Object), + EntityTodoContent: expect.any(Function), + }); + }); +}); diff --git a/plugins/todo/src/index.ts b/plugins/todo/src/index.ts new file mode 100644 index 0000000000..be740259a8 --- /dev/null +++ b/plugins/todo/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 { todoApiRef } from './api'; +export { todoPlugin, EntityTodoContent } from './plugin'; diff --git a/plugins/todo/src/plugin.test.ts b/plugins/todo/src/plugin.test.ts new file mode 100644 index 0000000000..ad07c17207 --- /dev/null +++ b/plugins/todo/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 { todoPlugin } from './plugin'; + +describe('todo', () => { + it('should export plugin', () => { + expect(todoPlugin).toBeDefined(); + }); +}); diff --git a/plugins/todo/src/plugin.ts b/plugins/todo/src/plugin.ts new file mode 100644 index 0000000000..6dc06a7c34 --- /dev/null +++ b/plugins/todo/src/plugin.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 { + createApiFactory, + createPlugin, + createComponentExtension, + discoveryApiRef, + identityApiRef, +} from '@backstage/core'; +import { todoApiRef, TodoClient } from './api'; + +// import { rootRouteRef } from './routes'; + +export const todoPlugin = createPlugin({ + id: 'todo', + apis: [ + createApiFactory({ + api: todoApiRef, + deps: { + identityApi: identityApiRef, + discoveryApi: discoveryApiRef, + }, + factory({ identityApi, discoveryApi }) { + return new TodoClient({ identityApi, discoveryApi }); + }, + }), + ], + routes: { + // root: rootRouteRef, + }, +}); + +export const EntityTodoContent = todoPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components/TodoList').then(m => m.TodoList), + }, + // TODO(Rugvip): Switch back to routable extension once apps are migrated + // mountPoint: rootRouteRef, + }), +); diff --git a/plugins/todo/src/routes.ts b/plugins/todo/src/routes.ts new file mode 100644 index 0000000000..f34eea2391 --- /dev/null +++ b/plugins/todo/src/routes.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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: 'todo', +}); diff --git a/plugins/todo/src/setupTests.ts b/plugins/todo/src/setupTests.ts new file mode 100644 index 0000000000..0cec5b395d --- /dev/null +++ b/plugins/todo/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/user-settings/CHANGELOG.md b/plugins/user-settings/CHANGELOG.md index 7c9e5f74fc..cca47049ee 100644 --- a/plugins/user-settings/CHANGELOG.md +++ b/plugins/user-settings/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-user-settings +## 0.2.8 + +### Patch Changes + +- 147b4c5b1: Avoid using `ApiRef` descriptions in the UI. +- Updated dependencies [8686eb38c] +- Updated dependencies [9ca0e4009] +- Updated dependencies [34ff49b0f] + - @backstage/core@0.7.2 + ## 0.2.7 ### Patch Changes diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index ad8a1d8881..6e03c25396 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.7", + "version": "0.2.8", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index ac5473dfed..19174e674a 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -30,7 +30,7 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { - "@backstage/core": "^0.7.1", + "@backstage/core": "^0.7.2", "@backstage/theme": "^0.2.4", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -41,9 +41,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.6.4", + "@backstage/cli": "^0.6.5", "@backstage/dev-utils": "^0.1.13", - "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.9", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^12.0.7", diff --git a/scripts/check-docs-quality.js b/scripts/check-docs-quality.js index 8e3266a8fa..f4d2857b5c 100755 --- a/scripts/check-docs-quality.js +++ b/scripts/check-docs-quality.js @@ -40,14 +40,13 @@ const getFilesToLint = () => { command = `git ls-files | .\\node_modules\\.bin\\shx grep ".md"`; } + const ignored = ['', 'ADOPTERS.md']; return execSync(command, { stdio: ['ignore', 'pipe', 'inherit'], }) .toString() .split('\n') - .filter(function (el) { - return el !== ''; - }); + .filter(el => !ignored.includes(el)); }; // Proceed with the script only if Vale linter is installed. Limit the friction and surprises caused by the script. diff --git a/yarn.lock b/yarn.lock index 4144016dfa..0bf6a5152d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -263,6 +263,13 @@ dependencies: "@babel/highlight" "^7.8.3" +"@babel/code-frame@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" + integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== + dependencies: + "@babel/highlight" "^7.12.13" + "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" @@ -326,6 +333,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.13.0": + version "7.13.9" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.13.9.tgz#3a7aa96f9efb8e2be42d38d80e2ceb4c64d8de39" + integrity sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw== + dependencies: + "@babel/types" "^7.13.0" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" @@ -459,6 +475,15 @@ "@babel/template" "^7.10.4" "@babel/types" "^7.10.4" +"@babel/helper-function-name@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a" + integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA== + dependencies: + "@babel/helper-get-function-arity" "^7.12.13" + "@babel/template" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/helper-get-function-arity@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" @@ -466,6 +491,13 @@ dependencies: "@babel/types" "^7.10.4" +"@babel/helper-get-function-arity@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583" + integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" @@ -487,6 +519,13 @@ dependencies: "@babel/types" "^7.12.7" +"@babel/helper-member-expression-to-functions@^7.13.0": + version "7.13.0" + resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.0.tgz#6aa4bb678e0f8c22f58cdb79451d30494461b091" + integrity sha512-yvRf8Ivk62JwisqV1rFRMxiSMDGnN6KH1/mDMmIrij4jztpQNRoHqqMG3U6apYbGRPJpgPalhva9Yd06HlUxJQ== + dependencies: + "@babel/types" "^7.13.0" + "@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620" @@ -501,32 +540,26 @@ dependencies: "@babel/types" "^7.12.5" -"@babel/helper-module-transforms@^7.10.4": - version "7.11.0" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359" - integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg== +"@babel/helper-module-imports@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.12.13.tgz#ec67e4404f41750463e455cc3203f6a32e93fcb0" + integrity sha512-NGmfvRp9Rqxy0uHSSVP+SRIW1q31a7Ji10cLBcqSDUngGentY4FRiHOFZFE1CLU5eiL0oE8reH7Tg1y99TDM/g== dependencies: - "@babel/helper-module-imports" "^7.10.4" - "@babel/helper-replace-supers" "^7.10.4" - "@babel/helper-simple-access" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/template" "^7.10.4" - "@babel/types" "^7.11.0" - lodash "^4.17.19" + "@babel/types" "^7.12.13" -"@babel/helper-module-transforms@^7.12.1": - version "7.12.1" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" - integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== +"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.13.0": + version "7.13.0" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.13.0.tgz#42eb4bd8eea68bab46751212c357bfed8b40f6f1" + integrity sha512-Ls8/VBwH577+pw7Ku1QkUWIyRRNHpYlts7+qSqBBFCW3I8QteB9DxfcZ5YJpOwH6Ihe/wn8ch7fMGOP1OhEIvw== dependencies: - "@babel/helper-module-imports" "^7.12.1" - "@babel/helper-replace-supers" "^7.12.1" - "@babel/helper-simple-access" "^7.12.1" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/helper-validator-identifier" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.12.1" - "@babel/types" "^7.12.1" + "@babel/helper-module-imports" "^7.12.13" + "@babel/helper-replace-supers" "^7.13.0" + "@babel/helper-simple-access" "^7.12.13" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/helper-validator-identifier" "^7.12.11" + "@babel/template" "^7.12.13" + "@babel/traverse" "^7.13.0" + "@babel/types" "^7.13.0" lodash "^4.17.19" "@babel/helper-optimise-call-expression@^7.10.4": @@ -536,10 +569,17 @@ dependencies: "@babel/types" "^7.10.4" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" - integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== +"@babel/helper-optimise-call-expression@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz#5c02d171b4c8615b1e7163f888c1c81c30a2aaea" + integrity sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA== + dependencies: + "@babel/types" "^7.12.13" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.13.0" + resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af" + integrity sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ== "@babel/helper-regex@^7.10.4": version "7.10.5" @@ -577,20 +617,22 @@ "@babel/traverse" "^7.12.5" "@babel/types" "^7.12.5" -"@babel/helper-simple-access@^7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461" - integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw== +"@babel/helper-replace-supers@^7.13.0": + version "7.13.0" + resolved "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.13.0.tgz#6034b7b51943094cb41627848cb219cb02be1d24" + integrity sha512-Segd5me1+Pz+rmN/NFBOplMbZG3SqRJOBlY+mA0SxAv6rjj7zJqr1AVr3SfzUVTLCv7ZLU5FycOM/SBGuLPbZw== dependencies: - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" + "@babel/helper-member-expression-to-functions" "^7.13.0" + "@babel/helper-optimise-call-expression" "^7.12.13" + "@babel/traverse" "^7.13.0" + "@babel/types" "^7.13.0" -"@babel/helper-simple-access@^7.12.1": - version "7.12.1" - resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" - integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== +"@babel/helper-simple-access@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.12.13.tgz#8478bcc5cacf6aa1672b251c1d2dde5ccd61a6c4" + integrity sha512-0ski5dyYIHEfwpWGx5GPWhH35j342JaflmCeQmsPWcrOQDtCN6C1zKAVRFVbK53lPW2c9TsuLLSUDf0tIGJ5hA== dependencies: - "@babel/types" "^7.12.1" + "@babel/types" "^7.12.13" "@babel/helper-skip-transparent-expression-wrappers@^7.11.0": version "7.11.0" @@ -613,11 +655,23 @@ dependencies: "@babel/types" "^7.11.0" +"@babel/helper-split-export-declaration@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05" + integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-validator-identifier@^7.10.4": version "7.10.4" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== +"@babel/helper-validator-identifier@^7.12.11": + version "7.12.11" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" + integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== + "@babel/helper-validator-option@^7.12.1": version "7.12.1" resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" @@ -651,6 +705,15 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/highlight@^7.12.13": + version "7.13.10" + resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" + integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/parser@7.11.5", "@babel/parser@^7.11.5": version "7.11.5" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" @@ -671,6 +734,11 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.10.5.tgz#e7c6bf5a7deff957cec9f04b551e2762909d826b" integrity sha512-wfryxy4bE1UivvQKSQDU4/X6dr+i8bctjUjj8Zyt3DQy7NtPizJXT8M52nqpNKL+nq2PW8lxk4ZqLj0fD4B4hQ== +"@babel/parser@^7.12.13", "@babel/parser@^7.13.0": + version "7.13.11" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.13.11.tgz#f93ebfc99d21c1772afbbaa153f47e7ce2f50b88" + integrity sha512-PhuoqeHoO9fc4ffMEVk4qb/w/s2iOSWohvbHxLtxui0eBg3Lg5gN1U8wp1V1u61hOWkPQJJyJzGH6Y+grwkq8Q== + "@babel/parser@^7.12.7": version "7.12.7" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.12.7.tgz#fee7b39fe809d0e73e5b25eecaf5780ef3d73056" @@ -1207,24 +1275,14 @@ "@babel/helper-plugin-utils" "^7.10.4" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.4.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0" - integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w== +"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.12.1", "@babel/plugin-transform-modules-commonjs@^7.4.4": + version "7.13.8" + resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.13.8.tgz#7b01ad7c2dcf2275b06fa1781e00d13d420b3e1b" + integrity sha512-9QiOx4MEGglfYZ4XOnU79OHr6vIWUakIj9b4mioN8eQIoEh+pf5p/zEB36JpDFWA12nNMiRf7bfoRvl9Rn79Bw== dependencies: - "@babel/helper-module-transforms" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-simple-access" "^7.10.4" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-commonjs@^7.12.1": - version "7.12.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" - integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== - dependencies: - "@babel/helper-module-transforms" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-simple-access" "^7.12.1" + "@babel/helper-module-transforms" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-simple-access" "^7.12.13" babel-plugin-dynamic-import-node "^2.3.3" "@babel/plugin-transform-modules-systemjs@^7.12.1": @@ -1598,7 +1656,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.0", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.4", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.0", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.12.5" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== @@ -1614,6 +1672,15 @@ "@babel/parser" "^7.10.4" "@babel/types" "^7.10.4" +"@babel/template@^7.12.13": + version "7.12.13" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" + integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/parser" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/template@^7.12.7": version "7.12.7" resolved "https://registry.npmjs.org/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" @@ -1692,7 +1759,7 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9": +"@babel/traverse@^7.12.5", "@babel/traverse@^7.12.9": version "7.12.9" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.9.tgz#fad26c972eabbc11350e0b695978de6cc8e8596f" integrity sha512-iX9ajqnLdoU1s1nHt36JDI9KG4k+vmI8WgjK5d+aDTwQbL2fUnzedNedssA645Ede3PM2ma1n8Q4h2ohwXgMXw== @@ -1707,6 +1774,21 @@ globals "^11.1.0" lodash "^4.17.19" +"@babel/traverse@^7.13.0": + version "7.13.0" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.13.0.tgz#6d95752475f86ee7ded06536de309a65fc8966cc" + integrity sha512-xys5xi5JEhzC3RzEmSGrs/b3pJW/o87SypZ+G/PhaE7uqVQNv/jlmVIBXuoh5atqQ434LfXV+sf23Oxj0bchJQ== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/generator" "^7.13.0" + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/parser" "^7.13.0" + "@babel/types" "^7.13.0" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.19" + "@babel/types@7.11.5", "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.8.6", "@babel/types@^7.9.6": version "7.11.5" resolved "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" @@ -1743,6 +1825,15 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@babel/types@^7.12.13", "@babel/types@^7.13.0": + version "7.13.0" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.13.0.tgz#74424d2816f0171b4100f0ab34e9a374efdf7f80" + integrity sha512-hE+HE8rnG1Z6Wzo+MhaKE5lM5eMx71T4EHJgku2E3xIfaULhDcxiiRxUYgwX8qwP1BBSlag+TdGOt6JAidIZTA== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + lodash "^4.17.19" + to-fast-properties "^2.0.0" + "@backstage/catalog-model@^0.2.0": version "0.7.4" dependencies: @@ -1755,23 +1846,11 @@ uuid "^8.0.0" yup "^0.29.3" -"@backstage/catalog-model@^0.3.0": - version "0.7.4" - dependencies: - "@backstage/config" "^0.1.3" - "@types/json-schema" "^7.0.5" - "@types/yup" "^0.29.8" - ajv "^7.0.3" - json-schema "^0.2.5" - lodash "^4.17.15" - uuid "^8.0.0" - yup "^0.29.3" - "@backstage/core@^0.3.0": - version "0.7.1" + version "0.7.2" dependencies: - "@backstage/config" "^0.1.3" - "@backstage/core-api" "^0.2.13" + "@backstage/config" "^0.1.4" + "@backstage/core-api" "^0.2.14" "@backstage/errors" "^0.1.1" "@backstage/theme" "^0.2.4" "@material-ui/core" "^4.11.0" @@ -1810,10 +1889,10 @@ zen-observable "^0.8.15" "@backstage/core@^0.6.0": - version "0.7.1" + version "0.7.2" dependencies: - "@backstage/config" "^0.1.3" - "@backstage/core-api" "^0.2.13" + "@backstage/config" "^0.1.4" + "@backstage/core-api" "^0.2.14" "@backstage/errors" "^0.1.1" "@backstage/theme" "^0.2.4" "@material-ui/core" "^4.11.0" @@ -1852,10 +1931,10 @@ zen-observable "^0.8.15" "@backstage/core@^0.6.1": - version "0.7.1" + version "0.7.2" dependencies: - "@backstage/config" "^0.1.3" - "@backstage/core-api" "^0.2.13" + "@backstage/config" "^0.1.4" + "@backstage/core-api" "^0.2.14" "@backstage/errors" "^0.1.1" "@backstage/theme" "^0.2.4" "@material-ui/core" "^4.11.0" @@ -1894,13 +1973,14 @@ zen-observable "^0.8.15" "@backstage/plugin-catalog@^0.2.1": - version "0.4.2" + version "0.5.0" dependencies: - "@backstage/catalog-client" "^0.3.7" + "@backstage/catalog-client" "^0.3.8" "@backstage/catalog-model" "^0.7.4" - "@backstage/core" "^0.7.1" + "@backstage/core" "^0.7.2" "@backstage/integration" "^0.5.1" - "@backstage/plugin-catalog-react" "^0.1.1" + "@backstage/integration-react" "^0.1.1" + "@backstage/plugin-catalog-react" "^0.1.2" "@backstage/theme" "^0.2.4" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -1917,13 +1997,14 @@ swr "^0.3.0" "@backstage/plugin-catalog@^0.3.1": - version "0.4.2" + version "0.5.0" dependencies: - "@backstage/catalog-client" "^0.3.7" + "@backstage/catalog-client" "^0.3.8" "@backstage/catalog-model" "^0.7.4" - "@backstage/core" "^0.7.1" + "@backstage/core" "^0.7.2" "@backstage/integration" "^0.5.1" - "@backstage/plugin-catalog-react" "^0.1.1" + "@backstage/integration-react" "^0.1.1" + "@backstage/plugin-catalog-react" "^0.1.2" "@backstage/theme" "^0.2.4" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -2190,6 +2271,11 @@ resolved "https://registry.npmjs.org/@date-io/core/-/core-1.3.13.tgz#90c71da493f20204b7a972929cc5c482d078b3fa" integrity sha512-AlEKV7TxjeK+jxWVKcCFrfYAk8spX9aCyiToFIiLPtfQbsjmRGLIhb5VZgptQcJdHtLXo7+m0DuurwFgUToQuA== +"@date-io/core@2.10.7": + version "2.10.7" + resolved "https://registry.npmjs.org/@date-io/core/-/core-2.10.7.tgz#0fe1fa0ef02c827919e23c2802a4b25589ac522d" + integrity sha512-EG/1qDiQvd12RoNJ6H+sZcHVswC/3uMx/ySvfaJ24vB30rLjkgHggEXbgMbfgki7wMuiQ/zXI8QlmF1k3kWRGQ== + "@date-io/date-fns@^1.1.0": version "1.3.13" resolved "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-1.3.13.tgz#7798844041640ab393f7e21a7769a65d672f4735" @@ -2368,10 +2454,10 @@ retry-request "^4.0.0" teeny-request "^3.11.3" -"@google-cloud/common@^3.5.0": - version "3.5.0" - resolved "https://registry.npmjs.org/@google-cloud/common/-/common-3.5.0.tgz#0959e769e8075a06eb0823cc567eef00fd0c2d02" - integrity sha512-10d7ZAvKhq47L271AqvHEd8KzJqGU45TY+rwM2Z3JHuB070FeTi7oJJd7elfrnKaEvaktw3hH2wKnRWxk/3oWQ== +"@google-cloud/common@^3.6.0": + version "3.6.0" + resolved "https://registry.npmjs.org/@google-cloud/common/-/common-3.6.0.tgz#c2f6da5f79279a4a9ac7c71fc02d582beab98e8b" + integrity sha512-aHIFTqJZmeTNO9md8XxV+ywuvXF3xBm5WNmgWeeCK+XN5X+kGW0WEX94wGwj+/MdOnrVf4dL2RvSIt9J5yJG6Q== dependencies: "@google-cloud/projectify" "^2.0.0" "@google-cloud/promisify" "^2.0.0" @@ -2379,7 +2465,7 @@ duplexify "^4.1.1" ent "^2.2.0" extend "^3.0.2" - google-auth-library "^6.1.1" + google-auth-library "^7.0.2" retry-request "^4.1.1" teeny-request "^7.0.0" @@ -2456,20 +2542,21 @@ xdg-basedir "^3.0.0" "@google-cloud/storage@^5.6.0": - version "5.6.0" - resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.6.0.tgz#bc6925c7970c375212a4da21c123298fc9665dec" - integrity sha512-nLcym8IuCzy1O7tNTXNFuMHfX900sTM3kSTqbKe7oFSoKUiaIM+FHuuuDimMMlieY6StA1xYNPRFFHz57Nv8YQ== + version "5.8.1" + resolved "https://registry.npmjs.org/@google-cloud/storage/-/storage-5.8.1.tgz#00e627723614bcf97e6e29f9a59ec39339171847" + integrity sha512-qP8gCJ2myyMN3JMJN12d82Oo8VBSDO8vO4/x56dtQZX9+WISqcagurntnJVyFX885tIOtS97bsyv8qR1xv6HMg== dependencies: - "@google-cloud/common" "^3.5.0" + "@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.0" + date-and-time "^0.14.2" duplexify "^4.0.0" extend "^3.0.2" gaxios "^4.0.0" - gcs-resumable-upload "^3.1.0" + gcs-resumable-upload "^3.1.3" get-stream "^6.0.0" hash-stream-validation "^0.2.2" mime "^2.2.0" @@ -3191,10 +3278,10 @@ resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== -"@kubernetes/client-node@^0.13.2": - version "0.13.2" - resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.13.2.tgz#881eb407afbd5b499e5daf898ffcc40f839709e7" - integrity sha512-ufvGfjBXuy5LbZTJZ8bd1eRVgWUd9rRDCryMWNfxb4372Q60R1oG8qy7svUB9NqBmwFgKHuaXkrfq3rFFbGrew== +"@kubernetes/client-node@^0.14.0": + version "0.14.0" + resolved "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-0.14.0.tgz#a02806f3b6fdb68fb51d451ee8ff01faa446f557" + integrity sha512-/37JHuEUAQ5GQ4kLKBmCYvGgf5W1KZWKreKGWFYH8VvT2Hl/o0aJZasu2w0EHEfmE11JCn0X9arVmOTyVCYvww== dependencies: "@types/js-yaml" "^3.12.1" "@types/node" "^10.12.0" @@ -3964,6 +4051,17 @@ 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" @@ -4022,6 +4120,15 @@ prop-types "^15.7.2" react-is "^16.8.0" +"@material-ui/utils@^4.11.2": + version "4.11.2" + resolved "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz#f1aefa7e7dff2ebcb97d31de51aecab1bb57540a" + integrity sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA== + dependencies: + "@babel/runtime" "^7.4.4" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + "@mattiasbuelens/web-streams-polyfill@^0.2.0": version "0.2.1" resolved "https://registry.npmjs.org/@mattiasbuelens/web-streams-polyfill/-/web-streams-polyfill-0.2.1.tgz#d7c4aa94f98084ec0787be084d47167d62ea5f67" @@ -4188,10 +4295,10 @@ resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.2.0.tgz#123e0438a0bc718ccdac3b5a2e69b3dd00daa85b" integrity sha512-274lNUDonw10kT8wHg8fCcUc1ZjZHbWv0/TbAwb0ojhBQqZYc1cQ/4yqTVTtPMDeZ//g7xVEYe/s3vURkRghPg== -"@octokit/openapi-types@^4.0.3": - version "4.0.4" - resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-4.0.4.tgz#96fcce11e929802898646205ac567e5df592f82b" - integrity sha512-31zY8JIuz3h6RAFOnyA8FbOwhILILiBu1qD81RyZZWY7oMBhIdBn6MaAmnnptLhB4jk0g50nkQkUVP4kUzppcA== +"@octokit/openapi-types@^5.3.2": + version "5.3.2" + resolved "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-5.3.2.tgz#b8ac43c5c3d00aef61a34cf744e315110c78deb4" + integrity sha512-NxF1yfYOUO92rCx3dwvA2onF30Vdlg7YUkMVXkeptqpzA3tRLplThhFleV/UKWFgh7rpKu1yYRbvNDUtzSopKA== "@octokit/plugin-enterprise-rest@^6.0.1": version "6.0.1" @@ -4210,12 +4317,12 @@ resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz#394d59ec734cd2f122431fbaf05099861ece3c44" integrity sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg== -"@octokit/plugin-rest-endpoint-methods@4.10.3": - version "4.10.3" - resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.10.3.tgz#d78ddf926bca3b81a4d9b79a463d32b3750a4485" - integrity sha512-CsNQeVY34Vs9iea2Z9/TCPlebxv6KpjO9f1BUPz+14qundTSYT9kgf8j5wA1k37VstfBQ4xnuURYdnbGzJBJXw== +"@octokit/plugin-rest-endpoint-methods@4.13.5": + version "4.13.5" + resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.13.5.tgz#ad76285b82fe05fbb4adf2774a9c887f3534a880" + integrity sha512-kYKcWkFm4Ldk8bZai2RVEP1z97k1C/Ay2FN9FNTBg7JIyKoiiJjks4OtT6cuKeZX39tqa+C3J9xeYc6G+6g8uQ== dependencies: - "@octokit/types" "^6.8.3" + "@octokit/types" "^6.12.2" deprecation "^2.3.1" "@octokit/request-error@^2.0.0": @@ -4242,14 +4349,14 @@ universal-user-agent "^6.0.0" "@octokit/rest@^18.0.0", "@octokit/rest@^18.0.12", "@octokit/rest@^18.1.0": - version "18.1.1" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.1.1.tgz#bd7053c28db3577c936029e9da6bfbd046474a2f" - integrity sha512-ZcCHMyfGT1qtJD72usigAfUQ6jU89ZUPFb2AOubR6WZ7/RRFVZUENVm1I2yvJBUicqTujezPW9cY1+o3Mb4rNA== + version "18.3.5" + resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.3.5.tgz#a89903d46e0b4273bd3234674ec2777a651d68ab" + integrity sha512-ZPeRms3WhWxQBEvoIh0zzf8xdU2FX0Capa7+lTca8YHmRsO3QNJzf1H3PcuKKsfgp91/xVDRtX91sTe1kexlbw== dependencies: "@octokit/core" "^3.2.3" "@octokit/plugin-paginate-rest" "^2.6.2" "@octokit/plugin-request-log" "^1.0.2" - "@octokit/plugin-rest-endpoint-methods" "4.10.3" + "@octokit/plugin-rest-endpoint-methods" "4.13.5" "@octokit/types@^5.0.0", "@octokit/types@^5.0.1": version "5.5.0" @@ -4258,12 +4365,12 @@ dependencies: "@types/node" ">= 8" -"@octokit/types@^6.0.0", "@octokit/types@^6.0.1", "@octokit/types@^6.0.3", "@octokit/types@^6.8.3": - version "6.8.5" - resolved "https://registry.npmjs.org/@octokit/types/-/types-6.8.5.tgz#797dfdad8c75718e97dc687d4c9fc49200ca8d17" - integrity sha512-ZsQawftZoi0kSF2pCsdgLURbOjtVcHnBOXiSxBKSNF56CRjARt5rb/g8WJgqB8vv4lgUEHrv06EdDKYQ22vA9Q== +"@octokit/types@^6.0.0", "@octokit/types@^6.0.1", "@octokit/types@^6.0.3", "@octokit/types@^6.12.2", "@octokit/types@^6.8.2": + version "6.12.2" + resolved "https://registry.npmjs.org/@octokit/types/-/types-6.12.2.tgz#5b44add079a478b8eb27d78cf384cc47e4411362" + integrity sha512-kCkiN8scbCmSq+gwdJV0iLgHc0O/GTPY1/cffo9kECu1MvatLPh9E+qFhfRIktKfHEA6ZYvv6S1B4Wnv3bi3pA== dependencies: - "@octokit/openapi-types" "^4.0.3" + "@octokit/openapi-types" "^5.3.2" "@open-draft/until@^1.0.3": version "1.0.3" @@ -4421,24 +4528,25 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.3" -"@roadiehq/backstage-plugin-github-insights@^0.2.16": - version "0.2.16" - resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-0.2.16.tgz#bf7a391cf1a4024a511af90e740fabecb55eaea4" - integrity sha512-9P5L2JZ6Pd1asbIFFnA6ZSmaTRO7wfue22pFRrpPdp/NF++8LEdHFtxUZLH4/jjUtCHHRmD7VPjT9TRaZ9Fb6w== +"@roadiehq/backstage-plugin-github-insights@^0.3.2": + version "0.3.2" + resolved "https://registry.npmjs.org/@roadiehq/backstage-plugin-github-insights/-/backstage-plugin-github-insights-0.3.2.tgz#2db83e172ae34738677bd47c3b754c31bb2b5987" + integrity sha512-eIhqQS0jTRcdTCu+GxrRCH1tq+kDrTb6mMJpD/6sGnE9QXgj5nKHIm6KHmFYEfIiukQrGxXY4lABNWstvi7hgA== dependencies: - "@backstage/catalog-model" "^0.3.0" - "@backstage/core" "^0.3.0" - "@backstage/theme" "^0.2.1" + "@backstage/catalog-model" "^0.7.1" + "@backstage/core" "^0.6.0" + "@backstage/theme" "^0.2.3" + "@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.45" + "@material-ui/lab" "4.0.0-alpha.57" "@octokit/rest" "^18.0.0" - "@octokit/types" "^5.0.1" + "@octokit/types" "^6.8.2" history "^5.0.0" react "^16.13.1" react-dom "^16.13.1" react-router "^6.0.0-beta.0" - react-use "^15.3.3" + react-use "^17.1.0" "@roadiehq/backstage-plugin-github-pull-requests@^0.7.6": version "0.7.8" @@ -4594,9 +4702,9 @@ "@sinonjs/commons" "^1.7.0" "@spotify/eslint-config-base@^9.0.0": - version "9.0.0" - resolved "https://registry.npmjs.org/@spotify/eslint-config-base/-/eslint-config-base-9.0.0.tgz#e122b2e6d1a11750170badc5be6774ded95953d2" - integrity sha512-LNGY6bBM0IlosvMIx9Ic5gqatpa5LPvrJhN4RnrUnP9oaXNLgPTKDE89p3eC5Tnh78ADdX4HD+rbHDSyP2tBgg== + version "9.0.2" + resolved "https://registry.npmjs.org/@spotify/eslint-config-base/-/eslint-config-base-9.0.2.tgz#a4830f610f40de935de795d3def486c3e5064ee4" + integrity sha512-HS+eLpVMbUUBXZ9ZlPcd6yCje/OVNhdcGDeU+DoK+C9AFfzbRtuZ1EJ8ewzj58ENrhlfJPuxCS68bypA/esyjw== "@spotify/eslint-config-oss@^1.0.1": version "1.0.2" @@ -5687,9 +5795,9 @@ pretty-format "^26.6.2" "@testing-library/jest-dom@^5.10.1": - version "5.11.4" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.4.tgz#f325c600db352afb92995c2576022b35621ddc99" - integrity sha512-6RRn3epuweBODDIv3dAlWjOEHQLpGJHB2i912VS3JQtsD22+ENInhdDNl4ZZQiViLlIfFinkSET/J736ytV9sw== + version "5.11.9" + resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.9.tgz#e6b3cd687021f89f261bd53cbe367041fbd3e975" + integrity sha512-Mn2gnA9d1wStlAIT2NU8J15LNob0YFBVjs2aEQ3j8rsfRQo+lAs7/ui1i2TGaJjapLmuNPLTsrm+nPjmZDwpcQ== dependencies: "@babel/runtime" "^7.9.2" "@types/testing-library__jest-dom" "^5.9.1" @@ -6062,9 +6170,9 @@ "@types/range-parser" "*" "@types/express-session@^1.17.2": - version "1.17.2" - resolved "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.2.tgz#ed6a36dd9f267c7fef86004f653bfb9b5cea3c21" - integrity sha512-QRm/fUuvr/BAosL9CvK351SDQP7wpD8+h3S8ZEE/8IvHJ/ZqHrjZbjx/flYfazyPw7yNi9O5fbjFZbh0vZ1ccg== + version "1.17.3" + resolved "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.3.tgz#4a37c5c4428b8f922ac8ac1cb4bd9190a4d2b097" + integrity sha512-57DnyxiqClXOIjoCgeKCUYfKxBPOlOY/k+l1TPK+7bSwyiPTrS5FIk1Ycql7twk4wO7P5lfOVy6akDGiaMSLfw== dependencies: "@types/express" "*" @@ -6085,24 +6193,10 @@ dependencies: "@types/node" "*" -"@types/fs-extra@^9.0.1": - version "9.0.1" - resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.1.tgz#91c8fc4c51f6d5dbe44c2ca9ab09310bd00c7918" - integrity sha512-B42Sxuaz09MhC3DDeW5kubRcQ5by4iuVQ0cRRWM2lggLzAa/KVom0Aft/208NgMvNQQZ86s5rVcqDdn/SH0/mg== - dependencies: - "@types/node" "*" - -"@types/fs-extra@^9.0.3": - version "9.0.3" - resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.3.tgz#9996e5cce993508c32325380b429f04a1327523e" - integrity sha512-NKdGoXLTFTRED3ENcfCsH8+ekV4gbsysanx2OPbstXVV6fZMgUCqTxubs6I9r7pbOJbFgVq1rpFtLURjKCZWUw== - dependencies: - "@types/node" "*" - -"@types/fs-extra@^9.0.5": - version "9.0.5" - resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.5.tgz#2afb76a43a4bef80a363b94b314d0ca1694fc4f8" - integrity sha512-wr3t7wIW1c0A2BIJtdVp4EflriVaVVAsCAIHVzzh8B+GiFv9X1xeJjCs4upRXtzp7kQ6lP5xvskjoD4awJ1ZeA== +"@types/fs-extra@^9.0.1", "@types/fs-extra@^9.0.3", "@types/fs-extra@^9.0.5": + version "9.0.8" + resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.8.tgz#32c3c07ddf8caa5020f84b5f65a48470519f78ba" + integrity sha512-bnlTVTwq03Na7DpWxFJ1dvnORob+Otb8xHyUqUWhqvz/Ksg8+JXPlR52oeMSZ37YEOa5PyccbgUNutiQdi13TA== dependencies: "@types/node" "*" @@ -6281,7 +6375,7 @@ dependencies: "@types/sizzle" "*" -"@types/js-cookie@2.2.6": +"@types/js-cookie@2.2.6", "@types/js-cookie@^2.2.6": version "2.2.6" resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f" integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw== @@ -6291,6 +6385,11 @@ resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz#136d5e6a57a931e1cce6f9d8126aa98a9c92a6bb" integrity sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww== +"@types/js-yaml@^4.0.0": + version "4.0.0" + resolved "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.0.tgz#d1a11688112091f2c711674df3a65ea2f47b5dfb" + integrity sha512-4vlpCM5KPCL5CfGmTbpjwVKbISRYhduEJvvUWsH5EB7QInhEj94XPZ3ts/9FPiLZFqYO0xoW4ZL8z2AabTGgJA== + "@types/json-schema-merge-allof@^0.6.0": version "0.6.0" resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.0.tgz#0f587d8a3bcb41a55ef2e91d3ba96430c9bc1813" @@ -6367,9 +6466,9 @@ "@types/node" "*" "@types/lodash@^4.14.151": - version "4.14.165" - resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" - integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== + version "4.14.168" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" + integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== "@types/long@^4.0.0", "@types/long@^4.0.1": version "4.0.1" @@ -6478,15 +6577,10 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@>= 8": - version "14.0.26" - resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" - integrity sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA== - -"@types/node@>=12.12.47": - version "14.14.31" - resolved "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055" - integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g== +"@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@^10.1.0", "@types/node@^10.12.0": version "10.17.35" @@ -6503,11 +6597,6 @@ resolved "https://registry.npmjs.org/@types/node/-/node-13.13.45.tgz#e6676bcca092bae5751d015f074a234d5a82eb63" integrity sha512-703YTEp8AwQeapI0PTXDOj+Bs/mtdV/k9VcTP7z/de+lx6XjFMKdB+JhKnK+6PZ5za7omgZ3V6qm/dNkMj/Zow== -"@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/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -7373,7 +7462,7 @@ dependencies: tslib "^1.9.3" -"@xobotyi/scrollbar-width@1.9.5": +"@xobotyi/scrollbar-width@1.9.5", "@xobotyi/scrollbar-width@^1.9.5": version "1.9.5" resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d" integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ== @@ -7588,6 +7677,13 @@ alphanum-sort@^1.0.0: resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= +anafanafo@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/anafanafo/-/anafanafo-1.0.0.tgz#2e67190aed5dc67f5f0043b710146e7276c1afb8" + integrity sha512-pDPbI7SFRHu0byMXHAf+v74+LCcHSxnLYkcbfiV91XRlE+NSLpFCpEQdVUy9ZxZw/LuhTrOin4r8wlR3OFrKBA== + dependencies: + char-width-table-consumer "^1.0.0" + ansi-align@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" @@ -7983,14 +8079,6 @@ argparse@^2.0.1: resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-query@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/aria-query/-/aria-query-3.0.0.tgz#65b3fcc1ca1155a8c9ae64d6eee297f15d5133cc" - integrity sha1-ZbP8wcoRVajJrmTW7uKX8V1RM8w= - dependencies: - ast-types-flow "0.0.7" - commander "^2.11.0" - aria-query@^4.2.2: version "4.2.2" resolved "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" @@ -8044,13 +8132,15 @@ array-ify@^1.0.0: resolved "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" integrity sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4= -array-includes@^3.0.3, array-includes@^3.1.1: - version "3.1.1" - resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348" - integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ== +array-includes@^3.0.3, array-includes@^3.1.1, array-includes@^3.1.2: + version "3.1.3" + resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a" + integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0" + es-abstract "^1.18.0-next.2" + get-intrinsic "^1.1.1" is-string "^1.0.5" array-union@^1.0.1, array-union@^1.0.2: @@ -8152,7 +8242,7 @@ assign-symbols@^1.0.0: resolved "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= -ast-types-flow@0.0.7, ast-types-flow@^0.0.7: +ast-types-flow@^0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= @@ -8194,7 +8284,7 @@ async-lock@^1.1.0: resolved "https://registry.npmjs.org/async-lock/-/async-lock-1.2.4.tgz#80d0d612383045dd0c30eb5aad08510c1397cb91" integrity sha512-UBQJC2pbeyGutIfYmErGc9RaJYnpZ1FHaxuKwb0ahvGiiCkPUf3p67Io+YLPmmv3RHY+mF6JEtNW8FlHsraAaA== -async-retry@^1.2.1: +async-retry@^1.2.1, async-retry@^1.3.1: version "1.3.1" resolved "https://registry.npmjs.org/async-retry/-/async-retry-1.3.1.tgz#139f31f8ddce50c0870b0ba558a6079684aaed55" integrity sha512-aiieFW/7h3hY0Bq5d+ktDBejxuwR78vRu9hDUdR8rNhSaQ29VzPL4AoIRG7D/c7tdenwOcKvgPM6tIxB3cB6HA== @@ -8303,6 +8393,11 @@ aws4@^1.8.0: resolved "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== +axe-core@^4.0.2: + version "4.1.3" + resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz#64a4c85509e0991f5168340edc4bedd1ceea6966" + integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ== + axios@^0.20.0: version "0.20.0" resolved "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz#057ba30f04884694993a8cd07fa394cff11c50bd" @@ -8317,10 +8412,10 @@ axios@^0.21.1: dependencies: follow-redirects "^1.10.0" -axobject-query@^2.0.2: - version "2.1.2" - resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.1.2.tgz#2bdffc0371e643e5f03ba99065d5179b9ca79799" - integrity sha512-ICt34ZmrVt8UQnvPl6TVyDTkmhXmAyAT4Jh5ugfGUX4MOrZ+U/ZY6/sdylRw3qGNr9Ub5AJsaHeDMzNLehRdOQ== +axobject-query@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" + integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA== azure-devops-node-api@^10.1.1: version "10.2.1" @@ -8725,6 +8820,14 @@ backoff@^2.5.0: dependencies: precond "0.2" +badge-maker@^3.3.0: + version "3.3.0" + resolved "https://registry.npmjs.org/badge-maker/-/badge-maker-3.3.0.tgz#996cb2beeb458cbc9f559c4276f0d285758e5106" + integrity sha512-jYOlYss1BqwrjHnV6cIFD7G6XDdJ6rbZrzXxJulee3WhUjmXmHWOBm4xhtq10C6rrM5vEeWOB8WpZCD/LfI9CA== + dependencies: + anafanafo "^1.0.0" + css-color-converter "^2.0.0" + bail@^1.0.0: version "1.0.5" resolved "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" @@ -8846,6 +8949,11 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== +binary-search@^1.3.5: + version "1.3.6" + resolved "https://registry.npmjs.org/binary-search/-/binary-search-1.3.6.tgz#e32426016a0c5092f0f3598836a1c7da3560565c" + integrity sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA== + binary@~0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" @@ -9346,6 +9454,14 @@ call-bind@^1.0.0: function-bind "^1.1.1" get-intrinsic "^1.0.0" +call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + call-me-maybe@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" @@ -9592,6 +9708,13 @@ char-regex@^1.0.2: resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== +char-width-table-consumer@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/char-width-table-consumer/-/char-width-table-consumer-1.0.0.tgz#bb44ccd1ba3ed4fcdb062e22876721858a7697a8" + integrity sha512-Fz4UD0LBpxPgL9i29CJ5O4KANwaMnX/OhhbxzvNa332h+9+nRKyeuLw4wA51lt/ex67+/AdsoBQJF3kgX2feYQ== + dependencies: + binary-search "^1.3.5" + character-entities-legacy@^1.0.0: version "1.1.4" resolved "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" @@ -9966,6 +10089,11 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" +color-convert@^0.5.2: + version "0.5.3" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd" + integrity sha1-vbbGnOZg+t/+CwAHzER+G59ygr0= + color-convert@^1.9.0, color-convert@^1.9.1: version "1.9.3" resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -9985,7 +10113,7 @@ color-name@1.1.3: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= -color-name@^1.0.0, color-name@~1.1.4: +color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== @@ -10077,7 +10205,7 @@ commander@2.3.0: resolved "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz#fd430e889832ec353b9acd1de217c11cb3eef873" integrity sha1-/UMOiJgy7DU7ms0d4hfBHLPu+HM= -commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: +commander@^2.19.0, commander@^2.20.0, commander@^2.20.3, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -10092,7 +10220,7 @@ commander@^5.0.0, commander@^5.1.0: resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== -commander@^6.1.0: +commander@^6.1.0, commander@^6.2.1: version "6.2.1" resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== @@ -10460,7 +10588,7 @@ copy-descriptor@^0.1.0: resolved "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -copy-to-clipboard@^3, copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.1.0, copy-to-clipboard@^3.2.0: +copy-to-clipboard@^3, copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.1.0, copy-to-clipboard@^3.2.0, copy-to-clipboard@^3.3.1: version "3.3.1" resolved "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae" integrity sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw== @@ -10714,6 +10842,15 @@ css-box-model@^1.2.0: dependencies: tiny-invariant "^1.0.6" +css-color-converter@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/css-color-converter/-/css-color-converter-2.0.0.tgz#70c00fa451a19675e2808f28de9be360c84db5fb" + integrity sha512-oLIG2soZz3wcC3aAl/7Us5RS8Hvvc6I8G8LniF/qfMmrm7fIKQ8RIDDRZeKyGL2SrWfNqYspuLShbnjBMVWm8g== + dependencies: + color-convert "^0.5.2" + color-name "^1.1.4" + css-unit-converter "^1.1.2" + css-color-names@0.0.4, css-color-names@^0.0.4: version "0.0.4" resolved "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" @@ -10761,18 +10898,6 @@ css-loader@^3.5.3: schema-utils "^2.7.0" semver "^6.3.0" -css-modules-loader-core@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/css-modules-loader-core/-/css-modules-loader-core-1.1.0.tgz#5908668294a1becd261ae0a4ce21b0b551f21d16" - integrity sha1-WQhmgpShvs0mGuCkziGwtVHyHRY= - dependencies: - icss-replace-symbols "1.1.0" - postcss "6.0.1" - postcss-modules-extract-imports "1.1.0" - postcss-modules-local-by-default "1.2.0" - postcss-modules-scope "1.1.0" - postcss-modules-values "1.3.0" - css-select-base-adapter@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" @@ -10798,15 +10923,6 @@ css-select@^2.0.0: domutils "^1.7.0" nth-check "^1.0.2" -css-selector-tokenizer@^0.7.0: - version "0.7.2" - resolved "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.2.tgz#11e5e27c9a48d90284f22d45061c303d7a25ad87" - integrity sha512-yj856NGuAymN6r8bn8/Jl46pR+OC3eEvAhfGYDUe7YPtTPAYrSSw4oAniZ9Y8T5B92hjhwTBLUen0/vKPxf6pw== - dependencies: - cssesc "^3.0.0" - fastparse "^1.1.2" - regexpu-core "^4.6.0" - css-tree@1.0.0-alpha.37: version "1.0.0-alpha.37" resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz#98bebd62c4c1d9f960ec340cf9f7522e30709a22" @@ -10823,6 +10939,19 @@ css-tree@^1.0.0-alpha.28: 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" + integrity sha512-wCoWush5Aeo48GLhfHPbmvZs59Z+M7k5+B1xDnXbdWNcEF423DoFdqSWE0PM5aNk5nI5cp1q7ms36zGApY/sKQ== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + +css-unit-converter@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21" + integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA== + css-vendor@^2.0.7: version "2.0.7" resolved "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.7.tgz#4e6d53d953c187981576d6a542acc9fb57174bda" @@ -10969,6 +11098,11 @@ csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.5, csstype@^2.5.7, csstype@^2.6.5, resolved "https://registry.npmjs.org/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q== +csstype@^3.0.6: + version "3.0.7" + resolved "https://registry.npmjs.org/csstype/-/csstype-3.0.7.tgz#2a5fb75e1015e84dd15692f71e89a1450290950b" + integrity sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g== + csv-generate@^3.2.4: version "3.2.4" resolved "https://registry.npmjs.org/csv-generate/-/csv-generate-3.2.4.tgz#440dab9177339ee0676c9e5c16f50e2b3463c019" @@ -11216,7 +11350,7 @@ dagre@^0.8.5: graphlib "^2.1.8" lodash "^4.17.15" -damerau-levenshtein@^1.0.4: +damerau-levenshtein@^1.0.6: version "1.0.6" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" integrity sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug== @@ -11261,10 +11395,10 @@ 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.0: - version "0.14.1" - resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-0.14.1.tgz#969634697b78956fb66b8be6fb0f39fbd631f2f6" - integrity sha512-M4RggEH5OF2ZuCOxgOU67R6Z9ohjKbxGvAQz48vj53wLmL0bAgumkBvycR32f30pK+Og9pIR+RFDyChbaE4oLA== +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" @@ -11287,9 +11421,9 @@ dateformat@^3.0.0: integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== dayjs@^1.9.4: - version "1.9.4" - resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.9.4.tgz#fcde984e227f4296f04e7b05720adad2e1071f1b" - integrity sha512-ABSF3alrldf7nM9sQ2U+Ln67NRwmzlLOqG7kK03kck0mw3wlSSEKv/XhKGGxUjQcS57QeiCyNdrFgtj9nWlrng== + version "1.10.4" + resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" + integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== de-indent@^1.0.2: version "1.0.2" @@ -12102,7 +12236,7 @@ emittery@^0.7.1: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4= -emoji-regex@^7.0.1, emoji-regex@^7.0.2: +emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== @@ -12112,6 +12246,11 @@ emoji-regex@^8.0.0: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.0.0: + version "9.2.2" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" @@ -12212,6 +12351,11 @@ envinfo@^7.7.4: resolved "https://registry.npmjs.org/envinfo/-/envinfo-7.7.4.tgz#c6311cdd38a0e86808c1c9343f667e4267c4a320" integrity sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ== +eol@^0.9.1: + version "0.9.1" + resolved "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz#f701912f504074be35c6117a5c4ade49cd547acd" + integrity sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg== + err-code@^2.0.2: version "2.0.3" resolved "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" @@ -12295,6 +12439,28 @@ es-abstract@^1.18.0-next.1: string.prototype.trimend "^1.0.1" string.prototype.trimstart "^1.0.1" +es-abstract@^1.18.0-next.2: + version "1.18.0" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz#ab80b359eecb7ede4c298000390bc5ac3ec7b5a4" + integrity sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw== + dependencies: + call-bind "^1.0.2" + es-to-primitive "^1.2.1" + function-bind "^1.1.1" + get-intrinsic "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.2" + is-callable "^1.2.3" + is-negative-zero "^2.0.1" + is-regex "^1.1.2" + is-string "^1.0.5" + object-inspect "^1.9.0" + object-keys "^1.1.1" + object.assign "^4.1.2" + string.prototype.trimend "^1.0.4" + string.prototype.trimstart "^1.0.4" + unbox-primitive "^1.0.0" + es-array-method-boxes-properly@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" @@ -12510,19 +12676,21 @@ eslint-plugin-jest@^24.1.0: "@typescript-eslint/experimental-utils" "^4.0.1" eslint-plugin-jsx-a11y@^6.2.1: - version "6.2.3" - resolved "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.2.3.tgz#b872a09d5de51af70a97db1eea7dc933043708aa" - integrity sha512-CawzfGt9w83tyuVekn0GDPU9ytYtxyxyFZ3aSWROmnRRFQFT2BiPJd7jvRdzNDi6oLWaS2asMeYSNMjWTV4eNg== + version "6.4.1" + resolved "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.4.1.tgz#a2d84caa49756942f42f1ffab9002436391718fd" + integrity sha512-0rGPJBbwHoGNPU73/QCLP/vveMlM1b1Z9PponxO87jfr6tuH5ligXbDT6nHSSzBC8ovX2Z+BQu7Bk5D/Xgq9zg== dependencies: - "@babel/runtime" "^7.4.5" - aria-query "^3.0.0" - array-includes "^3.0.3" + "@babel/runtime" "^7.11.2" + aria-query "^4.2.2" + array-includes "^3.1.1" ast-types-flow "^0.0.7" - axobject-query "^2.0.2" - damerau-levenshtein "^1.0.4" - emoji-regex "^7.0.2" + axe-core "^4.0.2" + axobject-query "^2.2.0" + damerau-levenshtein "^1.0.6" + emoji-regex "^9.0.0" has "^1.0.3" - jsx-ast-utils "^2.2.1" + jsx-ast-utils "^3.1.0" + language-tags "^1.0.5" eslint-plugin-monorepo@^0.3.2: version "0.3.2" @@ -12936,12 +13104,12 @@ expect@^26.6.2: jest-message-util "^26.6.2" jest-regex-util "^26.0.0" -express-promise-router@^3.0.3: - version "3.0.3" - resolved "https://registry.npmjs.org/express-promise-router/-/express-promise-router-3.0.3.tgz#5e6d22a5a3f013d71833172fe8d7ab780c3f6b70" - integrity sha1-Xm0ipaPwE9cYMxcv6NereAw/a3A= +express-promise-router@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/express-promise-router/-/express-promise-router-4.1.0.tgz#79160e145c27610ba411bceb0552a36f11dbab4f" + integrity sha512-nvg0X1Rj8oajPPC+fG3t4e740aNmQZRZY6dRLbiiM56Dvd8213RJ4kaxhZVTdQLut+l4DZdfeJkyx2VENPMBdw== dependencies: - is-promise "^2.1.0" + is-promise "^4.0.0" lodash.flattendeep "^4.0.0" methods "^1.0.0" @@ -13161,10 +13329,10 @@ fastest-stable-stringify@^1.0.1: resolved "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-1.0.1.tgz#9122d406d4c9d98bea644a6b6853d5874b87b028" integrity sha1-kSLUBtTJ2YvqZEpraFPVh0uHsCg= -fastparse@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9" - integrity sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ== +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" + integrity sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q== fastq@^1.6.0: version "1.6.1" @@ -13752,17 +13920,6 @@ gaxios@^1.0.2, gaxios@^1.0.4, gaxios@^1.2.1, gaxios@^1.5.0: https-proxy-agent "^2.2.1" node-fetch "^2.3.0" -gaxios@^3.0.0: - version "3.2.0" - resolved "https://registry.npmjs.org/gaxios/-/gaxios-3.2.0.tgz#11b6f0e8fb08d94a10d4d58b044ad3bec6dd486a" - integrity sha512-+6WPeVzPvOshftpxJwRi2Ozez80tn/hdtOUag7+gajDHRJvAblKxTFSSMPtr2hmnLy7p0mvYz0rMXLBl8pSO7Q== - dependencies: - abort-controller "^3.0.0" - extend "^3.0.2" - https-proxy-agent "^5.0.0" - is-stream "^2.0.0" - node-fetch "^2.3.0" - gaxios@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/gaxios/-/gaxios-4.0.1.tgz#bc7b205a89d883452822cc75e138620c35e3291e" @@ -13802,16 +13959,16 @@ gcs-resumable-upload@^1.0.0: pumpify "^1.5.1" stream-events "^1.0.4" -gcs-resumable-upload@^3.1.0: - version "3.1.1" - resolved "https://registry.npmjs.org/gcs-resumable-upload/-/gcs-resumable-upload-3.1.1.tgz#67c766a0555d6a352f9651b7603337207167d0de" - integrity sha512-RS1osvAicj9+MjCc6jAcVL1Pt3tg7NK2C2gXM5nqD1Gs0klF2kj5nnAFSBy97JrtslMIQzpb7iSuxaG8rFWd2A== +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== dependencies: abort-controller "^3.0.0" configstore "^5.0.0" extend "^3.0.2" - gaxios "^3.0.0" - google-auth-library "^6.0.0" + gaxios "^4.0.0" + google-auth-library "^7.0.0" pumpify "^2.0.0" stream-events "^1.0.4" @@ -13851,6 +14008,15 @@ get-intrinsic@^1.0.0: has "^1.0.3" has-symbols "^1.0.1" +get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-symbols "^1.0.1" + get-monorepo-packages@^1.1.0: version "1.2.0" resolved "https://registry.npmjs.org/get-monorepo-packages/-/get-monorepo-packages-1.2.0.tgz#3eee88d30b11a5f65955dec6ae331958b2a168e4" @@ -13890,6 +14056,11 @@ get-stdin@^6.0.0: resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== +get-stdin@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53" + integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg== + get-stream@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" @@ -14232,7 +14403,7 @@ google-auth-library@^3.0.0, google-auth-library@^3.1.1: lru-cache "^5.0.0" semver "^5.5.0" -google-auth-library@^6.0.0, google-auth-library@^6.1.1: +google-auth-library@^6.1.1: version "6.1.3" resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-6.1.3.tgz#39d868140b70d0c4b32c6f6d8f4ccc1400d84dca" integrity sha512-m9mwvY3GWbr7ZYEbl61isWmk+fvTmOt0YNUfPOUY2VH8K5pZlAIWJjxEi0PqR3OjMretyiQLI6GURMrPSwHQ2g== @@ -14247,7 +14418,7 @@ google-auth-library@^6.0.0, google-auth-library@^6.1.1: jws "^4.0.0" lru-cache "^6.0.0" -google-auth-library@^7.0.2: +google-auth-library@^7.0.0, google-auth-library@^7.0.2: version "7.0.2" resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.0.2.tgz#cab6fc7f94ebecc97be6133d6519d9946ccf3e9d" integrity sha512-vjyNZR3pDLC0u7GHLfj+Hw9tGprrJwoMwkYGqURCXYITjCrP9HprOyxVV+KekdLgATtWGuDkQG2MTh0qpUPUgg== @@ -14555,9 +14726,9 @@ handle-thing@^2.0.0: integrity sha512-d4sze1JNC454Wdo2fkuyzCr6aHcbL6PGGuFAz0Li/NcOm1tCHGnWDRmJP85dh9IhQErTc2svWFEX5xHIOo//kQ== handlebars@^4.7.3, handlebars@^4.7.6: - version "4.7.6" - resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz#d4c05c1baf90e9945f77aa68a7a219aa4a7df74e" - integrity sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA== + version "4.7.7" + resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== dependencies: minimist "^1.2.5" neo-async "^2.6.0" @@ -14596,10 +14767,10 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" -has-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" - integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo= +has-bigints@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" + integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== has-flag@^3.0.0: version "3.0.0" @@ -14623,6 +14794,11 @@ has-symbols@^1.0.0, has-symbols@^1.0.1: resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== +has-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" + integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== + has-unicode@^2.0.0, has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -14859,16 +15035,16 @@ html-to-react@^1.3.4: ramda "^0.26" html-webpack-plugin@^4.2.1, html-webpack-plugin@^4.3.0: - version "4.4.1" - resolved "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.4.1.tgz#61ab85aa1a84ba181443345ebaead51abbb84149" - integrity sha512-nEtdEIsIGXdXGG7MjTTZlmhqhpHU9pJFc1OYxcP36c5/ZKP6b0BJMww2QTvJGQYA9aMxUnjDujpZdYcVOXiBCQ== + version "4.5.2" + resolved "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.5.2.tgz#76fc83fa1a0f12dd5f7da0404a54e2699666bc12" + integrity sha512-q5oYdzjKUIPQVjOosjgvCHQOv9Ett9CYYHlgvJeXG0qQvdSojnBq4vAdQBwn1+yGveAwHCoe/rMR86ozX3+c2A== dependencies: "@types/html-minifier-terser" "^5.0.0" "@types/tapable" "^1.0.5" "@types/webpack" "^4.41.8" html-minifier-terser "^5.0.1" loader-utils "^1.2.3" - lodash "^4.17.15" + lodash "^4.17.20" pretty-error "^2.1.1" tapable "^1.1.3" util.promisify "1.0.0" @@ -15108,7 +15284,7 @@ iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -icss-replace-symbols@1.1.0, icss-replace-symbols@^1.1.0: +icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" integrity sha1-Bupvg2ead0njhs/h/oEq5dsiPe0= @@ -15120,6 +15296,11 @@ icss-utils@^4.0.0, icss-utils@^4.1.1: dependencies: postcss "^7.0.14" +icss-utils@^5.0.0: + version "5.1.0" + resolved "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" + integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== + identity-obj-proxy@3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14" @@ -15336,6 +15517,13 @@ inline-style-prefixer@^4.0.0: 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" + integrity sha512-XTHvRUS4ZJNzC1GixJRmOlWSS45fSt+DJoyQC9ytj0WxQfcgofQtDtyKKYxHUqEsWCs+LIWftPF1ie7+i012Fg== + dependencies: + css-in-js-utils "^2.0.0" + inquirer@7.0.4: version "7.0.4" resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703" @@ -15488,6 +15676,11 @@ is-arrayish@^0.3.1: resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== +is-bigint@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.1.tgz#6923051dfcbc764278540b9ce0e6b3213aa5ebc2" + integrity sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg== + is-binary-path@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" @@ -15502,6 +15695,13 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-boolean-object@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.0.tgz#e2aaad3a3a8fca34c28f6eee135b156ed2587ff0" + integrity sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA== + dependencies: + call-bind "^1.0.0" + is-buffer@^1.0.2, is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -15522,6 +15722,11 @@ is-callable@^1.2.2: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== +is-callable@^1.2.3: + version "1.2.3" + resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" + integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== + is-ci@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" @@ -15733,11 +15938,21 @@ is-negative-zero@^2.0.0: resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= +is-negative-zero@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + is-npm@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz#c90dd8380696df87a7a6d823c20d0b12bbe3c84d" integrity sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig== +is-number-object@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" + integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw== + is-number@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" @@ -15830,7 +16045,7 @@ is-potential-custom-element-name@^1.0.0: resolved "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= -is-promise@4.0.0: +is-promise@4.0.0, is-promise@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== @@ -15854,6 +16069,14 @@ is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.1: dependencies: has-symbols "^1.0.1" +is-regex@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz#81c8ebde4db142f2cf1c53fc86d6a45788266251" + integrity sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg== + dependencies: + call-bind "^1.0.2" + has-symbols "^1.0.1" + is-regexp@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" @@ -16881,6 +17104,11 @@ json-to-pretty-yaml@^1.2.2: remedial "^1.0.7" remove-trailing-spaces "^1.0.6" +json2xml@^0.1.3: + version "0.1.3" + resolved "https://registry.npmjs.org/json2xml/-/json2xml-0.1.3.tgz#9ae7c220bedd7c66a668e26f7ac182f6704eca21" + integrity sha1-mufCIL7dfGamaOJvesGC9nBOyiE= + json3@^3.3.2: version "3.3.3" resolved "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" @@ -17061,21 +17289,13 @@ jss@10.1.1, jss@^10.0.3: is-in-browser "^1.1.3" tiny-warning "^1.0.2" -jsx-ast-utils@^2.2.1: - version "2.2.3" - resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f" - integrity sha512-EdIHFMm+1BPynpKOpdPqiOsvnIrInRGJD7bzPZdPkjitQEqpdpUuFpq4T0npZFKTiB3RhWFdGN+oqOJIdhDhQA== +"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.1.0: + version "3.2.0" + resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82" + integrity sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q== dependencies: - array-includes "^3.0.3" - object.assign "^4.1.0" - -"jsx-ast-utils@^2.4.1 || ^3.0.0": - version "3.1.0" - resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.1.0.tgz#642f1d7b88aa6d7eb9d8f2210e166478444fa891" - integrity sha512-d4/UOjg+mxAWxCiF0c5UTSwyqbchkbqCvK87aBovhnh8GtysTjWmgC63tY0cJx/HzGgm9qnA147jVBdpOiQ2RA== - dependencies: - array-includes "^3.1.1" - object.assign "^4.1.1" + array-includes "^3.1.2" + object.assign "^4.1.2" junk@^3.1.0: version "3.1.0" @@ -17227,6 +17447,18 @@ kuler@1.0.x: dependencies: colornames "^1.1.1" +language-subtag-registry@~0.3.2: + version "0.3.21" + resolved "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a" + integrity sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg== + +language-tags@^1.0.5: + version "1.0.5" + resolved "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz#d321dbc4da30ba8bf3024e040fa5c14661f9193a" + integrity sha1-0yHbxNowuovzAk4ED6XBRmH5GTo= + dependencies: + language-subtag-registry "~0.3.2" + latest-version@5.1.0, latest-version@^5.0.0: version "5.1.0" resolved "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" @@ -17282,9 +17514,9 @@ ldap-filter@^0.3.3: assert-plus "^1.0.0" ldapjs@^2.2.0: - version "2.2.0" - resolved "https://registry.npmjs.org/ldapjs/-/ldapjs-2.2.0.tgz#c23846d35bf215b4ba4721ff22f11d3df6391e2f" - integrity sha512-9+ekbj97nxRYQMRgEm/HYFhFLWSRKah2PnReUfhfM5f62XBeUSFolB+AQ2Jij5tqowpksTnrdNCZLP6C+V3uag== + version "2.2.4" + resolved "https://registry.npmjs.org/ldapjs/-/ldapjs-2.2.4.tgz#d4e3f4ae2277b6e760a83ebd61f7f5c4097c446b" + integrity sha512-OoeAXPNPPt4D6qva2/p6rkCIHknyYFd42Vp8JhSazBs9BbkEBmoajzj2F0ElD3vR+yAuzIVCjqh1W4uR8dfn0A== dependencies: abstract-logging "^2.0.0" asn1 "^0.2.4" @@ -17295,6 +17527,23 @@ ldapjs@^2.2.0: vasync "^2.2.0" verror "^1.8.1" +leasot@^11.5.0: + version "11.5.0" + resolved "https://registry.npmjs.org/leasot/-/leasot-11.5.0.tgz#a99eb4479618c9d2ea442a32ee006e5b9da4844d" + integrity sha512-L08QKlmofYIRs5gfOmhOtbEJUu6U/zFGvYpboPq34yAHQ2Oc/QznOw62noe29yRJLiV/XnIDS8vO2um1e1sikA== + dependencies: + async "^3.2.0" + chalk "^4.1.0" + commander "^6.2.1" + eol "^0.9.1" + get-stdin "^8.0.0" + globby "^11.0.1" + json2xml "^0.1.3" + lodash "^4.17.20" + log-symbols "^4.0.0" + strip-ansi "^6.0.0" + text-table "^0.2.0" + left-pad@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" @@ -18158,6 +18407,11 @@ mdast-util-to-string@^1.0.0: resolved "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527" integrity sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A== +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" @@ -18422,17 +18676,7 @@ mime@1.6.0, mime@^1.4.0, mime@^1.4.1: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.2.0: - version "2.4.6" - resolved "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" - integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== - -mime@^2.3.1, mime@^2.4.4: - version "2.4.4" - resolved "https://registry.npmjs.org/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" - integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== - -mime@^2.4.1: +mime@^2.2.0, mime@^2.3.1, mime@^2.4.1, mime@^2.4.4: version "2.5.2" resolved "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== @@ -18906,6 +19150,20 @@ nano-css@^5.1.0, nano-css@^5.2.1: stacktrace-js "^2.0.0" stylis "3.5.0" +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== + dependencies: + css-tree "^1.1.2" + csstype "^3.0.6" + fastest-stable-stringify "^2.0.2" + inline-style-prefixer "^6.0.0" + rtl-css-js "^1.14.0" + sourcemap-codec "^1.4.8" + stacktrace-js "^2.0.2" + stylis "^4.0.6" + nanoid@^2.1.0: version "2.1.11" resolved "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" @@ -19523,6 +19781,11 @@ object-inspect@^1.8.0: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== +object-inspect@^1.9.0: + version "1.9.0" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" + integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== + object-is@^1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4" @@ -19555,7 +19818,7 @@ object.assign@^4.1.0: has-symbols "^1.0.0" object-keys "^1.0.11" -object.assign@^4.1.1: +object.assign@^4.1.1, object.assign@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== @@ -19660,14 +19923,7 @@ onetime@^2.0.0: dependencies: mimic-fn "^1.0.0" -onetime@^5.1.0: - version "5.1.0" - resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5" - integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== - dependencies: - mimic-fn "^2.1.0" - -onetime@^5.1.2: +onetime@^5.1.0, onetime@^5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -19952,14 +20208,6 @@ p-pipe@^3.1.0: resolved "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" integrity sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw== -p-queue@^6.3.0: - version "6.4.0" - resolved "https://registry.npmjs.org/p-queue/-/p-queue-6.4.0.tgz#5050b379393ea1814d6f9613a654f687d92c0466" - integrity sha512-X7ddxxiQ+bLR/CUt3/BVKrGcJDNxBr0pEEFKHHB6vTPWNUhgDv36GpIH18RmGM3YGPpBT+JWGjDDqsVGuF0ERw== - dependencies: - eventemitter3 "^4.0.0" - p-timeout "^3.1.0" - p-queue@^6.6.2: version "6.6.2" resolved "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" @@ -20797,7 +21045,7 @@ postcss-flexbugs-fixes@^4.1.0: dependencies: postcss "^7.0.26" -postcss-load-config@^2.0.0, postcss-load-config@^2.1.0: +postcss-load-config@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-2.1.0.tgz#c84d692b7bb7b41ddced94ee62e8ab31b417b003" integrity sha512-4pV3JJVPLd5+RueiVVB+gFOAa7GWc25XQcMp86Zexzke69mKf6Nx9LRcQywdz7yZI9n1udOxmLuAwTBypypF8Q== @@ -20805,6 +21053,14 @@ postcss-load-config@^2.0.0, postcss-load-config@^2.1.0: cosmiconfig "^5.0.0" import-cwd "^2.0.0" +postcss-load-config@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.0.1.tgz#d214bf9cfec1608ffaf0f4161b3ba20664ab64b9" + integrity sha512-/pDHe30UYZUD11IeG8GWx9lNtu1ToyTsZHnyy45B4Mrwr/Kb6NgYl7k753+05CJNKnjbwh4975amoPJ+TEjHNQ== + dependencies: + cosmiconfig "^7.0.0" + import-cwd "^3.0.0" + postcss-loader@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" @@ -20877,13 +21133,6 @@ postcss-minify-selectors@^4.0.2: postcss "^7.0.0" postcss-selector-parser "^3.0.0" -postcss-modules-extract-imports@1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.1.0.tgz#b614c9720be6816eaee35fb3a5faa1dba6a05ddb" - integrity sha1-thTJcgvmgW6u41+zpfqh26agXds= - dependencies: - postcss "^6.0.1" - postcss-modules-extract-imports@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" @@ -20891,13 +21140,10 @@ postcss-modules-extract-imports@^2.0.0: dependencies: postcss "^7.0.5" -postcss-modules-local-by-default@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" - integrity sha1-99gMOYxaOT+nlkRmvRlQCn1hwGk= - dependencies: - css-selector-tokenizer "^0.7.0" - postcss "^6.0.1" +postcss-modules-extract-imports@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" + integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== postcss-modules-local-by-default@^3.0.2: version "3.0.2" @@ -20909,13 +21155,14 @@ postcss-modules-local-by-default@^3.0.2: postcss-selector-parser "^6.0.2" postcss-value-parser "^4.0.0" -postcss-modules-scope@1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" - integrity sha1-1upkmUx5+XtipytCb75gVqGUu5A= +postcss-modules-local-by-default@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" + integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== dependencies: - css-selector-tokenizer "^0.7.0" - postcss "^6.0.1" + icss-utils "^5.0.0" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.1.0" postcss-modules-scope@^2.2.0: version "2.2.0" @@ -20925,13 +21172,12 @@ postcss-modules-scope@^2.2.0: postcss "^7.0.6" postcss-selector-parser "^6.0.0" -postcss-modules-values@1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" - integrity sha1-7P+p1+GSUYOJ9CrQ6D9yrsRW6iA= +postcss-modules-scope@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" + integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== dependencies: - icss-replace-symbols "^1.1.0" - postcss "^6.0.1" + postcss-selector-parser "^6.0.4" postcss-modules-values@^3.0.0: version "3.0.0" @@ -20941,15 +21187,25 @@ postcss-modules-values@^3.0.0: icss-utils "^4.0.0" postcss "^7.0.6" -postcss-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/postcss-modules/-/postcss-modules-2.0.0.tgz#473d0d7326651d8408585c2a154115d5cb36cce0" - integrity sha512-eqp+Bva+U2cwQO7dECJ8/V+X+uH1HduNeITB0CPPFAu6d/8LKQ32/j+p9rQ2YL1QytVcrNU0X+fBqgGmQIA1Rw== +postcss-modules-values@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" + integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== + dependencies: + icss-utils "^5.0.0" + +postcss-modules@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.0.0.tgz#2bc7f276ab88f3f1b0fadf6cbd7772d43b5f3b9b" + integrity sha512-ghS/ovDzDqARm4Zj6L2ntadjyQMoyJmi0JkLlYtH2QFLrvNlxH5OAVRPWPeKilB0pY7SbuhO173KOWkPAxRJcw== dependencies: - css-modules-loader-core "^1.1.0" generic-names "^2.0.1" + icss-replace-symbols "^1.1.0" lodash.camelcase "^4.3.0" - postcss "^7.0.1" + postcss-modules-extract-imports "^3.0.0" + postcss-modules-local-by-default "^4.0.0" + postcss-modules-scope "^3.0.0" + postcss-modules-values "^4.0.0" string-hash "^1.1.1" postcss-normalize-charset@^4.0.1: @@ -21080,6 +21336,16 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: indexes-of "^1.0.1" uniq "^1.0.1" +postcss-selector-parser@^6.0.4: + version "6.0.4" + resolved "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3" + integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw== + dependencies: + cssesc "^3.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + util-deprecate "^1.0.2" + postcss-svgo@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258" @@ -21118,24 +21384,6 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^ source-map "^0.6.1" supports-color "^6.1.0" -postcss@6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/postcss/-/postcss-6.0.1.tgz#000dbd1f8eef217aa368b9a212c5fc40b2a8f3f2" - integrity sha1-AA29H47vIXqjaLmiEsX8QLKo8/I= - dependencies: - chalk "^1.1.3" - source-map "^0.5.6" - supports-color "^3.2.3" - -postcss@^6.0.1: - version "6.0.23" - resolved "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" - integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag== - dependencies: - chalk "^2.4.1" - source-map "^0.6.1" - supports-color "^5.4.0" - postcss@^8.0.2: version "8.2.6" resolved "https://registry.npmjs.org/postcss/-/postcss-8.2.6.tgz#5d69a974543b45f87e464bc4c3e392a97d6be9fe" @@ -21963,7 +22211,7 @@ react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: +"react-is@^16.8.0 || ^17.0.0", react-is@^17.0.1: version "17.0.1" resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== @@ -22232,6 +22480,26 @@ react-use@^15.3.3, react-use@^15.3.6: ts-easing "^0.2.0" tslib "^2.0.0" +react-use@^17.1.0: + 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== + dependencies: + "@types/js-cookie" "^2.2.6" + "@xobotyi/scrollbar-width" "^1.9.5" + copy-to-clipboard "^3.3.1" + fast-deep-equal "^3.1.3" + fast-shallow-equal "^1.0.0" + js-cookie "^2.2.1" + nano-css "^5.3.1" + react-universal-interface "^0.6.2" + resize-observer-polyfill "^1.5.1" + screenfull "^5.1.0" + set-harmonic-interval "^1.0.1" + throttle-debounce "^3.0.1" + ts-easing "^0.2.0" + tslib "^2.1.0" + react-virtualized@^9.21.0: version "9.21.2" resolved "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.21.2.tgz#02e6df65c1e020c8dbf574ec4ce971652afca84e" @@ -22618,7 +22886,7 @@ regexpp@^3.0.0, regexpp@^3.1.0: resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2" integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== -regexpu-core@^4.6.0, regexpu-core@^4.7.0: +regexpu-core@^4.7.0: version "4.7.0" resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938" integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ== @@ -22916,7 +23184,7 @@ resolve-url@^0.2.1: resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.16.1, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.19.0, resolve@^1.3.2, resolve@^1.9.0: +resolve@^1.1.6, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.19.0, resolve@^1.3.2, resolve@^1.9.0: version "1.20.0" resolved "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== @@ -23070,24 +23338,23 @@ rollup-plugin-peer-deps-external@^2.2.2: resolved "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz#8a420bbfd6dccc30aeb68c9bf57011f2f109570d" integrity sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g== -rollup-plugin-postcss@^3.1.1: - version "3.1.8" - resolved "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-3.1.8.tgz#d1bcaf8eb0fcb0936e3684c22dd8628d13a82fd1" - integrity sha512-JHnGfW8quNc6ePxEkZ05HEZ1YiRxDgY9RKEetMfsrwxR2kh/d90OVScTc6b1c2Q17Cs/5TRYL+1uddG21lQe3w== +rollup-plugin-postcss@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/rollup-plugin-postcss/-/rollup-plugin-postcss-4.0.0.tgz#2131fb6db0d5dce01a37235e4f6ad4523c681cea" + integrity sha512-OQzT+YspV01/6dxfyEw8lBO2px3hyL8Xn+k2QGctL7V/Yx2Z1QaMKdYVslP1mqv7RsKt6DROIlnbpmgJ3yxf6g== dependencies: - chalk "^4.0.0" + chalk "^4.1.0" concat-with-sourcemaps "^1.1.0" cssnano "^4.1.10" import-cwd "^3.0.0" - p-queue "^6.3.0" + p-queue "^6.6.2" pify "^5.0.0" - postcss "^7.0.27" - postcss-load-config "^2.1.0" - postcss-modules "^2.0.0" + postcss-load-config "^3.0.0" + postcss-modules "^4.0.0" promise.series "^0.2.0" - resolve "^1.16.1" + resolve "^1.19.0" rollup-pluginutils "^2.8.2" - safe-identifier "^0.4.1" + safe-identifier "^0.4.2" style-inject "^0.3.0" rollup-pluginutils@^2.8.2: @@ -23117,7 +23384,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.9.0: +rtl-css-js@^1.14.0, rtl-css-js@^1.9.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== @@ -23172,10 +23439,10 @@ safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== -safe-identifier@^0.4.1: - version "0.4.1" - resolved "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.1.tgz#b6516bf72594f03142b5f914f4c01842ccb1b678" - integrity sha512-73tOz5TXsq3apuCc3vC8c9QRhhdNZGiBhHmPPjqpH4TO5oCDqk8UIsDcSs/RG6dYcFAkOOva0pqHS3u7hh7XXA== +safe-identifier@^0.4.2: + version "0.4.2" + resolved "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb" + integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w== safe-json-stringify@~1: version "1.2.0" @@ -23279,6 +23546,11 @@ screenfull@^5.0.0: resolved "https://registry.npmjs.org/screenfull/-/screenfull-5.0.2.tgz#b9acdcf1ec676a948674df5cd0ff66b902b0bed7" integrity sha512-cCF2b+L/mnEiORLN5xSAz6H3t18i2oHh9BA8+CQlAh5DRw2+NFAGQJOSYbcGw8B2k04g/lVvFcfZ83b3ysH5UQ== +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== + scuid@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz#d3f9f920956e737a60f72d0e4ad280bf324d5dab" @@ -23840,7 +24112,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.1, 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== @@ -24080,7 +24352,7 @@ stacktrace-gps@^3.0.4: source-map "0.5.6" stackframe "^1.1.1" -stacktrace-js@^2.0.0: +stacktrace-js@^2.0.0, 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== @@ -24322,6 +24594,14 @@ string.prototype.trimend@^1.0.1: call-bind "^1.0.0" define-properties "^1.1.3" +string.prototype.trimend@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" + integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + string.prototype.trimleft@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74" @@ -24346,6 +24626,14 @@ string.prototype.trimstart@^1.0.1: call-bind "^1.0.0" define-properties "^1.1.3" +string.prototype.trimstart@^1.0.4: + version "1.0.4" + resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" + integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== + dependencies: + call-bind "^1.0.2" + define-properties "^1.1.3" + string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -24489,6 +24777,11 @@ stylis@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" + integrity sha512-OFFeUXFgwnGOKvEXaSv0D0KQ5ADP0n6g3SVONx6I/85JzNZ3u50FRwB3lVIk1QO2HNdI75tbVzc4Z66Gdp9voA== + subscriptions-transport-ws@0.9.18, subscriptions-transport-ws@^0.9.11, subscriptions-transport-ws@^0.9.16, subscriptions-transport-ws@^0.9.18: version "0.9.18" resolved "https://registry.npmjs.org/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.18.tgz#bcf02320c911fbadb054f7f928e51c6041a37b97" @@ -24546,14 +24839,7 @@ supports-color@^2.0.0: resolved "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= -supports-color@^3.2.3: - version "3.2.3" - resolved "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" - integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY= - dependencies: - has-flag "^1.0.0" - -supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0: +supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -24779,19 +25065,7 @@ tar@^4, tar@^4.4.12, tar@^4.4.2: safe-buffer "^5.1.2" yallist "^3.0.3" -tar@^6.0.1, tar@^6.0.2, tar@^6.0.5: - version "6.0.5" - resolved "https://registry.npmjs.org/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f" - integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^3.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - -tar@^6.1.0: +tar@^6.0.1, tar@^6.0.2, tar@^6.0.5, tar@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== @@ -24970,6 +25244,11 @@ throttle-debounce@^2.0.1, throttle-debounce@^2.1.0: resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.2.1.tgz#fbd933ae6793448816f7d5b3cae259d464c98137" integrity sha512-i9hAVld1f+woAiyNGqWelpDD5W1tpMroL3NofTz9xzwq6acWBlO2dC8k5EFSZepU6oOINtV5Q3aSPoRg7o4+fA== +throttle-debounce@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb" + integrity sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg== + throttleit@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" @@ -25381,7 +25660,7 @@ tslib@^1.10.0, tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@~2.1.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@~2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== @@ -25582,6 +25861,16 @@ umask@^1.1.0: resolved "https://registry.npmjs.org/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d" integrity sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0= +unbox-primitive@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.0.tgz#eeacbc4affa28e9b3d36b5eaeccc50b3251b1d3f" + integrity sha512-P/51NX+JXyxK/aigg1/ZgyccdAxm5K1+n8+tvqSntjOivPt19gvm1VC49RWYetsiub8WViUchdxl/KWHHB0kzA== + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.0" + has-symbols "^1.0.0" + which-boxed-primitive "^1.0.1" + unc-path-regex@^0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" @@ -26455,6 +26744,17 @@ whatwg-url@^8.4.0: tr46 "^2.0.2" webidl-conversions "^6.1.0" +which-boxed-primitive@^1.0.1: + version "1.0.2" + resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + which-module@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"